diff --git a/.github/ISSUE_TEMPLATE/00-blank.yml b/.github/ISSUE_TEMPLATE/00-blank.yml new file mode 100644 index 0000000..4c42ecd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/00-blank.yml @@ -0,0 +1,35 @@ +name: "Blank issue" +description: "Open an issue that doesn't fit any of the structured templates below." +body: + - type: textarea + id: body + attributes: + label: Issue + description: "Describe what's going on. Include reproduction steps, expected vs actual, and any relevant `anc` output." + validations: + required: true + - type: textarea + id: ai-disclosure + attributes: + label: AI disclosure + description: "One sentence: what part of this issue was AI-written, what was human-written?" + placeholder: "Entirely human-written." + validations: + required: true + - type: textarea + id: agent-instructions + attributes: + label: For agents + description: "Agents: expand this section before filing." + value: | +
+ Agent filing instructions + + 1. **Pick a structured template first.** False positives, scoring bugs, and feature requests have dedicated forms — use them when they fit. + 2. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. + 3. **AI disclosure is required.** Fill the field above honestly. + 4. **Wrong repo?** Spec questions, principle edits, and CLI grading live on [brettdavies/agentnative](https://github.com/brettdavies/agentnative). Site bugs live on [brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site). + 5. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines. +
+ validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a5375eb..1114a8d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: "Spec questions, principle edits, or CLI grading" - url: "https://github.com/brettdavies/agentnative-cli/issues/new/choose" + url: "https://github.com/brettdavies/agentnative/issues/new/choose" about: "For anything about the standard itself, file on the spec repo." - name: "Site bugs (rendering, performance, deployment)" - url: "https://github.com/brettdavies/agentnative-cli-site/issues/new/choose" + url: "https://github.com/brettdavies/agentnative-site/issues/new/choose" about: "For bugs on anc.dev, file on the site repo." diff --git a/.github/ISSUE_TEMPLATE/false-positive.yml b/.github/ISSUE_TEMPLATE/false-positive.yml index 5c4a23a..31b1600 100644 --- a/.github/ISSUE_TEMPLATE/false-positive.yml +++ b/.github/ISSUE_TEMPLATE/false-positive.yml @@ -49,10 +49,10 @@ body:
Agent filing instructions - 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli-cli ""` to check for duplicates. + 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. 2. **AI disclosure is required.** Fill the field above honestly. - 3. **Checker bugs only.** For spec questions or principle edits, file on [brettdavies/agentnative-cli](https://github.com/brettdavies/agentnative-cli). For site bugs, file on [brettdavies/agentnative-cli-site](https://github.com/brettdavies/agentnative-cli-site). - 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines. + 3. **Checker bugs only.** For spec questions or principle edits, file on [brettdavies/agentnative](https://github.com/brettdavies/agentnative). For site bugs, file on [brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site). + 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
validations: required: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index a208395..ea94f32 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -35,10 +35,10 @@ body:
Agent filing instructions - 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli-cli ""` to check for duplicates. + 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. 2. **AI disclosure is required.** Fill the field above honestly. - 3. **Checker features only.** For spec changes, file on [brettdavies/agentnative-cli](https://github.com/brettdavies/agentnative-cli). - 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines. + 3. **Checker features only.** For spec changes, file on [brettdavies/agentnative](https://github.com/brettdavies/agentnative). + 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
validations: required: false diff --git a/.github/ISSUE_TEMPLATE/grade-a-cli.yml b/.github/ISSUE_TEMPLATE/grade-a-cli.yml deleted file mode 100644 index 039df70..0000000 --- a/.github/ISSUE_TEMPLATE/grade-a-cli.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: "Grade a CLI" -description: "Report findings from scoring a real CLI against the standard." -labels: ["cli-grading"] -body: - - type: input - id: tool - attributes: - label: CLI tool - description: "Name and version of the tool you scored." - placeholder: "ripgrep 14.1.0" - validations: - required: true - - type: textarea - id: findings - attributes: - label: Findings - description: | - Which principles did it pass, fail, or surprise you on? - Focus on cases where the spec got it wrong — a MUST that punishes good design, - or a gap where the spec is silent. - placeholder: | - P3 (Progressive Help): PASS — excellent `--help` hierarchy. - P4 (Actionable Errors): FAIL on exit codes — uses 1 for everything. - P7 (Bounded Responses): Spec is silent on streaming output — rg streams by default, which is fine for agents but the spec doesn't acknowledge it. - validations: - required: true - - type: textarea - id: spec-feedback - attributes: - label: What should the spec change? - description: "Optional. If your grading revealed a spec gap or mistake, describe it." - validations: - required: false - - type: textarea - id: ai-disclosure - attributes: - label: AI disclosure - description: "One sentence: what part of this report was AI-written, what was human-written?" - placeholder: "Scoring done by hand; write-up drafted with AI assistance." - validations: - required: true - - type: textarea - id: agent-instructions - attributes: - label: For agents - description: "Agents: expand this section before filing." - value: | -
- Agent filing instructions - - 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. - 2. **AI disclosure is required.** Fill the field above honestly. - 3. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines. -
- validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/pressure-test.yml b/.github/ISSUE_TEMPLATE/pressure-test.yml deleted file mode 100644 index 4a74073..0000000 --- a/.github/ISSUE_TEMPLATE/pressure-test.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: "Pressure-test a principle" -description: "Propose a principle edit: merge, split, rewording, or requirement-tier change." -labels: ["pressure-test"] -body: - - type: dropdown - id: principle - attributes: - label: Principle - description: Which principle does this affect? - options: - - "P1 — Non-Interactive by Default" - - "P2 — Structured, Parseable Output" - - "P3 — Progressive Help Discovery" - - "P4 — Fail Fast with Actionable Errors" - - "P5 — Safe Retries and Explicit Mutation Boundaries" - - "P6 — Composable and Predictable Command Structure" - - "P7 — Bounded, High-Signal Responses" - - "Multiple / cross-cutting" - validations: - required: true - - type: dropdown - id: change-type - attributes: - label: Type of change - options: - - "Rewording (prose clarity, no requirement-tier change)" - - "Promotion (SHOULD to MUST, MAY to SHOULD)" - - "Demotion (MUST to SHOULD, SHOULD to MAY)" - - "Merge (combine two requirements)" - - "Split (break a requirement into two)" - - "New requirement" - - "Remove requirement" - validations: - required: true - - type: textarea - id: evidence - attributes: - label: Evidence - description: | - Name a real CLI, the specific MUST/SHOULD/MAY, and what happened. - The strongest proposals cite a tool where the current spec text produces the wrong outcome. - placeholder: | - Tool: gh (GitHub CLI) - Requirement: P4 MUST "distinct exit codes for distinct failure categories" - Observed: gh uses exit code 1 for everything — auth, network, not-found. - Problem: The current MUST is correct but underspecified — it doesn't say how many categories are enough. - validations: - required: true - - type: textarea - id: proposal - attributes: - label: Proposed change - description: What should the spec say instead? Quote the current text and show the diff. - validations: - required: true - - type: input - id: human-reviewer - attributes: - label: Human reviewer - description: "GitHub handle of the human who reviewed and approved this submission." - placeholder: "@username" - validations: - required: true - - type: textarea - id: ai-disclosure - attributes: - label: AI disclosure - description: "One sentence: what part of this submission was AI-written, what was human-written?" - placeholder: "Evidence gathered by hand; proposed wording drafted with Claude and edited." - validations: - required: true - - type: textarea - id: agent-instructions - attributes: - label: For agents - description: "Agents: expand this section before filing." - value: | -
- Agent filing instructions - - 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. - 2. **AI disclosure is required.** Fill the field above honestly. - 3. **Human reviewer is required.** A human must review and approve spec change proposals before submission. - 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines. -
- validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/scoring-bug.yml b/.github/ISSUE_TEMPLATE/scoring-bug.yml index 3639a84..9e2d8bd 100644 --- a/.github/ISSUE_TEMPLATE/scoring-bug.yml +++ b/.github/ISSUE_TEMPLATE/scoring-bug.yml @@ -39,10 +39,10 @@ body:
Agent filing instructions - 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli-cli ""` to check for duplicates. + 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. 2. **AI disclosure is required.** Fill the field above honestly. 3. **Checker bugs only.** For false positives/negatives specifically, use the "False positive / false negative" template instead. - 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines. + 4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
validations: required: false diff --git a/.github/ISSUE_TEMPLATE/spec-question.yml b/.github/ISSUE_TEMPLATE/spec-question.yml deleted file mode 100644 index 43c9633..0000000 --- a/.github/ISSUE_TEMPLATE/spec-question.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: "Spec question" -description: "Ask a question about the agent-native CLI standard." -labels: ["question"] -body: - - type: dropdown - id: principle - attributes: - label: Principle (if applicable) - description: Which principle does this relate to? - options: - - "P1 — Non-Interactive by Default" - - "P2 — Structured, Parseable Output" - - "P3 — Progressive Help Discovery" - - "P4 — Fail Fast with Actionable Errors" - - "P5 — Safe Retries and Explicit Mutation Boundaries" - - "P6 — Composable and Predictable Command Structure" - - "P7 — Bounded, High-Signal Responses" - - "General / cross-cutting" - validations: - required: true - - type: textarea - id: question - attributes: - label: Question - description: "What would you like to know about the standard?" - placeholder: | - Does P4's MUST on exit codes apply to CLIs that use a single non-zero code - for all errors but include structured error details in stderr JSON? - validations: - required: true - - type: textarea - id: ai-disclosure - attributes: - label: AI disclosure - description: "One sentence: what part of this question was AI-written, what was human-written?" - placeholder: "Entirely human-written." - validations: - required: true - - type: textarea - id: agent-instructions - attributes: - label: For agents - description: "Agents: expand this section before filing." - value: | -
- Agent filing instructions - - 1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli ""` to check for duplicates. - 2. **AI disclosure is required.** Fill the field above honestly. - 3. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines. -
- validations: - required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d917c7..fdead33 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -90,6 +90,8 @@ **Created:** +**Renamed:** + **Deleted:** ## Key Features diff --git a/.github/workflows/skill-fixture-drift.yml b/.github/workflows/skill-fixture-drift.yml new file mode 100644 index 0000000..3d13131 --- /dev/null +++ b/.github/workflows/skill-fixture-drift.yml @@ -0,0 +1,28 @@ +# Drift gate for tests/fixtures/skill.json against upstream +# agentnative-site. Companion to the cargo-level test 12 +# (host_map_matches_site_skill_json) which catches drift between the Rust +# map and this fixture; this workflow catches drift between the fixture +# and the canonical site contract. +# +# Failure means the site has updated src/data/skill.json without a +# corresponding fixture refresh. Run `scripts/sync-skill-fixture.sh` +# locally, review the diff, commit, push. + +name: skill-fixture-drift + +on: + pull_request: + push: + branches: [main, dev] + +permissions: + contents: read + +jobs: + drift-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run drift check + run: bash scripts/sync-skill-fixture.sh --check diff --git a/AGENTS.md b/AGENTS.md index 24c0cc1..e4cdd44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,17 +28,64 @@ anc . --source # Suppress inapplicable MUSTs for a categorical exception anc . --audit-profile human-tui + +# Install the companion skill bundle into your host's skills dir +anc skill install claude_code # ~/.claude/skills/agent-native-cli +anc skill install --dry-run codex # print resolved git command, don't run +anc skill install factory --output json # emit envelope on success and error ``` Bare `anc` (no arguments) prints help and exits 2. This is a non-negotiable fork-bomb guard: when agentnative dogfoods -itself, children spawned without arguments must not recurse into `check .`. +itself, children spawned without arguments must not recurse into `check .`. Bare `anc skill` likewise prints help and +exits 2. + +## Skill install + +`anc skill install ` clones the `agentnative-skill` bundle into a host's canonical skills directory. Six hosts +ship at v0.1: `claude_code`, `codex`, `cursor`, `factory`, `kiro`, `opencode`. `--help` enumerates them; the JSON +envelope's `host` field reports the chosen one verbatim. + +Output envelope (`--output json`) is uniform across success and error and across `--dry-run` and live install: + +```json +{ + "action": "skill-install", + "host": "claude_code", + "mode": "dry-run", + "command": "git clone --depth 1 ", + "destination": "", + "destination_status": "absent", + "status": "success", + "would_succeed": true +} +``` + +Field-presence rules: `would_succeed` only on `mode: "dry-run"`; `exit_code` only on `mode: "install"` AND only when +`git` actually spawned (e.g. `git-not-found` leaves it absent); `reason` only when `status: "error"`, with one of the +typed values `destination-not-empty` / `destination-is-file` / `home-not-set` / `git-not-found` / `git-clone-failed`. +`destination_status` is one of `absent` / `empty-dir` / `non-empty-dir` / `file`. + +Exit codes follow the P4 convention: `0` for success, `1` for any envelope error (typed `reason` set), `2` for clap +usage errors (unknown host, missing positional, bare `anc skill`). + +The `git clone` invocation runs with named-const hardening (`GIT_HARDEN_FLAGS`, `GIT_HARDEN_ENV_REMOVE`, +`GIT_HARDEN_ENV_SET` — the last includes `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` to disable +user-controlled git config, plus `GIT_TERMINAL_PROMPT=0`). No `sh -c`, no `env_clear`. Defense against `insteadOf` +URL-rewriting comes from disabling user config wholesale, not from a `-c url..insteadOf=` flag (which would do the +opposite of blocking). + +The host map (`SkillHost` enum, `KNOWN_HOSTS`, `resolve_host`, `host_envelope_str`) is **build-time-generated** from +`src/skill_install/skill.json` by `build.rs::emit_skill_hosts`. To add or change a host, edit the JSON (or run `bash +scripts/sync-skill-fixture.sh` to pull the upstream site contract) and `cargo build` regenerates the Rust map — no hand +edits to `src/skill_install.rs`. CI's `skill-fixture-drift.yml` runs `--check` on every PR to catch fixture vs upstream +drift. ## Agent-facing JSON surface -`anc check --output json` emits a `schema_version: "0.3"` scorecard. The schema is at `0.x` while `anc` is +`anc check --output json` emits a `schema_version: "0.5"` scorecard. The schema is at `0.x` while `anc` is pre-launch — shape may evolve before first public release, when it locks at `1.0`. During `0.x`, additive fields are the -norm; consumers should feature-detect new keys rather than pinning to an exact value. The current shape includes five -scorecard-level fields beyond the base `results` / `summary`: +norm; consumers should feature-detect new keys rather than pinning to an exact value. The current shape includes the +following scorecard-level fields beyond the base `results` / `summary`: - `audience` — `"agent-optimized"` / `"mixed"` / `"human-primary"` / `null`. Derived from 4 signal behavioral checks (`p1-non-interactive`, `p2-json-output`, `p7-quiet`, `p6-no-color-behavioral`). Informational only; never gates totals @@ -51,6 +98,21 @@ scorecard-level fields beyond the base `results` / `summary`: - `spec_version` — the `agentnative-spec` version this CLI was built against. Sourced at build time from `src/principles/spec/VERSION` by `build.rs`; reads `"unknown"` if that file was missing at build time. Pin against this to know which spec contract the scorecard's requirement IDs reference. +- `tool` — `{ name, binary, version }`. Identifies what was scored. `version` is best-effort (manifest field for project + mode, ` --version` / `-V` for binary/command mode); `null` when probing fails or is declined by the self-spawn + guard. Schema `0.4` addition. +- `anc` — `{ version, commit }`. Identifies the `anc` build that produced the scorecard. `commit` is `null` for builds + outside a Git checkout. Informational, not signed provenance. Schema `0.4` addition. +- `run` — `{ invocation, started_at, duration_ms, platform: { os, arch } }`. `invocation` reflects what the user typed + (captured pre-injection). `started_at` is RFC 3339 UTC. Schema `0.4` addition. +- `target` — `{ kind, path, command }`. `kind` is `"project"` / `"binary"` / `"command"`. The unused field is always + `null`, never missing. Schema `0.4` addition. +- `badge` — `{ eligible, score_pct, embed_markdown, scorecard_url, badge_url, convention_url }`. Agent-native badge + derivation from the live run. `score_pct` is the rounded percent of `pass / (pass + warn + fail)` (Skips and Errors + excluded from the ratio). `eligible` is true iff `score_pct >= 80` and a tool slug was derivable. `embed_markdown` is + `null` below the floor (do-not-nag contract). `scorecard_url` / `badge_url` are populated whenever a slug exists, even + below the floor; `convention_url` always points at `https://anc.dev/badge`. Schema `0.5` addition. The text-mode hint + (`--output text`) prints the same embed snippet only when eligible; below-floor runs print nothing badge-related. `--audit-profile` accepts exactly 4 values: `human-tui`, `file-traversal`, `posix-utility`, `diagnostic-only`. Unknown values exit 2 with a structured error. The full per-category mapping of suppressed check IDs is committed to @@ -111,17 +173,17 @@ cargo test -- --ignored # fixture tests (slower) The canonical specification of the 7 agent-readiness principles lives in [`brettdavies/agentnative`](https://github.com/brettdavies/agentnative), one file per principle under `principles/`. A -pinned snapshot is **vendored** into this crate at `src/principles/spec/`, and `build.rs` parses its frontmatter at -build time to generate the `REQUIREMENTS` slice — IDs in the spec frontmatter are the contract this CLI checks against. -There is no manual sync of requirement IDs; only the `Check::covers()` declarations are hand-maintained. +snapshot is **vendored** into this crate at `src/principles/spec/`, and `build.rs` parses its frontmatter at build time +to generate the `REQUIREMENTS` slice — IDs in the spec frontmatter are the contract this CLI checks against. There is no +manual sync of requirement IDs; only the `Check::covers()` declarations are hand-maintained. The `anc` checks in `src/checks/` themselves are derived **manually** from each principle's prose. When a principle's spec adds, removes, or reworks a requirement, propagate to the relevant check(s) deliberately. -**Resync cadence:** rerun `scripts/sync-spec.sh` after every new `agentnative-spec` tag. The default `SPEC_REF` in the -script is the current pin; bump via `SPEC_REF=v0.2.1 scripts/sync-spec.sh` when adopting a newer spec release. The -companion `repository_dispatch` from the spec's publish workflow is the canonical trigger; if a future GitHub Action -opens a resync PR automatically, this script becomes that action's body. +**Resync cadence:** rerun `scripts/sync-spec.sh` after every new `agentnative-spec` tag. The script queries the remote +for the latest `v*` tag automatically and falls back to a local checkout (`$HOME/dev/agentnative-spec` by default) if +the remote is unreachable. The companion `repository_dispatch` from the spec's publish workflow is the canonical +trigger; if a future GitHub Action opens a resync PR automatically, this script becomes that action's body. For iteration workflow, pressure-test protocol, and per-file structure of the spec itself, see [`agentnative:principles/AGENTS.md`](https://github.com/brettdavies/agentnative/blob/main/principles/AGENTS.md). Read diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea5481..d23a016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ All notable changes to this project will be documented in this file. +## [0.3.0] - 2026-05-01 + +### Added + +- Add four scorecard metadata blocks (`tool`, `anc`, `run`, `target`) to `--output json` — identifies the scored tool/version, the `anc` build that produced the scorecard, the user-typed invocation with timestamp and duration, and the resolved target (project / binary / command). by @brettdavies in [#34](https://github.com/brettdavies/agentnative-cli/pull/34) +- Add `time = "=0.3.47"` dependency for RFC 3339 timestamps in `run.started_at`. +- Add `anc skill install ` subcommand to install the [`agentnative-skill`](https://github.com/brettdavies/agentnative-skill) bundle into a host's canonical skills directory. Six hosts: `claude_code`, `codex`, `cursor`, `factory`, `kiro`, `opencode`. by @brettdavies in [#35](https://github.com/brettdavies/agentnative-cli/pull/35) +- Add `--dry-run` flag (P5): prints the resolved `git clone` command without spawning. Captures cleanly via `eval $(anc skill install --dry-run )`. +- Add `--output {text,json}` flag (P2): JSON envelope is uniform across success and error and across dry-run / live install. Typed `reason` on error (`destination-not-empty`, `destination-is-file`, `home-not-set`, `git-not-found`, `git-clone-failed`). +- `--output text` now appends an agent-native badge embed hint after the summary line when the tool clears the 80% eligibility floor. Below the floor, nothing badge-related is printed (the convention's "do not nag" rule). by @brettdavies in [#36](https://github.com/brettdavies/agentnative-cli/pull/36) +- `--output json` scorecard now includes a `badge` block (`eligible`, `score_pct`, `embed_markdown`, `scorecard_url`, `badge_url`, `convention_url`). `embed_markdown` is `null` below the floor; `scorecard_url` / `badge_url` are populated whenever a tool slug exists, since the site renders an SVG for every scored tool. +- `scripts/sync-dev-after-release.sh` — backports `Cargo.toml` `[package].version`, `Cargo.lock`, and `CHANGELOG.md` from `main` to `dev` after a release tag publishes. Surgical (preserves dev's other Cargo.toml lines), idempotent (re-runs are a no-op when dev is already in sync), and signed via the operator's normal commit signing — satisfies `protect-dev`'s `required_signatures` ruleset without needing a CI bot identity. by @brettdavies in [#37](https://github.com/brettdavies/agentnative-cli/pull/37) + +### Changed + +- Vendoring now always tracks the latest published spec tag — `SPEC_REF` env override removed. Run `bash scripts/sync-spec.sh` to refresh; no environment configuration required. by @brettdavies in [#33](https://github.com/brettdavies/agentnative-cli/pull/33) +- Bump scorecard `schema_version` from `"0.3"` to `"0.4"` (additive within the documented `0.x` policy — older consumers feature-detect). by @brettdavies in [#34](https://github.com/brettdavies/agentnative-cli/pull/34) +- Bump `rust-version` from `1.87` to `1.88` (let-chain stabilization). +- Bumped scorecard `schema_version` from `"0.4"` to `"0.5"`. Pre-`0.5` consumers feature-detect the new `badge` key and continue to work. by @brettdavies in [#36](https://github.com/brettdavies/agentnative-cli/pull/36) +- `p7-naked-println` source check now exempts `build.rs` at any crate root. Cargo build scripts use `println!("cargo:…")` directives by protocol; flagging them produces noise without an alternative API. Misnamed `src/build.rs` or `tests/build.rs` files stay flagged. by @brettdavies in [#38](https://github.com/brettdavies/agentnative-cli/pull/38) +- README refreshed for current state: schema 0.5 with `badge` block, `--audit-profile ` documented under CLI Reference, `target.path` documented as basename-only (PII-safe), refreshed Example Output to match the live 33-check dogfood and the post-summary badge embed hint. by @brettdavies in [#40](https://github.com/brettdavies/agentnative-cli/pull/40) +- `--output json` scorecard `anc` block no longer includes a `commit` field. `anc.version` (the crate version pin) remains as the build identity. Removed because the per-build Git SHA capture made cached builds fragile (stale SHAs across local commits) without solving any consumer-facing problem — `anc.version` already identifies the released binary unambiguously, and pre-launch no public consumer of `anc.commit` exists. by @brettdavies in [#47](https://github.com/brettdavies/agentnative-cli/pull/47) + +### Fixed + +- Eliminated four `.unwrap()` calls on infallible operations across `src/skill_install.rs` and `build.rs`. Replaced with `.expect("…")` naming the upstream contract that guarantees `Some`/`Ok`. No behavior change — these were already infallible; the `expect` messages document why. by @brettdavies in [#38](https://github.com/brettdavies/agentnative-cli/pull/38) +- `target.path` in `anc check --output json` now emits the basename of the resolved target instead of the canonicalized absolute path, eliminating a home-directory / username PII leak that flowed into committed scorecards, badge URLs, and agent-posted artifacts. Project mode emits the directory name (e.g. `"agentnative-cli"`); binary mode emits the file name (e.g. `"anc"`); command mode unchanged at `null`. No schema bump — value semantics changed, schema shape did not. by @brettdavies in [#39](https://github.com/brettdavies/agentnative-cli/pull/39) +- Corrected cross-repo URLs in `.github/ISSUE_TEMPLATE/` so contact links and agent-filing instructions point at the right repos. Spec repo references switched from `agentnative-cli` to `agentnative`; site repo references switched from `agentnative-cli-site` to `agentnative-site`; the `agentnative-cli-cli` double-suffix typo in agent gh-search guidance was corrected to `agentnative-cli`. Affects `config.yml`, `false-positive.yml`, `feature-request.yml`, `scoring-bug.yml`. by @brettdavies in [#42](https://github.com/brettdavies/agentnative-cli/pull/42) + +### Documentation + +- `AGENTS.md` and `src/principles/spec/README.md` updated to reflect the simpler vendor flow. by @brettdavies in [#33](https://github.com/brettdavies/agentnative-cli/pull/33) +- Document the four new top-level objects in README.md, AGENTS.md, and CLAUDE.md, including the publishing-PII review reminder for `run.invocation` and `target.path`. by @brettdavies in [#34](https://github.com/brettdavies/agentnative-cli/pull/34) +- Add `## Install the skill` section to README with one-line examples per host and the manual `git clone` fallback for hosts not yet in the binary's map. by @brettdavies in [#35](https://github.com/brettdavies/agentnative-cli/pull/35) +- `RELEASES.md` § "After publish — sync ``dev`` with the release" documents the backport step, supersedes the prior "never back-merged" rule for these three specific files, and points operators at the script. by @brettdavies in [#37](https://github.com/brettdavies/agentnative-cli/pull/37) +- Add the `[![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc)` badge plus crates.io and license shields at the top of `README.md`. by @brettdavies in [#40](https://github.com/brettdavies/agentnative-cli/pull/40) +- Trim `.github/ISSUE_TEMPLATE/` to `false-positive`, `feature-request`, `scoring-bug`, plus a new `00-blank.yml` that lets a Blank issue option sit first in the chooser ahead of the structured forms. Spec-side templates (`pressure-test`, `grade-a-cli`, `spec-question`) were duplicates of the spec repo's set from before the rename — they belong on `brettdavies/agentnative` only, and `config.yml` already redirects there. +- Add `scripts/SYNCS.md` — cross-repo sync map covering every spec/skill/coverage/release data flow with mechanism, payload, trigger, and drift check per edge. Includes a flowchart of inbound/outbound edges, a release-pipeline sequence diagram, and a cadence summary table reducing the system to "automatic vs manual" per sync point. by @brettdavies in [#41](https://github.com/brettdavies/agentnative-cli/pull/41) +- `RELEASES.md` § "Releasing dev to main" step 4 replaced with a triple-diff verification block (A: main→release, B: release→dev, C: dev→main) plus a `git cherry HEAD origin/dev` patch-id check. The new flow catches both directions of drift before the release tag goes out — guarded paths leaking IN (the original concern) and missed cherry-picks that should have shipped (the new concern). Discovered during v0.3.0 prep when an ad-hoc triple-diff caught 4 `.github/ISSUE_TEMPLATE/*.yml` files that had drifted on `main` since the v0.1.1 squash. by @brettdavies in [#45](https://github.com/brettdavies/agentnative-cli/pull/45) +- `RELEASES.md` § "Releasing dev to main" step 4 — expanded the `git cherry` patch-id check comment with squash-merge triage guidance (three expected noise sources, what a real miss looks like, and a two-command triage recipe). Discovered during v0.3.0 prep when the check produced 55 noisy `+` lines that all turned out to be expected; the original comment didn't explain that this is normal in a squash-merge workflow. by @brettdavies in [#46](https://github.com/brettdavies/agentnative-cli/pull/46) + +**Full Changelog**: [v0.2.0...v0.3.0](https://github.com/brettdavies/agentnative-cli/compare/v0.2.0...v0.3.0) + ## [0.2.0] - 2026-04-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 01e0e95..3e0ed99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,8 @@ Design doc: `~/.gstack/projects/brettdavies-agentnative/brett-main-design-202603 ## Skill Routing -When the user's request matches an available skill, ALWAYS invoke it using the Skill -tool as your FIRST action. Do NOT answer directly, do NOT use other tools first. +When the user's request matches an available skill, ALWAYS invoke it using the Skill tool as your FIRST action. Do NOT +answer directly, do NOT use other tools first. **gstack skills (ideation, planning, shipping, ops):** @@ -39,9 +39,9 @@ For the full routing table, see `~/.claude/skills/docs/workflow-routing.md`. ## Documented Solutions -`docs/solutions/` (symlink to `~/dev/solutions-docs/`) — searchable archive of past -solutions and best practices, organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Search -with `qmd query "" --collection solutions`. Relevant when implementing or debugging in documented areas. +`docs/solutions/` (symlink to `~/dev/solutions-docs/`) — searchable archive of past solutions and best practices, +organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Search with `qmd query "" +--collection solutions`. Relevant when implementing or debugging in documented areas. ## gstack Project History @@ -66,8 +66,8 @@ Key decisions already made: ## Source Check Convention -Most source checks follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use -different helper shapes but still satisfy the core contract that `run()` is the sole `CheckResult` constructor): +Most source checks follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use different +helper shapes but still satisfy the core contract that `run()` is the sole `CheckResult` constructor): - **Struct** implements `Check` trait with `id()`, `label()`, `group()`, `layer()`, `applicable()`, `run()` - **`check_x()` helper** takes `(source: &str)` (or `(source: &str, file: &str)` when evidence needs file location @@ -139,21 +139,122 @@ either source. Regenerate whenever you add a requirement, change a check's `covers()`, or rename a check ID. The regeneration is a deliberate commit, not a build-time artifact — the matrix is citable from outside this repo. -## Scorecard v1.1 Fields +## Scorecard v0.5 Fields -`src/scorecard/mod.rs` emits `schema_version: "1.1"` with three additions over the v1.0 shape: +`src/scorecard/mod.rs` emits `schema_version: "0.5"`. The schema evolves additively during the `0.x` pre-launch window — +consumers feature-detect each addition rather than pinning exact shape. Cumulative history: -- `coverage_summary` — three-way `{must, should, may} × {total, verified}` counts, computed from the checks that - actually ran. Populated every run. +- `0.2` — `coverage_summary` (three-way `{must, should, may} × {total, verified}` counts), `audience`, `audit_profile`. +- `0.3` — `spec_version` (vendored agentnative-spec version, sourced by `build.rs` from `src/principles/spec/VERSION`). +- `0.4` — four top-level objects making the scorecard self-describing: `tool`, `anc`, `run`, `target`. +- `0.5` — `badge` block surfacing agent-native badge eligibility, embed snippet, and badge/scorecard URLs derived from + the live run. + +Existing field semantics: + +- `coverage_summary` — populated every run. Checks suppressed by `--audit-profile` do not count toward `verified`. - `audience` — `Option`, derived by `src/scorecard/audience.rs::classify()` from the 4 signal behavioral checks. - Emits `"agent-optimized"`, `"mixed"`, `"human-primary"`, or `null` when any signal check is missing from results - (including when suppressed by `--audit-profile`). The classifier is read-only over results and never gates totals or - exit codes — per CEO review Finding #3, label mismatches are fixed via registry, not classifier logic. + Emits `"agent-optimized"`, `"mixed"`, `"human-primary"`, or `null` when any signal check is missing (including + `--audit-profile` suppression). Read-only over results; never gates totals or exit codes — per CEO review Finding #3, + label mismatches are fixed via registry, not classifier logic. - `audit_profile` — `Option`, echoes the applied `--audit-profile` flag value (`"human-tui"`, `"file-traversal"`, `"posix-utility"`, `"diagnostic-only"`). `null` when no profile is set. - -Consumers (notably the site's `/score/` page) must feature-detect the new fields — pre-v1.1 scorecards lack -them. v0.1.2 scorecards carry `audience: null` and `audit_profile: null`; v0.1.3+ populates both. +- `spec_version` — `&'static str` — the vendored spec version this `anc` build was compiled against. + +`0.4` additions (defined as serde-derived sub-structs in `src/scorecard/mod.rs`): + +- `tool` — `ToolInfo { name: String, binary: Option, version: Option }`. Built in `main.rs`'s + `build_tool_info`. Project mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`); command/binary mode + probes ` --version` then `-V` via a fresh `BinaryRunner` with a 2-second timeout. Self-spawn guard compares the + resolved binary path against `std::env::current_exe()` — recursion declined → `tool.version: null`. +- `anc` — `AncInfo { version: &'static str }`. `version` is a build-time constant emitted by `build.rs` into + `$OUT_DIR/build_info.rs` (re-exported from `src/build_info.rs`). The `commit` field shipped pre-`0.5` was dropped + before the v0.3.0 tag — the version pin is sufficient build identity for scorecard consumers and the + `cargo:rerun-if-changed` watches on `.git/` made cached-build SHAs fragile across local commits. +- `run` — `RunInfo { invocation, started_at, duration_ms, platform: { os, arch } }`. `invocation` is captured **before** + `inject_default_subcommand` rewrites argv (so `anc .` records as `"anc ."`, not `"anc check ."`). `started_at` is RFC + 3339 UTC via the `time` crate (pinned `=0.3.45`). `duration_ms` uses `Instant` for monotonic measurement. + `platform.{os,arch}` come from `std::env::consts`. +- `target` — `TargetInfo { kind: String, path: Option, command: Option }`. `kind` is one of `"project"`, + `"binary"`, `"command"`. `path` is the **basename** of the resolved target (directory name in project mode, file name + in binary mode) — never the absolute path, which would leak operator PII (home-dir username, org/employer dir + structure) into committed scorecards, README badge URLs, and any agent-posted artifact. `null` for `command` mode. + Pathological paths where `Path::file_name()` returns `None` (e.g. `/`, `..`) fall back to `null`. The unused field is + always `null`, never missing. See `src/main.rs::build_target_info` for the leak-vector rationale; the regression guard + lives in `tests/scorecard_schema_v05.rs::schema_v05_target_path_carries_no_separators`. + +`0.5` addition (`BadgeInfo` in `src/scorecard/mod.rs`): + +- `badge` — `BadgeInfo { eligible, score_pct, embed_markdown, scorecard_url, badge_url, convention_url }`. Computed by + `compute_badge(results, tool_name)` from the leaderboard's pass-rate (`pass / (pass + warn + fail)`) — Skips and + Errors are excluded from both sides of the ratio. `eligible` is true iff `score_pct >= BADGE_ELIGIBILITY_FLOOR_PCT` + (currently `80`) **and** a tool slug was derivable; `embed_markdown` is `Some` only when `eligible` (the do-not-nag + contract from the site's badge convention). `scorecard_url` / `badge_url` are populated whenever a slug exists, even + below the floor — the site renders an SVG for every scored tool so a regression below the floor shifts color rather + than 404s. `convention_url` is the fixed `https://anc.dev/badge` pointer. URLs are anchored at `BADGE_BASE_URL = + "https://anc.dev"` so the URL pattern lives in one place. Authority for the floor is the site's published convention + (`agentnative-site/content/badge.md`); when the spec convention merges off `feat/badge-claim-convention` it will move + into the vendored spec via `sync-spec`. Text mode (`--output text`) appends a post-summary hint via + `BadgeInfo::text_hint()` when `eligible`; the same `tool.name` is used for the slug so the JSON `embed_markdown` and + the printed hint can never disagree. + +Always-present null contract: `tool.version`, `tool.binary`, `target.path`, `target.command` serialize as JSON `null` +when not applicable, never as missing keys. Consumers can access these paths unconditionally. The exception is +`audience_reason`, which uses `skip_serializing_if = "Option::is_none"` — its absence carries information (audience has +a label). + +Consumers (notably the site's `/score/` page) must feature-detect the new fields — pre-`0.4` scorecards lack the +four metadata blocks; pre-`0.5` scorecards lack `badge`. The site's `agentnative-site/registry.yaml` will eventually +drop its parallel `version` / `scored_at` fields once consumers read those facts from the scorecard's `tool.version` / +`run.started_at`. That follow-up lives in the `agentnative-site` repo, not here. + +## Skill Install Verb + +`anc skill install ` ships the `agentnative-skill` bundle into a host's canonical skills directory. The host map +is **build-time-generated** from `src/skill_install/skill.json` (a verbatim copy of +`agentnative-site/src/data/skill.json`) into `$OUT_DIR/generated_hosts.rs` by `build.rs::emit_skill_hosts`. +`src/skill_install.rs` `include!`s the generated file; there is no hand-maintained host enum or destination table. To +add or change a host, edit `skill.json` (or run `bash scripts/sync-skill-fixture.sh` to pull the upstream site contract) +and `cargo build` regenerates the Rust map. There is no `skill.json` parsing in production, no HTTPS fetch at runtime, +and no allowlist validator — the host map is compile-time data. + +CI catches drift between the committed fixture and the upstream site contract: + +- `scripts/sync-skill-fixture.sh --check` (CI workflow `skill-fixture-drift.yml`) clones the upstream site at + `SKILL_SITE_REF` (default `dev`, since the site uses a dev/main forever-branch flow) and `cmp`s the live blob against + `src/skill_install/skill.json`. Runs on every PR and on push to main/dev. + +Drift between the fixture and the generated Rust map cannot occur within a single build: `cargo:rerun-if-changed` on the +JSON file forces regeneration whenever the fixture moves. The earlier `host_map_matches_site_skill_json` test (test 12) +was deleted as provably redundant after this refactor. + +The `git clone` invocation runs with named-const hardening that defeats ambient git-config and env subversion. The full +surface lives in `src/skill_install.rs`: + +- `GIT_HARDEN_FLAGS: &[&str]` — five `-c key=value` pairs (`credential.helper=`, `core.askPass=`, + `protocol.allow=never`, `protocol.https.allow=always`, `http.followRedirects=false`). Applied via `Command::args` + *before* the `clone` subcommand — git's required position for top-level `-c` options. +- `GIT_HARDEN_ENV_REMOVE: &[&str]` — five env vars stripped via `env_remove` (`GIT_SSH{,_COMMAND}`, `GIT_PROXY_COMMAND`, + `GIT_ASKPASS`, `GIT_EXEC_PATH`). +- `GIT_HARDEN_ENV_SET: &[(&str, &str)]` — three env vars **set** on the spawned process. The + `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` pair disables every layer of user-controlled git + config — the actual defense against `insteadOf` URL-rewriting attacks (an earlier draft tried `-c + url..insteadOf=`, which does the *opposite* of blocking and doubles the clone URL). `GIT_TERMINAL_PROMPT=0` + blocks credential prompts; git's default-when-unset is to prompt, which is the wrong default for a non-interactive + subcommand. + +**Rules for changes touching skill install:** + +- NEVER call `Command::env_clear()` — it strips PATH and breaks git's helper resolution. Use `env_remove` per var. +- NEVER use `sh -c` or any shell-mediated invocation. Tokens go directly to `git` via `Command::args`. +- NEVER reintroduce `skill.json` parsing in production code. The fixture is a build-time codegen input, not a runtime + resource. +- NEVER hand-edit `SkillHost`, `KNOWN_HOSTS`, `resolve_host`, or `host_envelope_str` in `src/skill_install.rs` — those + identifiers come from the generated `$OUT_DIR/generated_hosts.rs` and any apparent definition in source is the + include! macro. To add a host, edit `src/skill_install/skill.json` (or run the sync script) and rebuild. +- The codegen rejects malformed install commands at build time. Each `install.` value MUST tokenize as exactly + `git clone --depth 1 ` (six whitespace-separated tokens, dest not ending in `.git`). The validation + mirrors `agentnative-site/src/build/skill.mjs` so the two binaries reject the same inputs. ## Dogfooding Safety diff --git a/Cargo.lock b/Cargo.lock index bcce0ae..9432723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agentnative" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", @@ -18,7 +18,9 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "thiserror", + "time", "toml", ] @@ -276,6 +278,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "difflib" version = "0.4.0" @@ -472,6 +483,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -493,6 +510,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "3.1.4" @@ -760,6 +783,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/Cargo.toml b/Cargo.toml index ad0c448..0fb3ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentnative" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "The agent-native CLI linter — check whether your CLI follows agent-readiness principles" license = "MIT OR Apache-2.0" @@ -11,7 +11,7 @@ keywords = ["cli", "linter", "agent", "ast-grep", "developer-tools"] categories = ["command-line-utilities", "development-tools"] authors = ["Brett Davies "] readme = "README.md" -rust-version = "1.87" +rust-version = "1.88" exclude = [ ".claude/", ".context/", @@ -55,11 +55,18 @@ thiserror = "2" # Platform libc = "0.2" +# RFC 3339 timestamps for scorecard `run.started_at`. Pinned exact (pre-1.0 +# convention). `formatting` feature enables `OffsetDateTime::format` with the +# `Rfc3339` well-known description; default `std` feature is required for +# `OffsetDateTime::now_utc`. +time = { version = "=0.3.47", default-features = false, features = ["std", "formatting"] } + [build-dependencies] # Vendored spec frontmatter parser (build.rs → $OUT_DIR/generated_requirements.rs). # serde_yaml is deprecated upstream but still functional; pinned tightly. # Re-evaluate (saphyr / yaml-rust2) if cargo-deny flags it. serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" serde_yaml = "=0.9.34" [dev-dependencies] @@ -68,6 +75,10 @@ insta = "1" predicates = "3" # Used by tests/build_parser.rs to exercise the build_support/parser.rs module. serde_yaml = "=0.9.34" +# Tempdir / tempfile fixtures for skill_install destination-check tests. +# Already a transitive dev-dep via insta — declared here so it's a direct +# dependency we control. +tempfile = "3" [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/agentnative-{ target }.tar.gz" diff --git a/README.md b/README.md index 0b0a7c9..f500f4e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,13 @@ # agentnative +[![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc) +[![Crates.io](https://img.shields.io/crates/v/agentnative.svg)](https://crates.io/crates/agentnative) +[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue.svg)](#license) + The agent-native CLI linter. Checks whether your CLI follows the 7 agent-readiness principles. +`anc` dogfoods the spec it enforces — the badge above is its own live score. + ## Install The crate is published as `agentnative`. The binary is called `anc`. @@ -20,6 +26,42 @@ cargo binstall agentnative # https://github.com/brettdavies/agentnative-cli/releases ``` +## Install the skill + +`anc` ships a companion skill bundle (`agentnative-skill`) that teaches AI coding agents how to operate the linter and +where to apply the principles. Install it with one command per host: + +```bash +anc skill install claude_code # ~/.claude/skills/agent-native-cli +anc skill install codex # ~/.codex/skills/agent-native-cli +anc skill install cursor # ~/.cursor/skills/agent-native-cli +anc skill install factory # ~/.factory/skills/agent-native-cli (Factory Droid) +anc skill install kiro # ~/.kiro/skills/agent-native-cli (Kiro) +anc skill install opencode # ~/.config/opencode/skills/agent-native-cli +``` + +Inspect the resolved command before running it: + +```bash +anc skill install --dry-run claude_code +# git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /home/you/.claude/skills/agent-native-cli +``` + +JSON output (mode `dry-run` and `install`, success and error) is uniform — agents can rely on the same envelope shape +across every outcome: + +```bash +anc skill install --dry-run claude_code --output json +``` + +If the site adds a host before this `anc` release knows about it, fall back to a manual `git clone`: + +```bash +git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /agent-native-cli +``` + +The host map is hardcoded in this binary; new hosts ship via patch release after the site updates `skill.json`. + ## Quick Start ```bash @@ -61,6 +103,8 @@ agentnative checks your CLI against seven agent-readiness principles: ```text P1 — Non-Interactive by Default [PASS] Non-interactive by default (p1-non-interactive) + [PASS] Flags advertise env-var bindings in --help (p1-env-hints) + [PASS] TTY detection for color output (p1-tty-detection-source) [PASS] No interactive prompt dependencies (p1-non-interactive-source) P3 — Progressive Help @@ -69,19 +113,30 @@ P3 — Progressive Help P4 — Actionable Errors [PASS] Rejects invalid arguments (p4-bad-args) + [PASS] Structured error types (p4-error-types) + [PASS] Exit codes use named constants (p4-exit-codes) [PASS] No process::exit outside main (p4-process-exit) + [PASS] Dedicated error module exists (p4-error-module) P6 — Composable Structure [PASS] Handles SIGPIPE gracefully (p6-sigpipe) - [PASS] Respects NO_COLOR (p6-no-color) + [PASS] Respects NO_COLOR (p6-no-color-behavioral) [PASS] Shell completions support (p6-completions) Code Quality [PASS] No .unwrap() in source (code-unwrap) -30 checks: 26 pass, 2 warn, 0 fail, 2 skip, 0 error +33 checks: 28 pass, 1 warn, 0 fail, 4 skip, 0 error + +🏆 Score: 97% — your tool qualifies for the agent-native badge. + Embed in your README: + [![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc) + Convention: https://anc.dev/badge ``` +The badge hint appears in `text` output when a tool scores at or above the 80% eligibility floor. Below the floor, `anc` +prints nothing badge-related — the convention is to surface the embed only when earned. + ## Three Check Layers agentnative uses three layers to analyze your CLI: @@ -95,9 +150,9 @@ agentnative uses three layers to analyze your CLI: ## CLI Reference -When the first non-flag argument is not a recognized subcommand, `check` is inserted automatically. `anc .`, -`anc -q .`, and `anc --command ripgrep` all resolve to `anc check …`. Bare `anc` (no arguments) still prints help and -exits 2 — this is deliberate fork-bomb prevention when agentnative dogfoods itself. +When the first non-flag argument is not a recognized subcommand, `check` is inserted automatically. `anc .`, `anc -q .`, +and `anc --command ripgrep` all resolve to `anc check …`. Bare `anc` (no arguments) still prints help and exits 2 — this +is deliberate fork-bomb prevention when agentnative dogfoods itself. ```text Usage: anc check [OPTIONS] [PATH] @@ -106,26 +161,34 @@ Arguments: [PATH] Path to project directory or binary [default: .] Options: - --command Resolve a command from PATH and run behavioral checks against it - --binary Run only behavioral checks (skip source analysis) - --source Run only source checks (skip behavioral) - --principle Filter checks by principle number (1-7) - --output Output format [default: text] [possible values: text, json] - -q, --quiet Suppress non-essential output - --include-tests Include test code in source analysis - -h, --help Print help + --command Resolve a command from PATH and run behavioral checks against it + --binary Run only behavioral checks (skip source analysis) + --source Run only source checks (skip behavioral) + --principle Filter checks by principle number (1-7) + --output Output format [default: text] [possible values: text, json] + -q, --quiet Suppress non-essential output [env: AGENTNATIVE_QUIET=] + --include-tests Include test code in source analysis + --audit-profile Exemption category for the target [possible values: + human-tui, file-traversal, posix-utility, diagnostic-only] + -h, --help Print help ``` `--command` and `[PATH]` are mutually exclusive — pick one. `--command` runs behavioral checks only; source and project checks are skipped because there is no source tree to analyze. +`--audit-profile` suppresses checks that legitimately do not apply to a class of tool (e.g., `human-tui` for TUI apps +like `lazygit` whose contract IS the TTY, `posix-utility` for stdin-primary tools like `cat`/`sed`/`awk`, +`diagnostic-only` for read-only tools like `nvidia-smi`, `file-traversal` reserved for upcoming subcommand-structure +relaxations on `fd`/`find`-class tools). Suppressed checks emit `Skip` with structured evidence. The full per-category +mapping lives in `coverage/matrix.json` under `audit_profiles[]` — agents should read it rather than scrape `--help`. + ### Exit Codes -| Code | Meaning | -| ---- | ------- | -| 0 | All checks passed | -| 1 | Warnings present (no failures) | -| 2 | Failures, errors, or usage errors | +| Code | Meaning | +| ---- | --------------------------------- | +| 0 | All checks passed | +| 1 | Warnings present (no failures) | +| 2 | Failures, errors, or usage errors | Exit 2 covers both check failures (a real `[FAIL]` or `[ERROR]` result) and usage errors (bare `anc`, unknown flag, mutually exclusive flags). Agents distinguishing the two should parse `stderr` (usage errors print `Usage:`) or call @@ -158,11 +221,13 @@ Pre-generated scripts are also available in `completions/`. anc check . --output json ``` -Produces a scorecard (`schema_version: "1.1"`) with results, summary, and coverage against the 7 principles: +Produces a self-describing scoring run record (`schema_version: "0.5"`) with results, summary, coverage against the 7 +principles, plus contextual metadata identifying which tool was scored, by which `anc` build, on which platform, and +how: ```json { - "schema_version": "1.1", + "schema_version": "0.5", "results": [ { "id": "p3-help", @@ -170,24 +235,43 @@ Produces a scorecard (`schema_version: "1.1"`) with results, summary, and covera "group": "P3", "layer": "behavioral", "status": "pass", - "evidence": null + "evidence": null, + "confidence": "high" } ], "summary": { - "total": 30, - "pass": 26, - "warn": 2, + "total": 33, + "pass": 28, + "warn": 1, "fail": 0, - "skip": 2, + "skip": 4, "error": 0 }, "coverage_summary": { - "must": { "total": 23, "verified": 17 }, + "must": { "total": 23, "verified": 17 }, "should": { "total": 16, "verified": 2 }, - "may": { "total": 7, "verified": 0 } + "may": { "total": 7, "verified": 0 } }, "audience": "agent-optimized", - "audit_profile": null + "audit_profile": null, + "spec_version": "0.3.0", + "tool": { "name": "ripgrep", "binary": "rg", "version": "ripgrep 15.1.0" }, + "anc": { "version": "0.3.0", "commit": "abc1234" }, + "run": { + "invocation": "anc check --command rg --output json", + "started_at": "2026-04-29T16:00:00Z", + "duration_ms": 412, + "platform": { "os": "linux", "arch": "x86_64" } + }, + "target": { "kind": "command", "path": null, "command": "rg" }, + "badge": { + "eligible": true, + "score_pct": 97, + "embed_markdown": "[![agent-native](https://anc.dev/badge/ripgrep.svg)](https://anc.dev/score/ripgrep)", + "scorecard_url": "https://anc.dev/score/ripgrep", + "badge_url": "https://anc.dev/badge/ripgrep.svg", + "convention_url": "https://anc.dev/badge" + } } ``` @@ -202,16 +286,46 @@ Produces a scorecard (`schema_version: "1.1"`) with results, summary, and covera `audit_profile`'s format within the same JSON document. - `audience_reason` — present only when `audience` is `null`. Values: `suppressed` (at least one signal check was masked by `--audit-profile`) or `insufficient_signal` (signal check never produced, e.g. source-only run). Additive to schema - v1.1; v1.1 consumers feature-detect. + `0.2`; older consumers feature-detect. - `audit_profile` — echoes the applied `--audit-profile ` flag value (`human-tui`, `file-traversal`, `posix-utility`, or `diagnostic-only`). `null` when no profile is set. See `coverage/matrix.json` under `audit_profiles` for the committed per-category mapping of which check IDs each profile suppresses. +- `tool` — identifies what was scored. `name` is always present (deterministic from path or command). `binary` is the + executable basename when one is located; `null` for project-mode runs without a built artifact. `version` is + best-effort: project-mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`), command/binary mode probes + ` --version` then `-V`. `null` when probing failed or was declined by the self-spawn guard. The site's + `registry.yaml` `version_extract` snippets remain authoritative for tools whose self-report is unreliable. Schema + `0.4` addition. +- `anc` — identifies the `anc` build that produced the scorecard. `version` is the crate version at compile time. + `commit` is the short Git SHA at compile time, or `null` for builds outside a Git checkout (e.g., `cargo install` from + crates.io). Informational, not a signed provenance signal — pair with a Sigstore-signed release artifact if provenance + is required. Schema `0.4` addition. +- `run` — run-level facts. `invocation` is the user's argv joined with shell-safe quoting, captured **before** + default-subcommand injection so it reflects what the user typed (`anc .`, not `anc check .`). `started_at` is RFC 3339 + UTC. `duration_ms` is wall-clock milliseconds. `platform.os` / `platform.arch` come from `std::env::consts`. Schema + `0.4` addition. +- `target` — what `anc` was pointed at. `kind` is `"project"` (directory), `"binary"` (executable file), or `"command"` + (PATH-resolved name from `--command`). `path` is the **basename** of the resolved target (project directory name or + binary file name) — never the absolute path, so home-dir usernames and employer directory layouts don't leak into + scorecards committed to repos or posted by agents. `command` carries the user-supplied name for command mode. The + unused field is always `null`, never missing — consumer code can access both unconditionally. Schema `0.4` addition. +- `badge` — agent-native badge derivation from the live run. `score_pct` is `pass / (pass + warn + fail)` rounded (Skips + and Errors excluded from both sides of the ratio). `eligible` is true iff `score_pct >= 80` **and** a tool slug was + derivable. `embed_markdown` is `null` below the floor — the convention is "do not nag" until earned; `scorecard_url` + and `badge_url` are populated whenever a slug exists, even below the floor, so the site renders an SVG for every + scored tool (a regression below the floor shifts color rather than 404s). `convention_url` always points at + `https://anc.dev/badge`. Schema `0.5` addition. + +> Publishing a scorecard? `run.invocation` may carry usernames or absolute paths from the machine that produced the +> scorecard. `target.path` is intentionally the basename only and is safe to commit. Review `run.invocation` before +> publishing — `anc` does not silently redact, since that would surprise users debugging their own runs. ## Contributing ```bash git clone https://github.com/brettdavies/agentnative-cli -cd agentnative +cd agentnative-cli +git config core.hooksPath scripts/hooks # mirror CI locally on every push cargo test cargo run -- check . ``` @@ -220,19 +334,21 @@ cargo run -- check . Open an issue at [github.com/brettdavies/agentnative-cli/issues/new/choose](https://github.com/brettdavies/agentnative-cli/issues/new/choose). -Seven structured templates cover the common cases: - -| Template | Use it when | -| --- | --- | -| False positive | A check flagged your CLI but you believe your CLI is doing the right thing. | -| Scoring bug | Results don't match what the check should be doing (wrong status, miscategorized group/layer, evidence pointing at the wrong line). | -| Feature request | Missing capability, flag, or output format in the checker itself. | -| Grade a CLI | Nominate a CLI for an `anc`-graded readiness review. | -| Pressure test | Challenge a principle or check definition — "this check is too strict / too loose / wrong on this class of CLI." | -| Spec question | Ambiguity or gap in the 7-principle spec (not the checker). | -| Something else | Chooser for anything outside the templates above. | - -Filing on the right template front-loads the triage context we need and keeps issues out of a single-bucket backlog. +The chooser surfaces three structured templates plus a blank fallback for everything else: + +| Template | Use it when | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Blank issue | Anything outside the structured templates below. | +| False positive | A check flagged your CLI but you believe your CLI is doing the right thing. | +| Scoring bug | Results don't match what the check should be doing (wrong status, miscategorized group/layer, evidence pointing at the wrong line). | +| Feature request | Missing capability, flag, or output format in the checker itself. | + +Spec questions, principle pressure-tests, and CLI grading live on the spec repo — +[brettdavies/agentnative](https://github.com/brettdavies/agentnative/issues/new/choose). The chooser config redirects +those automatically. Site bugs (rendering, performance) go to +[brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site/issues/new/choose). See +[CONTRIBUTING.md on the spec repo](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for the full +cross-repo routing table. ## License diff --git a/RELEASES.md b/RELEASES.md index 99dc94d..e24c070 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -12,12 +12,12 @@ feature branch → PR to dev (squash merge) ## Branches -| Branch | Role | Lifetime | Protection | -| ------ | ---- | -------- | ---------- | -| `main` | Production. Only release commits. | Forever. | `.github/rulesets/protect-main.json` | -| `dev` | Integration. All feature PRs land here. | Forever. Never delete. | `.github/rulesets/protect-dev.json` | -| `feat/*`, `fix/*`, `chore/*`, `docs/*` | Feature work. | One PR's worth. Auto-deleted on merge. | None — squash into dev freely. | -| `release/*` | Head of a dev → main PR. | One release's worth. Auto-deleted on merge. | None. | +| Branch | Role | Lifetime | Protection | +| -------------------------------------- | --------------------------------------- | ------------------------------------------- | ------------------------------------ | +| `main` | Production. Only release commits. | Forever. | `.github/rulesets/protect-main.json` | +| `dev` | Integration. All feature PRs land here. | Forever. Never delete. | `.github/rulesets/protect-dev.json` | +| `feat/*`, `fix/*`, `chore/*`, `docs/*` | Feature work. | One PR's worth. Auto-deleted on merge. | None — squash into dev freely. | +| `release/*` | Head of a dev → main PR. | One release's worth. Auto-deleted on merge. | None. | `dev` is a **forever branch**. Never delete it locally or remotely, even after a `release/* → main` merge. The next release cycle reuses the same `dev`. The repo's `deleteBranchOnMerge: true` setting doesn't touch `dev` as long as `dev` @@ -41,10 +41,9 @@ gh pr create --base dev --title "feat(scope): what changed" ## Releasing dev to main -Engineering docs (`docs/plans/`, `docs/solutions/`, `docs/brainstorms/`, -`docs/reviews/`) live on `dev` only. `guard-main-docs.yml` blocks them from reaching `main`, and -`guard-release-branch.yml` rejects any PR to main whose head isn't `release/*`. Use the release-branch cherry-pick -pattern: +Engineering docs (`docs/plans/`, `docs/solutions/`, `docs/brainstorms/`, `docs/reviews/`) live on `dev` only. +`guard-main-docs.yml` blocks them from reaching `main`, and `guard-release-branch.yml` rejects any PR to main whose head +isn't `release/*`. Use the release-branch cherry-pick pattern: **Branch naming**: `release/v` or `release/v-` (e.g. `release/v0.1.0`, `release/v0.2.0-python-checks`). The `v` prefix is required — `scripts/generate-changelog.sh` extracts the @@ -62,10 +61,59 @@ git log --oneline dev --not origin/main # 3. Cherry-pick the ones you want to ship. Docs commits stay on dev. git cherry-pick ... -# 4. Verify no guarded paths leaked through: -git diff origin/main --stat -# If anything under docs/plans/, docs/solutions/, or docs/brainstorms/ -# shows up, you cherry-picked a docs commit by mistake — reset and redo. +# 4. Triple-diff verification — belt-and-suspenders sweep that catches both +# directions of drift before the release tag goes out: +# +# A. main → release (what users will see; the intended ship surface) +# B. release → dev (should be empty for non-doc paths until the +# bump/completions/CHANGELOG commits land, and even +# then should only list those release-prep files — +# anything else is a missed cherry-pick) +# C. dev → main (sanity: phantom commits dev "appears ahead" on +# because cherry-pick rewrites SHAs post-squash) +git diff origin/main..HEAD --stat # A +git diff HEAD..origin/dev --name-only | grep -v '^docs/' || echo "(none)" # B +git diff origin/dev..origin/main --stat | tail -5 # C +# +# Re-confirm no guarded paths leaked (this caught the original miss class): +git diff origin/main..HEAD --name-only \ + | grep -E '^(docs/plans|docs/brainstorms|docs/ideation|docs/reviews|docs/solutions|\.context)' \ + && echo "LEAKED — reset and redo" || echo "(clean — no guarded paths)" +# +# Patch-id cherry check — catches commits on dev that have NO patch-id +# equivalent on release. The file-level diff in B misses this class when +# the same content happens to land via a different commit. +# +# IMPORTANT: in a squash-merge workflow this output is noisy. Every '+' +# line needs human triage — it does NOT auto-block the release. Expected +# sources of '+' lines that are NOT real misses: +# +# 1. Historical commits squash-merged in prior releases. The squash +# commit on main has a different patch-id than the dev commits it +# consolidates, so old commits show as '+' forever. Anything older +# than the previous release tag is almost always this. +# 2. Cherry-picks where conflict resolution stripped guarded paths +# (docs/plans, docs/brainstorms, etc.) or otherwise altered the +# tree. Same source-code intent, different patch-id. +# 3. Intentionally skipped commits — docs-only commits, release-prep +# backports, revert-and-redo prep steps. +# +# A real miss looks like: a recent feat/fix/chore commit on dev whose +# *file content* is not yet on main. To triage a '+' line: +# +# git show --stat # what did it touch? +# git diff origin/main..HEAD -- # already on release? +# +# If every touched file is guarded (docs/plans/, docs/brainstorms/, etc.) +# OR the content is already on main via a prior squash, it's a false +# positive — no action. Otherwise cherry-pick the commit and re-run the +# triple-diff. +git cherry HEAD origin/dev | grep '^+' || echo "(none — release is patch-equivalent through dev)" +# +# If B lists any non-docs path you didn't expect, fetch dev, identify the +# commit (`git log dev --not origin/main`), cherry-pick it, re-run the +# triple-diff. Missed cherry-picks have shipped to main on this and sibling +# repos before — this step is the cheap way to catch them. # 5. Bump version in Cargo.toml and commit: # sed -i 's/^version = ".*"/version = "0.2.0"/' Cargo.toml @@ -76,11 +124,22 @@ git diff origin/main --stat ./scripts/generate-completions.sh git add completions/ && git commit -m "chore: regenerate shell completions" || true -# 7. Generate CHANGELOG.md (auto-detects version from branch name; CI enforces this): +# 7. Refresh the skill.json fixture from upstream and review the diff. CI's +# skill-fixture-drift workflow runs --check on every PR, but pulling the +# latest content here catches any site changes since dev was branched and +# avoids tagging a release with the codegen-derived host map one revision +# behind upstream: +bash scripts/sync-skill-fixture.sh && git diff src/skill_install/skill.json +# The Rust map (SkillHost / KNOWN_HOSTS / resolve_host) regenerates from the +# JSON automatically on the next `cargo build` — no manual src edits needed. +git add src/skill_install/skill.json && \ + git commit -m "chore(skill): refresh fixture for v0.2.0" || true + +# 8. Generate CHANGELOG.md (auto-detects version from branch name; CI enforces this): ./scripts/generate-changelog.sh git add CHANGELOG.md && git commit -m "docs: update CHANGELOG.md for v0.2.0" -# 8. Push and open the PR: +# 9. Push and open the PR: git push -u origin release/v0.2.0 gh pr create --base main --head release/v0.2.0 --title "release: v0.2.0" ``` @@ -112,19 +171,38 @@ git push origin main --tags The tag push triggers `.github/workflows/release.yml`, which calls the reusable `brettdavies/.github/.github/workflows/rust-release.yml@main` and runs: -| Step | What | -| ---- | ---- | -| `check-version` | Verify the tag matches `Cargo.toml` version (gate). | -| `audit` | `cargo deny check` (license + advisory + ban). | -| `build` | Cross-compile binaries for 5 targets: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`. Each archive includes the `anc` binary, completions, README, and licenses. | -| `publish-crate` | `cargo publish` to crates.io via Trusted Publishing (OIDC, no static token after first publish). | -| `release` | Create a **non-draft** GitHub Release with `make_latest: false` — visible immediately (so `cargo-binstall` and `/releases/latest` don't 404 during the bottle-build window) but not yet promoted to "Latest". Includes all 5 archives + `sha256sum.txt`. | -| `homebrew` | Dispatch `update-formula` to `brettdavies/homebrew-tap` (formula name: `agentnative`, installs `anc`). | +| Step | What | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `check-version` | Verify the tag matches `Cargo.toml` version (gate). | +| `audit` | `cargo deny check` (license + advisory + ban). | +| `build` | Cross-compile binaries for 5 targets: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`. Each archive includes the `anc` binary, completions, README, and licenses. | +| `publish-crate` | `cargo publish` to crates.io via Trusted Publishing (OIDC, no static token after first publish). | +| `release` | Create a **non-draft** GitHub Release with `make_latest: false` — visible immediately (so `cargo-binstall` and `/releases/latest` don't 404 during the bottle-build window) but not yet promoted to "Latest". Includes all 5 archives + `sha256sum.txt`. | +| `homebrew` | Dispatch `update-formula` to `brettdavies/homebrew-tap` (formula name: `agentnative`, installs `anc`). | After the homebrew-tap workflow uploads bottles to this repo's release assets, it dispatches `finalize-release` back to this repo, which idempotently flips `make_latest: true`. End result: crate on crates.io, GitHub Release marked latest, Homebrew formula updated with bottles, all atomically advertised. +### After publish — sync `dev` with the release + +Once `finalize-release.yml` has flipped the GitHub Release to `published`, backport the release-bookkeeping files from +`main` to `dev` so future builds from `dev` report the released version (and so `anc check`'s embedded badge URL points +at the right slug, not stale `0.1.0`): + +```bash +./scripts/sync-dev-after-release.sh v0.2.0 +git push origin dev +``` + +The script surgically updates only `Cargo.toml`'s `[package].version` line (other `Cargo.toml` lines on `dev` — +post-launch deps, rust-version bumps — are preserved), regenerates `Cargo.lock` via `cargo build --release`, and copies +`CHANGELOG.md` verbatim from `origin/main`. The single commit lands directly on `dev` (signed via your normal commit +signing — no PR), establishing release backport as a deliberate convention rather than the prior "never back-merged" +norm. + +The backport is idempotent: re-running on a `dev` already in sync exits 0 with no commit. + ### First-time publish (one-time) The very first crate publish requires a regular crates.io API token (Trusted Publishing needs the crate to exist first). @@ -197,10 +275,10 @@ gh api repos/brettdavies/agentnative-cli/commits//check-runs --jq '.check_r ## Required secrets -| Secret | Purpose | Lifecycle | -| ------ | ------- | --------- | -| `CI_RELEASE_TOKEN` | Fine-grained PAT, Contents R+W, Pull requests R+W. Used by `release.yml` to dispatch the Homebrew formula update. | Rotated annually. | -| `CARGO_REGISTRY_TOKEN` | crates.io API token. Required only for the first publish. | Remove after Trusted Publishing is configured. | +| Secret | Purpose | Lifecycle | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `CI_RELEASE_TOKEN` | Fine-grained PAT, Contents R+W, Pull requests R+W. Used by `release.yml` to dispatch the Homebrew formula update. | Rotated annually. | +| `CARGO_REGISTRY_TOKEN` | crates.io API token. Required only for the first publish. | Remove after Trusted Publishing is configured. | `GITHUB_TOKEN` is automatic; CI (`ci.yml`) only needs `contents: read` and uses no extra secrets. diff --git a/build.rs b/build.rs index 2dcda24..941ef00 100644 --- a/build.rs +++ b/build.rs @@ -1,11 +1,16 @@ -//! Build script: parse vendored spec frontmatter into `$OUT_DIR/generated_requirements.rs`. +//! Build script. Two codegen pipelines plus build_info: //! -//! See `build_support/parser.rs` for the parser; this file is a thin driver -//! that reads `src/principles/spec/principles/p*-*.md`, hands each file to the -//! parser, aggregates, and writes the generated Rust source. +//! 1. Vendored spec frontmatter (`src/principles/spec/principles/p*-*.md`) → +//! `$OUT_DIR/generated_requirements.rs`. Driven by `build_support/parser.rs`. +//! 2. Vendored skill manifest (`src/skill_install/skill.json`) → +//! `$OUT_DIR/generated_hosts.rs`. The manifest's `install` map is the +//! single source of truth for the `SkillHost` enum, `KNOWN_HOSTS` const, +//! and `resolve_host` fn. Updates to the JSON regenerate the Rust map +//! on next build — no manual sync. //! -//! Errors here are *intentionally loud* — every parse failure cites the file, -//! requirement id, and field. The build is the right time to catch spec drift. +//! Errors here are *intentionally loud* — every parse failure cites the +//! file, requirement / host id, and field. The build is the right time to +//! catch fixture drift. use std::env; use std::fs; @@ -22,6 +27,9 @@ fn main() { println!("cargo:rerun-if-changed=src/principles/spec/"); println!("cargo:rerun-if-changed=build_support/parser.rs"); + emit_build_info(&manifest_dir); + emit_skill_hosts(&manifest_dir); + let spec_version = match fs::read_to_string(spec_dir.join("VERSION")) { Ok(s) => s.trim().to_string(), Err(_) => { @@ -58,7 +66,12 @@ fn main() { let mut parsed_per_file = Vec::with_capacity(files.len()); for path in &files { - let name = path.file_name().unwrap().to_str().unwrap().to_string(); + let name = path + .file_name() + .expect("glob match always has a file name component") + .to_str() + .expect("principle filenames are ASCII per spec contract") + .to_string(); let content = fs::read_to_string(path) .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); let reqs = parser::parse_principle_file(&name, &content) @@ -76,3 +89,202 @@ fn main() { fs::write(&out_path, rust_src) .unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display())); } + +/// Emit `$OUT_DIR/build_info.rs` with the `ANC_VERSION` constant. +/// +/// `ANC_VERSION` is the crate version (always present via `CARGO_PKG_VERSION`) +/// and surfaces in the scorecard's `anc.version` so a consumer can identify the +/// `anc` build that produced a scorecard. Per-build Git SHA capture was dropped +/// in v0.3.0 — the version pin is sufficient identity for scorecard consumers +/// and the `cargo:rerun-if-changed` watches on `.git/` made cached-build SHAs +/// fragile across local commits. +fn emit_build_info(_manifest_dir: &std::path::Path) { + let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_string()); + + let mut src = String::new(); + src.push_str("// @generated by build.rs. Do not edit by hand.\n\n"); + src.push_str("/// Crate version (`CARGO_PKG_VERSION`) at build time.\n"); + src.push_str(&format!("pub const ANC_VERSION: &str = \"{version}\";\n")); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let out_path = out_dir.join("build_info.rs"); + fs::write(&out_path, src) + .unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display())); +} + +/// Emit `$OUT_DIR/generated_hosts.rs` from `src/skill_install/skill.json`. +/// +/// Reads the manifest's `install` map and emits, for every `` key: +/// +/// - a `SkillHost` enum variant (PascalCase of the snake_case key) with +/// `clap::ValueEnum` derive + `#[value(rename_all = "snake_case")]` so +/// surface names round-trip back to the JSON key verbatim; +/// - an entry in `KNOWN_HOSTS: &[&str]`; +/// - a match arm in `resolve_host(SkillHost) -> (&'static str, &'static str)` +/// returning the `(url, dest_template)` parsed from the host's install +/// command. +/// +/// Each install command MUST have the canonical shape +/// `git clone --depth 1 ` — six whitespace-separated tokens. +/// Anything else panics the build with the offending host and command. +/// This mirrors `agentnative-site/src/build/skill.mjs` validation so the +/// two binaries reject the same malformed inputs. +fn emit_skill_hosts(manifest_dir: &std::path::Path) { + let skill_json_path = manifest_dir.join("src/skill_install/skill.json"); + println!("cargo:rerun-if-changed=src/skill_install/skill.json"); + + let content = fs::read_to_string(&skill_json_path) + .unwrap_or_else(|e| panic!("read {}: {e}", skill_json_path.display())); + let manifest: serde_json::Value = serde_json::from_str(&content) + .unwrap_or_else(|e| panic!("parse {}: {e}", skill_json_path.display())); + + let install = manifest + .get("install") + .and_then(|v| v.as_object()) + .unwrap_or_else(|| { + panic!( + "{}: \"install\" must be an object (host -> command map)", + skill_json_path.display() + ) + }); + + if install.is_empty() { + panic!( + "{}: install map is empty — at least one host required", + skill_json_path.display() + ); + } + + // Collect (json_key, variant, url, dest) — sorted by JSON key so the + // generated source has stable byte output across runs (mirrors the + // site emitter's sorted-keys contract). + let mut hosts: Vec<(String, String, String, String)> = Vec::with_capacity(install.len()); + for (key, cmd_value) in install { + let cmd = cmd_value.as_str().unwrap_or_else(|| { + panic!( + "{}: install.{key:?} must be a string", + skill_json_path.display() + ) + }); + let tokens: Vec<&str> = cmd.split_whitespace().collect(); + if tokens.len() != 6 + || tokens[0] != "git" + || tokens[1] != "clone" + || tokens[2] != "--depth" + || tokens[3] != "1" + { + panic!( + "{}: install.{key:?} must match `git clone --depth 1 ` (got {} tokens: {cmd:?})", + skill_json_path.display(), + tokens.len(), + ); + } + let url = tokens[4].to_string(); + let dest = tokens[5].to_string(); + if dest.ends_with(".git") { + panic!( + "{}: install.{key:?} dest {dest:?} ends in `.git` — host commands must terminate with an explicit destination, not the bare repo name", + skill_json_path.display() + ); + } + let variant = pascal_case(key).unwrap_or_else(|e| { + panic!( + "{}: install.{key:?} is not a valid Rust identifier: {e}", + skill_json_path.display() + ) + }); + hosts.push((key.clone(), variant, url, dest)); + } + hosts.sort_by(|a, b| a.0.cmp(&b.0)); + + // Render Rust source. + let mut src = String::new(); + src.push_str( + "// @generated by build.rs from src/skill_install/skill.json. Do not edit by hand.\n", + ); + src.push_str( + "// Add or remove hosts via the JSON file (or `bash scripts/sync-skill-fixture.sh`)\n", + ); + src.push_str("// and `cargo build` regenerates this file.\n\n"); + + src.push_str("/// Hosts the binary knows how to install into. Surface names match\n"); + src.push_str("/// `agentnative-site/src/data/skill.json` keys verbatim via\n"); + src.push_str("/// `rename_all = \"snake_case\"`.\n"); + src.push_str("#[derive(Clone, Copy, Debug, PartialEq, Eq, ::clap::ValueEnum)]\n"); + src.push_str("#[value(rename_all = \"snake_case\")]\n"); + src.push_str("pub enum SkillHost {\n"); + for (_, variant, _, _) in &hosts { + src.push_str(&format!(" {variant},\n")); + } + src.push_str("}\n\n"); + + src.push_str( + "/// Host names accepted by `anc skill install `, in JSON-key sort order.\n", + ); + src.push_str("/// Surfaces externally for shell-completion enumeration and as the seed\n"); + src.push_str("/// for a future `anc skill list` verb. Stays in lockstep with [`SkillHost`]\n"); + src.push_str("/// variants because both are generated from the same source.\n"); + src.push_str("#[allow(dead_code)]\n"); + src.push_str("pub const KNOWN_HOSTS: &[&str] = &[\n"); + for (key, _, _, _) in &hosts { + src.push_str(&format!(" {key:?},\n")); + } + src.push_str("];\n\n"); + + src.push_str("/// Resolve a host enum to its `(url, dest_template)` pair, parsed\n"); + src.push_str("/// at build time from the install command in src/skill_install/skill.json.\n"); + src.push_str("/// Pure function — no I/O, no side effects.\n"); + src.push_str("pub fn resolve_host(host: SkillHost) -> (&'static str, &'static str) {\n"); + src.push_str(" match host {\n"); + for (_, variant, url, dest) in &hosts { + src.push_str(&format!( + " SkillHost::{variant} => ({url:?}, {dest:?}),\n" + )); + } + src.push_str(" }\n"); + src.push_str("}\n\n"); + + src.push_str("/// JSON-key string for the envelope's `host` field. Generated alongside\n"); + src.push_str("/// the enum so the surface stays in lockstep with the JSON contract.\n"); + src.push_str("pub fn host_envelope_str(host: SkillHost) -> &'static str {\n"); + src.push_str(" match host {\n"); + for (key, variant, _, _) in &hosts { + src.push_str(&format!(" SkillHost::{variant} => {key:?},\n")); + } + src.push_str(" }\n"); + src.push_str("}\n"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let out_path = out_dir.join("generated_hosts.rs"); + fs::write(&out_path, src) + .unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display())); +} + +/// Convert a snake_case ASCII identifier to PascalCase. Rejects empty +/// strings, leading digits, and any character outside `[a-z0-9_]` so the +/// emitted variant is always a valid Rust identifier. +fn pascal_case(snake: &str) -> Result { + if snake.is_empty() { + return Err("empty identifier".into()); + } + if snake.starts_with(|c: char| c.is_ascii_digit()) { + return Err(format!("{snake:?} starts with a digit")); + } + if !snake + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + { + return Err(format!( + "{snake:?} contains non-snake_case ASCII characters" + )); + } + let mut out = String::with_capacity(snake.len()); + for word in snake.split('_') { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + out.push(first.to_ascii_uppercase()); + out.push_str(chars.as_str()); + } + } + Ok(out) +} diff --git a/completions/anc.bash b/completions/anc.bash index 32173bc..e02e30f 100644 --- a/completions/anc.bash +++ b/completions/anc.bash @@ -28,6 +28,9 @@ _anc() { anc,help) cmd="anc__help" ;; + anc,skill) + cmd="anc__skill" + ;; anc__generate,coverage-matrix) cmd="anc__generate__coverage__matrix" ;; @@ -52,9 +55,27 @@ _anc() { anc__help,help) cmd="anc__help__help" ;; + anc__help,skill) + cmd="anc__help__skill" + ;; anc__help__generate,coverage-matrix) cmd="anc__help__generate__coverage__matrix" ;; + anc__help__skill,install) + cmd="anc__help__skill__install" + ;; + anc__skill,help) + cmd="anc__skill__help" + ;; + anc__skill,install) + cmd="anc__skill__install" + ;; + anc__skill__help,help) + cmd="anc__skill__help__help" + ;; + anc__skill__help,install) + cmd="anc__skill__help__install" + ;; *) ;; esac @@ -62,7 +83,7 @@ _anc() { case "${cmd}" in anc) - opts="-q -h -V --quiet --help --version check completions generate help" + opts="-q -h -V --quiet --help --version check completions generate skill help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -198,7 +219,7 @@ _anc() { return 0 ;; anc__help) - opts="check completions generate help" + opts="check completions generate skill help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -281,6 +302,108 @@ _anc() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + anc__help__skill) + opts="install" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__help__skill__install) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__skill) + opts="-q -h --quiet --help install help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__skill__help) + opts="install help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__skill__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__skill__help__install) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__skill__install) + opts="-q -h --dry-run --output --quiet --help claude_code codex cursor factory kiro opencode" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "text json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; esac } diff --git a/completions/anc.elvish b/completions/anc.elvish index e6402dc..a118dc1 100644 --- a/completions/anc.elvish +++ b/completions/anc.elvish @@ -27,6 +27,7 @@ set edit:completion:arg-completer[anc] = {|@words| cand check 'Check a CLI project or binary for agent-readiness' cand completions 'Generate shell completions' cand generate 'Generate build artifacts (coverage matrix, etc.)' + cand skill 'Install or manage the agentnative skill bundle' cand help 'Print this message or the help of the given subcommand(s)' } &'anc;check'= { @@ -73,10 +74,35 @@ set edit:completion:arg-completer[anc] = {|@words| } &'anc;generate;help;help'= { } + &'anc;skill'= { + cand -q 'Suppress non-essential output' + cand --quiet 'Suppress non-essential output' + cand -h 'Print help' + cand --help 'Print help' + cand install 'Install the skill bundle into a host''s canonical skills directory' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'anc;skill;install'= { + cand --output 'Output format for the result envelope' + cand --dry-run 'Print the resolved git command without spawning. Captures cleanly via `eval $(anc skill install --dry-run )`' + cand -q 'Suppress non-essential output' + cand --quiet 'Suppress non-essential output' + cand -h 'Print help (see more with ''--help'')' + cand --help 'Print help (see more with ''--help'')' + } + &'anc;skill;help'= { + cand install 'Install the skill bundle into a host''s canonical skills directory' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'anc;skill;help;install'= { + } + &'anc;skill;help;help'= { + } &'anc;help'= { cand check 'Check a CLI project or binary for agent-readiness' cand completions 'Generate shell completions' cand generate 'Generate build artifacts (coverage matrix, etc.)' + cand skill 'Install or manage the agentnative skill bundle' cand help 'Print this message or the help of the given subcommand(s)' } &'anc;help;check'= { @@ -88,6 +114,11 @@ set edit:completion:arg-completer[anc] = {|@words| } &'anc;help;generate;coverage-matrix'= { } + &'anc;help;skill'= { + cand install 'Install the skill bundle into a host''s canonical skills directory' + } + &'anc;help;skill;install'= { + } &'anc;help;help'= { } ] diff --git a/completions/anc.fish b/completions/anc.fish index 6c11e44..4be08e7 100644 --- a/completions/anc.fish +++ b/completions/anc.fish @@ -30,6 +30,7 @@ complete -c anc -n "__fish_anc_needs_command" -s V -l version -d 'Print version' complete -c anc -n "__fish_anc_needs_command" -f -a "check" -d 'Check a CLI project or binary for agent-readiness' complete -c anc -n "__fish_anc_needs_command" -f -a "completions" -d 'Generate shell completions' complete -c anc -n "__fish_anc_needs_command" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)' +complete -c anc -n "__fish_anc_needs_command" -f -a "skill" -d 'Install or manage the agentnative skill bundle' complete -c anc -n "__fish_anc_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c anc -n "__fish_anc_using_subcommand check" -l command -d 'Resolve a command from PATH and run behavioral checks against it' -r -f -a "(__fish_complete_command)" complete -c anc -n "__fish_anc_using_subcommand check" -l principle -d 'Filter checks by principle number (1-7)' -r @@ -57,8 +58,21 @@ complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcom complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcommand_from coverage-matrix" -s h -l help -d 'Print help' complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "coverage-matrix" -d 'Render the spec coverage matrix (registry → checks → artifact)' complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "check" -d 'Check a CLI project or binary for agent-readiness' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "completions" -d 'Generate shell completions' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -s q -l quiet -d 'Suppress non-essential output' +complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -s h -l help -d 'Print help' +complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory' +complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -l output -d 'Output format for the result envelope' -r -f -a "text\t'' +json\t''" +complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -l dry-run -d 'Print the resolved git command without spawning. Captures cleanly via `eval $(anc skill install --dry-run )`' +complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -s q -l quiet -d 'Suppress non-essential output' +complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from help" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory' +complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "check" -d 'Check a CLI project or binary for agent-readiness' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "completions" -d 'Generate shell completions' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "skill" -d 'Install or manage the agentnative skill bundle' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c anc -n "__fish_anc_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "coverage-matrix" -d 'Render the spec coverage matrix (registry → checks → artifact)' +complete -c anc -n "__fish_anc_using_subcommand help; and __fish_seen_subcommand_from skill" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory' diff --git a/completions/anc.powershell b/completions/anc.powershell index 6e94bab..a8048ba 100644 --- a/completions/anc.powershell +++ b/completions/anc.powershell @@ -30,6 +30,7 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { [CompletionResult]::new('check', 'check', [CompletionResultType]::ParameterValue, 'Check a CLI project or binary for agent-readiness') [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions') [CompletionResult]::new('generate', 'generate', [CompletionResultType]::ParameterValue, 'Generate build artifacts (coverage matrix, etc.)') + [CompletionResult]::new('skill', 'skill', [CompletionResultType]::ParameterValue, 'Install or manage the agentnative skill bundle') [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') break } @@ -84,10 +85,40 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { 'anc;generate;help;help' { break } + 'anc;skill' { + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install the skill bundle into a host''s canonical skills directory') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'anc;skill;install' { + [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'Output format for the result envelope') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Print the resolved git command without spawning. Captures cleanly via `eval $(anc skill install --dry-run )`') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') + break + } + 'anc;skill;help' { + [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install the skill bundle into a host''s canonical skills directory') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'anc;skill;help;install' { + break + } + 'anc;skill;help;help' { + break + } 'anc;help' { [CompletionResult]::new('check', 'check', [CompletionResultType]::ParameterValue, 'Check a CLI project or binary for agent-readiness') [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions') [CompletionResult]::new('generate', 'generate', [CompletionResultType]::ParameterValue, 'Generate build artifacts (coverage matrix, etc.)') + [CompletionResult]::new('skill', 'skill', [CompletionResultType]::ParameterValue, 'Install or manage the agentnative skill bundle') [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') break } @@ -104,6 +135,13 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { 'anc;help;generate;coverage-matrix' { break } + 'anc;help;skill' { + [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install the skill bundle into a host''s canonical skills directory') + break + } + 'anc;help;skill;install' { + break + } 'anc;help;help' { break } diff --git a/completions/anc.zsh b/completions/anc.zsh index 2d903aa..29cc19f 100644 --- a/completions/anc.zsh +++ b/completions/anc.zsh @@ -113,6 +113,61 @@ esac ;; esac ;; +(skill) +_arguments "${_arguments_options[@]}" : \ +'-q[Suppress non-essential output]' \ +'--quiet[Suppress non-essential output]' \ +'-h[Print help]' \ +'--help[Print help]' \ +":: :_anc__skill_commands" \ +"*::: :->skill" \ +&& ret=0 + + case $state in + (skill) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:anc-skill-command-$line[1]:" + case $line[1] in + (install) +_arguments "${_arguments_options[@]}" : \ +'--output=[Output format for the result envelope]:OUTPUT:(text json)' \ +'--dry-run[Print the resolved git command without spawning. Captures cleanly via \`eval \$(anc skill install --dry-run )\`]' \ +'-q[Suppress non-essential output]' \ +'--quiet[Suppress non-essential output]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':host -- Target host (claude_code, codex, cursor, opencode):(claude_code codex cursor factory kiro opencode)' \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +":: :_anc__skill__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:anc-skill-help-command-$line[1]:" + case $line[1] in + (install) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_anc__help_commands" \ @@ -153,6 +208,26 @@ _arguments "${_arguments_options[@]}" : \ ;; esac ;; +(skill) +_arguments "${_arguments_options[@]}" : \ +":: :_anc__help__skill_commands" \ +"*::: :->skill" \ +&& ret=0 + + case $state in + (skill) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:anc-help-skill-command-$line[1]:" + case $line[1] in + (install) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -172,6 +247,7 @@ _anc_commands() { 'check:Check a CLI project or binary for agent-readiness' \ 'completions:Generate shell completions' \ 'generate:Generate build artifacts (coverage matrix, etc.)' \ +'skill:Install or manage the agentnative skill bundle' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'anc commands' commands "$@" @@ -223,6 +299,7 @@ _anc__help_commands() { 'check:Check a CLI project or binary for agent-readiness' \ 'completions:Generate shell completions' \ 'generate:Generate build artifacts (coverage matrix, etc.)' \ +'skill:Install or manage the agentnative skill bundle' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'anc help commands' commands "$@" @@ -254,6 +331,49 @@ _anc__help__help_commands() { local commands; commands=() _describe -t commands 'anc help help commands' commands "$@" } +(( $+functions[_anc__help__skill_commands] )) || +_anc__help__skill_commands() { + local commands; commands=( +'install:Install the skill bundle into a host'\''s canonical skills directory' \ + ) + _describe -t commands 'anc help skill commands' commands "$@" +} +(( $+functions[_anc__help__skill__install_commands] )) || +_anc__help__skill__install_commands() { + local commands; commands=() + _describe -t commands 'anc help skill install commands' commands "$@" +} +(( $+functions[_anc__skill_commands] )) || +_anc__skill_commands() { + local commands; commands=( +'install:Install the skill bundle into a host'\''s canonical skills directory' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'anc skill commands' commands "$@" +} +(( $+functions[_anc__skill__help_commands] )) || +_anc__skill__help_commands() { + local commands; commands=( +'install:Install the skill bundle into a host'\''s canonical skills directory' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'anc skill help commands' commands "$@" +} +(( $+functions[_anc__skill__help__help_commands] )) || +_anc__skill__help__help_commands() { + local commands; commands=() + _describe -t commands 'anc skill help help commands' commands "$@" +} +(( $+functions[_anc__skill__help__install_commands] )) || +_anc__skill__help__install_commands() { + local commands; commands=() + _describe -t commands 'anc skill help install commands' commands "$@" +} +(( $+functions[_anc__skill__install_commands] )) || +_anc__skill__install_commands() { + local commands; commands=() + _describe -t commands 'anc skill install commands' commands "$@" +} if [ "$funcstack[1]" = "_anc" ]; then _anc "$@" diff --git a/scripts/SYNCS.md b/scripts/SYNCS.md new file mode 100644 index 0000000..619a992 --- /dev/null +++ b/scripts/SYNCS.md @@ -0,0 +1,151 @@ +# Cross-repo sync map + +How spec / skill / coverage data flows in and out of this repo. Source of truth for sync mechanisms. Update when scripts +or workflows change. + +This file complements the per-script header comments (which document mechanism + env vars) and the prose in +`RELEASES.md` / `CLAUDE.md` / `AGENTS.md` (which document a single sync point in context). The job here is to lay every +sync edge out in one table so the system is legible at a glance. + +## Cross-repo data map + +```mermaid +flowchart LR + subgraph Inbound["Inbound — data INTO this repo"] + SPEC["agentnative-spec
(principles + VERSION + CHANGELOG)"] + SITE_IN["agentnative-site
src/data/skill.json"] + MAIN["self: main branch
(Cargo.toml, Cargo.lock, CHANGELOG)"] + end + + CLI(["agentnative-cli
(this repo)"]) + + subgraph Outbound["Outbound — data OUT of this repo"] + SITE_COV["agentnative-site
coverage-matrix.json"] + SITE_SCORE["agentnative-site
per-tool scorecards"] + TAP["brettdavies/homebrew-tap
(formula + bottles)"] + CRATES["crates.io
(agentnative crate)"] + end + + SPEC -->|sync-spec.sh| CLI + SITE_IN -->|sync-skill-fixture.sh| CLI + MAIN -->|sync-dev-after-release.sh
main → dev| CLI + + CLI -->|site's sync-coverage-matrix.sh
(cli is authoritative)| SITE_COV + CLI -->|site's regen-scorecards.sh
(anc check ... --output json)| SITE_SCORE + CLI -->|release.yml
repository_dispatch:update-formula| TAP + CLI -->|release.yml
cargo publish via OIDC| CRATES + + TAP -.->|repository_dispatch:finalize-release
(inverse — flips make_latest)| CLI + + classDef repo fill:#1f2937,stroke:#60a5fa,color:#f9fafb + classDef self fill:#0f766e,stroke:#5eead4,color:#f0fdfa + class SPEC,SITE_IN,MAIN,SITE_COV,SITE_SCORE,TAP,CRATES repo + class CLI self +``` + +## Upstream — data flowing INTO this repo + +| Source | Mechanism | What's synced | Trigger / cadence | Drift check | +| ------------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `brettdavies/agentnative` (spec) @ latest `v*` tag | `scripts/sync-spec.sh` (manual; remote-first, falls back to `$SPEC_ROOT`) | `principles/p*-*.md` + top-level `VERSION` + `CHANGELOG.md` → `src/principles/spec/` | Rerun after every new `agentnative-spec` `v*` tag. The intended trigger is a `repository_dispatch` from the spec's publish workflow; until that exists, manual. | `build.rs` is *intentionally loud* — fails on missing `VERSION`, missing `principles/` dir, parse errors, duplicate IDs, or missing fields. `cargo test` (`integration::*` + `dangling_cover_ids`) catches `covers()` IDs that drift from the vendored registry. | +| `brettdavies/agentnative-site` `src/data/skill.json` @ `dev` | `scripts/sync-skill-fixture.sh` (manual; `--check` in CI) | Skill bundle manifest (install map / hosts) → `src/skill_install/skill.json` | Rerun whenever the site changes `src/data/skill.json`. Pre-release checklist in `RELEASES.md` step 7 captures this for every release. | `.github/workflows/skill-fixture-drift.yml` runs `sync-skill-fixture.sh --check` on every PR + push to main/dev. Companion cargo test `host_map_matches_site_skill_json` catches drift between the Rust-codegen map and this fixture. | +| this repo's own `main` branch (release artifacts) | `scripts/sync-dev-after-release.sh vX.Y.Z` (manual; idempotent) | `Cargo.toml` `[package].version` (surgical, single-line awk) + regenerated `Cargo.lock` (`cargo build --release`) + `CHANGELOG.md` (verbatim from `origin/main`) → `dev` | Run AFTER (1) `release/v*` → `main` PR merges, (2) `git tag vX.Y.Z` pushed, (3) `finalize-release.yml` flips the GitHub Release to `published`. | n/a — single signed commit, surgical edits, idempotent re-run is a no-op. Pre-flight checks: working tree clean, tag exists locally, tag is reachable from `origin/main`. | + +## Downstream — data flowing OUT of this repo + +| Consumer | Mechanism | What's synced | Trigger / cadence | Drift check | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `brettdavies/agentnative-site` (`/coverage` page) | site's `scripts/sync-coverage-matrix.sh` `cp`s from `$ANC_ROOT/coverage/matrix.json` (default `$HOME/dev/agentnative-cli`) → `src/data/coverage-matrix.json` | `coverage/matrix.json` (`schema_version: "1.0"`), generated here by `anc generate coverage-matrix`, committed as a tracked artifact (not gitignored) | Run on the site after this repo bumps the matrix (new check, registry change, or `Check::covers()` change). | This repo's CI (via `cargo test`) runs `test_generate_coverage_matrix_drift_check_passes_on_committed_artifacts`, which invokes `anc generate coverage-matrix --check` and exits non-zero when `docs/coverage-matrix.md` or `coverage/matrix.json` disagree with the registry. Site has no automated drift check — the cli-side gate is authoritative. | +| `brettdavies/agentnative-site` (per-tool scorecards) | site's `scripts/regen-scorecards.sh` runs `anc check --command [--audit-profile ] --output json` against each registry entry; writes `scorecards/-v.json` in the site repo | Per-tool scorecard JSONs (`schema_version: "0.5"`); the `anc` binary embeds `spec_version` at compile time (sourced from the vendored `src/principles/spec/VERSION`) | Run on the site after `anc` is upgraded on the box (`brew upgrade brettdavies/tap/agentnative`); also run on registry changes. Script enforces `MIN_ANC_VERSION` (currently `0.1.3`) unless `--allow-dev-build` is passed. | Site validates schema 0.5 invariants at build time (`bun test` + `bun run build`). Filename owns the canonical version anchor — the actually-installed `anc --version` determines the output filename, so a filename can never lie about which release was scored. | +| `brettdavies/homebrew-tap` (formula bump) | `.github/workflows/release.yml` → reusable `brettdavies/.github/.github/workflows/rust-release.yml@main` → `homebrew` job fires `repository_dispatch` (`event_type=update-formula`, payload: formula=`agentnative`, version=`X.Y.Z`, repo) | Triggers homebrew-tap to bump the `agentnative` formula and build bottles | On every `git tag v*.*.*` push to this repo. Authenticated via `CI_RELEASE_TOKEN` (fine-grained PAT with Contents R+W). | n/a at this boundary. Bottle-build success is observable via the homebrew-tap workflow run; bottle-upload back to this repo's Release assets is what triggers the inverse `finalize-release` dispatch (next row). | +| `brettdavies/agentnative-cli` (this repo's own `finalize-release.yml`) | Inverse `repository_dispatch` from homebrew-tap's publish workflow — `event_type=finalize-release` | Bottle SHAs uploaded to this Release's assets; `make_latest` flips from `false` → `true` on the GitHub Release | Fired by homebrew-tap after bottles upload. Idempotent — re-dispatch is safe. | n/a — the flip is observable on the Release page. | +| `crates.io` (`agentnative` crate) | Same `release.yml` → `publish-crate` job, `cargo publish` via OIDC Trusted Publishing (no static token after first publish) | The compiled crate at the tag's version | On every `git tag v*.*.*` push. First publish requires `CARGO_REGISTRY_TOKEN` one-time; subsequent publishes are token-less. | `check-version` job gates the pipeline: tag must match `Cargo.toml` `[package].version` exactly, else release aborts before any publish. | + +## Release / sync orchestration + +The full sync graph clusters around two events: **"new spec tag upstream"** and **"new `anc` release downstream"**. + +### When `agentnative-spec` cuts a new `v*` tag + +1. **Manual** — rerun `scripts/sync-spec.sh` here. Diff `src/principles/spec/`. Commit on a feature branch. +2. **Manual** — propagate any new/changed requirement IDs into `src/checks/*` `Check::covers()` declarations. The + build's `dangling_cover_ids` drift detector forces this — typos surface at `cargo test`, not at render time. +3. **Manual** — if the registry shape or covers map changed, run `anc generate coverage-matrix` and commit + `docs/coverage-matrix.md` + `coverage/matrix.json`. The cargo-level `--check` test fails CI otherwise. +4. **Manual on the site** — once a new `anc` version ships (see next section), rerun + `agentnative-site/scripts/sync-coverage-matrix.sh` to pick up the new `coverage/matrix.json`. + +### When `agentnative-cli` cuts a new `v*` tag + +1. **Manual pre-release** (`RELEASES.md` step 7) — `bash scripts/sync-skill-fixture.sh` and review the diff. Catches any + site-side `skill.json` changes since `dev` was branched. The Rust host map regenerates from the JSON on the next + `cargo build` — no manual src edits. +2. **Automatic on tag push** — `release.yml` runs `check-version` → `audit` → `build` (5 targets) → `publish-crate` + (crates.io OIDC) → `release` (draft GH Release, `make_latest: false`) → `homebrew` (`repository_dispatch` to + homebrew-tap). +3. **Automatic, inverse** — homebrew-tap builds bottles, uploads them as assets on this repo's Release, then dispatches + `finalize-release` back to this repo. `finalize-release.yml` flips `make_latest: true` idempotently. +4. **Manual post-release** — `./scripts/sync-dev-after-release.sh vX.Y.Z` then `git push origin dev`. Backports the + release-bookkeeping single-commit (Cargo.toml version, regenerated Cargo.lock, CHANGELOG.md from main) to `dev` so + future builds from `dev` report the released version and the embedded badge URL points at the right slug. +5. **Manual on the site** — `scripts/regen-scorecards.sh` against the upgraded `anc` (gated by `MIN_ANC_VERSION` = + `0.1.3`) refreshes per-tool scorecards. Then `scripts/sync-coverage-matrix.sh` if the matrix changed. + +#### Release pipeline sequence + +```mermaid +sequenceDiagram + autonumber + actor Maintainer + participant CLI as agentnative-cli repo + participant REL as release.yml + participant CRATES as crates.io + participant TAP as brettdavies/homebrew-tap + participant FIN as finalize-release.yml + + Maintainer->>CLI: git push origin vX.Y.Z (tag) + CLI->>REL: tag push triggers workflow + REL->>REL: check-version (tag == Cargo.toml) + REL->>REL: audit + build (5 targets) + REL->>CRATES: publish-crate (cargo publish via OIDC) + CRATES-->>REL: published + REL->>CLI: create draft GH Release (make_latest: false) + REL->>TAP: repository_dispatch (event_type=update-formula) + TAP->>TAP: bump formula + build bottles + TAP->>CLI: upload bottle assets to Release + TAP->>FIN: repository_dispatch (event_type=finalize-release) + FIN->>CLI: flip make_latest: true (idempotent) + FIN-->>Maintainer: Release is now "latest" + Maintainer->>CLI: ./scripts/sync-dev-after-release.sh vX.Y.Z (manual) +``` + +### Cadence summary — what's automatic vs manual + +| Step | Automation | +| ------------------------------------------------ | ------------------------------------------------------------- | +| spec → cli (`sync-spec.sh`) | manual (intended: spec `repository_dispatch`, not yet wired) | +| site → cli (`sync-skill-fixture.sh`) update | manual; CI enforces no-drift via `--check` | +| cli → site (coverage matrix) | manual on the site side; CI enforces no-drift on the cli side | +| cli → site (scorecards) | manual on the site side | +| cli → crates.io | automatic on `v*` tag push | +| cli → homebrew-tap (formula) | automatic on `v*` tag push | +| homebrew-tap → cli (`finalize-release`) | automatic on bottle upload | +| cli main → cli dev (`sync-dev-after-release.sh`) | manual after `finalize-release` publishes | + +## Reference + +- [`scripts/sync-spec.sh`](sync-spec.sh) — header comment has detailed usage, env vars, and resync cadence. +- [`scripts/sync-skill-fixture.sh`](sync-skill-fixture.sh) — header comment covers `--check` mode and CI integration. +- [`scripts/sync-dev-after-release.sh`](sync-dev-after-release.sh) — header comment lists pre-flight conditions. +- [`../RELEASES.md`](../RELEASES.md) — full release pipeline (branch flow, tag/publish, post-release sync). +- [`../docs/plans/2026-04-23-001-feat-spec-vendor-plan.md`](../docs/plans/2026-04-23-001-feat-spec-vendor-plan.md) — + status: completed. The plan that originated the vendored-spec mechanism. +- agentnative-spec roadmap (parent of the spec-vendor plan): + `agentnative-spec/docs/plans/2026-04-22-002-post-frontmatter-roadmap.md`. +- + [`../docs/solutions/architecture-patterns/cross-repo-artifact-sync-commit-over-fetch-20260420.md`](../docs/solutions/architecture-patterns/cross-repo-artifact-sync-commit-over-fetch-20260420.md) + — the "commit-over-fetch" decision that anchors why `coverage/matrix.json` is a tracked artifact rather than a + build-time fetch. +- + [`../docs/solutions/best-practices/cross-repo-artifact-consumption-static-sites-2026-04-21.md`](../docs/solutions/best-practices/cross-repo-artifact-consumption-static-sites-2026-04-21.md) + — the consumer-side pattern for the site. diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index a9d2349..f3522c0 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -41,26 +41,47 @@ else echo " - deny (skipped, cargo-deny not installed)" fi -# 5. Windows cross-check: verify no unconditional use of unix-only APIs -# This catches the libc::SIGPIPE issue without needing a Windows toolchain. +# 5. Windows cross-check (fast): verify no unconditional use of unix-only APIs. +# Catches the libc::SIGPIPE class of issue without invoking a Windows toolchain +# — runs in milliseconds. Step 6 is the comprehensive backstop. if rg -n 'libc::(SIGPIPE|SIG_DFL|signal)' --type rust src/ | rg -v '#\[cfg(unix)\]' | rg -v '// *#\[cfg' | rg -qv '^$'; then - # Found libc unix calls — check they're inside #[cfg(unix)] blocks violations=$(rg -n 'libc::(SIGPIPE|SIG_DFL|signal)' --type rust src/ 2>/dev/null || true) if [ -n "$violations" ]; then - # Check each file for proper cfg gating while IFS= read -r line; do file=$(echo "$line" | cut -d: -f1) lineno=$(echo "$line" | cut -d: -f2) - # Look for #[cfg(unix)] in the 3 lines before the libc call before=$(sed -n "$((lineno > 3 ? lineno - 3 : 1)),${lineno}p" "$file") if ! echo "$before" | rg -q 'cfg\(unix\)'; then fail "libc unix-only API used without #[cfg(unix)] at $file:$lineno" fi done <<< "$violations" fi - pass "windows compat" + pass "windows compat (libc grep)" else - pass "windows compat" + pass "windows compat (libc grep)" +fi + +# 6. Windows cross-clippy: full type-check of the codebase as if compiling for +# x86_64-pc-windows-gnu. Catches cfg-gated unused imports, conditional dead +# code, and any other warning class that only surfaces on Windows. Linux +# clippy alone misses these because `#[cfg(unix)]` filters out their usage +# sites locally — the warning only fires on the platform where the gate +# excludes them. +# +# Requirements (one-time setup): +# sudo apt install mingw-w64 # the C linker tree-sitter needs +# rustup target add x86_64-pc-windows-gnu # the Windows std library +# +# If either is missing the step is skipped with a one-line note rather than +# failing — but local Windows coverage is then absent, and CI's Windows job +# becomes the only backstop. Install both to close that gap. +if command -v x86_64-w64-mingw32-gcc &>/dev/null \ + && rustup target list --installed 2>/dev/null | grep -q '^x86_64-pc-windows-gnu$'; then + RUSTFLAGS="-Dwarnings" cargo clippy --target x86_64-pc-windows-gnu --all-targets --quiet 2>&1 \ + || fail "cargo clippy --target x86_64-pc-windows-gnu -Dwarnings" + pass "windows clippy (cross-target)" +else + echo " - windows clippy (skipped: install \`mingw-w64\` + \`rustup target add x86_64-pc-windows-gnu\` to enable)" fi echo -e "${BOLD}${GREEN}All checks passed.${RESET}" diff --git a/scripts/sync-dev-after-release.sh b/scripts/sync-dev-after-release.sh new file mode 100755 index 0000000..aa558c2 --- /dev/null +++ b/scripts/sync-dev-after-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Backport release artifacts from main to dev after a release tag publishes. +# +# Pulls three files from main and lands them as a single signed commit on dev: +# - Cargo.toml — surgically updates ONLY the [package].version line. Other +# Cargo.toml lines on dev (deps, rust-version, etc) may legitimately be +# ahead of main; they are preserved. +# - Cargo.lock — regenerated cleanly via `cargo build --release` after the +# version bump. Never hand-patched. +# - CHANGELOG.md — copied verbatim from origin/main. Main is fully +# authoritative for CHANGELOG; dev never edits it directly. +# +# Run AFTER: +# 1. The release/v* → main PR has merged. +# 2. `git tag -a vX.Y.Z` has been pushed to origin. +# 3. `finalize-release.yml` has flipped the GitHub Release to `published` +# (i.e. homebrew bottles uploaded, make_latest=true, full release done). +# +# Usage: +# ./scripts/sync-dev-after-release.sh v0.2.0 +# +# Idempotent: safe to re-run. If dev already matches main on these three +# files, the script exits 0 with no commit. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 vX.Y.Z" >&2 + exit 64 +fi + +VERSION="$1" +if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: version must match vMAJOR.MINOR.PATCH (got: $VERSION)" >&2 + exit 64 +fi +VERSION_NO_V="${VERSION#v}" + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +if [[ -n "$(git status --porcelain)" ]]; then + echo "error: working tree not clean — commit or stash first" >&2 + git status --short >&2 + exit 65 +fi + +git fetch origin --tags --quiet + +# Verify the release tag exists on origin/main. +if ! git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then + echo "error: tag $VERSION not found locally — run 'git fetch origin --tags' or verify the release published" >&2 + exit 66 +fi + +# Verify main is at or past the tag (i.e. release actually merged). +TAG_SHA="$(git rev-parse "$VERSION")" +if ! git merge-base --is-ancestor "$TAG_SHA" origin/main; then + echo "error: tag $VERSION is not reachable from origin/main — wait for release/v* to merge" >&2 + exit 66 +fi + +git switch dev +git pull --ff-only origin dev + +# Surgical Cargo.toml version bump (only the first `version = "..."` line, +# which is the [package] section's by convention). Uses awk to avoid sed -i +# portability issues across Linux/macOS. +awk -v ver="$VERSION_NO_V" ' + !done && /^version = "[^"]*"$/ { print "version = \"" ver "\""; done=1; next } + { print } +' Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml + +# CHANGELOG.md from main (authoritative). +git checkout origin/main -- CHANGELOG.md + +# Regenerate Cargo.lock cleanly. +cargo build --release --quiet + +if git diff --quiet Cargo.toml Cargo.lock CHANGELOG.md; then + echo "no changes — dev already in sync with $VERSION" + exit 0 +fi + +git add Cargo.toml Cargo.lock CHANGELOG.md +git commit -m "chore(release): backport $VERSION artifacts to dev + +Brings dev's release-bookkeeping current with the $VERSION release on main: +Cargo.toml [package].version, regenerated Cargo.lock, and CHANGELOG.md +copied from origin/main." + +echo "committed; push with: git push origin dev" diff --git a/scripts/sync-skill-fixture.sh b/scripts/sync-skill-fixture.sh new file mode 100755 index 0000000..efd9770 --- /dev/null +++ b/scripts/sync-skill-fixture.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Sync src/skill_install/skill.json from agentnative-site/src/data/skill.json. +# +# The fixture is also the build-time input for build.rs's host-map codegen +# (which emits the SkillHost enum, KNOWN_HOSTS const, and resolve_host fn +# into $OUT_DIR/generated_hosts.rs). Single source of truth: rerun this +# script on site updates and `cargo build` regenerates the Rust map. CI's +# skill-fixture-drift workflow runs `--check` on every PR to catch drift +# between the committed fixture and the canonical site contract. +# +# Modes: +# scripts/sync-skill-fixture.sh Update the fixture in place. +# scripts/sync-skill-fixture.sh --check Verify the fixture is current; +# exit non-zero on drift. +# +# Env vars (mirroring scripts/sync-spec.sh shape): +# SKILL_SITE_REMOTE_URL Remote URL to query first. +# Default: https://github.com/brettdavies/agentnative-site.git +# SKILL_SITE_REF Ref to extract from. Default: dev. +# (agentnative-site uses a dev/main forever-branch +# flow — dev is the working trunk; main is older.) +# SKILL_SITE_ROOT Local checkout to fall back to when the remote is +# unreachable. Default: $HOME/dev/agentnative-site +# +# Resync cadence: rerun whenever agentnative-site changes +# src/data/skill.json. Pre-release checklist captures this in RELEASES.md. + +set -euo pipefail + +SKILL_SITE_REMOTE_URL="${SKILL_SITE_REMOTE_URL:-https://github.com/brettdavies/agentnative-site.git}" +SKILL_SITE_REF="${SKILL_SITE_REF:-dev}" +SKILL_SITE_ROOT="${SKILL_SITE_ROOT:-$HOME/dev/agentnative-site}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEST_FILE="$REPO_ROOT/src/skill_install/skill.json" +SOURCE_PATH="src/data/skill.json" + +mode="update" +if [[ ${1:-} == "--check" ]]; then + mode="check" +elif [[ -n ${1:-} ]]; then + echo "error: unknown argument: $1" >&2 + echo "usage: $0 [--check]" >&2 + exit 2 +fi + +# Always-allocated workspace dir for the upstream copy. The remote path +# also clones into a child of this dir; the local path uses it just for +# the staged blob. +tmp_root="$(mktemp -d -t agentnative-site-sync-XXXXXX)" +cleanup() { + if [[ -n "$tmp_root" && -d "$tmp_root" ]]; then + rm -rf "$tmp_root" + fi +} +trap cleanup EXIT + +# === Remote-first resolution =========================================== +site_source="" +resolved_sha="" + +echo "querying $SKILL_SITE_REMOTE_URL for $SKILL_SITE_REF..." +remote_clone="$tmp_root/clone" +if git clone --depth 1 --branch "$SKILL_SITE_REF" --quiet \ + "$SKILL_SITE_REMOTE_URL" "$remote_clone" 2>/dev/null; then + site_source="$remote_clone" + resolved_sha="$(git -C "$site_source" rev-parse --short=7 HEAD)" + echo "extracting $SKILL_SITE_REF ($resolved_sha) from remote $SKILL_SITE_REMOTE_URL" +fi + +# === Local fallback ==================================================== +if [[ -z "$site_source" ]]; then + if [[ ! -d "$SKILL_SITE_ROOT/.git" ]]; then + echo "error: remote unreachable and SKILL_SITE_ROOT is not a git repository: $SKILL_SITE_ROOT" >&2 + echo " remote: $SKILL_SITE_REMOTE_URL" >&2 + echo " set SKILL_SITE_ROOT to your agentnative-site checkout, or check network access." >&2 + exit 1 + fi + echo "warning: remote query failed; falling back to local $SKILL_SITE_ROOT" >&2 + site_source="$SKILL_SITE_ROOT" + resolved_sha="$(git -C "$site_source" rev-parse --short=7 "$SKILL_SITE_REF" 2>/dev/null || echo "unknown")" + echo "extracting $SKILL_SITE_REF ($resolved_sha) from local $site_source" +fi + +# === Extract via git show (works identically for remote and local) ===== +if ! git -C "$site_source" cat-file -e "$SKILL_SITE_REF:$SOURCE_PATH" 2>/dev/null; then + echo "error: $SKILL_SITE_REF has no $SOURCE_PATH in $site_source" >&2 + exit 1 +fi + +# Stream the upstream blob to a temp file. Variable capture (`$(...)`) would +# strip trailing newlines, breaking byte-for-byte parity with the committed +# fixture. File-based comparison preserves exact bytes including the EOF +# newline boundary. +upstream_tmp="$tmp_root/skill.json" +git -C "$site_source" show "$SKILL_SITE_REF:$SOURCE_PATH" >"$upstream_tmp" + +# === Mode-specific behavior ============================================ +case "$mode" in + update) + mkdir -p "$(dirname "$DEST_FILE")" + cp "$upstream_tmp" "$DEST_FILE" + echo "wrote $DEST_FILE" + echo + echo "next: review \`git diff $DEST_FILE\` for unexpected changes, then commit." + ;; + check) + if cmp -s "$DEST_FILE" "$upstream_tmp"; then + echo "ok: $DEST_FILE matches $SKILL_SITE_REF:$SOURCE_PATH ($resolved_sha)" + exit 0 + fi + echo "error: $DEST_FILE drifted from $SKILL_SITE_REF:$SOURCE_PATH ($resolved_sha)" >&2 + echo " run \`scripts/sync-skill-fixture.sh\` to refresh, then commit." >&2 + echo >&2 + diff -u "$DEST_FILE" "$upstream_tmp" || true + exit 1 + ;; +esac diff --git a/scripts/sync-spec.sh b/scripts/sync-spec.sh index 2deee8d..7109c0b 100755 --- a/scripts/sync-spec.sh +++ b/scripts/sync-spec.sh @@ -1,74 +1,118 @@ #!/usr/bin/env bash # Vendor agentnative-spec into src/principles/spec/. # -# Extracts files at a pinned git ref via `git show :` so the user's -# spec working tree is not perturbed. The vendored tree is the build-time input -# for build.rs, which generates the REQUIREMENTS slice. +# Resolves the latest v* tag of agentnative-spec, preferring the remote +# repository, and falls back to a local checkout if the remote is +# unreachable. Extracts files via `git show :` so neither +# checkout's working tree is perturbed. The vendored tree is the +# build-time input for build.rs, which generates the REQUIREMENTS slice. # # Usage: # scripts/sync-spec.sh -# SPEC_REF=v0.2.1 scripts/sync-spec.sh # SPEC_ROOT=/path/to/agentnative-spec scripts/sync-spec.sh +# SPEC_REMOTE_URL=git@github.com:brettdavies/agentnative.git scripts/sync-spec.sh # # Env vars: -# SPEC_ROOT Path to agentnative-spec checkout. Default: $HOME/dev/agentnative-spec -# SPEC_REF Git ref (tag, branch, or SHA) to vendor. Default: v0.3.0 +# SPEC_REMOTE_URL Remote URL to query first. +# Default: https://github.com/brettdavies/agentnative.git +# SPEC_ROOT Local checkout to fall back to when the remote is +# unreachable. Default: $HOME/dev/agentnative-spec # -# Resync cadence: rerun after every new agentnative-spec tag. Stale orphan -# files in src/principles/spec/principles/ (e.g., from a spec rename) are -# accepted; `git status` surfaces them at commit time. +# Resync cadence: rerun after every new agentnative-spec tag. The remote +# query picks up new tags automatically; a local fallback only sees what +# the local checkout already has fetched. +# +# Stale orphan files in src/principles/spec/principles/ (e.g., from a +# spec rename) are accepted; `git status` surfaces them at commit time. set -euo pipefail +SPEC_REMOTE_URL="${SPEC_REMOTE_URL:-https://github.com/brettdavies/agentnative.git}" SPEC_ROOT="${SPEC_ROOT:-$HOME/dev/agentnative-spec}" -SPEC_REF="${SPEC_REF:-v0.3.0}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" DEST_DIR="$REPO_ROOT/src/principles/spec" DEST_PRINCIPLES="$DEST_DIR/principles" -if [[ ! -d "$SPEC_ROOT/.git" ]]; then - echo "error: SPEC_ROOT is not a git repository: $SPEC_ROOT" >&2 - echo " set SPEC_ROOT to your agentnative-spec checkout, or clone it to the default" >&2 - echo " location: \$HOME/dev/agentnative-spec" >&2 - exit 1 -fi +# Cleanup hook for the temp clone (set only after mktemp succeeds). +tmp_root="" +cleanup() { + if [[ -n "$tmp_root" && -d "$tmp_root" ]]; then + rm -rf "$tmp_root" + fi +} +trap cleanup EXIT -if ! git -C "$SPEC_ROOT" rev-parse --verify --quiet "$SPEC_REF^{commit}" >/dev/null; then - echo "error: SPEC_REF does not resolve to a commit in $SPEC_ROOT: $SPEC_REF" >&2 - echo " try \`git -C $SPEC_ROOT fetch --tags\` or check the ref name" >&2 - exit 1 +# === Remote-first resolution =========================================== +spec_source="" +spec_tag="" + +echo "querying $SPEC_REMOTE_URL for latest v* tag..." +remote_tag="$(git ls-remote --tags --sort='-version:refname' \ + "$SPEC_REMOTE_URL" 'refs/tags/v*' 2>/dev/null \ + | awk '{print $2}' \ + | sed 's|refs/tags/||' \ + | grep -v '\^{}$' \ + | head -n 1 || true)" + +if [[ -n "$remote_tag" ]]; then + tmp_root="$(mktemp -d -t agentnative-spec-XXXXXX)" + if git clone --depth 1 --branch "$remote_tag" --quiet \ + "$SPEC_REMOTE_URL" "$tmp_root" 2>/dev/null; then + spec_source="$tmp_root" + spec_tag="$remote_tag" + resolved_sha="$(git -C "$spec_source" rev-parse --short=7 "$spec_tag^{commit}")" + echo "vendoring $spec_tag ($resolved_sha) from remote $SPEC_REMOTE_URL" + fi fi -resolved_sha="$(git -C "$SPEC_ROOT" rev-parse --short=7 "$SPEC_REF^{commit}")" -echo "vendoring $SPEC_REF ($resolved_sha) from $SPEC_ROOT" +# === Local fallback ==================================================== +if [[ -z "$spec_source" ]]; then + if [[ ! -d "$SPEC_ROOT/.git" ]]; then + echo "error: remote unreachable and SPEC_ROOT is not a git repository: $SPEC_ROOT" >&2 + echo " remote: $SPEC_REMOTE_URL" >&2 + echo " set SPEC_ROOT to your agentnative-spec checkout, or check network access." >&2 + exit 1 + fi + echo "warning: remote query failed; falling back to local $SPEC_ROOT" >&2 + + spec_source="$SPEC_ROOT" + spec_tag="$(git -C "$spec_source" tag --list 'v*' --sort='-version:refname' | head -n 1)" + if [[ -z "$spec_tag" ]]; then + echo "error: no v* tags found in $SPEC_ROOT" >&2 + echo " try \`git -C $SPEC_ROOT fetch --tags\` to pick up upstream tags" >&2 + exit 1 + fi + resolved_sha="$(git -C "$spec_source" rev-parse --short=7 "$spec_tag^{commit}")" + echo "vendoring $spec_tag ($resolved_sha) from local $spec_source" +fi -# Verify the principles/ tree exists at the ref. -if ! git -C "$SPEC_ROOT" cat-file -e "$SPEC_REF:principles" 2>/dev/null; then - echo "error: $SPEC_REF has no principles/ directory in $SPEC_ROOT" >&2 +# === Verify + extract (works identically for remote and local sources) = +if ! git -C "$spec_source" cat-file -e "$spec_tag:principles" 2>/dev/null; then + echo "error: $spec_tag has no principles/ directory in $spec_source" >&2 exit 1 fi mkdir -p "$DEST_PRINCIPLES" # VERSION and CHANGELOG.md are top-level in the spec repo. -git -C "$SPEC_ROOT" show "$SPEC_REF:VERSION" >"$DEST_DIR/VERSION" -git -C "$SPEC_ROOT" show "$SPEC_REF:CHANGELOG.md" >"$DEST_DIR/CHANGELOG.md" +git -C "$spec_source" show "$spec_tag:VERSION" >"$DEST_DIR/VERSION" +git -C "$spec_source" show "$spec_tag:CHANGELOG.md" >"$DEST_DIR/CHANGELOG.md" -# Enumerate principle files at the ref and extract each one. +# Enumerate principle files at the tag and extract each one. copied=0 while IFS= read -r path; do case "$path" in principles/p*-*.md) dest_name="${path#principles/}" - git -C "$SPEC_ROOT" show "$SPEC_REF:$path" >"$DEST_PRINCIPLES/$dest_name" + git -C "$spec_source" show "$spec_tag:$path" >"$DEST_PRINCIPLES/$dest_name" copied=$((copied + 1)) ;; esac -done < <(git -C "$SPEC_ROOT" ls-tree --name-only "$SPEC_REF" principles/) +done < <(git -C "$spec_source" ls-tree --name-only "$spec_tag" principles/) if [[ "$copied" -eq 0 ]]; then - echo "error: no principles/p*-*.md files found at $SPEC_REF" >&2 + echo "error: no principles/p*-*.md files found at $spec_tag" >&2 exit 1 fi diff --git a/src/argv.rs b/src/argv.rs index db3b3da..ee3450c 100644 --- a/src/argv.rs +++ b/src/argv.rs @@ -3,7 +3,7 @@ //! and so the injection logic is unit-testable in isolation. use std::collections::HashSet; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use crate::cli::Cli; @@ -113,10 +113,10 @@ where .iter() .any(|(l, _)| l.as_deref() == Some(rest)); } - if let Some(rest) = token.strip_prefix('-') { - if let Some(c) = rest.chars().next().filter(|_| rest.len() == 1) { - return all_value_flags.iter().any(|(_, s)| *s == Some(c)); - } + if let Some(rest) = token.strip_prefix('-') + && let Some(c) = rest.chars().next().filter(|_| rest.len() == 1) + { + return all_value_flags.iter().any(|(_, s)| *s == Some(c)); } false }; @@ -148,10 +148,10 @@ where // Track whether this flag belongs to a subcommand rather than the // top-level Cli. If so, the user clearly intends `check` even when // no positional argument follows (e.g. `anc --command rg`). - if let Some(base) = base_form(&token) { - if !top_level_flags.contains(&base) { - saw_subcommand_flag = true; - } + if let Some(base) = base_form(&token) + && !top_level_flags.contains(&base) + { + saw_subcommand_flag = true; } i += if consumes_next(&token) { 2 } else { 1 }; continue; @@ -174,9 +174,79 @@ where args } +/// Format a captured argv vector as a shell-quoted command string, suitable +/// for the scorecard's `run.invocation` field. Uses single-quote quoting: +/// args containing whitespace, single quotes, double quotes, or shell +/// metacharacters are wrapped in `'…'`, with embedded `'` escaped as +/// `'\''`. Lossy UTF-8 conversion is intentional — the field records what +/// the user typed for human review, not for byte-perfect replay. +/// +/// Captured *before* `inject_default_subcommand` rewrites the args, so the +/// recorded command reflects user intent (`anc .` stays as `anc .`, not +/// `anc check .`). +pub fn format_invocation(args: &[OsString]) -> String { + args.iter() + .map(|a| quote_arg(a)) + .collect::>() + .join(" ") +} + +fn quote_arg(arg: &OsStr) -> String { + let s = arg.to_string_lossy(); + if s.is_empty() { + return "''".to_string(); + } + if needs_quoting(&s) { + // Single-quote everything; escape embedded single quotes by closing + // the quoted run, emitting `\'`, and reopening — POSIX-shell idiom. + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' { + out.push_str("'\\''"); + } else { + out.push(c); + } + } + out.push('\''); + out + } else { + s.into_owned() + } +} + +fn needs_quoting(s: &str) -> bool { + s.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '\'' | '"' + | '\\' + | '$' + | '`' + | '|' + | '&' + | ';' + | '<' + | '>' + | '(' + | ')' + | '{' + | '}' + | '*' + | '?' + | '[' + | ']' + | '#' + | '~' + | '!' + ) + }) +} + #[cfg(test)] mod tests { - use super::inject_default_subcommand; + use super::{format_invocation, inject_default_subcommand}; use std::ffi::OsString; fn args(a: &[&str]) -> Vec { @@ -339,4 +409,67 @@ mod tests { let out = inject_default_subcommand(args(&["anc", ".", "--output", "json"])); assert_eq!(names(out), vec!["anc", "check", ".", "--output", "json"]); } + + // ---- format_invocation ---- + + #[test] + fn format_invocation_simple_args_unquoted() { + let out = format_invocation(&args(&["anc", "check", "."])); + assert_eq!(out, "anc check ."); + } + + #[test] + fn format_invocation_pre_injection_user_intent_preserved() { + // Plan R4 intent check: a user who typed `anc .` MUST see `anc .` in + // the scorecard, not `anc check .` (which would be a fact about anc's + // internals, not the user's command). + let out = format_invocation(&args(&["anc", "."])); + assert_eq!(out, "anc ."); + } + + #[test] + fn format_invocation_arg_with_space_is_single_quoted() { + let out = format_invocation(&args(&["anc", "check", "/tmp/with space/repo"])); + assert_eq!(out, "anc check '/tmp/with space/repo'"); + } + + #[test] + fn format_invocation_arg_with_single_quote_is_escaped() { + // POSIX-shell escape: close, emit `\'`, reopen. + let out = format_invocation(&args(&["anc", "check", "ab'cd"])); + assert_eq!(out, "anc check 'ab'\\''cd'"); + } + + #[test] + fn format_invocation_arg_with_metacharacters_is_quoted() { + let out = format_invocation(&args(&["anc", "check", "$(rm -rf)"])); + assert_eq!(out, "anc check '$(rm -rf)'"); + } + + #[test] + fn format_invocation_empty_arg_renders_as_empty_quotes() { + let out = format_invocation(&args(&["anc", ""])); + assert_eq!(out, "anc ''"); + } + + #[test] + fn format_invocation_double_quote_is_quoted() { + let out = format_invocation(&args(&["anc", "say\"hi"])); + assert_eq!(out, "anc 'say\"hi'"); + } + + #[test] + fn format_invocation_round_trip_no_panic_on_invalid_utf8() { + // Linux-only: build an OsString containing invalid UTF-8. Lossy + // conversion must not panic; the field carries the lossy form. + #[cfg(target_os = "linux")] + { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let invalid = OsString::from_vec(vec![0xff, 0xfe]); + let out = format_invocation(&[OsString::from("anc"), invalid]); + assert!(out.starts_with("anc ")); + } + } } diff --git a/src/build_info.rs b/src/build_info.rs new file mode 100644 index 0000000..a8f0d6f --- /dev/null +++ b/src/build_info.rs @@ -0,0 +1,25 @@ +//! Build-time identity constants for `anc` itself. +//! +//! `ANC_VERSION` is the crate version (`CARGO_PKG_VERSION`) and surfaces in the +//! scorecard's `anc.version` so a consumer can identify the `anc` build that +//! produced a scorecard. Generated by `build.rs` into `$OUT_DIR/build_info.rs`. + +include!(concat!(env!("OUT_DIR"), "/build_info.rs")); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anc_version_matches_cargo_pkg_version() { + // The build script must populate `ANC_VERSION` from `CARGO_PKG_VERSION`. + // If they ever diverge, the scorecard's `anc.version` would lie about + // which crate version produced it — silently. + assert_eq!(ANC_VERSION, env!("CARGO_PKG_VERSION")); + } + + #[test] + fn anc_version_is_non_empty() { + assert!(!ANC_VERSION.is_empty()); + } +} diff --git a/src/checks/project/dry_run.rs b/src/checks/project/dry_run.rs index cb0e861..af691f8 100644 --- a/src/checks/project/dry_run.rs +++ b/src/checks/project/dry_run.rs @@ -85,12 +85,11 @@ impl Check for DryRunCheck { } // Also check the manifest for subcommand names that imply writes - if !has_write_commands { - if let Some(manifest) = &project.manifest_path { - if let Ok(content) = fs::read_to_string(manifest) { - has_write_commands = WRITE_KEYWORDS.iter().any(|kw| content.contains(kw)); - } - } + if !has_write_commands + && let Some(manifest) = &project.manifest_path + && let Ok(content) = fs::read_to_string(manifest) + { + has_write_commands = WRITE_KEYWORDS.iter().any(|kw| content.contains(kw)); } let status = if !has_write_commands { diff --git a/src/checks/project/error_module.rs b/src/checks/project/error_module.rs index 5848b5d..7a6376b 100644 --- a/src/checks/project/error_module.rs +++ b/src/checks/project/error_module.rs @@ -55,22 +55,22 @@ impl Check for ErrorModuleCheck { } // Check subdirectories: src/*/error.rs, src/*/errors.rs - if src_dir.is_dir() { - if let Ok(entries) = fs::read_dir(&src_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - for name in &["error.rs", "errors.rs", "error.py", "errors.py"] { - if path.join(name).exists() { - return Ok(CheckResult { - id: self.id().to_string(), - label: self.label().into(), - group: self.group(), - layer: self.layer(), - status: CheckStatus::Pass, - confidence: Confidence::High, - }); - } + if src_dir.is_dir() + && let Ok(entries) = fs::read_dir(&src_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + for name in &["error.rs", "errors.rs", "error.py", "errors.py"] { + if path.join(name).exists() { + return Ok(CheckResult { + id: self.id().to_string(), + label: self.label().into(), + group: self.group(), + layer: self.layer(), + status: CheckStatus::Pass, + confidence: Confidence::High, + }); } } } diff --git a/src/checks/source/python/no_color.rs b/src/checks/source/python/no_color.rs index 234e1db..dcdd4a8 100644 --- a/src/checks/source/python/no_color.rs +++ b/src/checks/source/python/no_color.rs @@ -96,10 +96,10 @@ pub(crate) fn source_handles_no_color(source: &str) -> bool { r#"getenv('NO_COLOR')"#, ]; for p_str in access_patterns { - if let Ok(pattern) = Pattern::try_new(p_str, Python) { - if root.root().find(&pattern).is_some() { - return true; - } + if let Ok(pattern) = Pattern::try_new(p_str, Python) + && root.root().find(&pattern).is_some() + { + return true; } } diff --git a/src/checks/source/rust/error_types.rs b/src/checks/source/rust/error_types.rs index 93d934e..0b9f672 100644 --- a/src/checks/source/rust/error_types.rs +++ b/src/checks/source/rust/error_types.rs @@ -96,10 +96,10 @@ pub(crate) fn has_error_enum(source: &str) -> bool { // Extract the enum name from the matched text if let Some(enum_pos) = text.find("enum ") { let after_enum = &text[enum_pos + 5..]; - if let Some(name) = after_enum.split_whitespace().next() { - if name.contains("Error") { - return true; - } + if let Some(name) = after_enum.split_whitespace().next() + && name.contains("Error") + { + return true; } } } diff --git a/src/checks/source/rust/exit_codes.rs b/src/checks/source/rust/exit_codes.rs index 7f11bc3..86428ff 100644 --- a/src/checks/source/rust/exit_codes.rs +++ b/src/checks/source/rust/exit_codes.rs @@ -105,25 +105,25 @@ fn find_raw_exit_codes(source: &str, file: &str) -> Vec { for m in root_node.find_all(&pattern) { let text = m.text().to_string(); // Extract the argument inside exit(...) - if let Some(start) = text.rfind('(') { - if let Some(end) = text.rfind(')') { - let arg = text[start + 1..end].trim(); - // A raw integer literal is all digits (possibly with a leading minus) - let is_raw_literal = !arg.is_empty() - && arg - .strip_prefix('-') - .unwrap_or(arg) - .chars() - .all(|c| c.is_ascii_digit()); - if is_raw_literal { - let pos = m.start_pos(); - violations.push(SourceLocation { - file: file.to_string(), - line: pos.line() + 1, - column: pos.column(&m) + 1, - text, - }); - } + if let Some(start) = text.rfind('(') + && let Some(end) = text.rfind(')') + { + let arg = text[start + 1..end].trim(); + // A raw integer literal is all digits (possibly with a leading minus) + let is_raw_literal = !arg.is_empty() + && arg + .strip_prefix('-') + .unwrap_or(arg) + .chars() + .all(|c| c.is_ascii_digit()); + if is_raw_literal { + let pos = m.start_pos(); + violations.push(SourceLocation { + file: file.to_string(), + line: pos.line() + 1, + column: pos.column(&m) + 1, + text, + }); } } } diff --git a/src/checks/source/rust/naked_println.rs b/src/checks/source/rust/naked_println.rs index 0321c3a..34483c4 100644 --- a/src/checks/source/rust/naked_println.rs +++ b/src/checks/source/rust/naked_println.rs @@ -4,6 +4,9 @@ //! dedicated output module, not scatter `println!` calls across the codebase. //! `eprintln!` is exempt (diagnostics go to stderr). //! Files with "output" or "display" in their path are exempt (output modules). +//! `build.rs` is also exempt — Cargo build scripts emit metadata via +//! `println!("cargo:...")` directives by required-by-protocol convention; no +//! alternative API exists, so flagging them produces noise without recourse. use crate::check::Check; use crate::project::{Language, Project}; @@ -44,11 +47,15 @@ impl Check for NakedPrintlnCheck { for (path, parsed_file) in parsed.iter() { let file_str = path.display().to_string(); - // Exempt files with "output" or "display" in their path + // Exempt files with "output" or "display" in their path, and + // Cargo build scripts (build.rs at any crate root). let lower = file_str.to_lowercase(); if lower.contains("output") || lower.contains("display") { continue; } + if is_cargo_build_script(&file_str) { + continue; + } if let CheckStatus::Warn(evidence) = check_naked_println(&parsed_file.source, &file_str) { @@ -73,6 +80,30 @@ impl Check for NakedPrintlnCheck { } } +/// True when `path` names a Cargo build script (`build.rs` at any crate +/// root). The convention is fixed by Cargo — build scripts are always at +/// `/build.rs`, never nested under `src/`, `tests/`, `examples/`, +/// or `benches/`. Paths under those directories that happen to be named +/// `build.rs` are misnamed source files, not build scripts, and stay flagged. +fn is_cargo_build_script(path: &str) -> bool { + let lower = path.to_lowercase(); + // Normalize Windows separators so segment checks are uniform. + let normalized = lower.replace('\\', "/"); + let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + let Some(last) = segments.last() else { + return false; + }; + if *last != "build.rs" { + return false; + } + // Cargo build scripts live at crate root. Reject paths where any + // ancestor segment is a known non-root subdirectory. + let parents = &segments[..segments.len() - 1]; + !parents + .iter() + .any(|s| matches!(*s, "src" | "tests" | "examples" | "benches")) +} + /// Check a single source string for `println!` and `print!` calls. /// /// Kept public(crate) for unit testing with inline source strings. @@ -177,6 +208,39 @@ fn main() { } } + #[test] + fn build_script_path_recognized() { + assert!(is_cargo_build_script("build.rs")); + assert!(is_cargo_build_script("./build.rs")); + assert!(is_cargo_build_script("/abs/path/build.rs")); + assert!(is_cargo_build_script("BUILD.RS")); // case-insensitive + assert!(is_cargo_build_script("subcrate\\build.rs")); // Windows path + + assert!(!is_cargo_build_script("src/build.rs")); + // build.rs nested under src/ is not the cargo build script — it's a + // misnamed source file. Cargo build scripts only live at crate root. + // Exception: workspace member build scripts at /build.rs + // are correctly matched by the `/build.rs` suffix logic. + assert!(!is_cargo_build_script("src/skill_install.rs")); + assert!(!is_cargo_build_script("build.rs.bak")); + } + + #[test] + fn check_skips_build_script_println() { + // Direct check of the helper — the macro pattern matches, but the + // path-level filter in run() skips build.rs callers. + let source = r#" +fn main() { + println!("cargo:rerun-if-changed=src/principles/spec/"); +} +"#; + // The unit-level helper still warns (it doesn't know about file + // path); the run() loop is what skips. Confirm both behaviors. + let status = check_naked_println(source, "build.rs"); + assert!(matches!(status, CheckStatus::Warn(_))); + assert!(is_cargo_build_script("build.rs")); + } + #[test] fn applicable_for_rust() { let check = NakedPrintlnCheck; diff --git a/src/cli.rs b/src/cli.rs index 71efb66..436c43c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,7 @@ use clap::{Parser, Subcommand, ValueEnum, ValueHint}; use clap_complete::Shell; use crate::principles::registry::ExceptionCategory; +use crate::skill_install::SkillHost; #[derive(Parser)] #[command(name = "anc", version, about = "The agent-native CLI linter")] @@ -79,6 +80,35 @@ pub enum Commands { #[command(subcommand)] artifact: GenerateKind, }, + /// Install or manage the agentnative skill bundle + Skill { + #[command(subcommand)] + cmd: SkillCmd, + }, +} + +#[derive(Subcommand)] +pub enum SkillCmd { + /// Install the skill bundle into a host's canonical skills directory. + /// + /// If the site adds a host before this `anc` release knows about it, run + /// the manual fallback printed by `--dry-run` for any known host and + /// substitute the destination path: + /// + /// git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git + Install { + /// Target host (claude_code, codex, cursor, opencode). + host: SkillHost, + + /// Print the resolved git command without spawning. Captures cleanly + /// via `eval $(anc skill install --dry-run )`. + #[arg(long)] + dry_run: bool, + + /// Output format for the result envelope. + #[arg(long, default_value = "text")] + output: OutputFormat, + }, } #[derive(Subcommand)] diff --git a/src/error.rs b/src/error.rs index 65d0c59..86d1d0a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,4 +7,26 @@ pub enum AppError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), + + #[error("HOME environment variable is not set")] + MissingHome, + + #[error("`git` not found on PATH")] + GitNotFound, + + #[error("`git clone` failed with exit code {code}")] + GitCloneFailed { code: i32 }, + + #[error("destination exists as a regular file: {path}")] + DestIsFile { path: std::path::PathBuf }, + + #[error("destination directory is not empty: {path}")] + DestNotEmpty { path: std::path::PathBuf }, + + #[error("failed to read destination at {path}: {source}")] + DestReadFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, } diff --git a/src/main.rs b/src/main.rs index fdbd9a2..a15a624 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,30 +1,41 @@ mod argv; +mod build_info; mod check; mod checks; mod cli; mod error; +mod output; mod principles; mod project; mod runner; mod scorecard; +mod skill_install; mod source; mod types; +use std::time::{Duration, Instant}; + use clap::Parser as _; use clap_complete::generate; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; -use argv::inject_default_subcommand; +use argv::{format_invocation, inject_default_subcommand}; +use build_info::ANC_VERSION; use check::Check; use checks::behavioral::all_behavioral_checks; use checks::project::all_project_checks; use checks::source::all_source_checks; -use cli::{Cli, Commands, GenerateKind, OutputFormat}; +use cli::{Cli, Commands, GenerateKind, OutputFormat, SkillCmd}; use error::AppError; use principles::matrix; use principles::registry::{ExceptionCategory, SUPPRESSION_EVIDENCE_PREFIX, suppresses}; use project::Project; -use scorecard::audience; -use scorecard::{exit_code, format_json, format_text}; +use runner::{BinaryRunner, RunStatus}; +use scorecard::{ + AncInfo, PlatformInfo, RunInfo, RunMetadata, TargetInfo, ToolInfo, audience, compute_badge, + exit_code, format_json, format_text, +}; use types::{CheckGroup, CheckResult, CheckStatus, Confidence}; fn main() { @@ -45,7 +56,13 @@ fn main() { } fn run() -> Result { - let cli = Cli::parse_from(inject_default_subcommand(std::env::args_os())); + // Capture argv *before* `inject_default_subcommand` rewrites bare paths + // into `check `, so the scorecard's `run.invocation` reflects what + // the user actually typed (R4). The injection rewrite is an internal + // detail; recording it would lie about user intent. + let raw_argv: Vec = std::env::args_os().collect(); + + let cli = Cli::parse_from(inject_default_subcommand(raw_argv.iter().cloned())); // --quiet is global (visible in top-level --help for agent discoverability) let quiet = cli.quiet; @@ -82,6 +99,9 @@ fn run() -> Result { Some(Commands::Generate { artifact }) => { return run_generate(artifact); } + Some(Commands::Skill { cmd }) => { + return run_skill(cmd); + } None => { let mut cmd = ::command(); eprintln!("{}", cmd.render_help()); @@ -89,8 +109,18 @@ fn run() -> Result { } }; + // Run-level timing starts at the top of the Check arm (R4): wall-clock + // milliseconds and an RFC 3339 UTC timestamp. We use `OffsetDateTime` for + // formatting only — duration math goes through `Instant` which is + // monotonic and unaffected by wall-clock adjustments. + let start_instant = Instant::now(); + let started_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z")); + // --command resolves a binary from PATH and runs behavioral checks against // it. conflicts_with = "path" ensures only one of the two is provided. + let command_name = command.clone(); let resolved_path = match command { Some(name) => resolve_command_on_path(&name)?, None => path, @@ -141,21 +171,21 @@ fn run() -> Result { if !check.applicable(&project) { continue; } - if let Some(cat) = exception_category { - if suppresses(check.id(), cat) { - results.push(CheckResult { - id: check.id().to_string(), - label: check.label().to_string(), - group: check.group(), - layer: check.layer(), - status: CheckStatus::Skip(format!( - "{SUPPRESSION_EVIDENCE_PREFIX}{}", - cat.as_kebab_case() - )), - confidence: Confidence::High, - }); - continue; - } + if let Some(cat) = exception_category + && suppresses(check.id(), cat) + { + results.push(CheckResult { + id: check.id().to_string(), + label: check.label().to_string(), + group: check.group(), + layer: check.layer(), + status: CheckStatus::Skip(format!( + "{SUPPRESSION_EVIDENCE_PREFIX}{}", + cat.as_kebab_case() + )), + confidence: Confidence::High, + }); + continue; } let result = match check.run(&project) { Ok(r) => r, @@ -184,18 +214,219 @@ fn run() -> Result { let audit_profile_label = exception_category.map(|c| c.as_kebab_case().to_string()); // Format output. `format_json` needs the check catalog so it can map - // result IDs back to the requirements each check covers. + // result IDs back to the requirements each check covers, plus the + // run-level metadata (`tool`, `anc`, `run`, `target`). For text mode + // we still need the tool slug so the badge hint can render the + // canonical embed URL — derive it cheaply (no version probe) and + // hand it to `compute_badge`. let output_str = match output { - OutputFormat::Text => format_text(&results, quiet), + OutputFormat::Text => { + let tool_name = derive_tool_name(command_name.as_deref(), &project); + let badge = compute_badge(&results, &tool_name); + format_text(&results, quiet, Some(&badge)) + } OutputFormat::Json => { - format_json(&results, &all_checks, audience_label, audit_profile_label) + let target = build_target_info(command_name.as_deref(), &project); + let tool = build_tool_info(command_name.as_deref(), &project); + let invocation = format_invocation(&raw_argv); + let duration_ms = + u64::try_from(start_instant.elapsed().as_millis()).unwrap_or(u64::MAX); + let metadata = RunMetadata { + tool, + anc: AncInfo { + version: ANC_VERSION, + }, + run: RunInfo { + invocation, + started_at, + duration_ms, + platform: PlatformInfo { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + }, + }, + target, + }; + format_json( + &results, + &all_checks, + audience_label, + audit_profile_label, + metadata, + ) } }; - print!("{output_str}"); + output::emit(&output_str); Ok(exit_code(&results)) } +/// Classify what `anc check` was pointed at into structured `target` metadata. +/// Three modes: `command` (PATH-resolved), `binary` (file argument), `project` +/// (directory argument). +/// +/// `path` is the **basename** of the resolved target — the directory name in +/// project mode, the file name in binary mode. Absolute paths from +/// `Project::discover`'s canonicalization would leak operator PII (home-dir +/// username, org/employer dir structure) into committed scorecards, README +/// badge URLs, and any agent-posted artifact. The basename carries the only +/// signal a consumer needs (matches the slug used by `tool.name` and the +/// `/score/` URL); anything richer is information leakage with no +/// compensating use. For pathological paths where `file_name()` returns +/// `None` (e.g. `/`), `path` falls back to `null` per the always-present +/// null contract. +fn build_target_info(command_name: Option<&str>, project: &Project) -> TargetInfo { + match command_name { + Some(name) => TargetInfo { + kind: "command".into(), + path: None, + command: Some(name.to_string()), + }, + None if project.path.is_dir() => TargetInfo { + kind: "project".into(), + path: basename_string(&project.path), + command: None, + }, + None => TargetInfo { + kind: "binary".into(), + path: basename_string(&project.path), + command: None, + }, + } +} + +/// Return the basename of `path` as an owned `String`, or `None` if the path +/// has no file-name component (e.g. `/`, `..`). Uses `to_string_lossy` so +/// non-UTF-8 path components round-trip with replacement characters rather +/// than vanishing. +fn basename_string(path: &std::path::Path) -> Option { + path.file_name().map(|n| n.to_string_lossy().into_owned()) +} + +/// Cheap slug derivation: the same `name` `build_tool_info` would emit, but +/// without the manifest read or `--version` subprocess probe. Used by the +/// text-mode badge hint, where we need the slug to render the embed URL but +/// have no use for the version. Keeping this in lock-step with +/// `build_tool_info`'s `name` calculation guarantees the text-mode hint +/// references the same `` slug a `--output json` consumer would see. +fn derive_tool_name(command_name: Option<&str>, project: &Project) -> String { + match command_name { + Some(cmd) => cmd.to_string(), + None => project + .path + .file_name() + .and_then(|n| n.to_str()) + .map(String::from) + .unwrap_or_default(), + } +} + +/// Build the scorecard's `tool` block. `name` is always present (deterministic +/// from path / command name). `binary` is the executable basename when one +/// exists. `version` is best-effort: project-mode prefers the manifest version, +/// command/binary mode probes ` --version` / ` -V`. Any failure +/// yields `null` rather than aborting the run. +fn build_tool_info(command_name: Option<&str>, project: &Project) -> ToolInfo { + let name = derive_tool_name(command_name, project); + let (binary, version_seed) = match command_name { + Some(cmd) => { + // Command mode: binary echoes the user-supplied name (NOT the + // resolved path — we don't want to leak /usr/local/bin/foo as + // the binary identifier). + (Some(cmd.to_string()), None) + } + None => { + if project.path.is_dir() { + let manifest_version = project + .manifest_path + .as_deref() + .and_then(read_manifest_version); + let binary_name = project + .binary_paths + .first() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .map(String::from); + (binary_name, manifest_version) + } else { + // Binary file passed directly. + (Some(name.clone()), None) + } + } + }; + + // Manifest version takes precedence; fall back to the binary self-report. + let version = version_seed.or_else(|| probe_tool_version(project)); + + ToolInfo { + name, + binary, + version, + } +} + +/// Best-effort ` --version` / ` -V` probe. Reuses the runner's +/// timeout + 1MB cap primitives via a fresh `BinaryRunner` with a tighter +/// 2-second timeout (the version probe is one-shot, not a check). +/// +/// Self-spawn guard: comparing the resolved binary path to `current_exe()` +/// declines the probe when `anc` is asked to score itself. Without this, +/// `anc check --command anc` would recursively score `anc` — bounded only by +/// `arg_required_else_help` in `Cli`. Belt-and-suspenders. +fn probe_tool_version(project: &Project) -> Option { + let binary = project.binary_paths.first()?; + + if let Ok(self_exe) = std::env::current_exe() + && let (Ok(a), Ok(b)) = (binary.canonicalize(), self_exe.canonicalize()) + && a == b + { + // Both paths canonicalized (Project::discover canonicalizes; the OS + // resolves current_exe). Direct comparison is the right primitive. + return None; + } + + let runner = BinaryRunner::new(binary.clone(), Duration::from_secs(2)).ok()?; + for flag in ["--version", "-V"] { + let result = runner.run(&[flag], &[]); + if matches!(result.status, RunStatus::Ok) + && result.exit_code == Some(0) + && let Some(line) = result.stdout.lines().next() + { + let trimmed = line.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +/// Read `package.version` from a Cargo.toml or `project.version` from a +/// pyproject.toml. Returns `None` for unreadable / unparseable / missing-field +/// cases — the version probe falls through to the binary self-report. +fn read_manifest_version(manifest: &std::path::Path) -> Option { + let content = std::fs::read_to_string(manifest).ok()?; + let parsed: toml::Value = content.parse().ok()?; + + // Cargo.toml: [package] version = "...". + if let Some(v) = parsed + .get("package") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + { + return Some(v.to_string()); + } + // pyproject.toml: [project] version = "...". + if let Some(v) = parsed + .get("project") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + { + return Some(v.to_string()); + } + None +} + /// Resolve a command name to an absolute path by shelling out to `which` /// (Unix) or `where` (Windows). Returns a clear, actionable error when the /// name cannot be found on PATH. Subsequent `Project::discover()` validates @@ -228,6 +459,16 @@ fn resolve_command_on_path(name: &str) -> Result { Ok(std::path::PathBuf::from(first)) } +fn run_skill(cmd: SkillCmd) -> Result { + match cmd { + SkillCmd::Install { + host, + dry_run, + output, + } => skill_install::run_install(host, dry_run, output), + } +} + fn run_generate(artifact: GenerateKind) -> Result { match artifact { GenerateKind::CoverageMatrix { @@ -280,25 +521,25 @@ fn run_generate(artifact: GenerateKind) -> Result { return Ok(if md_matches && json_matches { 0 } else { 2 }); } - if let Some(parent) = out.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(|e| { - AppError::ProjectDetection(anyhow::anyhow!( - "creating parent dir for {}: {e}", - out.display() - )) - })?; - } + if let Some(parent) = out.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::ProjectDetection(anyhow::anyhow!( + "creating parent dir for {}: {e}", + out.display() + )) + })?; } - if let Some(parent) = json_out.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(|e| { - AppError::ProjectDetection(anyhow::anyhow!( - "creating parent dir for {}: {e}", - json_out.display() - )) - })?; - } + if let Some(parent) = json_out.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::ProjectDetection(anyhow::anyhow!( + "creating parent dir for {}: {e}", + json_out.display() + )) + })?; } std::fs::write(&out, &rendered_md).map_err(|e| { AppError::ProjectDetection(anyhow::anyhow!("writing {}: {e}", out.display())) diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..0417158 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,21 @@ +//! Single stdout hand-off point for the binary. +//! +//! Centralizing this lets the `p7-naked-println` source check exempt one +//! module by path convention (filename contains "output") instead of treating +//! every `println!`/`print!` site across the orchestration layer as a +//! naked-println violation. Callers route final user-facing output through +//! `emit` (no trailing newline) or `emit_line` (with trailing newline) per +//! whatever the caller's renderer already produces. + +/// Write `s` to stdout with no trailing newline. Use when the caller's +/// renderer has already appended whatever line terminator it wants. +pub fn emit(s: &str) { + print!("{s}"); +} + +/// Write `s` to stdout with a trailing newline. Use when the caller renders +/// content that does not end in a newline (e.g. JSON one-liners) and needs +/// the terminal cursor to advance. +pub fn emit_line(s: &str) { + println!("{s}"); +} diff --git a/src/principles/spec/README.md b/src/principles/spec/README.md index d123578..e34ee0a 100644 --- a/src/principles/spec/README.md +++ b/src/principles/spec/README.md @@ -1,22 +1,22 @@ # Vendored agentnative-spec This directory is a **vendored copy** of [`brettdavies/agentnative`](https://github.com/brettdavies/agentnative) — the -canonical specification of agent-native CLI principles. Files here are not edited by hand; they are mirrored from a -pinned upstream tag and consumed by `build.rs` to generate the `REQUIREMENTS` slice at build time. - -**Current pin:** `v0.2.0` +canonical specification of agent-native CLI principles. Files here are not edited by hand; they are mirrored from the +latest upstream `v*` tag and consumed by `build.rs` to generate the `REQUIREMENTS` slice at build time. The currently +vendored version is recorded in [`VERSION`](./VERSION). ## Resync Run from the repo root: ```sh -scripts/sync-spec.sh # default: SPEC_REF=v0.2.0 -SPEC_REF=v0.2.1 scripts/sync-spec.sh # bump to a newer tag +scripts/sync-spec.sh # queries the remote for the latest v* tag; falls back to local on network failure ``` -The script extracts files at the named git ref via `git show`, so the spec checkout's working tree is not perturbed. -Override `SPEC_ROOT` if your spec checkout is not at `$HOME/dev/agentnative-spec`. +The script queries `https://github.com/brettdavies/agentnative.git` for the latest `v*` tag and shallow-clones that tag +into a temp directory for extraction. If the remote is unreachable, it falls back to a local checkout +(`$HOME/dev/agentnative-spec` by default; override with `SPEC_ROOT`). Override `SPEC_REMOTE_URL` to query a different +remote. The script extracts files via `git show`, so neither source's working tree is perturbed. ## Layout diff --git a/src/project.rs b/src/project.rs index 397fa0f..61cc11e 100644 --- a/src/project.rs +++ b/src/project.rs @@ -195,30 +195,28 @@ fn discover_binaries( fn discover_rust_binaries(dir: &Path, manifest_path: Option<&Path>) -> Vec { let mut bin_names = Vec::new(); - if let Some(manifest) = manifest_path { - if let Ok(content) = fs::read_to_string(manifest) { - if let Ok(doc) = content.parse::() { - // Check [[bin]] entries - if let Some(bins) = doc.get("bin").and_then(|b| b.as_array()) { - for bin in bins { - if let Some(name) = bin.get("name").and_then(|n| n.as_str()) { - bin_names.push(name.to_string()); - } - } - } - - // Fallback to package name if no [[bin]] - if bin_names.is_empty() { - if let Some(name) = doc - .get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - { - bin_names.push(name.to_string()); - } + if let Some(manifest) = manifest_path + && let Ok(content) = fs::read_to_string(manifest) + && let Ok(doc) = content.parse::() + { + // Check [[bin]] entries + if let Some(bins) = doc.get("bin").and_then(|b| b.as_array()) { + for bin in bins { + if let Some(name) = bin.get("name").and_then(|n| n.as_str()) { + bin_names.push(name.to_string()); } } } + + // Fallback to package name if no [[bin]] + if bin_names.is_empty() + && let Some(name) = doc + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + bin_names.push(name.to_string()); + } } let mut paths = Vec::new(); @@ -239,13 +237,13 @@ fn discover_simple_binaries(dir: &Path, subdirs: &[&str]) -> Vec { let mut paths = Vec::new(); for subdir in subdirs { let bin_dir = dir.join(subdir); - if bin_dir.is_dir() { - if let Ok(entries) = fs::read_dir(&bin_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_file() { - paths.push(p); - } + if bin_dir.is_dir() + && let Ok(entries) = fs::read_dir(&bin_dir) + { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() { + paths.push(p); } } } diff --git a/src/runner/help_probe/mod.rs b/src/runner/help_probe/mod.rs index 77cf5f3..1c23ed3 100644 --- a/src/runner/help_probe/mod.rs +++ b/src/runner/help_probe/mod.rs @@ -334,10 +334,10 @@ fn parse_subcommands(raw: &str) -> Vec { // A new top-level section header ended the commands block. break; } - if let Some(name) = trimmed.split_whitespace().next() { - if is_subcommand_name(name) { - out.push(name.to_string()); - } + if let Some(name) = trimmed.split_whitespace().next() + && is_subcommand_name(name) + { + out.push(name.to_string()); } } out diff --git a/src/scorecard/mod.rs b/src/scorecard/mod.rs index 75ce1a9..049cff0 100644 --- a/src/scorecard/mod.rs +++ b/src/scorecard/mod.rs @@ -16,8 +16,32 @@ use crate::types::{CheckGroup, CheckResult, CheckStatus}; /// first public release of `anc`. During `0.x`, additive fields are the /// norm; consumers feature-detect new keys rather than pinning exact /// values. History: `0.1` (initial), `0.2` (audience, audit_profile, -/// coverage_summary), `0.3` (spec_version). -pub const SCHEMA_VERSION: &str = "0.3"; +/// coverage_summary), `0.3` (spec_version), `0.4` (tool / anc / run / +/// target metadata blocks — self-describing scoring run), `0.5` (`badge` +/// block — eligibility, embed snippet, and badge/scorecard URLs derived +/// from the run, so authors learn about the badge from the CLI itself +/// rather than a round-trip to the site). +pub const SCHEMA_VERSION: &str = "0.5"; + +/// Eligibility floor for the agent-native badge, expressed as an integer +/// percent. A score that meets or exceeds this floor qualifies a tool to +/// embed the badge. +/// +/// Authority is the site's published badge convention at +/// (mirrors the `badgeColor` brightline in +/// `agentnative-site/src/build/badge.mjs`). The spec convention currently +/// lives on the `agentnative-spec` `feat/badge-claim-convention` branch +/// and will move into the vendored spec via `sync-spec` once the floor +/// lands as a published constant. Until then, this constant carries the +/// site's authoritative value. +pub const BADGE_ELIGIBILITY_FLOOR_PCT: u32 = 80; + +/// Canonical base URL the badge convention publishes against. Per the +/// site convention, the URL is "always-latest" — `/badge/.svg` +/// reflects the most recent score against the most recent published spec. +/// The constant is centralized here so the URL pattern is the single +/// source of truth across `text_hint`, JSON emission, and tests. +pub const BADGE_BASE_URL: &str = "https://anc.dev"; /// Pre-launch (`0.x`) scorecard shape emitted by `anc check --output json`. /// @@ -65,6 +89,189 @@ pub struct Scorecard { /// (build still succeeds; warning emitted). Pre-launch additive /// (schema `0.3`); older consumers feature-detect. pub spec_version: &'static str, + /// Identity of the scored target. Pre-launch additive (schema `0.4`). + pub tool: ToolInfo, + /// Identity of the `anc` build that produced this scorecard. Pre-launch + /// additive (schema `0.4`). + pub anc: AncInfo, + /// Run-level facts: invocation, timestamp, duration, platform. + /// Pre-launch additive (schema `0.4`). + pub run: RunInfo, + /// What `anc` was pointed at: project path, binary file, or PATH-resolved + /// command. Pre-launch additive (schema `0.4`). + pub target: TargetInfo, + /// Agent-native badge eligibility + embed snippet for this run. Always + /// emitted; below-floor runs leave `embed_markdown` `null` per the + /// "do not nag" rule in the site's badge convention. Pre-launch + /// additive (schema `0.5`). + pub badge: BadgeInfo, +} + +/// Agent-native badge metadata derived from the current run. +/// +/// `score_pct` is the rounded integer percent of `pass / (pass + warn + +/// fail)` — the same denominator the site leaderboard uses. Skips and +/// errors do not count toward either side of the ratio. When the +/// denominator is zero (no scoring data — e.g., `--principle 99` filters +/// every check out) the score is reported as `0` and `eligible` is +/// `false`. +/// +/// `eligible` is `true` iff `score_pct >= BADGE_ELIGIBILITY_FLOOR_PCT` +/// **and** a tool slug was derivable. Without a slug we cannot construct +/// the embed URL truthfully, and silently emitting a placeholder would +/// mislead authors. +/// +/// `embed_markdown` is `Some` only when the tool is eligible — the field +/// drives the gating contract: a consumer that emits `embed_markdown` to +/// a README knows it's safe to show because the floor was checked here. +/// +/// `scorecard_url` and `badge_url` are populated whenever a tool slug +/// exists, even below the floor. The site renders the SVG for every +/// scored tool regardless of score so a regression below the floor shows +/// the visual color shift instead of a 404. +#[derive(Serialize)] +pub struct BadgeInfo { + pub eligible: bool, + pub score_pct: u32, + pub embed_markdown: Option, + pub scorecard_url: Option, + pub badge_url: Option, + pub convention_url: &'static str, +} + +impl BadgeInfo { + /// Render the post-summary text hint shown in `--output text` mode + /// when the tool qualifies for the badge. Returns `None` below the + /// eligibility floor so callers can append unconditionally without + /// nagging authors who are not yet eligible. + pub fn text_hint(&self) -> Option { + let embed = self.embed_markdown.as_deref()?; + Some(format!( + "\n🏆 Score: {}% — your tool qualifies for the agent-native badge.\n\ + \x20 Embed in your README:\n\ + \x20 {embed}\n\ + \x20 Convention: {}/badge\n", + self.score_pct, BADGE_BASE_URL, + )) + } +} + +/// Pure derivation of `BadgeInfo` from a result set and a tool slug. Used +/// by both `build_scorecard` (for JSON emission) and the runner's text +/// path (for the post-summary hint), so a single source of truth backs +/// both surfaces. +pub fn compute_badge(results: &[CheckResult], tool_name: &str) -> BadgeInfo { + let pct = score_pct(results); + let trimmed = tool_name.trim(); + let has_slug = !trimmed.is_empty(); + let eligible = has_slug && pct >= BADGE_ELIGIBILITY_FLOOR_PCT; + + let scorecard_url = has_slug.then(|| format!("{BADGE_BASE_URL}/score/{trimmed}")); + let badge_url = has_slug.then(|| format!("{BADGE_BASE_URL}/badge/{trimmed}.svg")); + let embed_markdown = if eligible { + Some(format!( + "[![agent-native]({BADGE_BASE_URL}/badge/{trimmed}.svg)]({BADGE_BASE_URL}/score/{trimmed})" + )) + } else { + None + }; + + BadgeInfo { + eligible, + score_pct: pct, + embed_markdown, + scorecard_url, + badge_url, + convention_url: "https://anc.dev/badge", + } +} + +/// Compute the rounded integer percent score using the leaderboard's +/// denominator (`pass + warn + fail`). Skips and errors are excluded from +/// both sides of the ratio. Returns `0` when no checks contribute (every +/// status was Skip or Error, or no checks ran at all) — pairs with +/// `BadgeInfo::eligible == false` so a zero score never qualifies. +fn score_pct(results: &[CheckResult]) -> u32 { + let mut pass = 0u32; + let mut denom = 0u32; + for r in results { + match &r.status { + CheckStatus::Pass => { + pass += 1; + denom += 1; + } + CheckStatus::Warn(_) | CheckStatus::Fail(_) => { + denom += 1; + } + CheckStatus::Skip(_) | CheckStatus::Error(_) => {} + } + } + if denom == 0 { + 0 + } else { + let ratio = f64::from(pass) / f64::from(denom); + (ratio * 100.0).round() as u32 + } +} + +/// Identity of the scored target. `version` is best-effort: when the binary +/// self-reports a parseable `--version` / `-V` first line we capture it, +/// otherwise the field is `null`. The site's `registry.yaml` continues to own +/// `version_extract` shell snippets as a fallback. Always-present keys +/// (`null` rather than missing) keep consumer code simple. +#[derive(Serialize)] +pub struct ToolInfo { + pub name: String, + /// Binary basename when an executable was located; `null` for + /// project-mode runs without a built artifact. + pub binary: Option, + /// Version string the tool self-reported. `null` when probing failed, + /// produced no parseable output, or was declined (self-spawn guard). + pub version: Option, +} + +/// Identity of the `anc` build that produced this scorecard. `version` is a +/// build-time constant generated by `build.rs`. +#[derive(Serialize)] +pub struct AncInfo { + pub version: &'static str, +} + +/// Run-level metadata. Captured by the runner immediately around the +/// `Commands::Check` arm so the scorecard reflects this specific scoring run. +/// +/// `invocation` is the user's argv joined with spaces, captured *before* +/// `inject_default_subcommand` rewrites bare paths into `check `. +/// `started_at` is RFC 3339 / ISO 8601 in UTC. `duration_ms` is wall-clock +/// milliseconds. +#[derive(Serialize)] +pub struct RunInfo { + pub invocation: String, + pub started_at: String, + pub duration_ms: u64, + pub platform: PlatformInfo, +} + +/// `os` / `arch` tuple sourced from `std::env::consts::{OS, ARCH}`. +#[derive(Serialize)] +pub struct PlatformInfo { + pub os: &'static str, + pub arch: &'static str, +} + +/// What `anc check` was pointed at. `kind` is one of `"project"`, `"binary"`, +/// or `"command"`. `path` carries the **basename** of the resolved target +/// (directory name in project mode, file name in binary mode) — never the +/// full filesystem path, which would leak operator PII (home-dir username, +/// org/employer dir structure) into committed scorecards, README badge URLs, +/// and any agent-posted artifact. `command` carries the user-supplied name +/// for `--command` mode. Always-present keys (the unused field is `null`, +/// not missing) keep consumer code simple. +#[derive(Serialize)] +pub struct TargetInfo { + pub kind: String, + pub path: Option, + pub command: Option, } /// Per-level verification counts: how many requirements at this level had @@ -198,7 +405,11 @@ fn group_order(group: &CheckGroup) -> u8 { } } -pub fn format_text(results: &[CheckResult], quiet: bool) -> String { +/// Format the scorecard as plain text. Pass `Some(badge)` to append the +/// post-summary embed hint when the tool qualifies for the agent-native +/// badge; below-floor runs see `text_hint()` return `None`, so nothing is +/// appended (the "do not nag" rule from the badge convention). +pub fn format_text(results: &[CheckResult], quiet: bool, badge: Option<&BadgeInfo>) -> String { let mut out = String::new(); // Group results by CheckGroup @@ -257,9 +468,27 @@ pub fn format_text(results: &[CheckResult], quiet: bool) -> String { s.total, s.pass, s.warn, s.fail, s.skip, s.error ); + // Badge embed hint — appended only when eligible. Below the floor the + // `text_hint()` returns None and nothing is added (the convention's + // "do not nag" rule). + if let Some(hint) = badge.and_then(BadgeInfo::text_hint) { + out.push_str(&hint); + } + out } +/// Bundle of run-level metadata captured by the runner around `Commands::Check` +/// and threaded into the scorecard. Grouped to keep `build_scorecard`'s +/// signature manageable as schema `0.x` continues to add fields. The runner +/// owns capture; this module owns serialization shape. +pub struct RunMetadata { + pub tool: ToolInfo, + pub anc: AncInfo, + pub run: RunInfo, + pub target: TargetInfo, +} + /// Build the scorecard. The `ran_checks` slice is the catalog of checks /// that produced `results` — needed to translate check IDs back to the /// requirement IDs they cover for `coverage_summary`. @@ -268,6 +497,7 @@ pub fn build_scorecard( ran_checks: &[Box], audience: Option, audit_profile: Option, + metadata: RunMetadata, ) -> Scorecard { // `audience_reason` is derived from `results` rather than threaded // through as a caller parameter — the reason is a property of the @@ -279,6 +509,17 @@ pub fn build_scorecard( } else { audience::classify_reason(results).map(|s| s.to_string()) }; + let RunMetadata { + tool, + anc, + run, + target, + } = metadata; + // Compute the badge from the same `tool.name` the JSON emits, so the + // embed URL in `badge.embed_markdown` and the slug in `tool.name` can + // never disagree (a regression that diverges them would mislead any + // author copy-pasting from the JSON). + let badge = compute_badge(results, &tool.name); Scorecard { schema_version: SCHEMA_VERSION, results: results.iter().map(CheckResultView::from_result).collect(), @@ -288,6 +529,11 @@ pub fn build_scorecard( audience_reason, audit_profile, spec_version: SPEC_VERSION, + tool, + anc, + run, + target, + badge, } } @@ -296,8 +542,9 @@ pub fn format_json( ran_checks: &[Box], audience: Option, audit_profile: Option, + metadata: RunMetadata, ) -> String { - let scorecard = build_scorecard(results, ran_checks, audience, audit_profile); + let scorecard = build_scorecard(results, ran_checks, audience, audit_profile, metadata); serde_json::to_string_pretty(&scorecard).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}")) } @@ -403,15 +650,44 @@ mod tests { } } + /// Synthesize the metadata bundle existing tests need but don't care + /// about. Tests that exercise metadata behavior build their own. + fn fixture_metadata() -> RunMetadata { + RunMetadata { + tool: ToolInfo { + name: "fixture-tool".into(), + binary: None, + version: None, + }, + anc: AncInfo { + version: "0.0.0-test", + }, + run: RunInfo { + invocation: "anc check .".into(), + started_at: "1970-01-01T00:00:00Z".into(), + duration_ms: 0, + platform: PlatformInfo { + os: "test-os", + arch: "test-arch", + }, + }, + target: TargetInfo { + kind: "project".into(), + path: Some(".".into()), + command: None, + }, + } + } + #[test] fn test_format_json_valid() { let results = vec![ make_result("c1", CheckStatus::Pass, CheckGroup::P1), make_result("c2", CheckStatus::Fail("bad".into()), CheckGroup::P2), ]; - let json = format_json(&results, &[], None, None); + let json = format_json(&results, &[], None, None, fixture_metadata()); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); - assert_eq!(parsed["schema_version"], "0.3"); + assert_eq!(parsed["schema_version"], "0.5"); assert_eq!(parsed["summary"]["total"], 2); assert_eq!(parsed["summary"]["pass"], 1); assert_eq!(parsed["summary"]["fail"], 1); @@ -626,11 +902,11 @@ mod tests { .map(|id| make_result(id, CheckStatus::Pass, CheckGroup::P1)) .collect(); let audience = classify(&results); - let json = format_json(&results, &[], audience, None); + let json = format_json(&results, &[], audience, None, fixture_metadata()); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert_eq!(parsed["audience"], "agent-optimized"); assert!(parsed["audit_profile"].is_null()); - assert_eq!(parsed["schema_version"], "0.3"); + assert_eq!(parsed["schema_version"], "0.5"); } #[test] @@ -650,7 +926,7 @@ mod tests { }) .collect(); let audience = classify(&results); - let json = format_json(&results, &[], audience, None); + let json = format_json(&results, &[], audience, None, fixture_metadata()); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert_eq!(parsed["audience"], "human-primary"); } @@ -666,7 +942,7 @@ mod tests { CheckGroup::P1, )]; let audience = classify(&results); - let json = format_json(&results, &[], audience, None); + let json = format_json(&results, &[], audience, None, fixture_metadata()); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert!(parsed["audience"].is_null()); } @@ -674,7 +950,13 @@ mod tests { #[test] fn format_json_echoes_audit_profile() { let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)]; - let json = format_json(&results, &[], None, Some("human-tui".into())); + let json = format_json( + &results, + &[], + None, + Some("human-tui".into()), + fixture_metadata(), + ); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert_eq!(parsed["audit_profile"], "human-tui"); } @@ -688,7 +970,7 @@ mod tests { CheckStatus::Pass, CheckGroup::P1, )]; - let json = format_json(&results, &[], None, None); + let json = format_json(&results, &[], None, None, fixture_metadata()); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert!(parsed["audience"].is_null()); assert_eq!(parsed["audience_reason"], "insufficient_signal"); @@ -703,7 +985,7 @@ mod tests { .map(|id| make_result(id, CheckStatus::Pass, CheckGroup::P1)) .collect(); let audience = classify(&results); - let json = format_json(&results, &[], audience, None); + let json = format_json(&results, &[], audience, None, fixture_metadata()); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); // audience has a label, so audience_reason must be omitted — not // merely null. `#[serde(skip_serializing_if = "Option::is_none")]` @@ -735,7 +1017,13 @@ mod tests { }) .collect(); let audience = classify(&results); - let json = format_json(&results, &[], audience, Some("human-tui".into())); + let json = format_json( + &results, + &[], + audience, + Some("human-tui".into()), + fixture_metadata(), + ); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert!(parsed["audience"].is_null()); assert_eq!(parsed["audience_reason"], "suppressed"); @@ -802,7 +1090,13 @@ mod tests { .map(|id| make_result(id, CheckStatus::Pass, CheckGroup::P1)) .collect(); let audience = classify(&results); - let json = format_json(&results, &[], audience, Some("human-tui".into())); + let json = format_json( + &results, + &[], + audience, + Some("human-tui".into()), + fixture_metadata(), + ); // audience: kebab-case. assert!( @@ -830,4 +1124,353 @@ mod tests { would desync from the --audit-profile flag value shape", ); } + + #[test] + fn schema_v05_emits_every_documented_key() { + // Drift guard for schema 0.5. Builds a synthetic Scorecard, parses + // the JSON, and asserts every documented key path resolves — + // including keys that hold `null` values (those are part of the + // contract: consumer code should treat null and missing differently + // only for `audience_reason`, which uses `skip_serializing_if`). + // + // A field rename, deletion, or accidental top-level relocation is + // caught here loudly with a named-field assertion. New fields land + // alongside this test, not at the expense of it. + let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)]; + let metadata = RunMetadata { + tool: ToolInfo { + name: "demo".into(), + binary: None, + version: None, + }, + anc: AncInfo { + version: "0.0.1-test", + }, + run: RunInfo { + invocation: "anc check .".into(), + started_at: "2026-04-29T16:00:00Z".into(), + duration_ms: 42, + platform: PlatformInfo { + os: "linux", + arch: "x86_64", + }, + }, + target: TargetInfo { + kind: "project".into(), + path: Some("/tmp/x".into()), + command: None, + }, + }; + let json = format_json(&results, &[], None, None, metadata); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + + // 0.1 / 0.2 / 0.3 keys remain — defends against accidental removal + // during schema work. + for path in [ + "schema_version", + "results", + "summary", + "coverage_summary", + "audience", + "audit_profile", + "spec_version", + ] { + assert!( + parsed.get(path).is_some(), + "0.1-0.3 key `{path}` must remain present in 0.5", + ); + } + + // 0.4 + 0.5 additions — every documented sub-key resolves. + assert_eq!(parsed["schema_version"], "0.5"); + for path in [ + // 0.4 + "tool.name", + "tool.binary", + "tool.version", + "anc.version", + "run.invocation", + "run.started_at", + "run.duration_ms", + "run.platform.os", + "run.platform.arch", + "target.kind", + "target.path", + "target.command", + // 0.5 — badge block + "badge.eligible", + "badge.score_pct", + "badge.embed_markdown", + "badge.scorecard_url", + "badge.badge_url", + "badge.convention_url", + ] { + let mut node = &parsed; + for segment in path.split('.') { + node = node + .get(segment) + .unwrap_or_else(|| panic!("0.5 key `{path}` missing — segment `{segment}`")); + } + } + + // Emitted values match the synthetic input. + assert_eq!(parsed["tool"]["name"], "demo"); + assert_eq!(parsed["anc"]["version"], "0.0.1-test"); + assert_eq!(parsed["run"]["invocation"], "anc check ."); + assert_eq!(parsed["run"]["duration_ms"], 42); + assert_eq!(parsed["run"]["platform"]["os"], "linux"); + assert_eq!(parsed["target"]["kind"], "project"); + assert_eq!(parsed["target"]["path"], "/tmp/x"); + + // Always-present-null contract: `tool.version`, `target.command` + // serialize as JSON null, not as missing keys. Consumer code should + // be able to access these paths unconditionally. + assert!(parsed["tool"]["version"].is_null()); + assert!(parsed["tool"]["binary"].is_null()); + assert!(parsed["target"]["command"].is_null()); + } + + #[test] + fn compute_badge_eligible_when_all_pass_and_slug_present() { + // Three Pass and zero failures → 100% → above the 80% floor. + let results = vec![ + make_result("c1", CheckStatus::Pass, CheckGroup::P1), + make_result("c2", CheckStatus::Pass, CheckGroup::P2), + make_result("c3", CheckStatus::Pass, CheckGroup::P3), + ]; + let badge = compute_badge(&results, "navi"); + assert!(badge.eligible); + assert_eq!(badge.score_pct, 100); + assert_eq!( + badge.embed_markdown.as_deref(), + Some("[![agent-native](https://anc.dev/badge/navi.svg)](https://anc.dev/score/navi)"), + ); + assert_eq!( + badge.scorecard_url.as_deref(), + Some("https://anc.dev/score/navi"), + ); + assert_eq!( + badge.badge_url.as_deref(), + Some("https://anc.dev/badge/navi.svg"), + ); + assert_eq!(badge.convention_url, "https://anc.dev/badge"); + } + + #[test] + fn compute_badge_below_floor_emits_urls_but_no_embed() { + // 4 of 5 fail → 1 pass / 5 denom = 20% → below floor. + let results = vec![ + make_result("c1", CheckStatus::Pass, CheckGroup::P1), + make_result("c2", CheckStatus::Fail("a".into()), CheckGroup::P2), + make_result("c3", CheckStatus::Fail("b".into()), CheckGroup::P3), + make_result("c4", CheckStatus::Fail("c".into()), CheckGroup::P4), + make_result("c5", CheckStatus::Fail("d".into()), CheckGroup::P5), + ]; + let badge = compute_badge(&results, "needs-work"); + assert!(!badge.eligible); + assert_eq!(badge.score_pct, 20); + assert!( + badge.embed_markdown.is_none(), + "below the floor: embed_markdown must be None per the do-not-nag rule", + ); + // The site renders an SVG for every scored tool regardless of + // score, so the URL is still useful below the floor. + assert!(badge.scorecard_url.is_some()); + assert!(badge.badge_url.is_some()); + } + + #[test] + fn compute_badge_at_floor_is_eligible() { + // 4 pass / 5 denom = 80% — exactly at the floor must qualify. + let results = vec![ + make_result("c1", CheckStatus::Pass, CheckGroup::P1), + make_result("c2", CheckStatus::Pass, CheckGroup::P2), + make_result("c3", CheckStatus::Pass, CheckGroup::P3), + make_result("c4", CheckStatus::Pass, CheckGroup::P4), + make_result("c5", CheckStatus::Fail("one fail".into()), CheckGroup::P5), + ]; + let badge = compute_badge(&results, "edge-case"); + assert!(badge.eligible, "score == floor must qualify"); + assert_eq!(badge.score_pct, 80); + assert!(badge.embed_markdown.is_some()); + } + + #[test] + fn compute_badge_skips_excluded_from_denominator() { + // 1 Pass + 1 Skip + 1 Error → denom is 1 (only Pass), score 100%. + // Skips and Errors must not pull the score down — they're not + // verdicts, so the leaderboard formula excludes them. + let results = vec![ + make_result("c1", CheckStatus::Pass, CheckGroup::P1), + make_result( + "c2", + CheckStatus::Skip("not applicable".into()), + CheckGroup::P2, + ), + make_result("c3", CheckStatus::Error("boom".into()), CheckGroup::P3), + ]; + let badge = compute_badge(&results, "skipper"); + assert_eq!(badge.score_pct, 100); + assert!(badge.eligible); + } + + #[test] + fn compute_badge_no_scoring_data_is_ineligible() { + // Every result is a Skip → denominator is zero. Score 0% and not + // eligible — guard against division-by-zero turning into NaN or a + // misleading 100%. + let results = vec![ + make_result("c1", CheckStatus::Skip("filtered".into()), CheckGroup::P1), + make_result("c2", CheckStatus::Skip("filtered".into()), CheckGroup::P2), + ]; + let badge = compute_badge(&results, "ghost"); + assert_eq!(badge.score_pct, 0); + assert!(!badge.eligible); + assert!(badge.embed_markdown.is_none()); + } + + #[test] + fn compute_badge_empty_slug_is_ineligible_even_at_perfect_score() { + // Without a tool slug the embed URL would be malformed + // (`/badge/.svg`); ineligible is the safe default — better to omit + // the hint than to emit a broken URL. + let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)]; + let badge = compute_badge(&results, ""); + assert_eq!(badge.score_pct, 100); + assert!(!badge.eligible); + assert!(badge.embed_markdown.is_none()); + assert!(badge.scorecard_url.is_none()); + assert!(badge.badge_url.is_none()); + // Convention URL is always emitted — it's the same for every tool. + assert_eq!(badge.convention_url, "https://anc.dev/badge"); + } + + #[test] + fn badge_text_hint_present_when_eligible() { + let badge = compute_badge( + &[make_result("c1", CheckStatus::Pass, CheckGroup::P1)], + "demo", + ); + let hint = badge.text_hint().expect("eligible run must produce hint"); + assert!( + hint.contains("Score: 100%"), + "hint should announce the score, got: {hint}", + ); + assert!( + hint.contains("https://anc.dev/badge/demo.svg"), + "hint should embed the canonical badge URL, got: {hint}", + ); + assert!( + hint.contains("https://anc.dev/score/demo"), + "hint should link to the per-tool scorecard page, got: {hint}", + ); + assert!( + hint.contains("https://anc.dev/badge"), + "hint should reference the convention page, got: {hint}", + ); + } + + #[test] + fn badge_text_hint_absent_when_below_floor() { + // The "do not nag" rule from the badge convention: below the floor + // we print nothing badge-related. + let badge = compute_badge( + &[ + make_result("c1", CheckStatus::Fail("a".into()), CheckGroup::P1), + make_result("c2", CheckStatus::Fail("b".into()), CheckGroup::P2), + ], + "needs-work", + ); + assert!(badge.text_hint().is_none()); + } + + #[test] + fn format_text_appends_hint_when_badge_eligible() { + let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)]; + let badge = compute_badge(&results, "demo"); + let text = format_text(&results, false, Some(&badge)); + assert!( + text.contains("qualifies for the agent-native badge"), + "format_text must append the badge hint when eligible:\n{text}", + ); + assert!( + text.contains("https://anc.dev/badge/demo.svg"), + "embedded URL must use the tool slug:\n{text}", + ); + } + + #[test] + fn format_text_omits_hint_when_below_floor() { + let results = vec![ + make_result("c1", CheckStatus::Fail("a".into()), CheckGroup::P1), + make_result("c2", CheckStatus::Fail("b".into()), CheckGroup::P2), + ]; + let badge = compute_badge(&results, "needs-work"); + let text = format_text(&results, false, Some(&badge)); + assert!( + !text.contains("agent-native badge"), + "below-floor runs must not nag:\n{text}", + ); + } + + #[test] + fn format_text_without_badge_arg_is_unchanged() { + // Callers that pass `None` (e.g., legacy plumbing or tests + // exercising the formatter alone) get the historical output with + // no badge tail. + let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)]; + let text = format_text(&results, false, None); + assert!(!text.contains("agent-native badge")); + } + + #[test] + fn scorecard_emits_badge_block() { + // End-to-end: a synthetic perfect run produces a JSON scorecard + // whose `badge` block reflects eligibility and the slug echoed in + // `tool.name`. Pins the contract that JSON consumers (notably the + // site's `/score/` renderer) can rely on without re-running + // `compute_badge` themselves. + let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)]; + let metadata = RunMetadata { + tool: ToolInfo { + name: "navi".into(), + binary: Some("navi".into()), + version: Some("0.1.0".into()), + }, + anc: AncInfo { + version: "0.0.0-test", + }, + run: RunInfo { + invocation: "anc check .".into(), + started_at: "1970-01-01T00:00:00Z".into(), + duration_ms: 0, + platform: PlatformInfo { + os: "test-os", + arch: "test-arch", + }, + }, + target: TargetInfo { + kind: "project".into(), + path: Some(".".into()), + command: None, + }, + }; + let json = format_json(&results, &[], None, None, metadata); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + assert_eq!(parsed["badge"]["eligible"], true); + assert_eq!(parsed["badge"]["score_pct"], 100); + assert_eq!( + parsed["badge"]["embed_markdown"], + "[![agent-native](https://anc.dev/badge/navi.svg)](https://anc.dev/score/navi)" + ); + assert_eq!( + parsed["badge"]["scorecard_url"], + "https://anc.dev/score/navi" + ); + assert_eq!( + parsed["badge"]["badge_url"], + "https://anc.dev/badge/navi.svg" + ); + assert_eq!(parsed["badge"]["convention_url"], "https://anc.dev/badge"); + } } diff --git a/src/skill_install.rs b/src/skill_install.rs new file mode 100644 index 0000000..ea36f60 --- /dev/null +++ b/src/skill_install.rs @@ -0,0 +1,831 @@ +//! `anc skill install ` — clone the `agentnative-skill` bundle into a +//! host's canonical skills directory using a hardcoded Rust host map and a +//! hardened `git clone` invocation. +//! +//! Pipeline (ASCII; the Mermaid version is plan-doc only — source comments +//! stay ASCII per repo convention): +//! +//! ```text +//! clap parse (host, --dry-run, --output) +//! | +//! v +//! resolve_host(SkillHost) -> (url, dest_template) +//! | +//! v +//! expand_tilde(dest_template) via $HOME -- HOME unset --> MissingHome +//! | (reason=home-not-set) +//! v +//! dry_run? --yes--> emit_result(mode=dry-run, would_succeed) -> exit 0 +//! | +//! no +//! v +//! check_destination() -- conflict --> emit_result(error, reason) -> exit 1 +//! (canonicalize + R9) +//! | +//! v +//! build_clone_command(url, dest) with hardening +//! (GIT_HARDEN_FLAGS, +//! env_remove GIT_HARDEN_ENV_REMOVE, +//! set GIT_TERMINAL_PROMPT=0) +//! | +//! v +//! spawn git -- not on PATH --> GitNotFound (reason=git-not-found, exit 1) +//! | -- nonzero ------> GitCloneFailed (reason=git-clone-failed, exit 1) +//! v +//! exit 0 ------------------> emit_result(mode=install, exit_code=0) -> exit 0 +//! ``` +//! +//! Hardening surface (R6c): +//! - `GIT_HARDEN_FLAGS` — five `-c key=value` pairs (`credential.helper`, +//! `core.askPass`, `protocol.allow=never`, `protocol.https.allow=always`, +//! `http.followRedirects`). +//! - `GIT_HARDEN_ENV_REMOVE` — five env vars stripped before spawn (SSH / +//! proxy / askpass / exec-path overrides). +//! - `GIT_HARDEN_ENV_SET` — three env vars set on the spawned process: +//! `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` +//! together disable every layer of user-controlled git config (the +//! actual defense against `insteadOf` URL-rewriting attacks); +//! `GIT_TERMINAL_PROMPT=0` blocks credential prompts. +//! +//! Never `env_clear()` (strips PATH, breaks git's helper resolution). Never +//! `sh -c` (tokens go directly to `git` via `Command::args`). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::cli::OutputFormat; +use crate::error::AppError; + +// `SkillHost`, `KNOWN_HOSTS`, and `resolve_host` are auto-generated at +// build time from `src/skill_install/skill.json`. To add or remove a host, +// edit the JSON file (or run `bash scripts/sync-skill-fixture.sh` to pull +// the upstream site contract) and `cargo build` regenerates this file. +// See `build.rs::emit_skill_hosts` for the codegen logic. +include!(concat!(env!("OUT_DIR"), "/generated_hosts.rs")); + +/// `git clone` config flags applied via `-c key=value` pairs, in token order +/// suitable for `Command::args`. Five logical pairs (10 string tokens): +/// +/// | Pair | Purpose | +/// |------|---------| +/// | `credential.helper=` | Suppress credential helpers — public clone, no creds | +/// | `core.askPass=` | Suppress askpass programs | +/// | `protocol.allow=never` | Default-deny every transport | +/// | `protocol.https.allow=always` | Permit HTTPS only — paired with the deny above | +/// | `http.followRedirects=false` | Pin destination — no transparent redirects | +/// +/// Two corrections over the plan's original wording, both surfaced by the +/// pre-merge manual smoke (R6c — verified against the actual `git` binary): +/// +/// 1. `protocol.allow=https-only` is **not** valid git syntax (`fatal: +/// unknown value`). The HTTPS-only intent is expressed as a default-deny +/// plus per-protocol allow, which is the documented git-config form. +/// 2. `url..insteadOf=` (empty value) does the **opposite** of +/// blocking — it rewrites every empty-prefix URL (i.e. all URLs) to +/// start with ``, doubling the clone URL. The defense against +/// `insteadOf` attacks is to disable global/system config entirely via +/// [`GIT_HARDEN_ENV_SET`] (`GIT_CONFIG_GLOBAL=/dev/null`, +/// `GIT_CONFIG_SYSTEM=/dev/null`), not via `-c`. The flag was dropped. +pub const GIT_HARDEN_FLAGS: &[&str] = &[ + "-c", + "credential.helper=", + "-c", + "core.askPass=", + "-c", + "protocol.allow=never", + "-c", + "protocol.https.allow=always", + "-c", + "http.followRedirects=false", +]; + +/// Environment variables removed via `Command::env_remove` before spawn. +/// Each one is a known git-side override that could redirect or hijack the +/// clone. `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are **not** in this +/// list — removing them would let git fall back to default config paths +/// (`~/.gitconfig`, `/etc/gitconfig`); we instead point them at `/dev/null` +/// via [`GIT_HARDEN_ENV_SET`] to actively disable user-controlled config. +/// Never `env_clear()` — that strips PATH and breaks git's helper +/// resolution. +pub const GIT_HARDEN_ENV_REMOVE: &[&str] = &[ + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_PROXY_COMMAND", + "GIT_ASKPASS", + "GIT_EXEC_PATH", +]; + +/// Environment variables *set* (not removed) on the spawned process. The +/// `GIT_CONFIG_*` pair points global / system config at `/dev/null` so +/// user-config `insteadOf` rewriting and other ambient overrides cannot +/// fire — this is the actual defense against URL-rewriting attacks. +/// `GIT_TERMINAL_PROMPT=0` blocks credential prompts; git's default-when- +/// unset is to prompt, which is the wrong default for a non-interactive +/// subcommand. +pub const GIT_HARDEN_ENV_SET: &[(&str, &str)] = &[ + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_SYSTEM", "/dev/null"), + ("GIT_TERMINAL_PROMPT", "0"), +]; + +/// Snapshot of what `check_destination` found at the resolved path. Drives +/// the JSON envelope's `destination_status` field. The success path returns +/// only `Absent`/`EmptyDir`; conflict cases (`NonEmptyDir`, `File`) are +/// inferred from the corresponding `AppError` variant in the caller. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DestinationStatus { + Absent, + EmptyDir, + NonEmptyDir, + File, +} + +impl DestinationStatus { + /// Kebab-case identifier for the JSON envelope `destination_status` field. + pub fn as_envelope_str(self) -> &'static str { + match self { + DestinationStatus::Absent => "absent", + DestinationStatus::EmptyDir => "empty-dir", + DestinationStatus::NonEmptyDir => "non-empty-dir", + DestinationStatus::File => "file", + } + } +} + +/// Expand a leading `~` or `~/` to `$HOME`. Pure passthrough on inputs that +/// do not start with `~` (R6a). `MissingHome` only fires when the input +/// actually begins with `~` and `$HOME` is unset or empty — non-`~` inputs +/// never read the environment. +pub fn expand_tilde(template: &str) -> Result { + let home = std::env::var("HOME").ok(); + expand_tilde_with(template, home.as_deref()) +} + +/// Pure-function core of [`expand_tilde`]. Tests pass `home` explicitly so +/// they never mutate the process environment (which would race with parallel +/// tests). The public wrapper performs the env lookup. +pub fn expand_tilde_with(template: &str, home: Option<&str>) -> Result { + let needs_home = template == "~" || template.starts_with("~/"); + if !needs_home { + return Ok(PathBuf::from(template)); + } + let home = home + .filter(|s| !s.is_empty()) + .ok_or(AppError::MissingHome)?; + if template == "~" { + return Ok(PathBuf::from(home)); + } + let rest = template + .strip_prefix("~/") + .expect("template starts with ~/ per the branch guard"); + let mut p = PathBuf::from(home); + p.push(rest); + Ok(p) +} + +/// R9 destination conflict check. Canonicalizes the path so a symlinked +/// skills directory resolves to its real target before the check runs (F4). +/// Returns `Absent`/`EmptyDir` on success; `DestIsFile` for a regular file, +/// `DestNotEmpty` for a populated directory, `DestReadFailed` for any I/O +/// error along the way. +/// +/// TOCTOU between this check and the subsequent `git clone` exec is +/// acknowledged residual single-user-machine risk — `git clone` itself +/// errors on a non-empty target, so the worst case is a less-actionable +/// error message, not a security failure. +pub fn check_destination(path: &Path) -> Result { + match path.try_exists() { + Ok(false) => return Ok(DestinationStatus::Absent), + Ok(true) => {} + Err(e) => { + return Err(AppError::DestReadFailed { + path: path.to_path_buf(), + source: e, + }); + } + } + + let canonical = fs::canonicalize(path).map_err(|e| AppError::DestReadFailed { + path: path.to_path_buf(), + source: e, + })?; + + let metadata = fs::metadata(&canonical).map_err(|e| AppError::DestReadFailed { + path: canonical.clone(), + source: e, + })?; + + if metadata.is_file() { + return Err(AppError::DestIsFile { path: canonical }); + } + + if metadata.is_dir() { + let mut entries = fs::read_dir(&canonical).map_err(|e| AppError::DestReadFailed { + path: canonical.clone(), + source: e, + })?; + if entries.next().is_some() { + return Err(AppError::DestNotEmpty { path: canonical }); + } + return Ok(DestinationStatus::EmptyDir); + } + + // Block / char devices, sockets, fifos — not a normal place to clone into. + // Treat as a file conflict; the typed reason maps to `destination-is-file` + // which best describes "this is not the directory we expected". + Err(AppError::DestIsFile { path: canonical }) +} + +/// Build the hardened `git clone` command. Pure constructor — no spawn, no +/// I/O. The returned `Command` carries the full hardening surface: +/// +/// 1. [`GIT_HARDEN_FLAGS`] applied as `-c key=value` pairs *before* the +/// `clone` subcommand (git's required position for top-level `-c`). +/// 2. [`GIT_HARDEN_ENV_REMOVE`] entries removed via `env_remove` — +/// user/attacker-controlled overrides we want to ignore. +/// 3. [`GIT_HARDEN_ENV_SET`] entries set via `env` — most importantly +/// `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`, +/// which together disable every layer of user-controlled git config +/// (the actual defense against `insteadOf` URL-rewriting attacks). +/// +/// `--depth 1` is included to match the canonical install command shipped +/// in `skill.json`. Verified during planning that `agentnative-skill`'s +/// `bin/check-update` curls the upstream `VERSION` file and does NOT +/// require local tag history, so shallow cloning is safe. +pub fn build_clone_command(url: &str, dest: &Path) -> Command { + let mut cmd = Command::new("git"); + cmd.args(GIT_HARDEN_FLAGS); + cmd.args(["clone", "--depth", "1"]); + cmd.arg(url); + cmd.arg(dest); + for var in GIT_HARDEN_ENV_REMOVE { + cmd.env_remove(var); + } + for (key, value) in GIT_HARDEN_ENV_SET { + cmd.env(key, value); + } + cmd +} + +/// User-visible representation of the clone command for the JSON envelope's +/// `command` field and the `--dry-run --output text` single-line output. +/// Intentionally omits the hardening flags — those are an implementation +/// detail. The displayed form matches `skill.json`'s `install.` +/// verbatim, so users can copy-paste-modify as a manual fallback. +pub fn format_clone_command(url: &str, dest: &Path) -> String { + format!("git clone --depth 1 {url} {}", dest.display()) +} + +/// Result envelope shared by both `--output text` and `--output json`. +/// Schema is uniform across success and error paths (R-OUT, C1). +/// +/// Field-presence rules: +/// - `would_succeed` — present in dry-run mode only. +/// - `exit_code` — present on the live install path only (and only when we +/// actually spawned `git`; e.g., `git-not-found` leaves it absent). +/// - `reason` — present on error only, with the typed values enumerated in +/// the plan's R-OUT. +#[derive(Debug, Clone, serde::Serialize)] +pub struct InstallEnvelope { + pub action: &'static str, + pub host: &'static str, + pub mode: &'static str, + pub command: String, + pub destination: String, + pub destination_status: &'static str, + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub would_succeed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<&'static str>, +} + +const ACTION: &str = "skill-install"; +const MODE_DRY_RUN: &str = "dry-run"; +const MODE_INSTALL: &str = "install"; +const STATUS_SUCCESS: &str = "success"; +const STATUS_ERROR: &str = "error"; + +const REASON_DEST_NOT_EMPTY: &str = "destination-not-empty"; +const REASON_DEST_IS_FILE: &str = "destination-is-file"; +const REASON_HOME_NOT_SET: &str = "home-not-set"; +const REASON_GIT_NOT_FOUND: &str = "git-not-found"; +const REASON_GIT_CLONE_FAILED: &str = "git-clone-failed"; + +/// Compute the envelope without performing I/O (dry-run) or, in install +/// mode, after spawning `git`. Internal I/O failures that don't fit the +/// typed reason taxonomy (e.g., `DestReadFailed` from a permission-denied +/// `read_dir`) propagate as `AppError` — the top-level handler renders +/// them on stderr and exits 2 (internal-error reserved per P4). +pub fn compute_install_envelope( + host: SkillHost, + dry_run: bool, +) -> Result { + let (url, dest_template) = resolve_host(host); + let host_str = host_envelope_str(host); + let mode_str = if dry_run { MODE_DRY_RUN } else { MODE_INSTALL }; + + // Step 1: tilde expand. MissingHome is an envelope error, not propagated. + let dest = match expand_tilde(dest_template) { + Ok(p) => p, + Err(AppError::MissingHome) => { + // Without $HOME we cannot show the resolved destination. Surface + // the template (with its literal `~`) so the consumer sees what + // would have been expanded; destination_status is `absent` + // because we never reached the filesystem. + let command = format!("git clone --depth 1 {url} {dest_template}"); + return Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: mode_str, + command, + destination: dest_template.to_string(), + destination_status: DestinationStatus::Absent.as_envelope_str(), + status: STATUS_ERROR, + would_succeed: if dry_run { Some(false) } else { None }, + exit_code: None, + reason: Some(REASON_HOME_NOT_SET), + }); + } + Err(e) => return Err(e), + }; + + let command = format_clone_command(url, &dest); + let dest_str = dest.display().to_string(); + + // Step 2: destination check. Conflict variants surface as envelope + // errors; DestReadFailed propagates (internal I/O failure). + let dest_status = match check_destination(&dest) { + Ok(s) => s, + Err(AppError::DestIsFile { .. }) => { + return Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: mode_str, + command, + destination: dest_str, + destination_status: DestinationStatus::File.as_envelope_str(), + status: STATUS_ERROR, + would_succeed: if dry_run { Some(false) } else { None }, + exit_code: None, + reason: Some(REASON_DEST_IS_FILE), + }); + } + Err(AppError::DestNotEmpty { .. }) => { + return Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: mode_str, + command, + destination: dest_str, + destination_status: DestinationStatus::NonEmptyDir.as_envelope_str(), + status: STATUS_ERROR, + would_succeed: if dry_run { Some(false) } else { None }, + exit_code: None, + reason: Some(REASON_DEST_NOT_EMPTY), + }); + } + Err(e) => return Err(e), + }; + + let dest_status_str = dest_status.as_envelope_str(); + + if dry_run { + return Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: MODE_DRY_RUN, + command, + destination: dest_str, + destination_status: dest_status_str, + status: STATUS_SUCCESS, + would_succeed: Some(true), + exit_code: None, + reason: None, + }); + } + + // Step 3: spawn `git`. The spawn helper produces typed `AppError` + // variants (`GitNotFound`, `GitCloneFailed`) so the envelope-mapping + // pattern stays uniform across error sources. Other I/O errors + // propagate. + let mut cmd = build_clone_command(url, &dest); + match spawn_git_clone(&mut cmd) { + Ok(()) => Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: MODE_INSTALL, + command, + destination: dest_str, + destination_status: dest_status_str, + status: STATUS_SUCCESS, + would_succeed: None, + exit_code: Some(0), + reason: None, + }), + Err(AppError::GitCloneFailed { code }) => Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: MODE_INSTALL, + command, + destination: dest_str, + destination_status: dest_status_str, + status: STATUS_ERROR, + would_succeed: None, + exit_code: Some(code), + reason: Some(REASON_GIT_CLONE_FAILED), + }), + Err(AppError::GitNotFound) => Ok(InstallEnvelope { + action: ACTION, + host: host_str, + mode: MODE_INSTALL, + command, + destination: dest_str, + destination_status: dest_status_str, + status: STATUS_ERROR, + would_succeed: None, + exit_code: None, + reason: Some(REASON_GIT_NOT_FOUND), + }), + Err(e) => Err(e), + } +} + +/// Spawn the prepared `git clone` command and reduce the result to typed +/// `AppError` variants matching the plan's reason taxonomy. Other I/O +/// failures (e.g., permission denied invoking the resolved binary) wrap +/// into `AppError::Io` and propagate. +fn spawn_git_clone(cmd: &mut Command) -> Result<(), AppError> { + match cmd.status() { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(AppError::GitCloneFailed { + code: status.code().unwrap_or(-1), + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(AppError::GitNotFound), + Err(e) => Err(AppError::Io(e)), + } +} + +/// Render the envelope as a single-line text command (`git clone …`) on +/// success, or as a human error line on failure. Single-line dry-run output +/// is the contract that `eval $(anc skill install --dry-run )` +/// depends on. +pub fn emit_result_text(env: &InstallEnvelope) -> String { + if env.status == STATUS_SUCCESS { + match env.mode { + // Dry-run success: just the command, captures via `eval $(...)`. + "dry-run" => env.command.clone(), + // Live install success: short confirmation line on stdout. + _ => format!("Installed agent-native-cli into {}", env.destination), + } + } else { + let reason = env.reason.unwrap_or("unknown"); + format!("error: {reason}: {}", env.destination) + } +} + +/// Render the envelope as pretty-printed JSON. Pretty-print matches the +/// existing `anc check --output json` style and keeps grep / `jaq` queries +/// readable. `serde_json::to_string_pretty` is infallible for this struct +/// (no map keys, no non-string keys, no skipped serializer), so we +/// `expect()` rather than propagate. +pub fn emit_result_json(env: &InstallEnvelope) -> String { + serde_json::to_string_pretty(env) + .expect("InstallEnvelope serialization is infallible by construction") +} + +/// Orchestrate the install pipeline. Always emits an envelope (text or +/// json) on stdout. Exit code is `0` for success, `1` for envelope errors +/// (typed reason set), and `AppError` for internal I/O failures handled by +/// `main` (exit 2 — internal-error reserved per P4). +pub fn run_install(host: SkillHost, dry_run: bool, output: OutputFormat) -> Result { + let envelope = compute_install_envelope(host, dry_run)?; + let rendered = match output { + OutputFormat::Text => emit_result_text(&envelope), + OutputFormat::Json => emit_result_json(&envelope), + }; + crate::output::emit_line(&rendered); + Ok(if envelope.status == STATUS_SUCCESS { + 0 + } else { + 1 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::ValueEnum; + + /// Helper used by hardening-surface tests: any host's URL works because + /// every install command in the v1 fixture shares one upstream. Reads + /// straight from `resolve_host` so the value tracks the build-time + /// codegen — no parallel hardcoded constant to keep in sync. + fn skill_repo_url() -> &'static str { + resolve_host(SkillHost::ClaudeCode).0 + } + + /// Test 1 — `resolve_host` returns the expected `(url, dest_template)` + /// for every variant. Drives off `KNOWN_HOSTS` so adding a host to + /// `skill.json` automatically extends coverage. Since both + /// `KNOWN_HOSTS` and `resolve_host` are generated from the same JSON, + /// this test catches build.rs codegen regressions (e.g. wrong URL, + /// off-by-one tokenisation) — without it, a buggy emitter could + /// produce arbitrary garbage and the rest of the suite would still + /// pass. + #[test] + fn resolve_host_returns_expected_pair_for_every_variant() { + let fixture_text = include_str!("skill_install/skill.json"); + let fixture: serde_json::Value = + serde_json::from_str(fixture_text).expect("fixture is valid JSON"); + let install = fixture + .get("install") + .and_then(|v| v.as_object()) + .expect("fixture has install map"); + + for &host_name in KNOWN_HOSTS { + let cmd = install + .get(host_name) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("fixture missing install.{host_name}")); + let tokens: Vec<&str> = cmd.split_whitespace().collect(); + let expected_url = tokens[4]; + let expected_dest = tokens[5]; + + let host = SkillHost::from_str(host_name, false) + .unwrap_or_else(|_| panic!("KNOWN_HOSTS entry {host_name:?} unparseable")); + let (url, dest) = resolve_host(host); + + assert_eq!(url, expected_url, "url mismatch for {host_name}"); + assert_eq!(dest, expected_dest, "dest mismatch for {host_name}"); + } + } + + /// Test 11 — `KNOWN_HOSTS` matches `SkillHost` variant count and names + /// exactly. Catches the regression where someone adds a variant but + /// forgets the const, or vice versa, before the next release ships. + #[test] + fn known_hosts_matches_skill_host_variant_count_and_names() { + let variant_names: Vec = SkillHost::value_variants() + .iter() + .map(|v| { + v.to_possible_value() + .expect("clap ValueEnum variant always has a possible value") + .get_name() + .to_string() + }) + .collect(); + let known: Vec = KNOWN_HOSTS.iter().map(|s| (*s).to_string()).collect(); + assert_eq!( + variant_names, known, + "SkillHost variants and KNOWN_HOSTS must stay in lockstep", + ); + } + + /// Defense-in-depth — the GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM + /// pointers must be set to a path that disables config loading + /// entirely (`/dev/null`). This is the actual defense against + /// user-config `insteadOf` URL-rewriting attacks; an earlier draft + /// used a `url..insteadOf=` `-c` flag that did the *opposite* + /// of blocking (it rewrote every URL to start with our repo, doubling + /// the clone URL). Pin the corrected shape so we don't regress. + #[test] + fn git_harden_env_set_disables_user_config() { + let pairs: std::collections::HashMap<&str, &str> = + GIT_HARDEN_ENV_SET.iter().copied().collect(); + for var in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] { + let v = pairs.get(var).unwrap_or_else(|| { + panic!("GIT_HARDEN_ENV_SET missing {var}; got {GIT_HARDEN_ENV_SET:?}") + }); + assert_eq!( + *v, "/dev/null", + "{var} must be set to /dev/null to disable user config; got {v:?}", + ); + } + } + + /// Sanity: no panics when constructing each variant via `ValueEnum`. + /// Catches the regression where `rename_all = "snake_case"` is dropped + /// and the surface names drift away from skill.json keys. + #[test] + fn skill_host_clap_value_names_match_known_hosts() { + for &expected in KNOWN_HOSTS { + let parsed = SkillHost::from_str(expected, false) + .unwrap_or_else(|_| panic!("KNOWN_HOSTS entry {expected:?} not parseable")); + let rendered = parsed + .to_possible_value() + .expect("clap ValueEnum variant always has a possible value") + .get_name() + .to_string(); + assert_eq!(rendered, expected); + } + } + + // Test 12 — `host_map_matches_site_skill_json` — was the cargo-level + // drift anchor between the hand-maintained Rust map and the vendored + // fixture. It is provably redundant after the build.rs codegen + // refactor: both `SkillHost` / `KNOWN_HOSTS` / `resolve_host` AND + // `tests/fixtures/skill.json` are now single-sourced from + // `src/skill_install/skill.json`. Cargo's `rerun-if-changed` directive + // ensures the codegen regenerates whenever the fixture changes, so + // they cannot drift relative to each other within a single build. + // Drift between the fixture and the upstream site contract is still + // caught by `scripts/sync-skill-fixture.sh --check` (CI workflow + // `skill-fixture-drift.yml`, test 26). + + /// Test 2 — `expand_tilde("~/.claude/skills/agent-native-cli")` with + /// `HOME=/home/test` resolves to the canonical absolute path. Uses the + /// pure helper to avoid mutating process env (which would race with + /// parallel tests). + #[test] + fn expand_tilde_replaces_leading_tilde_slash_with_home() { + let got = expand_tilde_with("~/.claude/skills/agent-native-cli", Some("/home/test")) + .expect("HOME present + ~/ prefix should expand cleanly"); + assert_eq!( + got, + PathBuf::from("/home/test/.claude/skills/agent-native-cli") + ); + } + + /// Test 3 — `expand_tilde` with `HOME` unset returns `MissingHome`, + /// but only when the input begins with `~`. + #[test] + fn expand_tilde_missing_home_only_when_input_starts_with_tilde() { + let err = expand_tilde_with("~/anything", None) + .expect_err("HOME unset + tilde input should be MissingHome"); + assert!(matches!(err, AppError::MissingHome)); + + let err_empty = + expand_tilde_with("~", Some("")).expect_err("HOME empty string is treated as unset"); + assert!(matches!(err_empty, AppError::MissingHome)); + } + + /// Test 4 — Passthrough contract: paths that don't start with `~` pass + /// through unchanged regardless of `$HOME`. The hardcoded map only ever + /// feeds `~`-prefixed templates, so this branch is unreachable in + /// practice but keeps the contract simple and total (D1 passthrough). + #[test] + fn expand_tilde_no_tilde_passthrough() { + let got_with_home = expand_tilde_with("/abs/path", Some("/home/test")) + .expect("non-tilde input never errors"); + assert_eq!(got_with_home, PathBuf::from("/abs/path")); + + let got_without_home = + expand_tilde_with("/abs/path", None).expect("non-tilde input ignores HOME"); + assert_eq!(got_without_home, PathBuf::from("/abs/path")); + } + + /// Test 5 — `check_destination` on a nonexistent path returns + /// `Absent`. A fresh tempdir's child is a deterministic nonexistent + /// path. + #[test] + fn check_destination_absent_for_nonexistent_path() { + let tmp = tempfile::tempdir().expect("tempdir creation"); + let target = tmp.path().join("does-not-exist"); + let status = check_destination(&target).expect("absent path should be Ok(Absent)"); + assert_eq!(status, DestinationStatus::Absent); + } + + /// Test 6 — `check_destination` on an empty directory returns + /// `EmptyDir`. + #[test] + fn check_destination_empty_dir() { + let tmp = tempfile::tempdir().expect("tempdir creation"); + let status = check_destination(tmp.path()).expect("empty tempdir should be Ok(EmptyDir)"); + assert_eq!(status, DestinationStatus::EmptyDir); + } + + /// Test 7 — `check_destination` on a non-empty directory returns + /// `DestNotEmpty`. + #[test] + fn check_destination_non_empty_dir_errors() { + let tmp = tempfile::tempdir().expect("tempdir creation"); + std::fs::write(tmp.path().join("placeholder"), b"x").expect("write placeholder"); + let err = check_destination(tmp.path()).expect_err("populated dir should be DestNotEmpty"); + assert!(matches!(err, AppError::DestNotEmpty { .. })); + } + + /// Test 8 — `check_destination` on a regular file returns `DestIsFile`. + #[test] + fn check_destination_regular_file_errors() { + let tmp = tempfile::tempdir().expect("tempdir creation"); + let target = tmp.path().join("a-file"); + std::fs::write(&target, b"contents").expect("write file"); + let err = check_destination(&target).expect_err("file should be DestIsFile"); + assert!(matches!(err, AppError::DestIsFile { .. })); + } + + /// Test 9 — Symlink follow via `fs::canonicalize`: a symlink pointing at + /// a non-empty directory returns `DestNotEmpty`, not the symlink's own + /// status. Defends F4 — a symlinked skills dir resolves to the target + /// before the conflict check runs. + #[cfg(unix)] + #[test] + fn check_destination_follows_symlink_to_non_empty_dir() { + let tmp = tempfile::tempdir().expect("tempdir creation"); + let real_dir = tmp.path().join("real"); + std::fs::create_dir(&real_dir).expect("mkdir real"); + std::fs::write(real_dir.join("placeholder"), b"x").expect("populate real"); + + let link = tmp.path().join("link"); + std::os::unix::fs::symlink(&real_dir, &link).expect("symlink real -> link"); + + let err = check_destination(&link).expect_err("symlinked non-empty dir is DestNotEmpty"); + assert!(matches!(err, AppError::DestNotEmpty { .. })); + } + + /// Test 10 — `build_clone_command` introspection. Spawns nothing; reads + /// the constructed `Command` via `get_args` / `get_envs`. Pins three + /// invariants: + /// + /// 1. Every flag in `GIT_HARDEN_FLAGS` appears in the args list. + /// 2. Every var in `GIT_HARDEN_ENV_REMOVE` is in the removal set + /// (`Some(None)` — set with no value means `env_remove`). + /// 3. `GIT_TERMINAL_PROMPT=0` is in the env-set list. + /// + /// Also pins the conventional `clone --depth 1 ` shape. + #[test] + fn build_clone_command_applies_hardening_surface() { + let url = skill_repo_url(); + let dest = Path::new("/tmp/anc-skill-introspect"); + let cmd = build_clone_command(url, dest); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().into_owned()) + .collect(); + + for &flag in GIT_HARDEN_FLAGS { + assert!( + args.iter().any(|a| a == flag), + "GIT_HARDEN_FLAGS entry {flag:?} missing from command args; got {args:?}", + ); + } + assert!( + args.iter().any(|a| a == "clone"), + "missing 'clone' subcommand: {args:?}" + ); + assert!( + args.iter().any(|a| a == "--depth"), + "missing --depth flag: {args:?}" + ); + assert!( + args.iter().any(|a| a == "1"), + "missing --depth value: {args:?}" + ); + assert!( + args.iter().any(|a| a == url), + "missing url operand: {args:?}", + ); + assert!( + args.iter().any(|a| a == "/tmp/anc-skill-introspect"), + "missing dest operand: {args:?}", + ); + + let envs: std::collections::HashMap> = cmd + .get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|s| s.to_string_lossy().into_owned()), + ) + }) + .collect(); + + for &var in GIT_HARDEN_ENV_REMOVE { + let entry = envs.get(var); + assert!( + matches!(entry, Some(None)), + "GIT_HARDEN_ENV_REMOVE entry {var:?} should be removed; got {entry:?}", + ); + } + + for &(key, value) in GIT_HARDEN_ENV_SET { + let entry = envs.get(key); + assert_eq!( + entry, + Some(&Some(value.to_string())), + "GIT_HARDEN_ENV_SET entry {key}={value:?} not present in env-set list; got {entry:?}", + ); + } + } + + /// Sanity: `format_clone_command` produces the canonical user-visible + /// form, matching the `install.` strings in `skill.json` once the + /// destination template is expanded. Hardening flags are intentionally + /// absent — implementation detail. + #[test] + fn format_clone_command_matches_canonical_shape() { + let s = format_clone_command( + skill_repo_url(), + Path::new("/home/u/.claude/skills/agent-native-cli"), + ); + assert_eq!( + s, + "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /home/u/.claude/skills/agent-native-cli", + ); + } +} diff --git a/src/skill_install/skill.json b/src/skill_install/skill.json new file mode 100644 index 0000000..1db85ea --- /dev/null +++ b/src/skill_install/skill.json @@ -0,0 +1,24 @@ +{ + "schema_version": 1, + "type": "agent-skill", + "name": "agent-native-cli", + "version": "0.1.0", + "description": "Build CLI tools that AI agents can operate reliably.", + "principles_url": "https://anc.dev/p1", + "license": "MIT", + "source": { + "type": "git", + "url": "https://github.com/brettdavies/agentnative-skill.git" + }, + "install": { + "claude_code": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.claude/skills/agent-native-cli", + "codex": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.codex/skills/agent-native-cli", + "cursor": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.cursor/skills/agent-native-cli", + "factory": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.factory/skills/agent-native-cli", + "kiro": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.kiro/skills/agent-native-cli", + "opencode": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.config/opencode/skills/agent-native-cli" + }, + "update": "cd && git pull --ff-only", + "uninstall": "rm -rf ", + "skill_page_html": "https://anc.dev/skill" +} diff --git a/tests/dogfood.rs b/tests/dogfood.rs new file mode 100644 index 0000000..92f1e08 --- /dev/null +++ b/tests/dogfood.rs @@ -0,0 +1,75 @@ +//! Dogfood guards for the new `anc skill install` verb. Tests 24 and 25 +//! from the plan, both CRITICAL: without them, the dogfood claim that +//! drove the binary-verb-vs-bash-one-liner decision (Problem Frame § "Why +//! a binary verb, not a bash one-liner?") breaks silently. +//! +//! Each test spawns the real binary in project mode against this repo +//! (CARGO_MANIFEST_DIR), parses the JSON envelope, and asserts no FAIL +//! status on any `p2-*` (test 25) or `p5-*` (test 24) check. Warnings +//! are tolerated; only `fail` breaks the guard. + +use assert_cmd::Command; +use serde_json::Value; + +fn cmd() -> Command { + Command::cargo_bin("anc").expect("anc binary should exist") +} + +fn check_repo_json() -> Value { + let manifest = env!("CARGO_MANIFEST_DIR"); + let out = cmd() + .args(["check", manifest, "--output", "json"]) + .output() + .expect("anc check spawn"); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("failed to parse `anc check` JSON: {e}\nstdout:\n{stdout}")) +} + +fn collect_failed(parsed: &Value, prefix: &str) -> Vec { + parsed["results"] + .as_array() + .expect("results array") + .iter() + .filter(|r| { + r["id"].as_str().is_some_and(|id| id.starts_with(prefix)) && r["status"] == "fail" + }) + .map(|r| { + format!( + "{} ({})", + r["id"].as_str().unwrap_or("?"), + r["evidence"].as_str().unwrap_or("(no evidence)"), + ) + }) + .collect() +} + +/// Test 24 — CRITICAL. P5 (introspection — `--dry-run`, `--print` etc.) +/// must show no `fail` after adding `anc skill install`. The new verb +/// supports `--dry-run`; landing it without dogfooding P5 would invalidate +/// the principle the spec ships against. +#[test] +fn dogfood_no_p5_fail_after_skill_subcommand() { + let parsed = check_repo_json(); + let failed = collect_failed(&parsed, "p5-"); + assert!( + failed.is_empty(), + "p5-* checks must not fail on this repo. Failures:\n {}", + failed.join("\n "), + ); +} + +/// Test 25 — CRITICAL. P2 (structured output — `--output {text,json}` and +/// the JSON envelope contract) must show no `fail` after adding the new +/// verb. `anc skill install` was specifically designed to dogfood P2 by +/// emitting an envelope on every outcome. +#[test] +fn dogfood_no_p2_fail_after_skill_subcommand() { + let parsed = check_repo_json(); + let failed = collect_failed(&parsed, "p2-"); + assert!( + failed.is_empty(), + "p2-* checks must not fail on this repo. Failures:\n {}", + failed.join("\n "), + ); +} diff --git a/tests/fixtures/hostile-hang/probe.sh b/tests/fixtures/hostile-hang/probe.sh new file mode 100755 index 0000000..450ddb5 --- /dev/null +++ b/tests/fixtures/hostile-hang/probe.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Hostile fixture: hangs on `--version` to verify the scorecard probe's +# 2-second timeout actually fires. probe_tool_version() builds a fresh +# BinaryRunner with Duration::from_secs(2); without that bound, anc would +# wait the full sleep duration on a wedged target. We sleep 30s so the +# timeout's effect is unambiguous: a successful guard returns in ~2-3s. +case "$1" in + --version|-V) + # `exec` replaces the shell with `sleep` so SIGKILL on the spawned + # PID kills sleep directly. Without exec, the shell forks sleep as + # a child; SIGKILL on the shell leaves an orphan sleep holding the + # inherited stdout/stderr pipes, which blocks the runner's reader + # threads until sleep finishes. That defeats the timeout's purpose + # and inflates total runtime to ~90s instead of ~4s. + exec sleep 30 + ;; + --help|-h) + echo "hostile-hang: probe --version to trigger hang" + exit 0 + ;; + *) + echo "hostile-hang" + exit 0 + ;; +esac diff --git a/tests/fixtures/hostile-nonzero-exit/probe.sh b/tests/fixtures/hostile-nonzero-exit/probe.sh new file mode 100755 index 0000000..bef06a8 --- /dev/null +++ b/tests/fixtures/hostile-nonzero-exit/probe.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Hostile fixture: exits nonzero on every probe variant the scorecard +# tries (`--version`, `-V`). Verifies the fall-through contract — when +# every version-probe attempt fails, tool.version must be null and the +# overall run must succeed (a target that refuses to self-report is not +# a scoring error). +case "$1" in + --help|-h) + echo "hostile-nonzero-exit: --help works, version probes fail" + exit 0 + ;; + --version|-V) + echo "version probe rejected" >&2 + exit 1 + ;; + *) + echo "hostile-nonzero-exit" + exit 0 + ;; +esac diff --git a/tests/fixtures/hostile-stdout-flood/probe.sh b/tests/fixtures/hostile-stdout-flood/probe.sh new file mode 100755 index 0000000..d29a89e --- /dev/null +++ b/tests/fixtures/hostile-stdout-flood/probe.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Hostile fixture: floods stdout with ~2 MiB on `--version` to verify the +# scorecard's tool.version probe survives a binary that ignores reasonable +# output sizes. The runner's read_capped() primitive enforces a 1 MiB +# ceiling — anc must complete the run without exhausting memory or +# panicking, regardless of what the captured first line looks like. +case "$1" in + --version|-V) + # Emit ~2 MiB of 'x' across many lines so the first line is + # bounded but total output exceeds the cap. + i=0 + while [ "$i" -lt 32768 ]; do + printf 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n' + i=$((i + 1)) + done + exit 0 + ;; + --help|-h) + echo "hostile-stdout-flood: probe --version to trigger flood" + exit 0 + ;; + *) + echo "hostile-stdout-flood" + exit 0 + ;; +esac diff --git a/tests/integration.rs b/tests/integration.rs index e515ff1..45d9d66 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -743,7 +743,7 @@ fn test_audit_profile_echoed_in_json_output() { let json_str = String::from_utf8(output).expect("utf8 stdout"); let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON"); assert_eq!(parsed["audit_profile"], "human-tui"); - assert_eq!(parsed["schema_version"], "0.3"); + assert_eq!(parsed["schema_version"], "0.5"); } #[test] @@ -902,6 +902,14 @@ fn test_scorecard_json_has_stable_top_level_keys() { "audience", "audit_profile", "spec_version", + // 0.4 additions — see docs/plans/2026-04-29-001-feat-scorecard-schema-metadata-plan.md. + "tool", + "anc", + "run", + "target", + // 0.5 addition — agent-native badge block (eligibility, embed + // snippet, scorecard/badge URLs). + "badge", ]; // `audience_reason` is present only when audience is null — on the // self-dogfood it should NOT appear, consistent with the skip rule. @@ -925,7 +933,7 @@ fn test_scorecard_json_has_stable_top_level_keys() { ); // Fixed enumerations also pin against the renderer contract. - assert_eq!(obj["schema_version"], "0.3"); + assert_eq!(obj["schema_version"], "0.5"); } #[test] diff --git a/tests/scorecard_metadata_security.rs b/tests/scorecard_metadata_security.rs new file mode 100644 index 0000000..25aef52 --- /dev/null +++ b/tests/scorecard_metadata_security.rs @@ -0,0 +1,194 @@ +//! Red-team regression tests for `tool.version` probing. +//! +//! These lock in the security guarantees the v0.4 schema plan promised: a +//! self-spawn declined, hostile-binary survival (memory cap, timeout, nonzero +//! exit). Without these tests, a future refactor could quietly drop the +//! self-spawn guard or replace `BinaryRunner::run` with a fresh +//! `Command::output()` and pass every shape-and-value test in the suite. +//! +//! Plan reference: `docs/plans/2026-04-29-001-feat-scorecard-schema-metadata-plan.md`, +//! Risks table + U5 Test scenarios. + +use assert_cmd::Command; +use serde_json::Value; +use std::time::Duration; +// `Instant` is only used by the unix-gated hostile-binary tests below; the +// `cfg(unix)` cuts it out on Windows where shell-script fixtures can't run. +#[cfg(unix)] +use std::time::Instant; + +fn cmd() -> Command { + Command::cargo_bin("anc").expect("anc binary should exist") +} + +fn fixture_path(name: &str) -> String { + format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")) +} + +/// Spawn anc, parse its stdout as JSON. The shared output-buffering used by +/// every test below. +fn run_and_parse(args: &[&str]) -> (Value, std::process::Output) { + let output = cmd() + .args(args) + .timeout(Duration::from_secs(20)) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout.clone()).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("anc must emit valid JSON, got error {e}; stdout was {stdout:?}") + }); + (parsed, output) +} + +#[test] +#[cfg(unix)] +fn self_spawn_against_anc_binary_yields_null_version() { + // Recursive-fork-bomb hazard: pointing anc at its own binary path must + // hit the self-spawn guard in `probe_tool_version` and emit + // `tool.version: null`. `arg_required_else_help` already prevents the + // bare-args recursion path; this guard is defense-in-depth against any + // future loosening of that contract or any version-probe refactor that + // forgets the comparison. + let anc_path = assert_cmd::cargo::cargo_bin("anc"); + let anc_str = anc_path.to_str().expect("utf-8 binary path"); + + let (parsed, output) = run_and_parse(&["check", anc_str, "--output", "json"]); + + assert_eq!( + parsed["target"]["kind"], "binary", + "self-spawn target is the running binary file, kind must be 'binary'", + ); + assert!( + parsed["tool"]["version"].is_null(), + "self-spawn guard must decline the version probe — \ + got {:?}; full stdout: {}", + parsed["tool"]["version"], + String::from_utf8_lossy(&output.stdout), + ); +} + +#[test] +#[cfg(unix)] +fn hostile_binary_flooding_stdout_does_not_exhaust_memory() { + // Fixture emits ~2 MiB on `--version`. The runner's `read_capped` + // primitive enforces a 1 MiB ceiling. anc must complete the run + // without crashing or exhausting memory; the captured `tool.version` + // remains a string-or-null. + let path = fixture_path("hostile-stdout-flood/probe.sh"); + let start = Instant::now(); + let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]); + let elapsed = start.elapsed(); + + assert!( + elapsed < Duration::from_secs(10), + "anc must complete promptly even when target floods stdout; took {elapsed:?}", + ); + let v = &parsed["tool"]["version"]; + assert!( + v.is_string() || v.is_null(), + "tool.version must serialize as string-or-null after stdout flood, got {v:?}", + ); + if let Some(s) = v.as_str() { + // The cap prevents memory exhaustion. The captured first line is + // bounded by what fits before the first newline in the fixture's + // output. We don't pin an exact size but require it stays sane. + assert!( + s.len() < 4 * 1024 * 1024, + "captured first line must be bounded, got {} bytes", + s.len(), + ); + } +} + +#[test] +#[cfg(unix)] +fn hostile_binary_that_hangs_is_killed_at_timeout() { + // Fixture sleeps 30s on `--version`. probe_tool_version's BinaryRunner + // has a 2-second timeout, so a healthy anc returns in ~2-4s (one + // timeout per probe attempt: --version then -V). If a regression drops + // the timeout, the test will time out at assert_cmd's 20s ceiling and + // fail loudly. + let path = fixture_path("hostile-hang/probe.sh"); + let start = Instant::now(); + let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]); + let elapsed = start.elapsed(); + + assert!( + elapsed < Duration::from_secs(15), + "hung version probe must be killed at 2s timeout; total run took {elapsed:?}", + ); + assert!( + parsed["tool"]["version"].is_null(), + "hung version probe must yield tool.version: null, got {:?}", + parsed["tool"]["version"], + ); +} + +#[test] +#[cfg(unix)] +fn hostile_binary_nonzero_version_exit_yields_null() { + // Fixture exits 1 on both `--version` and `-V`. Both probe attempts + // must fail through; tool.version becomes null. The overall run + // succeeds — a target that refuses to self-report its version is not + // a scoring error. + let path = fixture_path("hostile-nonzero-exit/probe.sh"); + let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]); + + assert!( + parsed["tool"]["version"].is_null(), + "every nonzero version probe must yield null, got {:?}", + parsed["tool"]["version"], + ); + // The scorecard itself must still emit — version probe failure is not + // a scoring failure. + assert_eq!(parsed["schema_version"], "0.5"); + assert_eq!(parsed["target"]["kind"], "binary"); +} + +#[test] +fn unknown_command_errors_with_actionable_message() { + // The v0.4 metadata work did NOT change `resolve_command_on_path`'s + // contract: an unknown command name still produces a top-level error, + // not a scorecard with null fields. Locking this in defends against a + // well-meaning refactor that "tolerates" unknown commands by emitting + // an empty scorecard — which would silently bless an invalid target. + let assert = cmd() + .args([ + "check", + "--command", + "definitely-not-a-real-cmd-xyzzy-0a8b", + "--output", + "json", + ]) + .timeout(Duration::from_secs(10)) + .assert() + .failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf-8"); + assert!( + stderr.contains("not found"), + "unknown command must explain itself, got stderr: {stderr:?}", + ); +} + +#[test] +fn project_mode_without_built_binary_emits_manifest_version_and_null_binary() { + // `tests/fixtures/perfect-rust` has Cargo.toml but no target/ — the + // canonical "freshly cloned, never built" state. Plan U5 contract: + // `tool.binary` is null (no executable to probe), `tool.version` falls + // through to the manifest's [package].version (0.1.0 in this fixture). + // A future change that probes a non-existent binary path would emit + // `tool.version: null` here and fail the assertion. + let path = fixture_path("perfect-rust"); + let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]); + + assert_eq!(parsed["target"]["kind"], "project"); + assert!( + parsed["tool"]["binary"].is_null(), + "no built binary in fixture — tool.binary must be null, got {:?}", + parsed["tool"]["binary"], + ); + assert_eq!( + parsed["tool"]["version"], "0.1.0", + "manifest version (Cargo.toml [package].version) must populate tool.version when no binary exists", + ); +} diff --git a/tests/scorecard_schema_v05.rs b/tests/scorecard_schema_v05.rs new file mode 100644 index 0000000..e066b05 --- /dev/null +++ b/tests/scorecard_schema_v05.rs @@ -0,0 +1,307 @@ +//! End-to-end schema 0.5 drift guard. +//! +//! Spawns the real binary in each of the three `anc check` modes (project, +//! binary, command) and asserts the documented v0.5 keys are all present in +//! the JSON output. Catches gaps that unit tests can't — argv capture must +//! actually flow through `inject_default_subcommand`, version probing must +//! actually spawn a child, the `badge` block must be derived from the live +//! tool slug, etc. + +use assert_cmd::Command; +use serde_json::Value; + +fn cmd() -> Command { + Command::cargo_bin("anc").expect("anc binary should exist") +} + +fn fixture_path(name: &str) -> String { + format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")) +} + +/// Assert every documented v0.5 key path resolves on the parsed JSON. The +/// segmented walk gives a precise failure message when a field is missing. +fn assert_v05_shape(parsed: &Value) { + assert_eq!( + parsed["schema_version"], "0.5", + "schema_version must be 0.5", + ); + + for path in [ + // 0.1-0.3 carryover — drift guard against accidental removal. + "results", + "summary", + "coverage_summary", + "audience", + "audit_profile", + "spec_version", + // 0.4 additions. + "tool.name", + "tool.binary", + "tool.version", + "anc.version", + "run.invocation", + "run.started_at", + "run.duration_ms", + "run.platform.os", + "run.platform.arch", + "target.kind", + "target.path", + "target.command", + // 0.5 additions — agent-native badge block. + "badge.eligible", + "badge.score_pct", + "badge.embed_markdown", + "badge.scorecard_url", + "badge.badge_url", + "badge.convention_url", + ] { + let mut node = parsed; + for segment in path.split('.') { + node = node + .get(segment) + .unwrap_or_else(|| panic!("expected key `{path}` — segment `{segment}` missing")); + } + } + + // The convention URL is fixed and shared across every scored tool. A + // regression that pointed it at a stale path would silently break the + // pre-launch surface — pin it loudly here. + assert_eq!( + parsed["badge"]["convention_url"], "https://anc.dev/badge", + "badge.convention_url must be the canonical /badge page", + ); +} + +#[test] +fn schema_v05_project_mode_emits_full_shape() { + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + assert_v05_shape(&parsed); + assert_eq!(parsed["target"]["kind"], "project"); + assert_eq!( + parsed["target"]["path"], "perfect-rust", + "project mode emits the basename of the resolved target, not the absolute path \ + (PII-leak guard — operator home dir / org dir structure must not appear)", + ); + assert!(parsed["target"]["command"].is_null()); +} + +#[test] +fn schema_v05_binary_mode_emits_full_shape() { + let path = fixture_path("binary-only/test.sh"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + assert_v05_shape(&parsed); + assert_eq!(parsed["target"]["kind"], "binary"); + assert_eq!( + parsed["target"]["path"], "test.sh", + "binary mode emits the basename of the resolved target, not the absolute path \ + (PII-leak guard)", + ); + assert!(parsed["target"]["command"].is_null()); +} + +/// Regression guard — `target.path` must never contain a path separator. If +/// this trips, the absolute-path leak from pre-fix `build_target_info` has +/// crept back in (or someone built a new code path that emits a richer +/// path representation). See `src/main.rs::build_target_info` doc comment +/// for the leak-vector rationale. +#[test] +fn schema_v05_target_path_carries_no_separators() { + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + let target_path = parsed["target"]["path"] + .as_str() + .expect("project mode emits a string target.path"); + assert!( + !target_path.contains('/') && !target_path.contains('\\'), + "target.path must be a basename only, got: {target_path:?}", + ); +} + +#[test] +fn schema_v05_command_mode_emits_full_shape() { + // `echo` exists on every supported platform; the version probe is + // best-effort and tolerates whatever `echo --version` happens to print. + let output = cmd() + .args(["check", "--command", "echo", "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + assert_v05_shape(&parsed); + assert_eq!(parsed["target"]["kind"], "command"); + assert!(parsed["target"]["path"].is_null()); + assert_eq!(parsed["target"]["command"], "echo"); + assert_eq!(parsed["tool"]["name"], "echo"); + assert_eq!(parsed["tool"]["binary"], "echo"); +} + +#[test] +fn schema_v05_run_invocation_captures_user_intent_pre_injection() { + // Plan R4: a user who typed `anc ` (default-subcommand injection) + // must see `anc ` in the scorecard, NOT `anc check `. + let path = fixture_path("perfect-rust"); + let output = cmd() + .args([&path, "--output", "json"]) // no explicit `check` + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + let invocation = parsed["run"]["invocation"] + .as_str() + .expect("run.invocation is a string"); + assert!( + !invocation.contains(" check "), + "run.invocation must reflect user intent (pre-injection), got: {invocation}", + ); +} + +#[test] +fn schema_v05_run_platform_matches_runtime_os_arch() { + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + assert_eq!(parsed["run"]["platform"]["os"], std::env::consts::OS); + assert_eq!(parsed["run"]["platform"]["arch"], std::env::consts::ARCH); +} + +#[test] +fn schema_v05_run_started_at_parses_as_rfc3339() { + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + let started_at = parsed["run"]["started_at"] + .as_str() + .expect("started_at is a string"); + // RFC 3339 shape check without pulling the `time` parsing feature into + // dev-deps just for one test: `YYYY-MM-DDTHH:MM:SS` plus optional + // fractional seconds, ending in `Z` or a timezone offset. + assert!( + started_at.len() >= 20 + && started_at.as_bytes()[4] == b'-' + && started_at.as_bytes()[7] == b'-' + && started_at.as_bytes()[10] == b'T' + && started_at.as_bytes()[13] == b':' + && started_at.as_bytes()[16] == b':', + "started_at must look like RFC 3339, got {started_at:?}", + ); + let last = started_at.chars().last().expect("non-empty"); + assert!( + last == 'Z' || started_at.contains('+') || started_at[10..].contains('-'), + "started_at must end in `Z` or a timezone offset, got {started_at:?}", + ); +} + +#[test] +fn schema_v05_anc_version_matches_cargo_pkg_version() { + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + assert_eq!(parsed["anc"]["version"], env!("CARGO_PKG_VERSION")); +} + +#[test] +fn schema_v05_badge_block_reflects_live_tool_slug() { + // End-to-end the `badge.*` URLs are derived from `tool.name` — a + // regression that hardcodes "demo" or pulls the slug from the wrong + // place would produce an embed URL that doesn't match the live + // scorecard page. This pins the slug↔URL relationship without + // depending on the actual score (a fixture's pass-rate may shift as + // checks evolve, so we only assert URL shape, not eligibility). + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + let slug = parsed["tool"]["name"] + .as_str() + .expect("tool.name is a string"); + assert!(!slug.is_empty(), "tool.name must be non-empty"); + + let badge_url = parsed["badge"]["badge_url"] + .as_str() + .expect("badge.badge_url present when slug derived"); + let scorecard_url = parsed["badge"]["scorecard_url"] + .as_str() + .expect("badge.scorecard_url present when slug derived"); + assert_eq!(badge_url, format!("https://anc.dev/badge/{slug}.svg")); + assert_eq!(scorecard_url, format!("https://anc.dev/score/{slug}")); + + // The two URL families MUST share the slug — a regression that + // computed them from different sources would point readers at one + // tool's scorecard via another tool's badge. + assert!(badge_url.contains(slug)); + assert!(scorecard_url.contains(slug)); +} + +#[test] +fn schema_v05_badge_eligibility_flag_matches_score() { + // Whatever the live score, `badge.eligible` must agree with + // `score_pct >= 80`. A regression that flipped the comparison or + // hard-coded `eligible: true` would slip the floor — caught here. + let path = fixture_path("perfect-rust"); + let output = cmd() + .args(["check", &path, "--output", "json"]) + .output() + .expect("anc spawn"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON"); + + let pct = parsed["badge"]["score_pct"] + .as_u64() + .expect("score_pct is numeric") as u32; + let eligible = parsed["badge"]["eligible"] + .as_bool() + .expect("eligible is boolean"); + assert_eq!( + eligible, + pct >= 80, + "badge.eligible must equal (score_pct >= 80); got pct={pct}, eligible={eligible}", + ); + + // Embed snippet contract: present iff eligible. A non-eligible tool + // emitting an embed would defeat the do-not-nag rule. + if eligible { + assert!(parsed["badge"]["embed_markdown"].is_string()); + } else { + assert!(parsed["badge"]["embed_markdown"].is_null()); + } +} diff --git a/tests/skill_install.rs b/tests/skill_install.rs new file mode 100644 index 0000000..82f5ed0 --- /dev/null +++ b/tests/skill_install.rs @@ -0,0 +1,420 @@ +//! Integration tests for `anc skill install`. Spawns the real binary; +//! introspects stdout / stderr / exit code. Tests numbered 13-23 per the +//! plan's `Test scenarios` section. (Tests 1-12 live in `src/skill_install.rs` +//! as unit tests; 24-25 live in `tests/dogfood.rs`; test 26 is a CI step.) + +use assert_cmd::Command; +use serde_json::Value; + +fn cmd() -> Command { + Command::cargo_bin("anc").expect("anc binary should exist") +} + +/// Build a clean `anc` invocation for tests that should be insensitive to +/// the host's `$HOME` and `$PATH`. Each caller can re-`.env(…)` as needed. +fn cmd_with_home(home: &std::path::Path) -> Command { + let mut c = cmd(); + c.env("HOME", home); + c +} + +// ------------------------------------------------------------------------- +// Test 13 — dry-run claude_code text mode prints a single-line `git clone` +// command on stdout and exits 0. +// ------------------------------------------------------------------------- +#[test] +fn dry_run_claude_code_text_prints_single_line_command() { + let tmp = tempfile::tempdir().expect("tempdir"); + let out = cmd_with_home(tmp.path()) + .args(["skill", "install", "--dry-run", "claude_code"]) + .output() + .expect("anc spawn"); + + assert!( + out.status.success(), + "expected exit 0; got {:?}", + out.status + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let trimmed = stdout.trim_end_matches('\n'); + assert!( + !trimmed.contains('\n'), + "dry-run text mode must be single-line; got {trimmed:?}", + ); + assert!( + trimmed.starts_with("git clone --depth 1 "), + "expected leading `git clone --depth 1 `; got {trimmed:?}", + ); + assert!( + trimmed.contains(".claude/skills/agent-native-cli"), + "expected the canonical claude_code dest path; got {trimmed:?}", + ); +} + +// ------------------------------------------------------------------------- +// Test 14 — dry-run claude_code json mode produces the envelope schema with +// mode=dry-run, would_succeed=true, status=success. +// ------------------------------------------------------------------------- +#[test] +fn dry_run_claude_code_json_emits_success_envelope() { + let tmp = tempfile::tempdir().expect("tempdir"); + let out = cmd_with_home(tmp.path()) + .args([ + "skill", + "install", + "--dry-run", + "claude_code", + "--output", + "json", + ]) + .output() + .expect("anc spawn"); + + assert!(out.status.success(), "expected exit 0"); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope"); + + assert_eq!(v["action"], "skill-install"); + assert_eq!(v["host"], "claude_code"); + assert_eq!(v["mode"], "dry-run"); + assert_eq!(v["status"], "success"); + assert_eq!(v["would_succeed"], true); + assert!(v["exit_code"].is_null(), "dry-run mode omits exit_code"); + assert!(v["reason"].is_null(), "success path omits reason"); + assert_eq!(v["destination_status"], "absent"); + assert!( + v["command"] + .as_str() + .unwrap() + .starts_with("git clone --depth 1 "), + "command must start with the canonical clone prefix", + ); + assert!( + v["destination"] + .as_str() + .unwrap() + .contains(".claude/skills/agent-native-cli"), + "destination must resolve to the claude_code canonical path", + ); +} + +// ------------------------------------------------------------------------- +// Test 15 — dry-run + pre-placed regular file at the canonical dest yields +// status=error, reason=destination-is-file, would_succeed=false, exit 1. +// ------------------------------------------------------------------------- +#[test] +fn dry_run_with_regular_file_at_dest_emits_destination_is_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + // Pre-place a regular file at the resolved canonical dest: + // $HOME/.claude/skills/agent-native-cli + let dest_dir = tmp.path().join(".claude/skills"); + std::fs::create_dir_all(&dest_dir).expect("mkdir parent"); + std::fs::write(dest_dir.join("agent-native-cli"), b"not-a-dir").expect("write file"); + + let out = cmd_with_home(tmp.path()) + .args([ + "skill", + "install", + "--dry-run", + "claude_code", + "--output", + "json", + ]) + .output() + .expect("anc spawn"); + + assert_eq!(out.status.code(), Some(1), "expected exit 1"); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope"); + + assert_eq!(v["status"], "error"); + assert_eq!(v["reason"], "destination-is-file"); + assert_eq!(v["would_succeed"], false); + assert_eq!(v["destination_status"], "file"); + assert!( + v["exit_code"].is_null(), + "no spawn happened — exit_code must be absent" + ); +} + +// ------------------------------------------------------------------------- +// Test 16b — `#[ignore]` end-to-end. Spawns the real binary with HOME set +// to a tempdir; runs the actual git clone against the upstream skill repo. +// Excluded from default `cargo test` since it depends on network and the +// public GitHub repo. Run with `cargo test -- --ignored skill_install` when +// vetting a release. +// ------------------------------------------------------------------------- +#[test] +#[ignore = "network: clones from github.com/brettdavies/agentnative-skill"] +fn live_install_clones_into_canonical_dest() { + let tmp = tempfile::tempdir().expect("tempdir"); + let out = cmd_with_home(tmp.path()) + .args(["skill", "install", "claude_code", "--output", "json"]) + .output() + .expect("anc spawn"); + + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert!( + out.status.success(), + "live install failed; stdout={stdout}, stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + let dest = tmp.path().join(".claude/skills/agent-native-cli"); + assert!( + dest.join(".git").is_dir(), + "expected .git/ at {}", + dest.display() + ); +} + +// ------------------------------------------------------------------------- +// Test 17 — clap rejects unknown host with exit 2 and lists possible values. +// ------------------------------------------------------------------------- +#[test] +fn unknown_host_rejected_with_clap_exit_2() { + let out = cmd() + .args(["skill", "install", "definitely-not-a-host"]) + .output() + .expect("anc spawn"); + + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); + assert!( + stderr.contains("invalid value"), + "expected clap 'invalid value' message; got {stderr:?}", + ); + for host in ["claude_code", "codex", "cursor", "opencode"] { + assert!( + stderr.contains(host), + "expected possible value {host:?} in error; got {stderr:?}", + ); + } +} + +// ------------------------------------------------------------------------- +// Test 18 — clap rejects missing positional host with exit 2. +// ------------------------------------------------------------------------- +#[test] +fn missing_host_rejected_with_clap_exit_2() { + let out = cmd() + .args(["skill", "install"]) + .output() + .expect("anc spawn"); + + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); + assert!( + stderr.contains("required") && stderr.contains("HOST"), + "expected required-arg- message; got {stderr:?}", + ); +} + +// ------------------------------------------------------------------------- +// Test 19 — live install on already-populated destination → envelope with +// status=error, reason=destination-not-empty, exit_code absent (we never +// spawned). +// ------------------------------------------------------------------------- +#[test] +fn live_install_on_populated_dest_does_not_spawn() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dest = tmp.path().join(".claude/skills/agent-native-cli"); + std::fs::create_dir_all(&dest).expect("mkdir dest"); + std::fs::write(dest.join("placeholder"), b"x").expect("populate dest"); + + let out = cmd_with_home(tmp.path()) + .args(["skill", "install", "claude_code", "--output", "json"]) + .output() + .expect("anc spawn"); + + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope"); + + assert_eq!(v["status"], "error"); + assert_eq!(v["reason"], "destination-not-empty"); + assert_eq!(v["mode"], "install"); + assert_eq!(v["destination_status"], "non-empty-dir"); + assert!( + v["exit_code"].is_null(), + "destination check failed — never spawned git, exit_code must be null", + ); + assert!( + v["would_succeed"].is_null(), + "would_succeed is dry-run-only" + ); +} + +// ------------------------------------------------------------------------- +// Test 20 — HOME unset surfaces reason=home-not-set, exit 1. +// ------------------------------------------------------------------------- +#[test] +fn home_unset_emits_home_not_set_envelope() { + let out = cmd() + .env_remove("HOME") + .args(["skill", "install", "claude_code", "--output", "json"]) + .output() + .expect("anc spawn"); + + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope"); + + assert_eq!(v["status"], "error"); + assert_eq!(v["reason"], "home-not-set"); + assert!( + v["destination"].as_str().unwrap().starts_with("~"), + "MissingHome surfaces the unexpanded template in destination", + ); + assert_eq!(v["destination_status"], "absent"); +} + +// ------------------------------------------------------------------------- +// Test 21 — `git` not on PATH surfaces reason=git-not-found, exit 1. +// Pinning PATH to an empty directory makes `git` unresolvable on Unix +// (where Command::status reports NotFound). Skipped on Windows since the +// test infra would need a different shape there. +// ------------------------------------------------------------------------- +#[cfg(unix)] +#[test] +fn git_not_on_path_emits_git_not_found_envelope() { + let tmp = tempfile::tempdir().expect("tempdir"); + let empty_path = tmp.path().to_str().expect("utf-8 tempdir"); + + let out = cmd_with_home(tmp.path()) + .env("PATH", empty_path) + .args(["skill", "install", "claude_code", "--output", "json"]) + .output() + .expect("anc spawn"); + + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope"); + + assert_eq!(v["status"], "error"); + assert_eq!(v["reason"], "git-not-found"); + assert!( + v["exit_code"].is_null(), + "git not on PATH means we never spawned — exit_code must be null", + ); +} + +// ------------------------------------------------------------------------- +// Test 22 — `arg_required_else_help_unaffected_by_skill_subcommand`: bare +// `anc skill` prints help and exits with code 2. Pins the fork-bomb-safety +// invariant from CLAUDE.md ("Bare invocation prints help"). Catches the +// regression where adding the skill subcommand accidentally drops +// arg_required_else_help on the parent. +// ------------------------------------------------------------------------- +#[test] +fn arg_required_else_help_unaffected_by_skill_subcommand() { + let out = cmd().arg("skill").output().expect("anc spawn"); + assert_eq!( + out.status.code(), + Some(2), + "bare `anc skill` must exit with code 2 (clap missing-subcommand)", + ); + let stderr = String::from_utf8(out.stderr).unwrap_or_default(); + let stdout = String::from_utf8(out.stdout).unwrap_or_default(); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("Usage:"), + "expected help text mentioning Usage; got stdout={stdout:?} stderr={stderr:?}", + ); +} + +// ------------------------------------------------------------------------- +// Test 23 — `exit_codes_match_p4_convention`: pins the P4 exit-code shape +// in one place rather than scattering assertions across tests 15/19/20/21. +// Table-driven across the user-error envelope cases. happy=0 is asserted in +// test 13/14; internal-error=2 is reserved (clap surfaces it; tests 17/18 +// pin that side). +// ------------------------------------------------------------------------- +#[test] +fn exit_codes_match_p4_convention() { + struct Case { + name: &'static str, + setup: Box Command>, + expected_exit: i32, + expected_reason: &'static str, + } + + let cases: Vec = vec![ + Case { + name: "DestNotEmpty", + setup: Box::new(|home: &std::path::Path| { + let dest = home.join(".claude/skills/agent-native-cli"); + std::fs::create_dir_all(&dest).expect("mkdir"); + std::fs::write(dest.join("x"), b"y").expect("populate"); + let mut c = cmd_with_home(home); + c.args(["skill", "install", "claude_code", "--output", "json"]); + c + }), + expected_exit: 1, + expected_reason: "destination-not-empty", + }, + Case { + name: "DestIsFile", + setup: Box::new(|home: &std::path::Path| { + let parent = home.join(".claude/skills"); + std::fs::create_dir_all(&parent).expect("mkdir parent"); + std::fs::write(parent.join("agent-native-cli"), b"not-a-dir").expect("write file"); + let mut c = cmd_with_home(home); + c.args(["skill", "install", "claude_code", "--output", "json"]); + c + }), + expected_exit: 1, + expected_reason: "destination-is-file", + }, + Case { + name: "MissingHome", + setup: Box::new(|_home: &std::path::Path| { + let mut c = cmd(); + c.env_remove("HOME"); + c.args(["skill", "install", "claude_code", "--output", "json"]); + c + }), + expected_exit: 1, + expected_reason: "home-not-set", + }, + #[cfg(unix)] + Case { + name: "GitNotFound", + setup: Box::new(|home: &std::path::Path| { + let mut c = cmd_with_home(home); + c.env("PATH", home.to_str().expect("utf-8")); + c.args(["skill", "install", "claude_code", "--output", "json"]); + c + }), + expected_exit: 1, + expected_reason: "git-not-found", + }, + ]; + + for case in cases { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut command = (case.setup)(tmp.path()); + let out = command + .output() + .unwrap_or_else(|e| panic!("anc spawn for {}: {e}", case.name)); + assert_eq!( + out.status.code(), + Some(case.expected_exit), + "{}: expected exit {}, got {:?}", + case.name, + case.expected_exit, + out.status.code(), + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "{}: expected JSON envelope; parse failed: {e}; stdout={stdout}", + case.name + ) + }); + assert_eq!( + v["reason"], case.expected_reason, + "{}: reason mismatch", + case.name, + ); + } +}