Skip to content

Commit 17cac32

Browse files
kreneskypAgent IXclaude
authored
feat(update): install-source-aware self-update subcommand (#7)
* test: fix flaky edit both-stdin race (BrokenPipe on early reject) quire edit - --content - rejects the both-stdin-args combination before it reads stdin, so the test's write_all raced the child's exit and panicked with BrokenPipe when the child won (intermittent CI failure on main). Tolerate the expected BrokenPipe; the real assertions (exit 1 + stderr message) are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(update): add install-source-aware self-update subcommand quire update upgrades the installed binary to the latest published version. Because quire ships via two channels (npm prebuilt-binary wrapper and cargo install --git), it detects the install source from the running executable path (node_modules -> npm, .cargo -> cargo, else unknown) and drives the matching package manager; an unknown source prints manual instructions and exits 0 without touching the network. The logic lives in a package-agnostic self_update engine (config-struct driven, no quire coupling) so it can later extract into a shared Rust CLI kit; commands/update.rs is the thin quire wrapper. No cross-scheme version diff (npm wrapper and Cargo versions are decoupled) -- idempotency is delegated to npm/cargo. A --registry override is applied as the --@agent-ix:registry= scope form (a plain --registry is ignored for a scoped package when npmrc pins a scope registry). Adds FR-016 + tests.md traceability. Deliberate exception to the quire-rs thin boundary (StR-004): binary lifecycle, not artifact behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Agent IX <agent@agent-ix.dev> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bce9807 commit 17cac32

12 files changed

Lines changed: 563 additions & 8 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
id: FR-016
3+
title: "quire update subcommand (install-source-aware self-update)"
4+
type: FR
5+
object_type: cli_command
6+
relationships:
7+
- target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001"
8+
type: "implements"
9+
cardinality: "1:1"
10+
---
11+
12+
## Description
13+
14+
The CLI SHALL expose an `update` subcommand that upgrades the installed `quire`
15+
binary to the latest published version. Because the single binary
16+
([StR-001](../stakeholder/StR-001-static-binary-hot-path.md)) is distributed
17+
through more than one channel — the npm prebuilt-binary wrapper
18+
(`@agent-ix/quire-cli`) and `cargo install --git``update` SHALL first detect
19+
**how the running binary was installed** and then drive the matching package
20+
manager.
21+
22+
`update` is the one subcommand exempt from the quire-rs thin-boundary
23+
([StR-004](../stakeholder/StR-004-thin-boundary-over-quire-rs.md)): it manages
24+
binary lifecycle, not artifact behavior, so it carries no parser/renderer/
25+
validator logic and wraps no `quire-rs` API. Its logic lives in a deliberately
26+
**package-agnostic** `self_update` engine (config-struct driven, no quire
27+
coupling), so the engine can later move verbatim into a shared Rust CLI kit; the
28+
`update` command itself is a thin wrapper that supplies quire's distribution
29+
coordinates.
30+
31+
The npm wrapper version and the Cargo crate version are independently numbered,
32+
so `update` SHALL NOT compare the running binary's version against a registry —
33+
a cross-scheme diff is meaningless. Idempotency is delegated to npm/cargo, which
34+
already no-op (or rebuild) when current.
35+
36+
## Behavior
37+
38+
```
39+
quire update [--check] [--registry <URL>]
40+
```
41+
42+
1. Resolve the running executable path (`current_exe`) and classify the install
43+
source: a path under a `node_modules` tree ⇒ **npm**; a path under `.cargo`
44+
**cargo**; anything else ⇒ **unknown**.
45+
2. **npm channel**:
46+
- `--check`: run `npm view @agent-ix/quire-cli version` and report the latest
47+
published version; install nothing.
48+
- otherwise: run `npm install -g @agent-ix/quire-cli@latest` with inherited
49+
stdio.
50+
- When `--registry <URL>` is given, the override is applied as the
51+
**scope-specific** `--@agent-ix:registry=<URL>` form, because a plain
52+
`--registry` is silently ignored for a scoped package when the user's npmrc
53+
pins a `@scope:registry`. With no `--registry`, the ambient npm config
54+
resolves the package (mirroring how it was installed).
55+
3. **cargo channel**:
56+
- `--check`: report that cargo installs track the git default branch (no
57+
single published version to compare); install nothing.
58+
- otherwise: run `cargo install --git https://github.com/agent-ix/quire-cli
59+
--force` with inherited stdio.
60+
4. **unknown source**: never guess and clobber a binary the tool did not place.
61+
Print manual upgrade instructions (the npm and cargo recipes plus the
62+
releases URL) and exit **0** — performing no install, touching no network.
63+
5. The summary lines describing the detected source and action are the
64+
subcommand's primary output on **stdout**; any npm/cargo progress is the
65+
child process's own inherited output.
66+
6. Exit codes: **0** on success (including `--check` and the unknown-source
67+
manual path); **1** when an invoked `npm`/`cargo` command fails or the
68+
registry cannot be reached.
69+
70+
## Acceptance Criteria
71+
72+
| ID | Criteria | Verification |
73+
|----|----------|--------------|
74+
| FR-016-AC-1 | A binary path under `node_modules/...` classifies as the npm channel; a path under `.cargo/...` classifies as the cargo channel; any other path classifies as unknown | Test |
75+
| FR-016-AC-2 | On an unknown source, `quire update` (with or without `--check`) prints manual upgrade instructions including the npm recipe, the cargo recipe, and the releases URL, exits **0**, and performs no install | Test |
76+
| FR-016-AC-3 | On the npm channel, `--check` runs `npm view @agent-ix/quire-cli version` and reports the latest version without installing | Test |
77+
| FR-016-AC-4 | A `--registry <URL>` override for the scoped package is passed as `--@agent-ix:registry=<URL>` (scope form), not a bare `--registry`; with no override no registry flag is added | Test |
78+
| FR-016-AC-5 | `update` performs no version comparison against the running binary and shells out to npm/cargo for idempotency (no cross-scheme version diff in `src/`) | Inspection |
79+
| FR-016-AC-6 | The `self_update` engine is package-agnostic: it takes quire's coordinates via a config struct and imports nothing from quire's `io`/command context, so it is extractable into a shared crate; `commands/update.rs` is the only quire-specific glue | Inspection |
80+
| FR-016-AC-7 | A failing `npm`/`cargo` invocation, or an unreachable registry, exits **1** | Test |
81+
82+
## Dependencies
83+
84+
- **Upstream**: none in `quire-rs` (binary-lifecycle feature, not an engine
85+
behavior). The shared `self_update` engine is in-crate today and is the
86+
intended extraction unit for a future Rust CLI kit.
87+
- **Downstream**: agent/CI setup docs that pin a single binary version
88+
([StR-001](../stakeholder/StR-001-static-binary-hot-path.md)) — `update`
89+
keeps that pinned binary current.

spec/functional/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ description: "Index of artifacts in this directory."
2222
* [FR-013: quire lint subcommand](./FR-013-lint-subcommand.md)
2323
* [FR-014: quire validate --okf bundle posture](./FR-014-validate-okf-bundle.md)
2424
* [FR-015: quire fix subcommand (unlinked-reference autofix)](./FR-015-fix-subcommand.md)
25+
* [FR-016: quire update subcommand (install-source-aware self-update)](./FR-016-update-subcommand.md)

spec/log.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ description: "Chronological log of structural changes to this bundle."
1010
* **2026-06-15** — Adopted OKF-compatible bundle structure with directory indexes.
1111
* **2026-06-16** — Added [FR-014](./functional/FR-014-validate-okf-bundle.md) (`quire validate --okf` permissive OKF bundle posture: `type` required, unknown-type/broken-link/index-incompleteness warn). Added [FR-003-AC-5](./functional/FR-003-extract-subcommand.md) (extract emits shared `[frontmatter]` untyped-document diagnostic). Backsynced the `artifact_type``type` discriminator rename across [FR-003](./functional/FR-003-extract-subcommand.md)/004/007/013 and spec.md via CR notes. Mapped IT-069..072 (`tests/cli_okf.rs`) + IT-026 reuse in tests.md.
1212
* **2026-06-17** — Added [FR-015](./functional/FR-015-fix-subcommand.md) (`quire fix` subcommand, ADR 0007): surfaces quire-rs unlinked-reference suggestions (FR-039) and, with `--write`, applies the auto-fixable ones via byte-exact writeback. Dry-run lists `would-fix`/`warning` and exits 1 when auto-fixes remain (CI gate); `--write` is idempotent; warn-only (unresolved/ambiguous) tokens are never written. Mapped IT-076..080 + AUDIT-002 (thin boundary) in tests.md.
13+
* **2026-06-20** — Added [FR-016](./functional/FR-016-update-subcommand.md) (`quire update` subcommand): install-source-aware self-update that detects the install channel from `current_exe` (`node_modules` ⇒ npm, `.cargo` ⇒ cargo, else unknown→manual instructions) and drives the matching package manager. No cross-scheme version diff (npm-wrapper and Cargo versions are decoupled); idempotency delegated to npm/cargo. A `--registry` override is applied as the scope-specific `--@agent-ix:registry=` form. Logic lives in a package-agnostic `self_update` engine (kit-extraction unit); the command is a thin wrapper. Deliberate exception to the quire-rs thin boundary ([StR-004](./stakeholder/StR-004-thin-boundary-over-quire-rs.md)) since it manages binary lifecycle, not artifact behavior.
1314
* **2026-06-19**[FR-004](./functional/FR-004-validate-subcommand.md) scoped discovery now also searches the default install root `~/.ix/filament/modules` and `IX_FILAMENT_MODULES_PATH` (preferred over the legacy `IX_SCHEMA_PATH`); on zero discovered modules it lazy-installs the default set via `quoin plugin ensure-defaults` and reloads once (FR-004-AC-13/AC-14). Added [ADR-0001](./assets/adr/0001-validate-lazy-init-module-bootstrap.md) and amended [NFR-004](./non-functional/NFR-004-no-network.md) via CR note: the no-network guarantee is scoped to quire's own process; the lazy-init's `quoin` child is the sole documented network exception. Mapped IT-081 (scoped discovery network-free) + IT-082 (quoin-absent actionable error) in tests.md.

spec/tests.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep
3636

3737
| StR | Trace to US/FR | Verifying IT/BENCH/AUDIT | Status |
3838
|-----|---------------|--------------------------|--------|
39-
| StR-001 Static binary hot path (revised — surviving subcommands) | US-002, US-003, US-004, US-005, FR-002..012 | IT-002, IT-004, IT-047, IT-033, AUDIT-001 (ldd), AUDIT-003 (no-network) ||
39+
| StR-001 Static binary hot path (revised — surviving subcommands) | US-002, US-003, US-004, US-005, FR-002..012, FR-016 (binary-lifecycle: keeps the pinned binary current) | IT-002, IT-004, IT-047, IT-033, IT-083, AUDIT-001 (ldd), AUDIT-003 (no-network) ||
4040
| StR-002 Sub-50 ms render budget | ⊘ RETIRED (§2bis) | — (render bench removed) ||
4141
| StR-003 Sandbox inheritance (revised — path-safety) | FR-005 | IT-005 (..), IT-006 (symlink escape), IT-055 (doc path safety) ||
4242
| StR-004 Thin boundary | FR-002..004, FR-009, FR-011, FR-014, NFR-005 | AUDIT-002 (src grep for parse logic), IT-033..038 ||
@@ -70,6 +70,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep
7070
| FR-013 lint subcommand | AC-1..5 | IT-064 (clean exit 0 silent), IT-065 (warning exit 0 + stderr), IT-066 (error exit 1), IT-067 (--archetype scoping), IT-068 (missing manifest fails fast — also covers the FR-004 CR-note eager-loader behavior for validate/extract/schema) ||
7171
| FR-014 validate --okf bundle posture (`type` discriminator) | AC-1..9 | IT-069 (untyped → exit 1, `[frontmatter]`), IT-070 (unknown type + broken link → warn, exit 0), IT-071 (index incompleteness → warn, exit 0), IT-072 (defaults to --scope dir), IT-026 (bare `validate` no `--okf` → exit 2, `required_unless_present`), AUDIT-002 (thin boundary) ||
7272
| FR-015 fix subcommand (unlinked-reference autofix, ADR 0007) | AC-1..6 | IT-076 (dry-run `would-fix` → exit 1, no write), IT-077 (`--write` applies + idempotent re-run exit 0), IT-078 (warn-only never written, no nonzero exit), IT-079 (clean bundle exit 0 empty stdout), IT-080 (`--scope` root + path-safety reject), AUDIT-002 (thin boundary) | 🚧 |
73+
| FR-016 update subcommand (install-source-aware self-update) | AC-1..7 | IT-083 (Unknown source: `update --check` prints npm+cargo+releases recipes, exit 0, no install/network), IT-084 (Unknown source: bare `update` also no-install, exit 0), UT-SU-1 (`detect_source` npm/cargo/unknown classification — `self_update::tests`), UT-SU-2 (`registry_args` scope-form for scoped pkg / plain for unscoped / empty when no override), UT-SU-3 (`cargo` `--check` reports branch-tracking, `latest: None`), AUDIT-002 (thin boundary — `update` carries no parser/validator), AUDIT-005 (`self_update` engine imports nothing from quire's `io`/command ctx — package-agnostic) ⚠️ npm-channel `--check`/install (AC-3), registry-unreachable/`npm`-fail exit 1 (AC-7), and cargo install (part of AC-1 dispatch) have **no automated trace** (network + global-install side effects) | ⚠️ |
7374

7475
## Non-Functional Requirement Coverage
7576

@@ -170,11 +171,17 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep
170171
| IT-078 | A warn-only (unresolved/ambiguous) token is surfaced as `warning: … (<reason>)`, never written even under `--write`, and does not alone cause a nonzero exit | Integration | P0 | FR-015-AC-3 |
171172
| IT-079 | A clean bundle (no auto-fix findings) exits 0 with empty stdout in both dry-run and `--write` | Integration | P1 | FR-015-AC-4 |
172173
| IT-080 | `quire fix --scope <DIR> --module $M` with no positional uses `--scope` as root; a `..`/symlink-escape on root or `--module` is rejected by path-safety before any load | Integration | P0 | FR-015-AC-5, FR-005 |
174+
| IT-083 | `update --check` on an Unknown install source (test binary under `target/`) prints manual instructions (npm recipe + cargo recipe + releases URL), exits 0, performs no install/network (`cli_update::update_check_on_unknown_source_prints_manual_instructions_and_exits_zero`) | Integration | P0 | FR-016-AC-1, FR-016-AC-2 |
175+
| IT-084 | bare `update` (no `--check`) on an Unknown source also performs no install and exits 0 (`cli_update::update_without_check_on_unknown_source_is_also_safe`) | Integration | P1 | FR-016-AC-2 |
176+
| UT-SU-1 | `detect_source` classifies `node_modules` path → Npm, `.cargo` path → Cargo, bare path → Unknown (`self_update::tests::detect_*`) | Unit | P0 | FR-016-AC-1 |
177+
| UT-SU-2 | `registry_args` yields the `--@scope:registry=` form for a scoped package, a plain `--registry <url>` for an unscoped package, and an empty vec when no override is supplied (`self_update::tests::registry_args_*`) | Unit | P0 | FR-016-AC-4 |
178+
| UT-SU-3 | `run_for_source(Cargo, --check)` reports git-branch tracking with `latest: None` (no cross-scheme version); `run_for_source(Unknown)` emits manual report without installing (`self_update::tests`) | Unit | P1 | FR-016-AC-1, FR-016-AC-5 |
173179
| BENCH-001 | ⊘ RETIRED (§2bis) — hyperfine render p95 ≤ 50 ms on FR archetype | Benchmark | P0 | NFR-001-AC-1..2 (retired), StR-002 (retired) |
174180
| AUDIT-001 | `ldd` shows only libc + loader (no project .so) | Static | P0 | NFR-002-AC-1 |
175181
| AUDIT-002 | `src/` grep finds no markdown parsing, no structural-validation logic, and **no render/template code** (validation delegated to quire-rs `validate_document` / `validate_bundle_at`; render removed per §2bis) | Static | P1 | StR-004-AC-2, FR-004-AC-9, FR-014-AC-9, FR-015-AC-6 |
176182
| AUDIT-003 | `cargo deny check bans` rejects HTTP client crates | Static | P0 | NFR-004-AC-1 |
177183
| AUDIT-004 | `scripts/check_unsafe_comments.sh` zero unsafe in src/ + tests/ | Static | P0 | NFR-003-AC-1 |
184+
| AUDIT-005 | `src/self_update/` imports nothing from `quire`'s `io`/command context (engine is package-agnostic, config-struct driven); `commands/update.rs` is the only quire-specific glue and carries no parser/renderer/validator logic | Static | P1 | FR-016-AC-5, FR-016-AC-6, StR-004-AC-2 |
178185

179186
---
180187

src/commands/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
//! Subcommand implementations. Each module owns one of the seven
2-
//! `quire <verb>` subcommands and stays a thin wrapper over `quire-rs`.
1+
//! Subcommand implementations. Each module owns one `quire <verb>` subcommand.
2+
//! The quire-rs-backed verbs stay thin wrappers over the engine; `update` is a
3+
//! thin wrapper over the package-agnostic `self_update` engine instead.
34
45
pub mod edit;
56
pub mod extract;
@@ -8,6 +9,7 @@ pub mod lint;
89
pub mod lookup;
910
pub mod parse;
1011
pub mod schema;
12+
pub mod update;
1113
pub mod validate;
1214

1315
use std::path::Path;

src/commands/update.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! `quire update [--check] [--registry <URL>]`
2+
//!
3+
//! Thin, quire-specific wrapper over the package-agnostic
4+
//! [`quire_cli::self_update`] engine. This file is the ONLY place quire's
5+
//! install coordinates live; the engine itself is reusable and is the unit
6+
//! intended to move into a shared Rust CLI kit later.
7+
8+
use clap::Parser;
9+
10+
use quire_cli::io;
11+
use quire_cli::self_update::{self, SelfUpdateConfig, SelfUpdateOpts};
12+
13+
use super::Ctx;
14+
15+
/// quire's distribution coordinates. Quire ships on public npm
16+
/// (`@agent-ix/quire-cli`, prebuilt-binary wrapper) and via `cargo install`
17+
/// from its GitHub repo.
18+
const CONFIG: SelfUpdateConfig = SelfUpdateConfig {
19+
npm_package: "@agent-ix/quire-cli",
20+
cargo_git: "https://github.com/agent-ix/quire-cli",
21+
releases_url: "https://github.com/agent-ix/quire-cli/releases",
22+
};
23+
24+
#[derive(Parser, Debug)]
25+
pub struct Args {
26+
/// Report whether an update is available without installing.
27+
#[arg(long)]
28+
pub check: bool,
29+
30+
/// Force an npm registry to query/install from (npm channel only).
31+
/// Defaults to the ambient npm config — i.e. however quire was installed.
32+
#[arg(long, value_name = "URL")]
33+
pub registry: Option<String>,
34+
}
35+
36+
pub fn run(_ctx: &Ctx, args: Args) -> anyhow::Result<()> {
37+
let report = self_update::run_self_update(
38+
&CONFIG,
39+
&SelfUpdateOpts {
40+
check: args.check,
41+
registry: args.registry,
42+
},
43+
)?;
44+
45+
// The engine already let npm/cargo draw their own progress to the inherited
46+
// streams; here we print the summary lines as the command's primary output.
47+
for line in &report.messages {
48+
io::write_primary_stdout(format!("{line}\n").as_bytes())?;
49+
}
50+
Ok(())
51+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
77
pub mod io;
88
pub mod safety;
9+
pub mod self_update;

src/main.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
//! `quire` binary entry point.
22
//!
3-
//! Dispatches to one of six subcommands: `parse`, `extract`, `lookup`,
4-
//! `edit`, `validate`, `schema`. Every command is a thin wrapper over `quire-rs` —
5-
//! no markdown parsing or structural-validation logic lives in this crate
6-
//! (StR-004).
3+
//! Dispatches to the `quire <verb>` subcommands: `parse`, `extract`, `lookup`,
4+
//! `edit`, `validate`, `schema`, `lint`, `fix`. Each is a thin wrapper over
5+
//! `quire-rs` — no markdown parsing or structural-validation logic lives in
6+
//! this crate (StR-004). `update` is the one exception: it wraps the
7+
//! package-agnostic `self_update` engine instead of `quire-rs`.
78
89
use clap::{Parser, Subcommand};
910

@@ -53,6 +54,8 @@ enum Command {
5354
Lint(commands::lint::Args),
5455
/// Surface (and with --write, apply) internal relative-path link fixes.
5556
Fix(commands::fix::Args),
57+
/// Check for and install the latest quire (auto-detects npm vs cargo).
58+
Update(commands::update::Args),
5659
}
5760

5861
fn main() {
@@ -70,6 +73,7 @@ fn main() {
7073
Command::Schema(a) => commands::schema::run(&ctx, a),
7174
Command::Lint(a) => commands::lint::run(&ctx, a),
7275
Command::Fix(a) => commands::fix::run(&ctx, a),
76+
Command::Update(a) => commands::update::run(&ctx, a),
7377
};
7478
match result {
7579
Ok(()) => std::process::exit(exit::OK),

0 commit comments

Comments
 (0)