Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions spec/functional/FR-016-update-subcommand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
id: FR-016
title: "quire update subcommand (install-source-aware self-update)"
type: FR
object_type: cli_command
relationships:
- target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001"
type: "implements"
cardinality: "1:1"
---

## Description

The CLI SHALL expose an `update` subcommand that upgrades the installed `quire`
binary to the latest published version. Because the single binary
([StR-001](../stakeholder/StR-001-static-binary-hot-path.md)) is distributed
through more than one channel — the npm prebuilt-binary wrapper
(`@agent-ix/quire-cli`) and `cargo install --git` — `update` SHALL first detect
**how the running binary was installed** and then drive the matching package
manager.

`update` is the one subcommand exempt from the quire-rs thin-boundary
([StR-004](../stakeholder/StR-004-thin-boundary-over-quire-rs.md)): it manages
binary lifecycle, not artifact behavior, so it carries no parser/renderer/
validator logic and wraps no `quire-rs` API. Its logic lives in a deliberately
**package-agnostic** `self_update` engine (config-struct driven, no quire
coupling), so the engine can later move verbatim into a shared Rust CLI kit; the
`update` command itself is a thin wrapper that supplies quire's distribution
coordinates.

The npm wrapper version and the Cargo crate version are independently numbered,
so `update` SHALL NOT compare the running binary's version against a registry —
a cross-scheme diff is meaningless. Idempotency is delegated to npm/cargo, which
already no-op (or rebuild) when current.

## Behavior

```
quire update [--check] [--registry <URL>]
```

1. Resolve the running executable path (`current_exe`) and classify the install
source: a path under a `node_modules` tree ⇒ **npm**; a path under `.cargo`
⇒ **cargo**; anything else ⇒ **unknown**.
2. **npm channel**:
- `--check`: run `npm view @agent-ix/quire-cli version` and report the latest
published version; install nothing.
- otherwise: run `npm install -g @agent-ix/quire-cli@latest` with inherited
stdio.
- When `--registry <URL>` is given, the override is applied as the
**scope-specific** `--@agent-ix:registry=<URL>` form, because a plain
`--registry` is silently ignored for a scoped package when the user's npmrc
pins a `@scope:registry`. With no `--registry`, the ambient npm config
resolves the package (mirroring how it was installed).
3. **cargo channel**:
- `--check`: report that cargo installs track the git default branch (no
single published version to compare); install nothing.
- otherwise: run `cargo install --git https://github.com/agent-ix/quire-cli
--force` with inherited stdio.
4. **unknown source**: never guess and clobber a binary the tool did not place.
Print manual upgrade instructions (the npm and cargo recipes plus the
releases URL) and exit **0** — performing no install, touching no network.
5. The summary lines describing the detected source and action are the
subcommand's primary output on **stdout**; any npm/cargo progress is the
child process's own inherited output.
6. Exit codes: **0** on success (including `--check` and the unknown-source
manual path); **1** when an invoked `npm`/`cargo` command fails or the
registry cannot be reached.

## Acceptance Criteria

| ID | Criteria | Verification |
|----|----------|--------------|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| FR-016-AC-7 | A failing `npm`/`cargo` invocation, or an unreachable registry, exits **1** | Test |

## Dependencies

- **Upstream**: none in `quire-rs` (binary-lifecycle feature, not an engine
behavior). The shared `self_update` engine is in-crate today and is the
intended extraction unit for a future Rust CLI kit.
- **Downstream**: agent/CI setup docs that pin a single binary version
([StR-001](../stakeholder/StR-001-static-binary-hot-path.md)) — `update`
keeps that pinned binary current.
1 change: 1 addition & 0 deletions spec/functional/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ description: "Index of artifacts in this directory."
* [FR-013: quire lint subcommand](./FR-013-lint-subcommand.md)
* [FR-014: quire validate --okf bundle posture](./FR-014-validate-okf-bundle.md)
* [FR-015: quire fix subcommand (unlinked-reference autofix)](./FR-015-fix-subcommand.md)
* [FR-016: quire update subcommand (install-source-aware self-update)](./FR-016-update-subcommand.md)
1 change: 1 addition & 0 deletions spec/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ description: "Chronological log of structural changes to this bundle."
* **2026-06-15** — Adopted OKF-compatible bundle structure with directory indexes.
* **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.
* **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.
* **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.
* **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.
9 changes: 8 additions & 1 deletion spec/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep

| StR | Trace to US/FR | Verifying IT/BENCH/AUDIT | Status |
|-----|---------------|--------------------------|--------|
| 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) | ✅ |
| 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) | ✅ |
| StR-002 Sub-50 ms render budget | ⊘ RETIRED (§2bis) | — (render bench removed) | ⊘ |
| StR-003 Sandbox inheritance (revised — path-safety) | FR-005 | IT-005 (..), IT-006 (symlink escape), IT-055 (doc path safety) | ✅ |
| 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 | ✅ |
Expand Down Expand Up @@ -70,6 +70,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep
| 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) | ✅ |
| 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) | ✅ |
| 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) | 🚧 |
| 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) | ⚠️ |

## Non-Functional Requirement Coverage

Expand Down Expand Up @@ -170,11 +171,17 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| BENCH-001 | ⊘ RETIRED (§2bis) — hyperfine render p95 ≤ 50 ms on FR archetype | Benchmark | P0 | NFR-001-AC-1..2 (retired), StR-002 (retired) |
| AUDIT-001 | `ldd` shows only libc + loader (no project .so) | Static | P0 | NFR-002-AC-1 |
| 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 |
| AUDIT-003 | `cargo deny check bans` rejects HTTP client crates | Static | P0 | NFR-004-AC-1 |
| AUDIT-004 | `scripts/check_unsafe_comments.sh` zero unsafe in src/ + tests/ | Static | P0 | NFR-003-AC-1 |
| 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 |

---

Expand Down
6 changes: 4 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Subcommand implementations. Each module owns one of the seven
//! `quire <verb>` subcommands and stays a thin wrapper over `quire-rs`.
//! Subcommand implementations. Each module owns one `quire <verb>` subcommand.
//! The quire-rs-backed verbs stay thin wrappers over the engine; `update` is a
//! thin wrapper over the package-agnostic `self_update` engine instead.

pub mod edit;
pub mod extract;
Expand All @@ -8,6 +9,7 @@ pub mod lint;
pub mod lookup;
pub mod parse;
pub mod schema;
pub mod update;
pub mod validate;

use std::path::Path;
Expand Down
51 changes: 51 additions & 0 deletions src/commands/update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! `quire update [--check] [--registry <URL>]`
//!
//! Thin, quire-specific wrapper over the package-agnostic
//! [`quire_cli::self_update`] engine. This file is the ONLY place quire's
//! install coordinates live; the engine itself is reusable and is the unit
//! intended to move into a shared Rust CLI kit later.

use clap::Parser;

use quire_cli::io;
use quire_cli::self_update::{self, SelfUpdateConfig, SelfUpdateOpts};

use super::Ctx;

/// quire's distribution coordinates. Quire ships on public npm
/// (`@agent-ix/quire-cli`, prebuilt-binary wrapper) and via `cargo install`
/// from its GitHub repo.
const CONFIG: SelfUpdateConfig = SelfUpdateConfig {
npm_package: "@agent-ix/quire-cli",
cargo_git: "https://github.com/agent-ix/quire-cli",
releases_url: "https://github.com/agent-ix/quire-cli/releases",
};

#[derive(Parser, Debug)]
pub struct Args {
/// Report whether an update is available without installing.
#[arg(long)]
pub check: bool,

/// Force an npm registry to query/install from (npm channel only).
/// Defaults to the ambient npm config — i.e. however quire was installed.
#[arg(long, value_name = "URL")]
pub registry: Option<String>,
}

pub fn run(_ctx: &Ctx, args: Args) -> anyhow::Result<()> {
let report = self_update::run_self_update(
&CONFIG,
&SelfUpdateOpts {
check: args.check,
registry: args.registry,
},
)?;

// The engine already let npm/cargo draw their own progress to the inherited
// streams; here we print the summary lines as the command's primary output.
for line in &report.messages {
io::write_primary_stdout(format!("{line}\n").as_bytes())?;
}
Ok(())
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@

pub mod io;
pub mod safety;
pub mod self_update;
12 changes: 8 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! `quire` binary entry point.
//!
//! Dispatches to one of six subcommands: `parse`, `extract`, `lookup`,
//! `edit`, `validate`, `schema`. Every command is a thin wrapper over `quire-rs` —
//! no markdown parsing or structural-validation logic lives in this crate
//! (StR-004).
//! Dispatches to the `quire <verb>` subcommands: `parse`, `extract`, `lookup`,
//! `edit`, `validate`, `schema`, `lint`, `fix`. Each is a thin wrapper over
//! `quire-rs` — no markdown parsing or structural-validation logic lives in
//! this crate (StR-004). `update` is the one exception: it wraps the
//! package-agnostic `self_update` engine instead of `quire-rs`.

use clap::{Parser, Subcommand};

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

fn main() {
Expand All @@ -70,6 +73,7 @@ fn main() {
Command::Schema(a) => commands::schema::run(&ctx, a),
Command::Lint(a) => commands::lint::run(&ctx, a),
Command::Fix(a) => commands::fix::run(&ctx, a),
Command::Update(a) => commands::update::run(&ctx, a),
};
match result {
Ok(()) => std::process::exit(exit::OK),
Expand Down
Loading
Loading