diff --git a/README.md b/README.md index d2ce2bd..7c590ce 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ npx thoth-agents@latest install --agent=opencode --dry-run npx thoth-agents@latest install --agent=opencode ``` +Here, `@latest` selects which CLI release runs. That CLI writes the exact +executing release to OpenCode configuration, for example +`plugin: ["thoth-agents@0.4.8"]`; it never writes a `latest` plugin entry or +falls back to one when package identity cannot be verified. + Restart OpenCode and initialize the current repository: ```text @@ -393,9 +398,31 @@ npx thoth-agents@latest list npx thoth-agents@latest install --agent=opencode npx thoth-agents@latest install --agent=codex npx thoth-agents@latest install --agent=claude +npx thoth-agents@latest update --harness=opencode +npx thoth-agents@latest update --harness=opencode --apply npx thoth-agents@latest model --harness=codex --role=deep --model=gpt-5.6-sol ``` +`update` previews by default. Applying it performs the same complete refresh as +installation for the selected harness: OpenCode refreshes its exact plugin pin, +managed configuration, owned skills, external skills, and provider setup; +Codex runs native plugin setup before its global agent pack, external skills, +and provider setup; Claude refreshes its native plugin before external skills +and provider setup. Any required failure makes the operation fail rather than +claiming a complete update. + +After every required step succeeds, the CLI records that harness's executing +version in +`${XDG_CONFIG_HOME:-~/.config}/thoth-agents/install-state.json`. `status` shows +the executing CLI version and this last complete CLI-managed version. The three +harness records are independent: native Codex or Claude marketplace updates do +not advance them and do not prove that CLI-managed agents, skills, +configuration, or provider setup are current. + +OpenCode's runtime release check is notification-only. It does not rewrite the +exact plugin pin, invalidate package state, or run a package manager. Follow the +notification by rerunning the latest CLI installer or applying CLI Update. + In the interactive `Configure models` flow, `Apply` is always available. When no role is dirty, it reapplies every currently displayed role value; after an edit, preview and apply remain limited to the dirty roles. In OpenCode, both diff --git a/docs/agent/cli-installation.md b/docs/agent/cli-installation.md index 669e67a..080ff03 100644 --- a/docs/agent/cli-installation.md +++ b/docs/agent/cli-installation.md @@ -9,6 +9,10 @@ phase execution does not. ## Invariants - OpenCode is the default CLI harness. +- `@latest` is valid for selecting the CLI package to execute, but every + OpenCode config mutation uses the executing package's exact semantic version. + Package identity is resolved before managed writes and failure never falls + back to a `latest` plugin entry. - OpenCode installation synchronizes all five packaged thoth-owned skills into `~/.config/opencode/skills/`; status, install, and sync share that inventory. `/thoth-init` owns only project `openspec/` governance. @@ -20,10 +24,26 @@ phase execution does not. - Provider diagnostics, manual actions, and receipt are surfaced. Consumer reset never becomes provider `--force`, rollback, removal, or file repair. - Browser and QA executables remain project-owned. -- Dry-run writes nothing; reset touches only bounded managed targets. +- Install and applied Update share the complete selected-harness orchestration: + OpenCode refreshes exact plugin/config plus owned skills; Codex performs + native plugin setup before its global pack; Claude performs native plugin + refresh. Every harness then installs required external skills, requires + provider-complete evidence, and records CLI completion last. +- Update previews by default. Preview and dry-run write nothing; any required + apply failure returns failure and does not claim or record completion. Reset + touches only bounded managed targets. +- `${XDG_CONFIG_HOME:-~/.config}/thoth-agents/install-state.json` is the + schema-versioned CLI-owned ledger. Its `opencode`, `codex`, and `claude` + records advance independently and atomically only after complete success. + Missing state remains missing; malformed state is backed up and repaired only + when a successful operation is ready to commit its selected harness. +- Status treats each ledger record as the official last complete CLI-managed + version, exposes it beside the executing CLI version, and never infers or + advances it from OpenCode package state or native marketplace state. - Codex and Claude marketplace trust, snapshots, and caches remain manager-owned; installers may invoke their official native manager commands - but never edit those files directly. + but never edit those files directly. Native plugin updates do not prove that + CLI-managed agents, skills, configuration, or provider setup are aligned. - Codex CLI installation is mandatory for global agents, root instructions, feature configuration, external global skills, and native plugin setup. It fails closed before global writes when Codex manager inspection or plugin @@ -31,6 +51,9 @@ phase execution does not. - Claude requires native marketplace add/install before its plugin surfaces exist; then the CLI installs external skills and requests provider setup without editing Claude's cache. +- OpenCode runtime update checks are notification-only. They do not rewrite + config, invalidate package state, or run package installation; operators must + rerun the latest CLI installer or apply Update explicitly. - CLI changes require parser/help/tests and public docs in the same change. ## Verification diff --git a/docs/installation.md b/docs/installation.md index e81c8ac..5e17133 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,8 +1,8 @@ # Installation -thoth-agents 0.3.0 supports OpenCode, Codex, and Claude Code. The distributions -share one seven-role and Spec Kit-compatible SDD contract. Installation uses the -CLI for every harness, while Codex additionally requires a CLI-managed global +thoth-agents supports OpenCode, Codex, and Claude Code. The distributions share +one seven-role and Spec Kit-compatible SDD contract. Installation uses the CLI +for every harness, while Codex additionally requires a CLI-managed global orchestration layer that its plugin manifest cannot provide. ## Requirements @@ -51,16 +51,22 @@ npx thoth-agents@latest install --agent=opencode --dry-run npx thoth-agents@latest install --agent=opencode ``` -The CLI adds `thoth-agents@latest` to OpenCode configuration, writes the -seven-role OpenAI preset, synchronizes all five packaged thoth-owned skills into -`~/.config/opencode/skills/`, and installs all four external skills with `npx -skills add`. Status and repair verify the resulting global discovery targets. -It then requires provider-owned thoth-mem setup to complete. Restart OpenCode -and invoke `/thoth-init`; it only preflights and synchronizes the minimum -`openspec/` governance structure while preserving existing constitutions. SDD -phases resolve templates directly from the globally installed `thoth-sdd` skill; -init leaves any legacy `openspec/templates/` tree untouched. No Kimi, Copilot, -ZAI/GLM, or mixed-provider preset is generated. +`@latest` selects the CLI release to execute. The CLI resolves that package's +version before any managed write and puts the exact version in OpenCode +configuration, for example `thoth-agents@0.4.8`. It replaces bare, tagged, or +older thoth-agents entries with one exact entry while preserving unrelated +plugins. If package identity or version cannot be verified, installation fails +before changing configuration and never substitutes `latest`. + +The CLI also writes the seven-role OpenAI preset, synchronizes all five packaged +thoth-owned skills into `~/.config/opencode/skills/`, and installs all four +external skills with `npx skills add`. Status and repair verify the resulting +global discovery targets. It then requires provider-owned thoth-mem setup to +complete. Restart OpenCode and invoke `/thoth-init`; it only preflights and +synchronizes the minimum `openspec/` governance structure while preserving +existing constitutions. SDD phases resolve templates directly from the globally +installed `thoth-sdd` skill; init leaves any legacy `openspec/templates/` tree +untouched. No Kimi, Copilot, ZAI/GLM, or mixed-provider preset is generated. ## Codex @@ -186,11 +192,65 @@ mirrored into thoth-mem. No distribution bundles thoth-mem or project QA executables. thoth-mem remains an independently owned provider/plugin installed through its own public setup. -## Status and repair +## Updates and authoritative install state + +Rerunning the latest installer and applying Update are the two supported update +paths. Update previews by default; add `--apply` only after reviewing the plan: + +```bash +npx thoth-agents@latest update --harness=opencode +npx thoth-agents@latest update --harness=opencode --apply +npx thoth-agents@latest update --harness=codex --apply +npx thoth-agents@latest update --harness=claude --apply +``` + +Applied Update is installation-equivalent for the selected harness: + +| Harness | Complete refresh order | +| --- | --- | +| OpenCode | Exact plugin pin and managed configuration, global thoth-owned skills, required external skills, provider setup, then the CLI record | +| Codex | Native plugin-manager setup, global agent pack/configuration, required external skills, provider setup, then the CLI record | +| Claude Code | Native marketplace/plugin refresh, required external skills, provider setup, then the CLI record | + +The versioned CLI-owned ledger is located at +`${XDG_CONFIG_HOME:-~/.config}/thoth-agents/install-state.json`. It keeps +independent `opencode`, `codex`, and `claude` records. Each record is the version +of the CLI release that most recently completed every required step for that +harness; it is not a native plugin version. + +For existing installations, a missing ledger is expected until each harness +first completes installation or applied Update under this contract. Status +reports that harness's record as missing rather than inferring it from OpenCode +package state or a Codex/Claude marketplace. Rerun the latest installer or apply +Update once per harness to establish its record. + +The CLI commits the selected harness record last using temporary-file +replacement. A preview, dry-run, cancellation, or failed native, managed, +required-skill, provider, or ledger step does not advance the record; the +previous completed version remains authoritative. A malformed ledger also +remains untouched after earlier failures. Once a complete operation is ready to +record success, the CLI preserves the malformed file as `install-state.json.bak` +and replaces it with valid schema-v1 state. + +Codex and Claude marketplace managers continue to own native plugin versions, +trust, snapshots, and caches. A native marketplace update neither changes this +ledger nor proves that the CLI-managed global agents, skills, configuration, or +provider setup were refreshed. Use `status` to compare the executing CLI version +with the recorded complete-install version. + +OpenCode runtime checks only notify when a newer release exists. They do not +rewrite the exact plugin entry, invalidate package state, or install packages in +the background. Follow the notification by rerunning +`npx thoth-agents@latest install --agent=opencode` or applying interactive or +command-line Update. + +## Status, update, and repair ```bash npx thoth-agents@latest status npx thoth-agents@latest status --harness=codex +npx thoth-agents@latest update --harness=codex +npx thoth-agents@latest update --harness=codex --apply npx thoth-agents@latest sync --harness=codex --apply npx thoth-agents@latest model --harness=codex --role=deep --model=gpt-5.6-sol ``` diff --git a/docs/quick-reference.md b/docs/quick-reference.md index 2fab702..31de8b1 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -96,9 +96,28 @@ repositories. ```bash npx thoth-agents@latest status npx thoth-agents@latest list +npx thoth-agents@latest update --harness=opencode +npx thoth-agents@latest update --harness=opencode --apply +npx thoth-agents@latest update --harness=codex --apply +npx thoth-agents@latest update --harness=claude --apply npx thoth-agents@latest model --harness=codex --role=deep --model=gpt-5.6-sol ``` +`@latest` selects the CLI release. OpenCode is configured with that release's +exact version, never a `latest` plugin entry. `update` previews by default; +`--apply` performs the complete selected-harness installation refresh, including +native setup where applicable, managed surfaces, required skills, and provider +setup. Rerunning `install --agent=` is the equivalent explicit update +path. + +The CLI records each harness's last fully completed version independently in +`${XDG_CONFIG_HOME:-~/.config}/thoth-agents/install-state.json`. Dry-runs and +failures do not advance it. Codex and Claude native marketplace updates do not +advance it either; `status` reports the executing and recorded CLI versions. + +OpenCode runtime update checks only notify. They never rewrite the plugin pin, +invalidate package state, or install the newer release. + ## Boundaries - OpenCode ships only the OpenAI built-in preset. @@ -109,6 +128,8 @@ npx thoth-agents@latest model --harness=codex --role=deep --model=gpt-5.6-sol config; `$thoth-init` creates project SDD governance only. - Claude requires both native marketplace commands before its namespaced skill exists. +- Codex and Claude native managers own plugin versions and caches; the CLI + ledger is the authority only for the separate complete CLI-managed setup. - thoth-mem owns its hooks, MCP, skill, lifecycle, persistence, receipts, and recovery. thoth-agents only invokes its public setup during installation. - Runtime memory authorization is `none`, `recall`, or `observe` and does not diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/archive-report.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/archive-report.md new file mode 100644 index 0000000..c0d01fd --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/archive-report.md @@ -0,0 +1,32 @@ +# Archive Report: Pin OpenCode Plugin and Unify Harness Updates + +**Status**: ARCHIVED
+**Oracle verdict**: PASS
+**Archive path**: `openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/` + +## Completed scope + +- US1 / FR-001-FR-002 / SC-001-SC-002: OpenCode installation and applied Update pin exactly the executing package release, replace prior managed forms, and fail before mutation when package identity is invalid. +- US2 / FR-003-FR-005 / SC-003-SC-005: Interactive and command Update paths perform the complete selected-harness refresh with truthful previews, ordered native/managed/skill/provider/ledger effects, and failing outcomes for incomplete required steps. +- US3 / FR-006-FR-007 / SC-006-SC-007: A versioned global ledger records the last complete CLI-managed release independently for OpenCode, Codex, and Claude and remains independent of native marketplace versions. +- US4 / FR-008-FR-009 / SC-008-SC-009: The OpenCode runtime only notifies about newer releases, while CLI/TUI/public documentation consistently identifies the CLI as the official complete update path. + +## Verification lineage + +- `verify-report.md` records independent oracle PASS with `pnpm run check:ci`, `pnpm run typecheck`, `pnpm run build`, `pnpm run integration:verify`, and `pnpm test` all passing; the full suite completed 939/939 tests. +- Oracle mapped every FR-001 through FR-009 and buildable SC-001 through SC-009 to implementation evidence and executed checks, with no critical issue. + +## Canonical specification sync + +- Updated: `cli-installation`. +## Deviations and residual warnings + +- No scope deviation. A stale provider-boundary source-layout assertion was updated to follow the accepted shared finalization seam without weakening provider ownership. +- W-001: The TUI preview renders five item details and summarizes remaining planned stages as a count; the underlying plan and CLI preview enumerate every required stage. +- R-001: Native manager, external skill, and provider effects were verified through isolated/mocked seams; live external mutation was intentionally not invoked. +- R-002: Ledger replacement is atomic, but already completed external effects are not rolled back if the final ledger commit fails; retry is the documented recovery path. +- R-003: Existing nonfatal Codex enforcement-gap notices remain truthful capability disclosures. + +## Follow-up + +- None. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/data-model.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/data-model.md new file mode 100644 index 0000000..ddfc39a --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/data-model.md @@ -0,0 +1,61 @@ +# Data Model: CLI-Managed Installation Ledger + +## Location and ownership + +The CLI owns one global state file: + +```text +${XDG_CONFIG_HOME:-~/.config}/thoth-agents/install-state.json +``` + +The path is independent of OpenCode, Codex, Claude Code, and thoth-mem native state. Test and embedded callers may inject the home/config root, but harness-specific environment variables do not relocate this cross-harness ledger. + +## Schema v1 + +```json +{ + "schemaVersion": 1, + "harnesses": { + "opencode": { "version": "0.4.8" }, + "codex": { "version": "0.4.8" }, + "claude": { "version": "0.4.8" } + } +} +``` + +Rules: + +- `schemaVersion` is exactly `1`. +- Harness keys are limited to `opencode`, `codex`, and `claude`. +- Each record contains only the exact package version that most recently completed the full CLI installation contract for that harness. +- A harness key may be absent when that harness has never completed installation through a ledger-aware CLI. +- No native marketplace version, provider state, receipt, secret, timestamp, or inferred cache version is stored. + +## State transitions + +| Existing state | Operation outcome | Result | +| --- | --- | --- | +| Missing ledger | Full non-dry-run success for harness H at version V | Create schema v1 with `H.version = V`. | +| Valid ledger | Full non-dry-run success for harness H at version V | Preserve every other harness record and atomically replace `H.version`. | +| Valid ledger | Preview, dry-run, cancellation, or any failed required step | Preserve the file byte-for-byte. | +| Malformed/unsupported ledger | Status | Report CLI-managed version as unknown and do not infer a replacement. | +| Malformed/unsupported ledger | Full non-dry-run success | Back up the invalid CLI-owned file, initialize schema v1, and commit only the newly proven harness record. | +| Any state | Native Codex/Claude marketplace update outside the CLI | No ledger transition. | + +## Atomicity and failure semantics + +1. Resolve and validate the executing package identity and version before any harness mutation. +2. Complete native-manager, CLI-managed surface, required-skill, and provider setup steps. +3. Serialize the next ledger to a sibling temporary file. +4. Preserve an invalid prior ledger as a backup when repair is required. +5. Rename the temporary file into place. +6. Report overall installation failure if the ledger cannot be committed; retain the previous authoritative record and make retry the recovery path. + +The transaction is atomic only for the ledger file. External manager, filesystem, skill, and provider effects are not rolled back after they have independently succeeded. + +## Status interpretation + +- **Recorded equals executing version**: the last complete CLI refresh used this CLI release; ordinary managed-surface checks still determine drift. +- **Recorded differs from executing version**: show both values and offer a complete update. Do not silently classify marketplace state as the recorded value. +- **Record missing or invalid**: show the CLI-managed version as unknown/missing and recommend a complete CLI update. +- A Codex or Claude marketplace plugin may be newer or older than the ledger without changing this interpretation. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/plan-review.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/plan-review.md new file mode 100644 index 0000000..42fda3b --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/plan-review.md @@ -0,0 +1,81 @@ +--- +schema: thoth-agents/sdd-plan-review/v1 +artifact: plan-review +change: pin-opencode-plugin-and-unify-updates +gate: oracle-review +status: "[OKAY]" +reviewer_role: oracle +reviewed_at: 2026-07-27T00:58:15.4399628Z +pipeline: accelerated +persistence_mode: openspec +override: + occurred: false + at: null + surface: null + context: null +reviewed_artifacts: + - role: spec + path: openspec/changes/pin-opencode-plugin-and-unify-updates/spec.md + required: true + sha256: sha256:c69d8e785993e3a8d8725a28c0b231d7961c6b9971e9991f6b27050401aca4e3 + - role: plan + path: openspec/changes/pin-opencode-plugin-and-unify-updates/plan.md + required: true + sha256: sha256:da38a42df7688ee9153fa19f399389459fb775aded2b7c8b002b14d8b6d6387e + - role: tasks + path: openspec/changes/pin-opencode-plugin-and-unify-updates/tasks.md + required: true + sha256: sha256:c1b892a4fb79b09001887f45c62af90ad67d6f20332aa829cd027fe6bc13675f + - role: research + path: openspec/changes/pin-opencode-plugin-and-unify-updates/research.md + required: false + sha256: sha256:b992d6622e12a826ddf945581312fbc438f8ccaab23f29eba08497efce8817c2 + - role: data-model + path: openspec/changes/pin-opencode-plugin-and-unify-updates/data-model.md + required: false + sha256: sha256:432863eaf28562d0ffc7fc9e7f0fbaa070c3dc4132443b8280270fe98c85f028 + - role: constitution + path: openspec/memory/constitution.md + required: true + sha256: sha256:b10811fefb9cb87435d4f852b9764f4e521e4d921113ba1082beb72fbd6de658 +--- + +# Plan Review: Pin OpenCode Plugin and Unify Harness Updates + +**Status**: [OKAY] + +## Oracle Result + +[OKAY] + +## Comments + +- FR-001 through FR-009 and SC-001 through SC-009 map to ordered test and implementation tasks T001 through T044, followed by focused and repository-wide validation T045 through T048. +- Existing package-root/config writers, explicit installers, all harness operation adapters, command/TUI dispatch, status rendering, and runtime updater are real seams; every new ledger, version, and finalization file is explicitly created. +- Red tests precede implementation, and the plan preserves thoth-mem ownership plus independent Codex and Claude marketplace behavior. + +## Non-Blocking Notes + +- T040 names `App.tsx`, while `StatusView.tsx` may also need a bounded change if the generic target renderer does not present ledger details clearly. +- T047 must invoke the existing `pnpm run integration:verify` script explicitly. + +## Blockers + +- None. + +## User Override Context + +None. + +## Source SHA-256 + +- `openspec/changes/pin-opencode-plugin-and-unify-updates/spec.md`: `sha256:c69d8e785993e3a8d8725a28c0b231d7961c6b9971e9991f6b27050401aca4e3` +- `openspec/changes/pin-opencode-plugin-and-unify-updates/plan.md`: `sha256:da38a42df7688ee9153fa19f399389459fb775aded2b7c8b002b14d8b6d6387e` +- `openspec/changes/pin-opencode-plugin-and-unify-updates/tasks.md`: `sha256:c1b892a4fb79b09001887f45c62af90ad67d6f20332aa829cd027fe6bc13675f` +- `openspec/changes/pin-opencode-plugin-and-unify-updates/research.md`: `sha256:b992d6622e12a826ddf945581312fbc438f8ccaab23f29eba08497efce8817c2` +- `openspec/changes/pin-opencode-plugin-and-unify-updates/data-model.md`: `sha256:432863eaf28562d0ffc7fc9e7f0fbaa070c3dc4132443b8280270fe98c85f028` +- `openspec/memory/constitution.md`: `sha256:b10811fefb9cb87435d4f852b9764f4e521e4d921113ba1082beb72fbd6de658` + +## Recovery Decision + +This result satisfies only optional plan review while all source digests remain unchanged. It does not authorize implementation or satisfy final Oracle verify. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/plan.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/plan.md new file mode 100644 index 0000000..3e2a591 --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/plan.md @@ -0,0 +1,86 @@ +# Implementation Plan: Pin OpenCode Plugin and Unify Harness Updates + +## Technical context + +The current OpenCode config merger and operation status hard-code `thoth-agents@latest`. The runtime update hook subsequently rewrites pinned entries to a newer registry version, invalidates OpenCode package state, and runs `pnpm install`. Explicit harness installation is broader than the interactive/public Update paths: OpenCode Update only changes the plugin entry, Codex Update omits native plugin-manager and provider setup, and Claude Update omits provider setup. No cross-harness state records which package release most recently completed all CLI-required surfaces. + +The change spans CLI configuration, install/operation orchestration, TUI/command messaging, status, a notification-only OpenCode runtime hook, tests, and public documentation. Codex and Claude native marketplace self-update behavior remains manager-owned and unchanged. Package version `0.3.8` is current repository metadata, but all behavior derives dynamically from the executing package and must work for stable or prerelease releases such as the user's `0.4.8` example. + +## Constitution Check (pre-design) + +- **Adaptive-root orchestration**: PASS — The user selected Accelerated SDD; root owns the canonical artifacts and used one read-only explorer only for bounded install/update parity discovery. +- **Explicit role boundaries**: PASS — Discovery made no writes, root is the sole artifact writer, one product writer will own implementation, and Oracle remains reserved for optional plan review and mandatory verification. +- **Proportional Spec Kit-compatible SDD**: PASS — The validated specification captures the exact pin, complete update, CLI ledger, native marketplace independence, and notification-only decisions without activating unrelated checklist or constitution work. +- **Truthful multi-harness contracts**: PASS — Research distinguishes OpenCode skill delivery, Codex external agent delivery, Claude packaging, and native marketplace ownership instead of claiming equivalent harness capabilities. +- **Independent provider ownership**: PASS — The design invokes only thoth-mem's public setup adapter and records no provider state, receipt, or inferred effect in the CLI ledger. +- **Evidence-led completion**: PASS — The plan requires focused regression tests, proportional repository checks, independent Oracle verification, and artifact-backed closeout before archive. + +## Design + +### Architecture + +1. Add a package identity resolver that locates the nearest root `package.json` from the executing module, requires `name: "thoth-agents"`, validates a stable or prerelease semantic version, and returns a typed failure instead of `latest`. The resolver must work from both `src/` tests and bundled `dist/` chunks and allow fixture injection. +2. Add a CLI-owned, versioned installation ledger at `${XDG_CONFIG_HOME:-~/.config}/thoth-agents/install-state.json`. Reads distinguish missing, valid, and invalid state. Writes preserve unrelated valid harness records, use temporary-file replacement, and back up malformed CLI-owned state only when a fully successful install is ready to repair it. +3. Add one shared install finalization boundary used by explicit installation and operation-plan install/update applies. It runs provider setup, accepts only consistent `complete` evidence, and commits the harness ledger record last. Dry-run plans provider setup and ledger recording without writing the ledger. +4. Keep native and managed setup in existing canonical lower-level builders/appliers. Expand operation `install` and `update` plans so OpenCode performs config plus owned/external skills, Codex performs native plugin setup before global agent-pack/external skills, and Claude performs native refresh plus external skills. All three then use shared provider/ledger finalization. `sync` remains a narrower reconciliation and never advances the complete-install ledger. +5. Make the OpenCode config API require an explicit resolved plugin version whenever it ensures the plugin entry. Plan provenance captures that version; apply revalidates it before mutation. Status and plan text use `thoth-agents@` and include a CLI-ledger target with recorded and executing versions. +6. Keep command and TUI Update on the same operation apply functions. Applied failures return a nonzero command result and a truthful TUI result. TUI copy describes a complete CLI refresh rather than only plugin/setup entries; the existing confirmation preview remains the mutation boundary. +7. Convert the OpenCode runtime updater into a notifier: retain version discovery and registry comparison, but remove config rewriting, cache invalidation, package-manager installation, and the `autoUpdate` execution option. The notification directs the operator to run the latest CLI installer or use Update. +8. Update help and routed public documentation. Preserve intentional `npx thoth-agents@latest ...` invocation examples and the schema URL; only the OpenCode plugin config entry becomes exact-versioned. + +### Requirement mapping + +| Requirement | Technical decision | Files/interfaces | Verification seam | +| --- | --- | --- | --- | +| FR-001 | Require an explicit executing package version in every OpenCode plugin merge and replace all bare/tagged/versioned managed entries with one exact entry. | `src/cli/package-version.ts`, `src/cli/config-io.ts`, `src/cli/install.ts`, `src/cli/operations/opencode.ts` | Config and operation tests cover stable/prerelease replacement, deduplication, preservation, and previews. | +| FR-002 | Resolve package identity before plan/apply mutation and fail closed for missing, mismatched, malformed, empty, or invalid metadata. | `src/cli/package-root.ts`, new `src/cli/package-version.ts` | Source-layout and published-layout fixtures plus all failure fixtures assert zero config writes. | +| FR-003 | Treat operation `install` and `update` applies as complete harness refreshes and keep TUI/public command dispatch on those same functions. | `src/cli/commands.ts`, `src/cli/tui/operations.ts`, `src/cli/tui/App.tsx`, `src/cli/operations/*.ts` | Cross-entrypoint tests assert the same issued plan and complete apply effects per harness. | +| FR-004 | Add missing OpenCode surfaces, Codex native plugin setup, and provider finalization while preserving each native manager boundary. | `src/cli/operations/opencode.ts`, `codex.ts`, `claude-code.ts`, `src/cli/codex-plugin-install.ts`, new `src/cli/install-completion.ts` | Harness operation tests inject native manager, skill, and provider outcomes and assert order/fail-closed behavior. | +| FR-005 | Keep plan creation side-effect-free, include provider/ledger targets, and propagate failed apply as nonzero/failed. | `src/cli/commands.ts`, `src/cli/tui/operations.ts`, `src/cli/operations/types.ts`, harness operation modules | Preview mutation spies remain zero; command and TUI tests assert truthful failure results. | +| FR-006 | Persist schema-v1 per-harness records only after provider-complete non-dry-run finalization. | new `src/cli/install-ledger.ts`, new `src/cli/install-completion.ts`, `src/cli/install.ts` | Ledger and installer tests cover 3 independent records, atomic replacement, invalid-state backup, and every no-advance path. | +| FR-007 | Add the ledger as a first-class managed status target and base CLI-managed version messaging on it, not native marketplace/cache state. | `src/cli/operations/types.ts`, `opencode.ts`, `codex.ts`, `claude-code.ts`, `src/cli/commands.ts`, `src/cli/tui/components/StatusView.tsx` | Status tests vary executing, recorded, missing, malformed, and marketplace state independently. | +| FR-008 | Remove runtime mutation/install branches and emit actionable CLI-only update guidance. | `src/hooks/auto-update-checker/index.ts`, `checker.ts`, `types.ts`, dead cache/update helpers and tests, `src/index.ts` | Hook tests observe a newer registry release with notification and zero write/install/cache calls. | +| FR-009 | Align operator-facing help, TUI copy, README, and installation docs with exact pins, the ledger, native marketplace independence, and complete updates. | `src/cli/commands.ts`, `src/cli/tui/App.tsx`, `README.md`, `docs/installation.md`, `docs/quick-reference.md`, routed agent docs if invariants change | Help/TUI/docs assertions and search review distinguish intentional CLI/schema `@latest` references from forbidden plugin-entry guidance. | + +### Shared interfaces + +- `resolveExecutingPackageVersion(options?) -> { ok: true; version; packageRoot } | { ok: false; error }` is the sole CLI source for the release being installed. +- `readInstallLedger(options?)` returns a discriminated missing/valid/invalid result; callers never infer a version on failure. +- `recordCompletedInstall({ harness, version, ... })` atomically commits only one proven harness record and returns structured path/error evidence. +- `finalizeHarnessInstall({ harness, version, dryRun, cwd, runThothMemSetup, ledgerOptions })` returns provider and ledger evidence without claiming success unless both required stages complete. +- Issued operation-plan provenance stores the resolved version and context/dependencies needed to reproduce the approved apply. Revalidation prevents an apply from changing the approved target version. + +### Verification sequence + +1. Red tests for package identity, exact OpenCode config merge, and ledger transitions. +2. Red parity/failure tests for OpenCode, Codex, Claude, command apply, and TUI apply/status. +3. Red runtime notification-only tests. +4. Implement the smallest shared helpers and harness changes to turn those tests green. +5. Run focused CLI/runtime tests, then `pnpm run check:ci`, `pnpm run typecheck`, `pnpm run build`, and `pnpm test`. +6. Inspect generated/build output and `git diff` to ensure no unrelated generated files or marketplace mutations were introduced. + +## Optional support artifacts + +- `research.md`: Created to preserve the confirmed per-harness install/update gaps and native ownership boundaries that determine scope. +- `data-model.md`: Created because the new cross-harness authoritative version ledger requires an explicit schema, transition, atomicity, and malformed-state contract. +- `contracts/`: Not needed; no external API or wire contract is introduced beyond the local versioned JSON state described in `data-model.md`. +- `quickstart.md`: Not needed; existing installation documentation is the operator-facing workflow and will be updated directly. + +## Risks and migrations + +- Existing users have no ledger record. Status reports the CLI-managed version as unknown until a complete ledger-aware install/update succeeds; it does not infer history from native plugin state. +- Exact OpenCode pins stop implicit dist-tag resolution. The runtime still notifies, and the documented recovery/update path is `npx thoth-agents@latest install --agent=opencode` or interactive Update. +- A ledger commit can fail after external setup already succeeded. The operation reports failure, retains the previous official record, and a repeated CLI update is the safe convergence path; no false rollback is claimed. +- Malformed CLI-owned ledger state is backed up and repaired only after a new complete install proves one harness version. Records that cannot be parsed are not invented. +- Codex and Claude may run a marketplace plugin version different from the recorded CLI-managed version. This is expected and must not trigger native cache mutation or overwrite the CLI ledger. +- Adding provider setup to operation apply can make formerly successful narrow updates fail on truthful non-complete provider evidence. This is required parity, and diagnostics/manual actions/receipts remain visible. +- Rollback is release-based: restoring the prior package code restores prior behavior. The new ledger is isolated consumer metadata and can be ignored by older releases; rollback never edits native marketplace or provider state. + +## Constitution Check (post-design) + +- **Adaptive-root orchestration**: PASS — The design keeps one implementation writer per code surface, uses the designer only for bounded TUI UX ownership if needed, and preserves mandatory Oracle verification. +- **Explicit role boundaries**: PASS — Root retains SDD artifacts, implementation is assigned to one bounded writer, read-only research stays evidence-only, and no child receives lifecycle or artifact ownership. +- **Proportional Spec Kit-compatible SDD**: PASS — Research and data-model artifacts exist only to resolve confirmed parity and state-transition risks; no unrelated checklist, contract, or quickstart ceremony was added. +- **Truthful multi-harness contracts**: PASS — The plan preserves native Codex/Claude marketplace updates while separately refreshing non-packageable Codex agents, non-packageable OpenCode skills, external skills, and harness-specific managed surfaces. +- **Independent provider ownership**: PASS — Shared finalization invokes the public setup command, validates its evidence, stores only the CLI package version, and never copies provider state or equates a CLI ledger record with provider health. +- **Evidence-led completion**: PASS — Every FR has a named test seam, behavior work follows red-green-refactor plus simplification, command/build/full-suite evidence is planned, and only Oracle may issue the final verdict. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/research.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/research.md new file mode 100644 index 0000000..18b0c5e --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/research.md @@ -0,0 +1,39 @@ +# Research: Existing Install and Update Semantics + +## Confirmed findings + +| Harness | Explicit `install --agent=...` | Current applied Update | Confirmed gap | +| --- | --- | --- | --- | +| OpenCode | Configures the plugin and default agents, writes managed config, synchronizes packaged thoth-owned skills, installs required external skills, and invokes thoth-mem setup. | Rewrites only the OpenCode plugin entry to `thoth-agents@latest`. | Managed config, owned skills, external skills, and provider setup are omitted. | +| Codex | Uses the native plugin manager first, then writes the global root/agent pack, installs required external skills, and invokes thoth-mem setup. | Refreshes the global agent-pack setup and required external skills. | Native plugin-manager setup and provider setup are omitted. | +| Claude Code | Uses the native marketplace/plugin manager, installs required external skills, and invokes thoth-mem setup. | Refreshes the native marketplace/plugin and required external skills. | Provider setup is omitted. | + +The interactive TUI and the public `update --apply` command share the operation-plan apply functions. Neither path currently delegates to the explicit installer. Existing tests verify individual plan behavior but do not assert complete install/update parity. + +## Ownership constraints + +- Codex and Claude Code native managers own marketplace discovery, plugin cache, enablement, trust, and their normal marketplace update behavior. +- Codex's plugin manifest cannot install the six global specialist agent TOMLs or the orchestrator block in `~/.codex/AGENTS.md`; the CLI must refresh those surfaces. +- OpenCode's npm plugin loading does not expose package-relative native skill roots; the CLI must refresh the packaged thoth-owned skills globally. +- Required external skills remain CLI-installed from canonical repositories for every harness. +- thoth-mem remains independently owned; thoth-agents may only invoke and validate its public setup result. + +## Design consequence + +Native marketplace versions cannot prove that the CLI-managed supplemental surfaces match the same release. The CLI therefore needs a separate per-harness record of the last package version that completed the entire CLI-owned plus provider setup sequence. Native marketplace updates remain valid and do not mutate that record. + +## Inspected seams + +- `src/cli/install.ts`: explicit harness installation and provider finalization. +- `src/cli/operations/opencode.ts`: OpenCode status, install/update/sync plans, and apply behavior. +- `src/cli/operations/codex.ts`: Codex operation plans and global agent-pack apply behavior. +- `src/cli/operations/claude-code.ts`: Claude native operation plans and apply behavior. +- `src/cli/commands.ts`: public operation preview/apply dispatch. +- `src/cli/tui/operations.ts` and `src/cli/tui/App.tsx`: interactive operation dispatch and confirmation. +- `src/hooks/auto-update-checker/`: current background config rewrite, cache invalidation, and package installation. + +## Rejected interpretations + +- Treating a newer Codex or Claude marketplace plugin as proof of a complete thoth-agents CLI refresh: rejected because separately installed agents, skills, and provider setup may remain stale. +- Continuing OpenCode runtime self-installation after exact version pinning: rejected by user decision; the runtime becomes notification-only. +- Using `thoth-agents@latest` as a fallback when package metadata cannot be resolved: rejected because it defeats deterministic installation. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/spec.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/spec.md new file mode 100644 index 0000000..cfb90cb --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/spec.md @@ -0,0 +1,134 @@ +# Feature Specification: Pin OpenCode Plugin and Unify Harness Updates + +**Change ID**: `pin-opencode-plugin-and-unify-updates`
+**Route**: Accelerated
+**Status**: Draft + +## Intent and scope + +**Why**: Operators need OpenCode to load the exact thoth-agents release they intentionally installed, and they need every CLI update path to refresh the same complete harness installation instead of applying a narrower subset of setup steps.
+**Impact**: OpenCode plugin entries will be pinned to the executing thoth-agents package version. Re-running installation or applying Update will replace an older pin with that version. Interactive and command-driven updates will perform the same harness-specific setup as explicit installation. The CLI will independently record the last fully installed version for each harness as its authoritative managed-install version, without overriding Codex or Claude marketplace self-updates. The OpenCode runtime will only notify about newer releases and will no longer mutate or install updates in the background.
+**Affected capabilities**: `cli-installation` + +## User stories + +### US1 - Install the exact OpenCode plugin release (Priority: P1) + +As an OpenCode operator, I can install a particular thoth-agents CLI release and receive an OpenCode plugin entry pinned to that same release so that plugin activation is deterministic. + +**Independent test**: Run the OpenCode config merge against isolated configurations using controlled package metadata and verify the resulting managed entry, preservation behavior, and failure behavior. + +**Covers**: FR-001, FR-002, SC-001, SC-002 + +**Acceptance scenarios**: + +1. **Given** the executing thoth-agents package version is `0.4.8`, **When** OpenCode installation configures the plugin, **Then** the resulting managed entry is exactly `thoth-agents@0.4.8` and is not `thoth-agents@latest`. +2. **Given** OpenCode configuration contains a bare, tagged, or differently versioned thoth-agents entry plus unrelated plugins, **When** installation runs again from version `0.4.8`, **Then** every prior thoth-agents entry is replaced by one `thoth-agents@0.4.8` entry and unrelated plugins retain their relative order. +3. **Given** the executing package version cannot be resolved as a non-empty valid package version, **When** installation or update would write the OpenCode plugin entry, **Then** the operation fails without substituting `latest` and without partially rewriting the configuration. + +### US2 - Refresh complete harness installations from Update (Priority: P1) + +As a thoth-agents operator, I can apply Update for any supported harness and receive the same complete refresh as re-running installation for that harness so that managed assets, native plugin setup, required skills, and provider setup do not drift. + +**Independent test**: Exercise preview and apply through the public update orchestration with dependency spies for OpenCode, Codex, and Claude, then compare the ordered effects and outcomes with each harness's explicit installation orchestration. + +**Covers**: FR-003, FR-004, FR-005, SC-003, SC-004, SC-005 + +**Acceptance scenarios**: + +1. **Given** OpenCode is selected in the interactive CLI or update command, **When** Update is applied, **Then** it performs the complete OpenCode installation refresh, including the exact plugin pin, default-agent configuration, managed configuration, thoth-owned skills, required external skills, and provider setup. +2. **Given** Codex is selected, **When** Update is applied, **Then** it performs native plugin-manager setup before refreshing the global agent pack, required external skills, and provider setup under the same failure rules as `install --agent=codex`. +3. **Given** Claude Code is selected, **When** Update is applied, **Then** it performs native marketplace/plugin refresh, required external skills, and provider setup under the same failure rules as `install --agent=claude`. +4. **Given** an update is only previewed or dry-run is requested, **When** the plan is rendered, **Then** every complete refresh step is represented and no harness, skill, provider, cache, or configuration mutation occurs. +5. **Given** any required harness-owned, skill, or provider step fails or returns a non-complete outcome, **When** Update is applied, **Then** the update returns failure and does not claim complete installation. + +### US3 - Track the authoritative CLI-managed version (Priority: P1) + +As a multi-harness operator, I can see which thoth-agents release most recently completed full CLI setup for each harness so that separately managed plugin updates do not falsely imply that supplemental agents, skills, configuration, and provider setup are aligned. + +**Independent test**: Complete and fail isolated harness installs against a temporary global state root, then inspect per-harness records and status while independently varying native marketplace plugin versions. + +**Covers**: FR-006, FR-007, SC-006, SC-007 + +**Acceptance scenarios**: + +1. **Given** version `0.4.8` completes every required installation step for OpenCode, Codex, or Claude Code, **When** the CLI commits installation success, **Then** it atomically records `0.4.8` as that harness's last complete CLI-managed version without changing the other harness records. +2. **Given** a dry-run, cancelled preview, or failed native-manager, managed-surface, required-skill, or provider step, **When** the operation ends, **Then** no harness record is advanced and the previous complete version remains authoritative. +3. **Given** Codex or Claude independently updates its marketplace plugin, **When** thoth-agents status is evaluated, **Then** the last successful CLI record remains the official CLI-managed version and the native marketplace version is not rewritten or treated as proof that separate managed surfaces were refreshed. +4. **Given** the executing CLI version differs from a harness's recorded version, **When** status or Update is opened, **Then** both versions and the need for a complete CLI refresh are presented without silently changing the record. +5. **Given** no valid record exists for a harness, **When** status is evaluated, **Then** the CLI reports the managed-install version as unknown or missing rather than inferring it from OpenCode cache or Codex/Claude marketplace state. + +### US4 - Keep release changes operator-controlled (Priority: P2) + +As an OpenCode operator, I can receive notice of a newer thoth-agents release without the running plugin changing its own configured version so that updates occur only through an explicit CLI action. + +**Independent test**: Simulate a newer registry release and verify that the runtime emits update guidance without writing configuration, invalidating package state, or invoking a package-manager install. + +**Covers**: FR-008, FR-009, SC-008, SC-009 + +**Acceptance scenarios**: + +1. **Given** OpenCode is running a pinned release and a newer release exists, **When** the background version check completes, **Then** it only notifies the operator and does not rewrite the plugin entry, invalidate cached package state, or run an installation command. +2. **Given** an operator wants the newer release, **When** they follow CLI guidance or apply Update, **Then** the selected harness receives the complete refresh and OpenCode, when selected, is pinned to the CLI release performing that refresh. +3. **Given** installation and update help or documentation, **When** an operator reads the OpenCode guidance, **Then** it explains the exact-version pin and that re-running the latest CLI installer or applying Update is the supported update mechanism. + +## Edge cases + +- The executing package version is a valid prerelease such as `0.4.8-beta.1`. +- OpenCode configuration contains multiple thoth-agents entries using bare, dist-tag, and exact-version forms. +- OpenCode configuration is malformed, read-only, or cannot be backed up atomically. +- Package metadata is missing, malformed, names another package, or lacks a usable version. +- The CLI-managed install ledger is missing, malformed, interrupted during replacement, or contains a record for only some harnesses. +- Codex or Claude has a marketplace plugin version newer than the last release that completed CLI-managed supplemental setup. +- An update preview is opened but never confirmed. +- Codex native manager inspection or post-install verification fails before global files may be refreshed. +- Claude Code reports a native marketplace or plugin failure. +- Required external skill installation or thoth-mem setup returns partial, contradictory, or user-action-required evidence. +- A newer OpenCode plugin release is detected while the current session is active. + +## Functional requirements + +- **FR-001 — Pin the OpenCode plugin to the executing release**: `[ADDED cli-installation]` Every OpenCode install or applied update MUST replace all managed thoth-agents plugin entry forms with exactly one `thoth-agents@` entry while preserving unrelated plugin entries. +- **FR-002 — Fail closed when the executing version is unavailable**: `[INTERNAL]` The CLI MUST derive the managed OpenCode plugin version from the installed thoth-agents package metadata and MUST fail before configuration mutation when that identity or version is missing or invalid; it MUST NOT fall back to `latest`. +- **FR-003 — Make applied Update installation-equivalent**: `[ADDED cli-installation]` Applying Update through either the interactive CLI or the public update command MUST execute the same complete harness-specific refresh contract as `install --agent=` rather than a reduced reconciliation plan. +- **FR-004 — Preserve complete per-harness setup**: `[ADDED cli-installation]` Complete update refreshes MUST include OpenCode managed configuration and owned skills, Codex native plugin setup and global agent-pack setup, Claude native plugin refresh, every harness's required external skills, and every harness's provider-owned thoth-mem setup in the same order and with the same fail-closed outcomes as explicit installation. +- **FR-005 — Keep previews non-mutating and truthful**: `[INTERNAL]` Update preview and dry-run MUST enumerate the complete selected-harness refresh without mutation, and applied update MUST return a failing outcome whenever any required step does not complete successfully. +- **FR-006 — Record the last complete CLI-managed version**: `[ADDED cli-installation]` The CLI MUST maintain a versioned global installation ledger keyed independently by OpenCode, Codex, and Claude Code, and MUST atomically record the executing package version for one harness only after every required install or applied-update step for that harness completes successfully. +- **FR-007 — Treat the CLI ledger as authoritative for managed setup**: `[ADDED cli-installation]` CLI status and update decisions MUST use each harness's last complete ledger record as the official CLI-managed version, MUST expose the executing and recorded versions when they differ, and MUST NOT infer or advance that record from OpenCode package cache or Codex/Claude marketplace state. +- **FR-008 — Prohibit runtime self-update mutation**: `[ADDED cli-installation]` The OpenCode runtime version checker MAY notify about a newer release but MUST NOT rewrite plugin configuration, invalidate package-manager state, or invoke package installation; release changes MUST require an explicit CLI install or Update action. +- **FR-009 — Document the explicit update contract**: `[ADDED cli-installation]` CLI help, status and operation messaging, and routed public installation guidance SHALL describe exact OpenCode version pinning, the last complete CLI-managed version, native marketplace independence, and the complete CLI-driven update path consistently. + +## Success criteria + +- **SC-001** `[buildable]`: Focused config tests demonstrate exact stable and prerelease pins, replacement of bare/tagged/older entries with one managed entry, preservation of unrelated plugins, and zero `thoth-agents@latest` output from plugin-entry producers. +- **SC-002** `[buildable]`: Focused package-version tests demonstrate that both published-layout and source-layout execution resolve the root package version, while all missing, mismatched, malformed, or empty metadata fixtures are rejected with zero OpenCode configuration mutations. +- **SC-003** `[buildable]`: Focused orchestration tests for all 3 harnesses demonstrate that every interactive and command update apply path invokes the same ordered complete-refresh boundary as explicit installation. +- **SC-004** `[buildable]`: Update tests demonstrate Codex and Claude native manager behavior plus provider setup for all harnesses, including nonzero/failing outcomes for native, required-skill, and non-complete provider failures. +- **SC-005** `[buildable]`: Preview and dry-run tests for all three harnesses observe zero filesystem, native-manager, skill-installer, provider, cache, or package-manager mutations while listing every required refresh step. +- **SC-006** `[buildable]`: Ledger tests demonstrate 3 independent harness records, atomic replacement after complete success, and zero record advancement across dry-run plus every injected native-manager, managed-surface, required-skill, and provider failure. +- **SC-007** `[buildable]`: Status tests preserve the recorded CLI-managed version across all simulated Codex and Claude marketplace version changes and report both recorded and executing versions for every mismatch or missing-record case. +- **SC-008** `[buildable]`: Runtime update-check tests simulate a newer release and observe notification guidance with zero configuration writes, cache invalidations, and package-manager install calls. +- **SC-009** `[buildable]`: All CLI help, status, TUI/operation copy, README, and installation documentation checks pass with the exact-version, CLI-ledger, native-marketplace, and explicit-update contracts and zero guidance that describes background self-installation. + +## Assumptions + +- The npm package continues to ship its root `package.json` alongside `dist/` and identifies itself as `thoth-agents`. +- Re-running an older explicitly selected CLI release intentionally permits pinning or downgrading OpenCode to that release. +- Update targets one selected harness at a time; bulk `--all` update semantics are not introduced by this change. +- The CLI-managed version ledger is consumer-owned metadata and does not replace or mutate native Codex, Claude, OpenCode, or provider-owned state. +- Provider setup remains owned by thoth-mem and succeeds only on internally consistent `complete` evidence. + +## Dependencies + +- Existing package-root discovery and Node.js `>=22.13` filesystem APIs. +- Existing harness install orchestration, native manager adapters, required-skill installer, and thoth-mem setup boundary. +- OpenCode's package-based plugin configuration contract. + +## Out of scope + +- Pinning the schema URL or documentation examples that intentionally invoke the latest CLI package. +- Changing Codex or Claude marketplace package identifiers or version ownership. +- Preventing Codex or Claude from discovering or installing newer marketplace plugin releases through their native mechanisms. +- Adding a bulk update transaction across multiple harnesses. +- Removing update availability notifications. +- Mutating provider-owned thoth-mem assets directly. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/tasks.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/tasks.md new file mode 100644 index 0000000..2fb552f --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/tasks.md @@ -0,0 +1,80 @@ +# Tasks: Pin OpenCode Plugin and Unify Harness Updates + +## Authoring contract + +Task identifiers are globally sequential. Every implementation task follows a failing focused test, and root may mark a task complete only after its stated verification evidence exists. + +## MVP scope + +US1 is the MVP: an OpenCode install performed by a known thoth-agents release writes exactly that release into the plugin entry, safely replaces prior managed forms, and fails without mutation when package identity cannot be proven. + +## Dependencies + +T001 -> T003; T002 -> T004; T003 and T004 -> T005 -> T006; T007 -> T008 -> T009 -> T010 -> T011 -> T012; T013 -> T016; T014 -> T017; T015 -> T018; T010 and T016 through T018 -> T019 through T024 -> T025 through T030; T031 -> T032 through T035 -> T036; T037 and T038 -> T039 and T040; T039 and T040 -> T041 through T044; all implementation tasks -> T045 -> T046 -> T047 -> T048. + +## Story US1 - Install the exact OpenCode plugin release + +- [x] T001 [US1] Add failing source-layout, published-layout, stable, prerelease, and invalid package identity tests for FR-002/SC-002 in `src/cli/package-version.test.ts` | Verify: focused test fails because no authoritative executing-package resolver exists. +- [x] T002 [US1] Add failing exact-pin, managed-entry replacement, unrelated-plugin preservation, and no-latest tests for FR-001/SC-001 in `src/cli/config-io.test.ts` | Verify: focused test fails against the current hard-coded latest entry. +- [x] T003 [US1] Implement the typed fail-closed executing package identity resolver for FR-002/SC-002 in `src/cli/package-version.ts` | Verify: all package identity fixtures pass and invalid metadata returns no usable version. +- [x] T004 [US1] Require an explicit resolved plugin version and write one exact managed entry for FR-001/FR-002/SC-001 in `src/cli/config-io.ts` | Verify: config tests pass with stable and prerelease pins, preserved unrelated entries, and zero latest fallbacks. +- [x] T005 [US1] Add failing explicit OpenCode installer preflight and exact-version propagation tests for FR-001/FR-002/SC-001/SC-002 in `src/cli/install.test.ts` | Verify: installer test fails before implementation and observes no config mutation for unresolved identity. +- [x] T006 [US1] Preflight package identity before OpenCode installation and pass the approved version through config writes for FR-001/FR-002/SC-001/SC-002 in `src/cli/install.ts` | Verify: explicit install pins the executing release and rejects unresolved package identity before any managed write. + +## Story US3 - Track the authoritative CLI-managed version + +- [x] T007 [US3] Add failing schema, independent-harness, atomic-write, malformed-backup, and no-advance transition tests for FR-006/FR-007/SC-006/SC-007 in `src/cli/install-ledger.test.ts` | Verify: focused test fails because no CLI-managed installation ledger exists. +- [x] T008 [US3] Implement schema-v1 global ledger reads, atomic per-harness commits, and invalid-state repair for FR-006/FR-007/SC-006/SC-007 in `src/cli/install-ledger.ts` | Verify: 3 harness records remain independent and every preview or failed transition preserves the prior authoritative record. +- [x] T009 [US3] Add failing provider-complete and ledger-last finalization tests for FR-004/FR-006/SC-004/SC-006 in `src/cli/install-completion.test.ts` | Verify: focused test fails and demonstrates that provider or ledger failure must prevent a complete result. +- [x] T010 [US3] Implement shared provider validation and ledger-last install finalization for FR-004/FR-005/FR-006/SC-004/SC-005/SC-006 in `src/cli/install-completion.ts` | Verify: complete provider evidence records one harness only, while dry-run and every non-complete outcome write zero ledger changes. +- [x] T011 [US3] Extend explicit harness install tests with successful records and all no-advance failure paths for FR-006/FR-007/SC-006/SC-007 in `src/cli/install.test.ts` | Verify: tests fail until every harness uses shared finalization and records only after complete setup. +- [x] T012 [US3] Route all explicit harness installs through shared finalization and surface ledger failures truthfully for FR-004/FR-006/FR-007/SC-004/SC-006 in `src/cli/install.ts` | Verify: OpenCode, Codex, and Claude explicit installs record their executing version only after provider-complete success. +- [x] T013 [US3] Add failing OpenCode status cases for matching, mismatched, missing, malformed, and marketplace-independent ledger state for FR-007/SC-007 in `src/cli/operations/opencode.test.ts` | Verify: status test fails because recorded and executing CLI versions are not exposed. +- [x] T014 [US3] Add failing Codex status cases proving native marketplace state cannot advance the ledger for FR-007/SC-007 in `src/cli/operations/codex.test.ts` | Verify: status test fails because no independent CLI-managed version target exists. +- [x] T015 [US3] Add failing Claude status cases proving native marketplace state cannot advance the ledger for FR-007/SC-007 in `src/cli/operations/claude-code.test.ts` | Verify: status test fails because no independent CLI-managed version target exists. +- [x] T016 [US3] Add the authoritative CLI-version target and mismatch classification to OpenCode status for FR-007/SC-007 in `src/cli/operations/opencode.ts` | Verify: status reports recorded and executing versions without inferring either from package cache. +- [x] T017 [US3] Add the authoritative CLI-version target while preserving Codex native manager ownership for FR-007/SC-007 in `src/cli/operations/codex.ts` | Verify: simulated marketplace changes leave the recorded CLI-managed version unchanged. +- [x] T018 [US3] Add the authoritative CLI-version target while preserving Claude native manager ownership for FR-007/SC-007 in `src/cli/operations/claude-code.ts` | Verify: simulated marketplace changes leave the recorded CLI-managed version unchanged. + +## Story US2 - Refresh complete harness installations from Update + +- [x] T019 [US2] Add failing OpenCode install/update parity, full-preview, provider, ledger, and failure-order tests for FR-003/FR-004/FR-005/SC-003/SC-004/SC-005 in `src/cli/operations/opencode.test.ts` | Verify: tests expose the current plugin-entry-only Update behavior. +- [x] T020 [US2] Add failing Codex native-plugin-first, global-pack, skill, provider, ledger, and dry-run parity tests for FR-003/FR-004/FR-005/SC-003/SC-004/SC-005 in `src/cli/operations/codex.test.ts` | Verify: tests expose missing native plugin and provider steps in Update. +- [x] T021 [US2] Add failing Claude native-refresh, skill, provider, ledger, and dry-run parity tests for FR-003/FR-004/FR-005/SC-003/SC-004/SC-005 in `src/cli/operations/claude-code.test.ts` | Verify: tests expose missing provider finalization in Update. +- [x] T022 [US2] Add failing public update preview/apply and nonzero failure propagation tests for FR-003/FR-005/SC-003/SC-005 in `src/cli/commands.test.ts` | Verify: failed operation apply currently returns a successful command code. +- [x] T023 [US2] Add failing interactive operation dispatch tests for complete selected-harness Update parity for FR-003/FR-004/SC-003/SC-004 in `src/cli/tui/operations.test.ts` | Verify: TUI operation tests expose incomplete harness update plans or applies. +- [x] T024 [US2] Add failing confirmation and truthful result-copy tests for complete Update behavior for FR-003/FR-005/SC-003/SC-005 in `src/cli/tui/App.test.tsx` | Verify: UI test fails until Update is represented as a complete CLI refresh. +- [x] T025 [US2] Make OpenCode operation install/update use the approved exact version and complete config, owned-skill, external-skill, provider, and ledger sequence for FR-001/FR-003/FR-004/FR-005/SC-001/SC-003/SC-004/SC-005 in `src/cli/operations/opencode.ts` | Verify: OpenCode Update and Install share all required effects and preview remains non-mutating. +- [x] T026 [US2] Add native plugin-manager setup before Codex managed surfaces and shared provider/ledger finalization for FR-003/FR-004/FR-005/SC-003/SC-004/SC-005 in `src/cli/operations/codex.ts` | Verify: Codex Update follows native plugin, agent pack, external skills, provider, then ledger order and fails closed at each boundary. +- [x] T027 [US2] Add shared provider/ledger finalization after Claude native refresh and external skills for FR-003/FR-004/FR-005/SC-003/SC-004/SC-005 in `src/cli/operations/claude-code.ts` | Verify: Claude Update is explicit-install equivalent while native marketplace ownership remains intact. +- [x] T028 [US2] Return nonzero for failed update apply while preserving preview-by-default behavior for FR-003/FR-005/SC-003/SC-005 in `src/cli/commands.ts` | Verify: command tests pass for successful preview/apply and failing applied updates. +- [x] T029 [US2] Keep interactive install/update dispatch on the same complete operation services for FR-003/FR-004/SC-003/SC-004 in `src/cli/tui/operations.ts` | Verify: all 3 selected harnesses use the same complete plans and apply boundaries as public operation commands. +- [x] T030 [US2] Present complete update intent, confirmation, and failure results without claiming marketplace ownership for FR-003/FR-005/SC-003/SC-005 in `src/cli/tui/App.tsx` | Verify: TUI tests show complete-refresh copy and preserve confirmation before mutation. + +## Story US4 - Keep release changes operator-controlled + +- [x] T031 [US4] Add failing newer-release notification tests with write, cache, and package-manager mutation spies for FR-008/SC-008 in `src/hooks/auto-update-checker/index.test.ts` | Verify: test exposes current automatic config rewrite and install behavior. +- [x] T032 [US4] Convert the runtime update flow to actionable notification-only behavior for FR-008/SC-008 in `src/hooks/auto-update-checker/index.ts` | Verify: newer releases produce CLI guidance with zero config, cache, or install mutations. +- [x] T033 [US4] Remove the obsolete pinned-version mutation helper and retain only read/comparison behavior for FR-008/SC-008 in `src/hooks/auto-update-checker/checker.ts` | Verify: checker tests pass without any exported config-write operation. +- [x] T034 [US4] Remove obsolete package-cache invalidation implementation for FR-008/SC-008 in `src/hooks/auto-update-checker/cache.ts` | Verify: no production import or test expects runtime cache deletion. +- [x] T035 [US4] Remove obsolete cache invalidation regression coverage for FR-008/SC-008 in `src/hooks/auto-update-checker/cache.test.ts` | Verify: focused hook suite covers notification-only behavior with no dead mutation tests. +- [x] T036 [US4] Register the simplified notification-only hook without an auto-install option or shell dependency for FR-008/SC-008 in `src/index.ts` | Verify: runtime composition tests pass and expose no automatic installer path. +- [x] T037 [US4] Add failing help, status, exact-pin, ledger, native-marketplace, and explicit-update copy assertions for FR-009/SC-009 in `src/cli/commands.test.ts` | Verify: assertions fail against current latest-entry and narrow-update wording. +- [x] T038 [US4] Add failing interactive exact-version, official-ledger, and complete-update copy assertions for FR-009/SC-009 in `src/cli/tui/App.test.tsx` | Verify: assertions fail until the TUI describes the official CLI update contract. +- [x] T039 [US4] Align CLI help and formatted status/plan output with exact pins and official CLI-managed versions for FR-007/FR-009/SC-007/SC-009 in `src/cli/commands.ts` | Verify: help/status tests distinguish intentional latest CLI invocation from exact plugin entries. +- [x] T040 [US4] Align interactive status and action copy with complete CLI refresh and native marketplace independence for FR-007/FR-009/SC-007/SC-009 in `src/cli/tui/App.tsx` | Verify: TUI tests show recorded and executing versions and make no native ownership claim. +- [x] T041 [US4] Document exact OpenCode pins, complete per-harness updates, and the last successful CLI-managed version for FR-009/SC-009 in `README.md` | Verify: public overview names the CLI as the official supplemental-surface update path while retaining intentional latest invocation examples. +- [x] T042 [US4] Document the ledger location, first-run migration, failure semantics, and Codex/Claude marketplace independence for FR-006/FR-007/FR-009/SC-006/SC-007/SC-009 in `docs/installation.md` | Verify: installation guide explains all 3 harness records and never equates marketplace version with complete CLI setup. +- [x] T043 [US4] Update concise operator commands and update guidance for FR-009/SC-009 in `docs/quick-reference.md` | Verify: quick reference directs operators to rerun the latest CLI installer or apply complete Update. +- [x] T044 [US4] Update durable routed CLI invariants for exact pinning, the official ledger, and complete updates for FR-006/FR-007/FR-009/SC-006/SC-007/SC-009 in `docs/agent/cli-installation.md` | Verify: routed context matches implementation and preserves native manager/provider ownership boundaries. + +## Parallel execution + +- None: package identity, ledger finalization, per-harness operations, TUI dispatch, runtime notification, and documentation all consume sequentially refined shared contracts; one implementation writer avoids overlapping changes and parity drift. + +## Final verification + +- [x] T045 Run all focused package-version, ledger, config, install, harness-operation, command, TUI, and runtime-hook tests covering FR-001 through FR-009 and SC-001 through SC-009 from `package.json` | Verify: every focused Vitest file passes with no skipped required scenario. +- [x] T046 Apply the mandatory behavior-preserving simplification pass to the completed installation orchestration centered on `src/cli/install-completion.ts` | Verify: duplication and dead mutation code are removed without changing any passing behavior test. +- [x] T047 Run formatting, type, build, integration, and full-suite validation for FR-001 through FR-009 and SC-001 through SC-009 from `package.json` | Verify: check:ci, typecheck, build, integration verification, and the complete Vitest suite all pass. +- [x] T048 Review final scope, generated output, secrets, and unrelated changes against the canonical plan in `openspec/changes/pin-opencode-plugin-and-unify-updates/plan.md` | Verify: diff contains only planned source, test, documentation, and governed artifact changes with all residual risks declared for Oracle. diff --git a/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/verify-report.md b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/verify-report.md new file mode 100644 index 0000000..f6912e7 --- /dev/null +++ b/openspec/changes/archive/2026-07-26-pin-opencode-plugin-and-unify-updates/verify-report.md @@ -0,0 +1,63 @@ +# Verification Report: Pin OpenCode Plugin and Unify Harness Updates + +**Reviewer**: oracle
+**Independent from implementer**: Yes
+**Verdict**: PASS + +## Review dimensions + +- **Completeness**: PASS — FR-001 through FR-009 and SC-001 through SC-009 are implemented and exercised; all implementation and verification tasks have evidence. +- **Correctness**: PASS — Exact-version resolution, fail-closed writes, ordered three-harness completion, ledger-last semantics, non-mutating previews, truthful failures, and notification-only runtime behavior passed repository-wide validation. +- **Coherence**: PASS — Specification, plan, tasks, implementation, tests, TUI copy, public documentation, and provider/native-manager boundaries agree. The optional plan review was inspected but not treated as verification. + +## Compliance matrix + +| Requirement | Implementation evidence | Executed check | Result | +| --- | --- | --- | --- | +| FR-001 | Exact plugin replacement in `src/cli/config-io.ts:95-167`; OpenCode propagation in `src/cli/operations/opencode.ts:1234-1341,1899-2122`. | Config I/O and OpenCode operation suites; built CLI resolved `thoth-agents@0.3.8` with no plugin-entry `@latest`. | PASS | +| FR-002 | Package identity and SemVer validation in `src/cli/package-version.ts:31-124`; installer preflight in `src/cli/install.ts:420-529`. | Package-version 8/8 and install 18/18; built-layout exact-pin diagnostic. | PASS | +| FR-003 | Shared complete install/update builders in all harness adapters; command dispatch in `src/cli/commands.ts:512-566`; TUI dispatch in `src/cli/tui/operations.ts:238-293`. | Harness operation suites, commands 23/23, TUI operations 16/16. | PASS | +| FR-004 | Ordered native/config, managed surfaces, required skills, provider, and ledger flows in `src/cli/operations/opencode.ts:1899-2122`, `codex.ts:915-1113`, and `claude-code.ts:691-813`. | Harness suites, completion 7/7, install 18/18, provider-boundary 8/8. | PASS | +| FR-005 | Dry-run plans, `applied: false` failures, command exit mapping in `src/cli/commands.ts:512-530`, and TUI confirmation/results in `src/cli/tui/components/PlanPreview.tsx:35-57,137-167`. | Preview/apply tests across commands, TUI, and all harness adapters. | PASS | +| FR-006 | Schema-v1 ledger, strict records, sibling-temp rename, malformed backup, and independent records in `src/cli/install-ledger.ts:13-190`; record-last finalizer in `src/cli/install-completion.ts:52-116`. | Ledger 10/10, completion 7/7, install 18/18. | PASS | +| FR-007 | Official status target derives only from executing metadata and ledger in `src/cli/operations/types.ts:117-157` and is included by all harness adapters. | Harness status, commands 23/23, TUI App 48/48. | PASS | +| FR-008 | Notification-only runtime flow in `src/hooks/auto-update-checker/index.ts:70-120`; cache/config mutation and installer paths removed. | Runtime notifier 1/1, checker 8/8, full suite, static search. | PASS | +| FR-009 | CLI help in `src/cli/commands.ts:266-346`, TUI copy, README, installation guide, quick reference, and routed CLI guide. | Commands/TUI suites, Biome, full diff and forbidden-guidance search. | PASS | +| SC-001 `[buildable]` | Tests cover stable/prerelease pins, all managed entry forms, unrelated order, and no plugin-entry `@latest`. | Config I/O and OpenCode operation suites. | PASS | +| SC-002 `[buildable]` | Source/published layouts plus missing, malformed, mismatched, empty, and invalid metadata; install tests assert no mutation. | Package-version 8/8 and install 18/18. | PASS | +| SC-003 `[buildable]` | Three harness suites verify ordered install/update parity; command and TUI share operation services. | Harness, command, and TUI operation suites. | PASS | +| SC-004 `[buildable]` | Native-manager, managed setup, skill, provider, and ledger failures return incomplete results and nonzero command status. | Harness, completion, command, and install suites. | PASS | +| SC-005 `[buildable]` | Preview/dry-run spies observe zero mutations while plans contain every required stage. | Harness preview/apply and runtime mutation-spy tests. | PASS | +| SC-006 `[buildable]` | Three independent records, atomic replacement, malformed repair, ledger-last ordering, and no advancement after dry-run/failure. | Ledger 10/10 and completion 7/7. | PASS | +| SC-007 `[buildable]` | Matching, mismatched, missing, and invalid records remain independent of Codex/Claude marketplace state. | Harness status, commands, and TUI status suites. | PASS | +| SC-008 `[buildable]` | Newer release emits actionable notification with zero writes, cache deletion, or install invocation. | Runtime notifier and checker suites. | PASS | +| SC-009 `[buildable]` | CLI help, status, TUI, and public/routed docs consistently describe exact pins, ledger authority, marketplace independence, and explicit updates. | Commands/TUI suites, documentation assertions, full diff review. | PASS | + +## Executed commands + +| Command or check | Result | +| --- | --- | +| `pnpm run check:ci` | PASS — 237 files checked, no fixes. | +| `pnpm run typecheck` | PASS. | +| `pnpm run build` | PASS — integration sync, tsup, declarations, and schema generation completed with no new tracked/generated diff. | +| `pnpm run integration:verify` | PASS — 2 files, 12/12 tests. | +| `pnpm test` | PASS — 81 files, 939/939 tests. | +| Built CLI exact-pin assertion | PASS — built CLI resolved package metadata to `thoth-agents@0.3.8`; no plugin-entry `@latest`. | +| IDE diagnostics | PASS — no problems in package-version, ledger, completion, or three operation adapters. | +| Final diff, whitespace, generated output, and secrets review | PASS — planned source/test/docs/artifact scope only; no changed-secret indicator. | + +## Findings + +| ID | Severity | Dimension | Evidence | Remediation anchor | +| --- | --- | --- | --- | --- | +| W-001 | Warning | Coherence | TUI preview renders five item details and summarizes remaining items as a count in `src/cli/tui/components/PlanPreview.tsx:96-113`; the underlying plan and CLI preview enumerate every stage. | None required; retain concise TUI preview unless product requirements change. | + +## Critical issues + +- None. + +## Residual risks + +- R-001: Native manager, external skill, and provider behavior is verified through isolated/mocked seams; live manager/provider integration was intentionally not invoked. +- R-002: Ledger replacement is atomic, but previously completed native/config/skill/provider effects are not rolled back if final ledger commit fails; this matches `data-model.md` and retry is the recovery path. +- R-003: Build emitted documented nonfatal Codex enforcement-gap notices; these are existing truthful harness capability disclosures, not regressions. diff --git a/openspec/specs/cli-installation/spec.md b/openspec/specs/cli-installation/spec.md index 5f590d9..609b185 100644 --- a/openspec/specs/cli-installation/spec.md +++ b/openspec/specs/cli-installation/spec.md @@ -153,3 +153,205 @@ The OpenCode installer MUST synchronize the five canonical thoth-owned workflow - **GIVEN** an incomplete canonical bundle or a failed global skill synchronization - **WHEN** OpenCode installation runs - **THEN** the overall installation fails and does not claim provider or combined installation completion + +### Requirement: Pin the OpenCode plugin to the executing release + +Every OpenCode install or applied update MUST replace all managed thoth-agents plugin entry forms with exactly one `thoth-agents@` entry while preserving unrelated plugin entries. + +#### Scenario: US1 - Install the exact OpenCode plugin release 1 + +- **GIVEN** the executing thoth-agents package version is `0.4.8` +- **WHEN** OpenCode installation configures the plugin +- **THEN** the resulting managed entry is exactly `thoth-agents@0.4.8` and is not `thoth-agents@latest` + +#### Scenario: US1 - Install the exact OpenCode plugin release 2 + +- **GIVEN** OpenCode configuration contains a bare, tagged, or differently versioned thoth-agents entry plus unrelated plugins +- **WHEN** installation runs again from version `0.4.8` +- **THEN** every prior thoth-agents entry is replaced by one `thoth-agents@0.4.8` entry and unrelated plugins retain their relative order + +#### Scenario: US1 - Install the exact OpenCode plugin release 3 + +- **GIVEN** the executing package version cannot be resolved as a non-empty valid package version +- **WHEN** installation or update would write the OpenCode plugin entry +- **THEN** the operation fails without substituting `latest` and without partially rewriting the configuration + +### Requirement: Make applied Update installation-equivalent + +Applying Update through either the interactive CLI or the public update command MUST execute the same complete harness-specific refresh contract as `install --agent=` rather than a reduced reconciliation plan. + +#### Scenario: US2 - Refresh complete harness installations from Update 1 + +- **GIVEN** OpenCode is selected in the interactive CLI or update command +- **WHEN** Update is applied +- **THEN** it performs the complete OpenCode installation refresh, including the exact plugin pin, default-agent configuration, managed configuration, thoth-owned skills, required external skills, and provider setup + +#### Scenario: US2 - Refresh complete harness installations from Update 2 + +- **GIVEN** Codex is selected +- **WHEN** Update is applied +- **THEN** it performs native plugin-manager setup before refreshing the global agent pack, required external skills, and provider setup under the same failure rules as `install --agent=codex` + +#### Scenario: US2 - Refresh complete harness installations from Update 3 + +- **GIVEN** Claude Code is selected +- **WHEN** Update is applied +- **THEN** it performs native marketplace/plugin refresh, required external skills, and provider setup under the same failure rules as `install --agent=claude` + +#### Scenario: US2 - Refresh complete harness installations from Update 4 + +- **GIVEN** an update is only previewed or dry-run is requested +- **WHEN** the plan is rendered +- **THEN** every complete refresh step is represented and no harness, skill, provider, cache, or configuration mutation occurs + +#### Scenario: US2 - Refresh complete harness installations from Update 5 + +- **GIVEN** any required harness-owned, skill, or provider step fails or returns a non-complete outcome +- **WHEN** Update is applied +- **THEN** the update returns failure and does not claim complete installation + +### Requirement: Preserve complete per-harness setup + +Complete update refreshes MUST include OpenCode managed configuration and owned skills, Codex native plugin setup and global agent-pack setup, Claude native plugin refresh, every harness's required external skills, and every harness's provider-owned thoth-mem setup in the same order and with the same fail-closed outcomes as explicit installation. + +#### Scenario: US2 - Refresh complete harness installations from Update 1 + +- **GIVEN** OpenCode is selected in the interactive CLI or update command +- **WHEN** Update is applied +- **THEN** it performs the complete OpenCode installation refresh, including the exact plugin pin, default-agent configuration, managed configuration, thoth-owned skills, required external skills, and provider setup + +#### Scenario: US2 - Refresh complete harness installations from Update 2 + +- **GIVEN** Codex is selected +- **WHEN** Update is applied +- **THEN** it performs native plugin-manager setup before refreshing the global agent pack, required external skills, and provider setup under the same failure rules as `install --agent=codex` + +#### Scenario: US2 - Refresh complete harness installations from Update 3 + +- **GIVEN** Claude Code is selected +- **WHEN** Update is applied +- **THEN** it performs native marketplace/plugin refresh, required external skills, and provider setup under the same failure rules as `install --agent=claude` + +#### Scenario: US2 - Refresh complete harness installations from Update 4 + +- **GIVEN** an update is only previewed or dry-run is requested +- **WHEN** the plan is rendered +- **THEN** every complete refresh step is represented and no harness, skill, provider, cache, or configuration mutation occurs + +#### Scenario: US2 - Refresh complete harness installations from Update 5 + +- **GIVEN** any required harness-owned, skill, or provider step fails or returns a non-complete outcome +- **WHEN** Update is applied +- **THEN** the update returns failure and does not claim complete installation + +### Requirement: Record the last complete CLI-managed version + +The CLI MUST maintain a versioned global installation ledger keyed independently by OpenCode, Codex, and Claude Code, and MUST atomically record the executing package version for one harness only after every required install or applied-update step for that harness completes successfully. + +#### Scenario: US3 - Track the authoritative CLI-managed version 1 + +- **GIVEN** version `0.4.8` completes every required installation step for OpenCode, Codex, or Claude Code +- **WHEN** the CLI commits installation success +- **THEN** it atomically records `0.4.8` as that harness's last complete CLI-managed version without changing the other harness records + +#### Scenario: US3 - Track the authoritative CLI-managed version 2 + +- **GIVEN** a dry-run, cancelled preview, or failed native-manager, managed-surface, required-skill, or provider step +- **WHEN** the operation ends +- **THEN** no harness record is advanced and the previous complete version remains authoritative + +#### Scenario: US3 - Track the authoritative CLI-managed version 3 + +- **GIVEN** Codex or Claude independently updates its marketplace plugin +- **WHEN** thoth-agents status is evaluated +- **THEN** the last successful CLI record remains the official CLI-managed version and the native marketplace version is not rewritten or treated as proof that separate managed surfaces were refreshed + +#### Scenario: US3 - Track the authoritative CLI-managed version 4 + +- **GIVEN** the executing CLI version differs from a harness's recorded version +- **WHEN** status or Update is opened +- **THEN** both versions and the need for a complete CLI refresh are presented without silently changing the record + +#### Scenario: US3 - Track the authoritative CLI-managed version 5 + +- **GIVEN** no valid record exists for a harness +- **WHEN** status is evaluated +- **THEN** the CLI reports the managed-install version as unknown or missing rather than inferring it from OpenCode cache or Codex/Claude marketplace state + +### Requirement: Treat the CLI ledger as authoritative for managed setup + +CLI status and update decisions MUST use each harness's last complete ledger record as the official CLI-managed version, MUST expose the executing and recorded versions when they differ, and MUST NOT infer or advance that record from OpenCode package cache or Codex/Claude marketplace state. + +#### Scenario: US3 - Track the authoritative CLI-managed version 1 + +- **GIVEN** version `0.4.8` completes every required installation step for OpenCode, Codex, or Claude Code +- **WHEN** the CLI commits installation success +- **THEN** it atomically records `0.4.8` as that harness's last complete CLI-managed version without changing the other harness records + +#### Scenario: US3 - Track the authoritative CLI-managed version 2 + +- **GIVEN** a dry-run, cancelled preview, or failed native-manager, managed-surface, required-skill, or provider step +- **WHEN** the operation ends +- **THEN** no harness record is advanced and the previous complete version remains authoritative + +#### Scenario: US3 - Track the authoritative CLI-managed version 3 + +- **GIVEN** Codex or Claude independently updates its marketplace plugin +- **WHEN** thoth-agents status is evaluated +- **THEN** the last successful CLI record remains the official CLI-managed version and the native marketplace version is not rewritten or treated as proof that separate managed surfaces were refreshed + +#### Scenario: US3 - Track the authoritative CLI-managed version 4 + +- **GIVEN** the executing CLI version differs from a harness's recorded version +- **WHEN** status or Update is opened +- **THEN** both versions and the need for a complete CLI refresh are presented without silently changing the record + +#### Scenario: US3 - Track the authoritative CLI-managed version 5 + +- **GIVEN** no valid record exists for a harness +- **WHEN** status is evaluated +- **THEN** the CLI reports the managed-install version as unknown or missing rather than inferring it from OpenCode cache or Codex/Claude marketplace state + +### Requirement: Prohibit runtime self-update mutation + +The OpenCode runtime version checker MAY notify about a newer release but MUST NOT rewrite plugin configuration, invalidate package-manager state, or invoke package installation; release changes MUST require an explicit CLI install or Update action. + +#### Scenario: US4 - Keep release changes operator-controlled 1 + +- **GIVEN** OpenCode is running a pinned release and a newer release exists +- **WHEN** the background version check completes +- **THEN** it only notifies the operator and does not rewrite the plugin entry, invalidate cached package state, or run an installation command + +#### Scenario: US4 - Keep release changes operator-controlled 2 + +- **GIVEN** an operator wants the newer release +- **WHEN** they follow CLI guidance or apply Update +- **THEN** the selected harness receives the complete refresh and OpenCode, when selected, is pinned to the CLI release performing that refresh + +#### Scenario: US4 - Keep release changes operator-controlled 3 + +- **GIVEN** installation and update help or documentation +- **WHEN** an operator reads the OpenCode guidance +- **THEN** it explains the exact-version pin and that re-running the latest CLI installer or applying Update is the supported update mechanism + +### Requirement: Document the explicit update contract + +CLI help, status and operation messaging, and routed public installation guidance SHALL describe exact OpenCode version pinning, the last complete CLI-managed version, native marketplace independence, and the complete CLI-driven update path consistently. + +#### Scenario: US4 - Keep release changes operator-controlled 1 + +- **GIVEN** OpenCode is running a pinned release and a newer release exists +- **WHEN** the background version check completes +- **THEN** it only notifies the operator and does not rewrite the plugin entry, invalidate cached package state, or run an installation command + +#### Scenario: US4 - Keep release changes operator-controlled 2 + +- **GIVEN** an operator wants the newer release +- **WHEN** they follow CLI guidance or apply Update +- **THEN** the selected harness receives the complete refresh and OpenCode, when selected, is pinned to the CLI release performing that refresh + +#### Scenario: US4 - Keep release changes operator-controlled 3 + +- **GIVEN** installation and update help or documentation +- **WHEN** an operator reads the OpenCode guidance +- **THEN** it explains the exact-version pin and that re-running the latest CLI installer or applying Update is the supported update mechanism diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 64732e4..ab97b83 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { formatHarnessList, formatHarnessStatusReport, @@ -26,6 +26,7 @@ import type { OperationContext, OperationPlan, } from './operations/types'; +import { resolveExecutingPackageVersion } from './package-version'; import { parseCliArgs } from './parser'; interface TestModelServices { @@ -34,6 +35,7 @@ interface TestModelServices { modelOptions( harness: 'codex' | 'opencode' | 'claude', ): Promise; + applyOperationPlan?(plan: OperationPlan): OperationApplyResult; } async function captureCommand( @@ -88,11 +90,18 @@ describe('commands plain operation formatters', () => { } const output = lines.join('\n'); + const executing = resolveExecutingPackageVersion(); + expect(executing.ok).toBe(true); + if (!executing.ok) return; expect(output).toContain('thoth-agents CLI (npm binary: thoth-agents)'); expect(output).toContain('Open the interactive TUI in a TTY'); expect(output).toContain('fall back to OpenCode install in CI/non-TTY'); - expect(output).toContain('plugin: ["thoth-agents@latest"]'); + expect(output).toContain(`plugin: ["thoth-agents@${executing.version}"]`); + expect(output).not.toContain('plugin: ["thoth-agents@latest"]'); + expect(output).toContain( + '@latest selects the CLI release; OpenCode receives that exact version pin.', + ); expect(output).toContain( 'That plugin entry does not create a global thoth-agents command.', ); @@ -107,6 +116,13 @@ describe('commands plain operation formatters', () => { expect(output).toContain( 'External required skills are installed for every harness', ); + expect(output).toContain( + 'Update performs the complete selected-harness CLI refresh', + ); + expect(output).toContain( + 'Codex and Claude marketplace versions remain native-manager-owned', + ); + expect(output).toContain('install-state.json'); expect(output).not.toContain('--skills'); expect(output).not.toContain('alternative providers'); expect(output).not.toContain('thoth-mem defaults'); @@ -178,6 +194,36 @@ describe('commands plain operation formatters', () => { expect(output).not.toContain('State: degraded'); }); + test('status output labels executing and recorded official CLI versions', () => { + const output = formatHarnessStatusReport([ + { + harness: 'claude', + displayName: 'Claude Code', + state: 'outdated', + summary: 'CLI refresh required.', + targets: [ + { + kind: 'file', + label: 'CLI-managed install version', + path: '/home/.config/thoth-agents/install-state.json', + state: 'outdated', + expected: 'executing 0.4.8', + observed: 'recorded 0.4.7', + }, + ], + diagnostics: [], + actions: [], + }, + ]); + + expect(output).toContain('Official CLI-managed install:'); + expect(output).toContain('Executing CLI version: 0.4.8'); + expect(output).toContain('Recorded complete-install version: 0.4.7'); + expect(output).toContain( + 'Native marketplace versions do not advance this record.', + ); + }); + test('list output shows supported harness metadata and unavailable entries', () => { const output = formatHarnessList([ { @@ -579,13 +625,52 @@ describe('explicit operation commands', () => { }); test('status dispatches to operation status services for all harnesses', async () => { - const result = await captureCommand(['status']); + const isolatedRoot = mkdtempSync(join(tmpdir(), 'thoth-command-status-')); + const originalConfigDir = process.env.OPENCODE_CONFIG_DIR; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.OPENCODE_CONFIG_DIR = join(isolatedRoot, 'opencode'); + process.env.XDG_CONFIG_HOME = isolatedRoot; + const services: TestModelServices = { + operationContext: () => + ({ + cwd: isolatedRoot, + env: { + HOME: join(isolatedRoot, 'home'), + XDG_CONFIG_HOME: isolatedRoot, + }, + homeDir: join(isolatedRoot, 'home'), + packageRoot: process.cwd(), + commandExecutor: () => ({ + exitCode: 0, + stdout: '[]', + stderr: '', + }), + }) as OperationContext, + modelRoles: () => [], + modelOptions: async () => [], + }; - expect(result.code).toBe(0); - expect(result.output).toContain('OpenCode (opencode)'); - expect(result.output).toContain('Codex (codex)'); - expect(result.output).toContain('State:'); - expectNoPlaceholder(result.output); + try { + const result = await captureCommand(['status'], services); + + expect(result.code).toBe(0); + expect(result.output).toContain('OpenCode (opencode)'); + expect(result.output).toContain('Codex (codex)'); + expect(result.output).toContain('State:'); + expectNoPlaceholder(result.output); + } finally { + if (originalConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = originalConfigDir; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + rmSync(isolatedRoot, { recursive: true, force: true }); + } }); test('list dispatches to operation registry metadata', async () => { @@ -606,10 +691,85 @@ describe('explicit operation commands', () => { expect(result.output).toContain('Target harness: OpenCode (opencode)'); expect(result.output).toContain('Action: update'); expect(result.output).toContain('Dry run: yes'); + expect(result.output).toContain('Plan provider-owned thoth-mem setup'); + expect(result.output).toContain('Record completed OpenCode CLI install'); + expect(result.output).not.toContain( + 'Ensure OpenCode plugin points at thoth-agents@latest', + ); expect(result.output).not.toContain('Applied: yes'); expectNoPlaceholder(result.output); }); + test('update preview is non-mutating and failed apply returns nonzero', async () => { + const isolatedRoot = mkdtempSync(join(tmpdir(), 'thoth-command-update-')); + const originalConfigDir = process.env.OPENCODE_CONFIG_DIR; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.OPENCODE_CONFIG_DIR = join(isolatedRoot, 'opencode'); + process.env.XDG_CONFIG_HOME = isolatedRoot; + const applyOperationPlan = vi.fn( + (plan: OperationPlan): OperationApplyResult => ({ + harness: plan.harness, + action: plan.action, + applied: false, + summary: 'injected update failure', + changedTargets: [], + backups: [], + warnings: [ + { + severity: 'critical', + message: 'required finalization failed', + }, + ], + disclaimers: [], + }), + ); + const services: TestModelServices = { + operationContext: () => ({ + cwd: isolatedRoot, + env: { + HOME: join(isolatedRoot, 'home'), + XDG_CONFIG_HOME: isolatedRoot, + }, + }), + modelRoles: () => [], + modelOptions: async () => [], + applyOperationPlan, + }; + + try { + const preview = await captureCommand( + ['update', '--harness=opencode'], + services, + ); + expect(preview.code).toBe(0); + expect(applyOperationPlan).not.toHaveBeenCalled(); + expect(existsSync(join(isolatedRoot, 'opencode', 'opencode.json'))).toBe( + false, + ); + + const applied = await captureCommand( + ['update', '--harness=opencode', '--apply'], + services, + ); + expect(applied.code).toBe(1); + expect(applied.output).toContain('Applied: no'); + expect(applied.output).toContain('injected update failure'); + expect(applyOperationPlan).toHaveBeenCalledOnce(); + } finally { + if (originalConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = originalConfigDir; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + rmSync(isolatedRoot, { recursive: true, force: true }); + } + }); + test('sync renders a dry-run plan for an explicit harness', async () => { const result = await captureCommand(['sync', '--harness=codex']); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 4ad8617..7c2baec 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -46,6 +46,7 @@ import type { OperationPlan, OperationWarning, } from './operations/types'; +import { resolveExecutingPackageVersion } from './package-version'; import { getModelOptions, type ModelOption } from './tui/model-catalog'; import { getClaudeCodeModelRoles, @@ -75,6 +76,21 @@ function formatTarget(target: ManagedTarget): string { return `- ${label}${location}${state}${expected}${observed}`; } +function formatCliManagedInstall(targets: readonly ManagedTarget[]): string[] { + const target = targets.find( + ({ label }) => label === 'CLI-managed install version', + ); + if (!target) return []; + const executing = target.expected?.replace(/^executing\s+/, '') ?? 'unknown'; + const recorded = target.observed?.replace(/^recorded\s+/, '') ?? 'unknown'; + return [ + 'Official CLI-managed install:', + `Executing CLI version: ${executing}`, + `Recorded complete-install version: ${recorded}`, + 'Native marketplace versions do not advance this record.', + ]; +} + function formatPaths(paths: OperationPath[]): string[] { return paths.map((path) => { const label = path.label ? `${path.label}: ` : ''; @@ -121,6 +137,7 @@ export function formatHarnessStatusReport( `${report.displayName ?? getOperationHarness(report.harness).displayName} (${report.harness})`, `State: ${report.state}`, `Summary: ${report.summary}`, + ...formatCliManagedInstall(report.targets), ...(report.providerCapability ? [ 'Provider evidence:', @@ -247,6 +264,10 @@ export function formatOperationApplyResult( } export function printHelp(): void { + const packageVersion = resolveExecutingPackageVersion(); + const exactPluginEntry = packageVersion.ok + ? `thoth-agents@${packageVersion.version}` + : 'thoth-agents@'; console.log(` thoth-agents CLI (npm binary: thoth-agents) @@ -260,9 +281,9 @@ Commands: (no command) Open the interactive TUI in a TTY; fall back to OpenCode install in CI/non-TTY install Install OpenCode, Codex, or Claude Code agent assets generate Generate harness-specific artifacts - status Show managed install status + status Show official CLI-managed versions and managed install status list List managed surfaces and actions - update Preview managed updates + update Preview a complete selected-harness CLI refresh sync Preview managed configuration sync model Preview role model/provider settings @@ -270,6 +291,7 @@ Options: --tmux=yes|no Enable tmux integration (yes/no) --no-tui Non-interactive mode --dry-run Simulate install without writing files + --apply Apply a reviewed update, sync, or model plan --reset Repair managed installer-owned targets --agent=opencode|codex|claude Select OpenCode plugin install (default), Codex agent-pack, or Claude Code plugin setup @@ -284,10 +306,18 @@ Generate options: OpenCode plugin config and the npm binary are separate surfaces. OpenCode loads the plugin with config such as: - plugin: ["thoth-agents@latest"] + plugin: ["${exactPluginEntry}"] That plugin entry does not create a global thoth-agents command. Run this CLI through a global install, npx, or pnpm dlx. +@latest selects the CLI release; OpenCode receives that exact version pin. + +Update performs the complete selected-harness CLI refresh and records success last. +The official record is $XDG_CONFIG_HOME/thoth-agents/install-state.json, +or ~/.config/thoth-agents/install-state.json when XDG_CONFIG_HOME is unset. +Codex and Claude marketplace versions remain native-manager-owned and do not prove +that CLI-managed agents, skills, configuration, or provider setup are aligned. +Runtime release checks notify only; use the latest CLI install or interactive CLI Update. OpenCode install configures the adaptive seven-role roster and native task delegation. Provider capability is external and reported only from caller-supplied evidence. @@ -326,10 +356,12 @@ function selectedHarness( return args.harness ?? fallback; } -function statusReports(args: OperationArgs): HarnessStatusReport[] { +function statusReports( + args: OperationArgs, + context: OperationContext = operationContext(), +): HarnessStatusReport[] { const harnesses = args.all || !args.harness ? SUPPORTED_OPERATION_HARNESSES : [args.harness]; - const context = operationContext(); return harnesses.map((harness) => { if (harness === 'opencode') return getOpenCodeStatus(context); if (harness === 'claude') return getClaudeCodeStatus(context); @@ -340,9 +372,9 @@ function statusReports(args: OperationArgs): HarnessStatusReport[] { function buildOperationPlan( command: Extract, args: OperationArgs, + context: OperationContext = operationContext(), ): OperationPlan { const harness = selectedHarness(args); - const context = operationContext(); if (harness === 'opencode') { return command === 'update' ? buildOpenCodeUpdatePlan(context) @@ -423,6 +455,7 @@ export interface CliModelCommandServices { operationContext(): OperationContext; modelRoles(harness: OperationHarnessArg): ModelRoleInput[]; modelOptions(harness: OperationHarnessArg): Promise; + applyOperationPlan?(plan: OperationPlan): OperationApplyResult; } const defaultModelCommandServices: CliModelCommandServices = { @@ -476,7 +509,11 @@ function applyOperationPlan(plan: OperationPlan): OperationApplyResult { return applyCodexPlan(plan); } -function printPlanOrApply(plan: OperationPlan, args: OperationArgs): number { +function printPlanOrApply( + plan: OperationPlan, + args: OperationArgs, + applyPlan: (plan: OperationPlan) => OperationApplyResult, +): number { if (args.apply && args.dryRun) { console.error('--apply cannot be combined with --dry-run.'); return 1; @@ -487,8 +524,9 @@ function printPlanOrApply(plan: OperationPlan, args: OperationArgs): number { return 0; } - console.log(formatOperationApplyResult(applyOperationPlan(plan))); - return 0; + const result = applyPlan(plan); + console.log(formatOperationApplyResult(result)); + return result.applied ? 0 : 1; } async function runOperationCommand( @@ -497,7 +535,11 @@ async function runOperationCommand( services: CliModelCommandServices, ): Promise { if (command === 'status') { - console.log(formatHarnessStatusReport(statusReports(args))); + console.log( + formatHarnessStatusReport( + statusReports(args, services.operationContext()), + ), + ); return 0; } @@ -507,12 +549,20 @@ async function runOperationCommand( } if (command === 'update' || command === 'sync') { - return printPlanOrApply(buildOperationPlan(command, args), args); + return printPlanOrApply( + buildOperationPlan(command, args, services.operationContext()), + args, + services.applyOperationPlan ?? applyOperationPlan, + ); } const plan = await buildModelPlan(args, services); if (!plan) return printModelGuidance(); - return printPlanOrApply(plan, args); + return printPlanOrApply( + plan, + args, + services.applyOperationPlan ?? applyOperationPlan, + ); } export function printHarnessGeneration(args: GenerateArgs): number { diff --git a/src/cli/config-io.test.ts b/src/cli/config-io.test.ts index 4529062..8e3f245 100644 --- a/src/cli/config-io.test.ts +++ b/src/cli/config-io.test.ts @@ -102,7 +102,7 @@ describe('config-io', () => { }); }); - test('addPluginToOpenCodeConfig adds plugin and removes duplicates', async () => { + test('addPluginToOpenCodeConfig writes the approved exact version', async () => { const configPath = join(tmpDir, 'opencode', 'opencode.json'); paths.ensureConfigDir(); writeFileSync( @@ -110,15 +110,70 @@ describe('config-io', () => { JSON.stringify({ plugin: ['other', 'thoth-agents@1.0.0'] }), ); - const result = await addPluginToOpenCodeConfig(); + const result = await addPluginToOpenCodeConfig('0.4.8'); expect(result.success).toBe(true); const saved = JSON.parse(readFileSync(configPath, 'utf-8')); - expect(saved.plugin).toContain('thoth-agents@latest'); + expect(saved.plugin).toContain('thoth-agents@0.4.8'); expect(saved.plugin).not.toContain('thoth-agents@1.0.0'); + expect(saved.plugin).not.toContain('thoth-agents@latest'); expect(saved.plugin.length).toBe(2); }); + test('replaces every managed entry form while preserving unrelated plugin order', () => { + const configPath = join(tmpDir, 'opencode', 'opencode.json'); + paths.ensureConfigDir(); + writeFileSync( + configPath, + JSON.stringify({ + plugin: [ + 'first', + 'thoth-agents', + 'second', + 'thoth-agents@next', + 'third', + 'thoth-agents@0.3.8', + 'fourth', + ], + }), + ); + + const result = updateOpenCodeMainConfig({ + ensurePlugin: true, + pluginVersion: '0.4.8-beta.1', + }); + + expect(result.success).toBe(true); + expect(JSON.parse(readFileSync(configPath, 'utf8')).plugin).toEqual([ + 'first', + 'second', + 'third', + 'fourth', + 'thoth-agents@0.4.8-beta.1', + ]); + }); + + test.each([ + undefined, + '', + 'latest', + ])('rejects an unapproved plugin version without mutating config: %s', (pluginVersion) => { + const configPath = join(tmpDir, 'opencode', 'opencode.json'); + paths.ensureConfigDir(); + const original = JSON.stringify({ plugin: ['user-plugin'] }); + writeFileSync(configPath, original); + + const result = updateOpenCodeMainConfig({ + ensurePlugin: true, + pluginVersion, + }); + + expect(result.success).toBe(false); + expect(readFileSync(configPath, 'utf8')).toBe(original); + expect(existsSync(`${configPath}.bak`)).toBe(false); + expect(existsSync(`${configPath}.tmp`)).toBe(false); + }); + test('writeLiteConfig writes lite config with OpenAI preset', () => { const litePath = join(tmpDir, 'opencode', 'thoth-agents.json'); paths.ensureConfigDir(); @@ -171,13 +226,14 @@ describe('config-io', () => { const result = updateOpenCodeMainConfig({ ensurePlugin: true, + pluginVersion: '0.4.8', disableDefaults: true, }); expect(result.success).toBe(true); expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe(original); expect(JSON.parse(readFileSync(configPath, 'utf8'))).toEqual({ - plugin: ['user-plugin', 'thoth-agents@latest'], + plugin: ['user-plugin', 'thoth-agents@0.4.8'], theme: 'user-owned', agent: { explore: { @@ -202,6 +258,7 @@ describe('config-io', () => { const result = updateOpenCodeMainConfig({ ensurePlugin: true, + pluginVersion: '0.4.8', disableDefaults: true, }); diff --git a/src/cli/config-io.ts b/src/cli/config-io.ts index 2c4da45..ed42bb6 100644 --- a/src/cli/config-io.ts +++ b/src/cli/config-io.ts @@ -6,6 +6,7 @@ import { statSync, writeFileSync, } from 'node:fs'; +import { isValidPackageVersion } from './package-version'; import { ensureConfigDir, ensureOpenCodeConfigDir, @@ -92,12 +93,15 @@ export function writeConfig(configPath: string, config: OpenCodeConfig): void { renameSync(tmpPath, configPath); } -export async function addPluginToOpenCodeConfig(): Promise { - return updateOpenCodeMainConfig({ ensurePlugin: true }); +export async function addPluginToOpenCodeConfig( + pluginVersion: string, +): Promise { + return updateOpenCodeMainConfig({ ensurePlugin: true, pluginVersion }); } export interface OpenCodeMainConfigUpdate { ensurePlugin?: boolean; + pluginVersion?: string; disableDefaults?: boolean; } @@ -112,7 +116,7 @@ function mergeOpenCodeMainConfig( (plugin) => plugin !== PACKAGE_NAME && !plugin.startsWith(`${PACKAGE_NAME}@`), ), - `${PACKAGE_NAME}@latest`, + `${PACKAGE_NAME}@${update.pluginVersion}`, ]; } @@ -142,6 +146,14 @@ export function updateOpenCodeMainConfig( ): ConfigMergeResult { const configPath = getExistingConfigPath(); + if (update.ensurePlugin && !isValidPackageVersion(update.pluginVersion)) { + return { + success: false, + configPath, + error: 'An approved plugin version is required.', + }; + } + try { ensureOpenCodeConfigDir(); } catch (err) { diff --git a/src/cli/install-completion.test.ts b/src/cli/install-completion.test.ts new file mode 100644 index 0000000..5a0a3bd --- /dev/null +++ b/src/cli/install-completion.test.ts @@ -0,0 +1,177 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { finalizeHarnessInstall } from './install-completion'; +import { + getInstallLedgerPath, + readInstallLedger, + recordCompletedInstall, +} from './install-ledger'; +import type { ThothMemSetupResult } from './thoth-mem-install'; + +function providerResult( + harness: 'opencode' | 'codex' | 'claude', + overrides: Partial = {}, +): ThothMemSetupResult { + return { + success: true, + evidenceValid: true, + status: 'complete', + changed: true, + harness, + target: `C:/provider/${harness}`, + steps: [{ name: 'Provider setup', outcome: 'complete' }], + diagnostics: ['provider diagnostic'], + manualActions: [], + receipt: 'C:/provider/receipt.json', + command: 'npx', + args: ['-y', 'thoth-mem@latest', 'setup', harness], + exitCode: 0, + ...overrides, + }; +} + +describe('finalizeHarnessInstall', () => { + let configRoot: string; + + beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), 'thoth-install-completion-')); + }); + + afterEach(() => { + rmSync(configRoot, { recursive: true, force: true }); + }); + + test('runs provider setup before recording the completed harness', () => { + const effects: string[] = []; + const runProvider = vi.fn((options) => { + effects.push(`provider:${options.harness}`); + return providerResult(options.harness); + }); + const recordInstall = vi.fn((options) => { + effects.push(`ledger:${options.harness}:${options.version}`); + return recordCompletedInstall(options); + }); + + const result = finalizeHarnessInstall({ + harness: 'opencode', + version: '0.4.8', + dryRun: false, + cwd: 'C:/project', + runThothMemSetup: runProvider, + recordCompletedInstall: recordInstall, + ledgerOptions: { configRoot }, + }); + + expect(result.success).toBe(true); + expect(result.ledger).toMatchObject({ status: 'recorded' }); + expect(result.provider).toMatchObject({ + diagnostics: ['provider diagnostic'], + receipt: 'C:/provider/receipt.json', + }); + expect(effects).toEqual(['provider:opencode', 'ledger:opencode:0.4.8']); + expect(readInstallLedger({ configRoot })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { opencode: { version: '0.4.8' } } }, + }); + }); + + test('dry-run plans provider and ledger finalization without writing state', () => { + const runProvider = vi.fn(() => providerResult('codex')); + const recordInstall = vi.fn(recordCompletedInstall); + + const result = finalizeHarnessInstall({ + harness: 'codex', + version: '0.4.8', + dryRun: true, + cwd: 'C:/project', + runThothMemSetup: runProvider, + recordCompletedInstall: recordInstall, + ledgerOptions: { configRoot }, + }); + + expect(result).toMatchObject({ + success: true, + ledger: { status: 'planned' }, + }); + expect(runProvider).toHaveBeenCalledWith({ + harness: 'codex', + dryRun: true, + cwd: 'C:/project', + }); + expect(recordInstall).not.toHaveBeenCalled(); + expect(existsSync(getInstallLedgerPath({ configRoot }))).toBe(false); + }); + + test.each([ + { + name: 'partial evidence', + result: { success: false, status: 'partial' as const, exitCode: 2 }, + }, + { + name: 'contradictory complete evidence', + result: { success: true, evidenceValid: false, exitCode: 1 }, + }, + { + name: 'contradictory success status', + result: { success: true, status: 'partial' as const, exitCode: 2 }, + }, + { + name: 'wrong harness evidence', + result: { success: true, harness: 'codex' as const }, + }, + ])('does not record $name', ({ result: overrides }) => { + const recordInstall = vi.fn(recordCompletedInstall); + + const result = finalizeHarnessInstall({ + harness: 'claude', + version: '0.4.8', + dryRun: false, + cwd: 'C:/project', + runThothMemSetup: () => providerResult('claude', overrides), + recordCompletedInstall: recordInstall, + ledgerOptions: { configRoot }, + }); + + expect(result).toMatchObject({ + success: false, + ledger: { status: 'not-attempted' }, + }); + expect(recordInstall).not.toHaveBeenCalled(); + expect(existsSync(getInstallLedgerPath({ configRoot }))).toBe(false); + }); + + test('reports ledger failure and retains the prior official version', () => { + expect( + recordCompletedInstall({ + harness: 'codex', + version: '0.4.7', + configRoot, + }).success, + ).toBe(true); + const ledgerPath = getInstallLedgerPath({ configRoot }); + const before = readFileSync(ledgerPath, 'utf8'); + const failingRecord = vi.fn(() => ({ + success: false as const, + path: ledgerPath, + error: 'injected ledger failure', + })); + + const result = finalizeHarnessInstall({ + harness: 'codex', + version: '0.4.8', + dryRun: false, + cwd: 'C:/project', + runThothMemSetup: () => providerResult('codex'), + recordCompletedInstall: failingRecord, + ledgerOptions: { configRoot }, + }); + + expect(result).toMatchObject({ + success: false, + ledger: { status: 'failed', error: 'injected ledger failure' }, + }); + expect(readFileSync(ledgerPath, 'utf8')).toBe(before); + }); +}); diff --git a/src/cli/install-completion.ts b/src/cli/install-completion.ts new file mode 100644 index 0000000..86d0cc5 --- /dev/null +++ b/src/cli/install-completion.ts @@ -0,0 +1,116 @@ +import { + getInstallLedgerPath, + type InstallHarnessId, + type InstallLedgerOptions, + type RecordCompletedInstallResult, + recordCompletedInstall, +} from './install-ledger'; +import { + runThothMemSetup, + type ThothMemSetupOptions, + type ThothMemSetupResult, +} from './thoth-mem-install'; + +export type InstallCompletionLedgerResult = + | { status: 'planned'; path: string } + | { status: 'not-attempted'; path: string } + | { + status: 'recorded'; + path: string; + repairedInvalidState: boolean; + backupPath?: string; + } + | { status: 'failed'; path: string; error: string }; + +export interface HarnessInstallCompletionResult { + success: boolean; + provider: ThothMemSetupResult; + ledger: InstallCompletionLedgerResult; + error?: string; +} + +export interface FinalizeHarnessInstallOptions { + harness: InstallHarnessId; + version: string; + dryRun?: boolean; + cwd: string; + runThothMemSetup?: (options: ThothMemSetupOptions) => ThothMemSetupResult; + recordCompletedInstall?: ( + options: Parameters[0], + ) => RecordCompletedInstallResult; + ledgerOptions?: InstallLedgerOptions; +} + +function isConsistentProviderSuccess( + result: ThothMemSetupResult, + harness: InstallHarnessId, +): boolean { + return ( + result.success && + result.evidenceValid && + result.status === 'complete' && + result.exitCode === 0 && + result.harness === harness + ); +} + +export function finalizeHarnessInstall( + options: FinalizeHarnessInstallOptions, +): HarnessInstallCompletionResult { + const ledgerPath = getInstallLedgerPath(options.ledgerOptions); + const setup = options.runThothMemSetup ?? runThothMemSetup; + const provider = setup({ + harness: options.harness, + dryRun: options.dryRun, + cwd: options.cwd, + }); + + if (!isConsistentProviderSuccess(provider, options.harness)) { + return { + success: false, + provider, + ledger: { status: 'not-attempted', path: ledgerPath }, + error: + provider.error ?? + 'thoth-mem setup did not return consistent complete evidence.', + }; + } + + if (options.dryRun) { + return { + success: true, + provider, + ledger: { status: 'planned', path: ledgerPath }, + }; + } + + const record = options.recordCompletedInstall ?? recordCompletedInstall; + const ledger = record({ + ...options.ledgerOptions, + harness: options.harness, + version: options.version, + }); + if (!ledger.success) { + return { + success: false, + provider, + ledger: { + status: 'failed', + path: ledger.path, + error: ledger.error, + }, + error: ledger.error, + }; + } + + return { + success: true, + provider, + ledger: { + status: 'recorded', + path: ledger.path, + repairedInvalidState: ledger.repairedInvalidState, + ...(ledger.backupPath ? { backupPath: ledger.backupPath } : {}), + }, + }; +} diff --git a/src/cli/install-ledger.test.ts b/src/cli/install-ledger.test.ts new file mode 100644 index 0000000..e539f89 --- /dev/null +++ b/src/cli/install-ledger.test.ts @@ -0,0 +1,224 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + getInstallLedgerPath, + readInstallLedger, + recordCompletedInstall, +} from './install-ledger'; + +describe('install ledger', () => { + let configRoot: string; + + beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), 'thoth-install-ledger-')); + }); + + afterEach(() => { + rmSync(configRoot, { recursive: true, force: true }); + }); + + test('distinguishes missing, valid, and invalid state without repairing reads', () => { + const options = { configRoot }; + const ledgerPath = getInstallLedgerPath(options); + + expect(readInstallLedger(options)).toEqual({ + status: 'missing', + path: ledgerPath, + }); + + mkdirSync(join(ledgerPath, '..'), { recursive: true }); + writeFileSync( + ledgerPath, + JSON.stringify({ + schemaVersion: 1, + harnesses: { opencode: { version: '0.4.8' } }, + }), + ); + expect(readInstallLedger(options)).toMatchObject({ + status: 'valid', + ledger: { + schemaVersion: 1, + harnesses: { opencode: { version: '0.4.8' } }, + }, + }); + + writeFileSync(ledgerPath, '{ malformed'); + expect(readInstallLedger(options)).toMatchObject({ + status: 'invalid', + path: ledgerPath, + error: expect.any(String), + }); + expect(existsSync(`${ledgerPath}.bak`)).toBe(false); + }); + + test.each([ + { schemaVersion: 2, harnesses: {} }, + { schemaVersion: 1, harnesses: { unknown: { version: '0.4.8' } } }, + { schemaVersion: 1, harnesses: { opencode: { version: 'latest' } } }, + { + schemaVersion: 1, + harnesses: { + codex: { version: '0.4.8', marketplaceVersion: '9.9.9' }, + }, + }, + ])('rejects unsupported or non-minimal schema: %#', (value) => { + const ledgerPath = getInstallLedgerPath({ configRoot }); + mkdirSync(join(ledgerPath, '..'), { recursive: true }); + writeFileSync(ledgerPath, JSON.stringify(value)); + + expect(readInstallLedger({ configRoot }).status).toBe('invalid'); + }); + + test('records independent harness versions with sibling-temp atomic replacement', () => { + const renames: [string, string][] = []; + const options = { + configRoot, + renameFile: (source: string, destination: string) => { + renames.push([source, destination]); + renameSync(source, destination); + }, + }; + + expect( + recordCompletedInstall({ + harness: 'opencode', + version: '0.4.8', + ...options, + }).success, + ).toBe(true); + expect( + recordCompletedInstall({ + harness: 'codex', + version: '0.4.8-beta.1', + ...options, + }).success, + ).toBe(true); + expect( + recordCompletedInstall({ + harness: 'claude', + version: '0.5.0', + ...options, + }).success, + ).toBe(true); + + const ledgerPath = getInstallLedgerPath(options); + expect(JSON.parse(readFileSync(ledgerPath, 'utf8'))).toEqual({ + schemaVersion: 1, + harnesses: { + opencode: { version: '0.4.8' }, + codex: { version: '0.4.8-beta.1' }, + claude: { version: '0.5.0' }, + }, + }); + expect(renames).toHaveLength(3); + expect(renames.at(-1)).toEqual([`${ledgerPath}.tmp`, ledgerPath]); + expect(existsSync(`${ledgerPath}.tmp`)).toBe(false); + }); + + test('updates only the completed harness record', () => { + for (const [harness, version] of [ + ['opencode', '0.4.8'], + ['codex', '0.4.7'], + ] as const) { + expect( + recordCompletedInstall({ harness, version, configRoot }).success, + ).toBe(true); + } + + expect( + recordCompletedInstall({ + harness: 'opencode', + version: '0.5.0', + configRoot, + }).success, + ).toBe(true); + + expect(readInstallLedger({ configRoot })).toMatchObject({ + status: 'valid', + ledger: { + harnesses: { + opencode: { version: '0.5.0' }, + codex: { version: '0.4.7' }, + }, + }, + }); + }); + + test('backs up invalid state only when a successful record repairs it', () => { + const ledgerPath = getInstallLedgerPath({ configRoot }); + mkdirSync(join(ledgerPath, '..'), { recursive: true }); + const malformed = '{ malformed'; + writeFileSync(ledgerPath, malformed); + + expect(readInstallLedger({ configRoot }).status).toBe('invalid'); + expect(existsSync(`${ledgerPath}.bak`)).toBe(false); + + const result = recordCompletedInstall({ + harness: 'claude', + version: '0.4.8', + configRoot, + }); + + expect(result).toMatchObject({ + success: true, + repairedInvalidState: true, + backupPath: `${ledgerPath}.bak`, + }); + expect(readFileSync(`${ledgerPath}.bak`, 'utf8')).toBe(malformed); + expect(JSON.parse(readFileSync(ledgerPath, 'utf8'))).toEqual({ + schemaVersion: 1, + harnesses: { claude: { version: '0.4.8' } }, + }); + }); + + test('retains the prior authoritative record when replacement fails', () => { + expect( + recordCompletedInstall({ + harness: 'codex', + version: '0.4.7', + configRoot, + }).success, + ).toBe(true); + const ledgerPath = getInstallLedgerPath({ configRoot }); + const before = readFileSync(ledgerPath, 'utf8'); + mkdirSync(`${ledgerPath}.tmp`); + + const result = recordCompletedInstall({ + harness: 'codex', + version: '0.4.8', + configRoot, + }); + + expect(result).toMatchObject({ success: false, error: expect.any(String) }); + expect(readFileSync(ledgerPath, 'utf8')).toBe(before); + expect(readInstallLedger({ configRoot })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { codex: { version: '0.4.7' } } }, + }); + }); + + test('rejects invalid versions without creating state', () => { + const renameFile = vi.fn(); + + const result = recordCompletedInstall({ + harness: 'opencode', + version: 'latest', + configRoot, + renameFile, + }); + + expect(result).toMatchObject({ success: false }); + expect(renameFile).not.toHaveBeenCalled(); + expect(existsSync(getInstallLedgerPath({ configRoot }))).toBe(false); + }); +}); diff --git a/src/cli/install-ledger.ts b/src/cli/install-ledger.ts new file mode 100644 index 0000000..0a3b440 --- /dev/null +++ b/src/cli/install-ledger.ts @@ -0,0 +1,190 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { isValidPackageVersion } from './package-version'; + +export const INSTALL_LEDGER_SCHEMA_VERSION = 1 as const; +export type InstallHarnessId = 'opencode' | 'codex' | 'claude'; + +export interface InstallLedger { + schemaVersion: typeof INSTALL_LEDGER_SCHEMA_VERSION; + harnesses: Partial>; +} + +export interface InstallLedgerOptions { + configRoot?: string; + env?: Readonly>; + homeDir?: string; + renameFile?: (source: string, destination: string) => void; +} + +export type InstallLedgerReadResult = + | { status: 'missing'; path: string } + | { status: 'valid'; path: string; ledger: InstallLedger } + | { status: 'invalid'; path: string; error: string }; + +export type RecordCompletedInstallResult = + | { + success: true; + path: string; + ledger: InstallLedger; + repairedInvalidState: boolean; + backupPath?: string; + } + | { success: false; path: string; error: string }; + +export interface RecordCompletedInstallOptions extends InstallLedgerOptions { + harness: InstallHarnessId; + version: string; +} + +const INSTALL_HARNESSES = new Set([ + 'opencode', + 'codex', + 'claude', +]); + +function hasOnlyKeys(value: Record, keys: string[]): boolean { + const actual = Object.keys(value); + return ( + actual.length === keys.length && actual.every((key) => keys.includes(key)) + ); +} + +function parseInstallLedger( + serialized: string, +): + | { success: true; ledger: InstallLedger } + | { success: false; error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch (error) { + return { success: false, error: `Malformed install ledger JSON: ${error}` }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { success: false, error: 'Install ledger must be a JSON object.' }; + } + const root = parsed as Record; + if ( + !hasOnlyKeys(root, ['schemaVersion', 'harnesses']) || + root.schemaVersion !== INSTALL_LEDGER_SCHEMA_VERSION || + !root.harnesses || + typeof root.harnesses !== 'object' || + Array.isArray(root.harnesses) + ) { + return { success: false, error: 'Install ledger schema is unsupported.' }; + } + + const harnesses = root.harnesses as Record; + for (const [harness, value] of Object.entries(harnesses)) { + if ( + !INSTALL_HARNESSES.has(harness as InstallHarnessId) || + !value || + typeof value !== 'object' || + Array.isArray(value) || + !hasOnlyKeys(value as Record, ['version']) || + !isValidPackageVersion((value as Record).version) + ) { + return { + success: false, + error: `Install ledger record for ${harness} is invalid.`, + }; + } + } + + return { success: true, ledger: parsed as InstallLedger }; +} + +export function getInstallLedgerPath( + options: InstallLedgerOptions = {}, +): string { + const env = options.env ?? process.env; + const configuredRoot = env.XDG_CONFIG_HOME?.trim(); + const homeDir = options.homeDir ?? env.HOME ?? env.USERPROFILE ?? homedir(); + const configRoot = + options.configRoot ?? (configuredRoot || join(homeDir, '.config')); + return join(configRoot, 'thoth-agents', 'install-state.json'); +} + +export function readInstallLedger( + options: InstallLedgerOptions = {}, +): InstallLedgerReadResult { + const path = getInstallLedgerPath(options); + if (!existsSync(path)) return { status: 'missing', path }; + + let serialized: string; + try { + serialized = readFileSync(path, 'utf8'); + } catch (error) { + return { + status: 'invalid', + path, + error: `Could not read install ledger: ${error}`, + }; + } + + const parsed = parseInstallLedger(serialized); + return parsed.success + ? { status: 'valid', path, ledger: parsed.ledger } + : { status: 'invalid', path, error: parsed.error }; +} + +export function recordCompletedInstall( + options: RecordCompletedInstallOptions, +): RecordCompletedInstallResult { + const { harness, version } = options; + const path = getInstallLedgerPath(options); + if (!INSTALL_HARNESSES.has(harness) || !isValidPackageVersion(version)) { + return { + success: false, + path, + error: 'A supported harness and valid package version are required.', + }; + } + + const current = readInstallLedger(options); + const ledger: InstallLedger = { + schemaVersion: INSTALL_LEDGER_SCHEMA_VERSION, + harnesses: + current.status === 'valid' ? { ...current.ledger.harnesses } : {}, + }; + ledger.harnesses[harness] = { version }; + + const temporaryPath = `${path}.tmp`; + const repairedInvalidState = current.status === 'invalid'; + const backupPath = repairedInvalidState ? `${path}.bak` : undefined; + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(temporaryPath, `${JSON.stringify(ledger, null, 2)}\n`); + if (backupPath) copyFileSync(path, backupPath); + (options.renameFile ?? renameSync)(temporaryPath, path); + return { + success: true, + path, + ledger, + repairedInvalidState, + ...(backupPath ? { backupPath } : {}), + }; + } catch (error) { + try { + rmSync(temporaryPath, { recursive: true, force: true }); + } catch { + // Preserve the original failure; a leftover sibling temp is non-authoritative. + } + return { + success: false, + path, + error: `Could not record completed install: ${error}`, + }; + } +} diff --git a/src/cli/install.test.ts b/src/cli/install.test.ts index 90fa77f..ca0bb8b 100644 --- a/src/cli/install.test.ts +++ b/src/cli/install.test.ts @@ -1,15 +1,40 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; +import { applyClaudeCodeSetup } from './claude-code-install'; import { buildCodexSetupPlan } from './codex-install'; import { applyCodexPluginSetup, buildCodexPluginSetupPlan, } from './codex-plugin-install'; import { createInstallConfig, install } from './install'; +import { + getInstallLedgerPath, + readInstallLedger, + recordCompletedInstall, +} from './install-ledger'; import type { ThothMemSetupResult } from './thoth-mem-install'; +const installRequiredSkillMock = vi.hoisted(() => + vi.fn(() => ({ status: 'installed' as const })), +); + +vi.mock('./skills', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + installRequiredSkill: installRequiredSkillMock, + }; +}); + vi.mock('./codex-plugin-install', () => ({ buildCodexPluginSetupPlan: vi.fn(() => ({ dryRun: true })), formatCodexPluginSetupPlan: vi.fn(() => 'Codex plugin setup plan'), @@ -40,6 +65,16 @@ vi.mock('./claude-code-install', () => ({ })), })); +vi.mock('./config-manager', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isOpenCodeInstalled: vi.fn(async () => true), + getOpenCodeVersion: vi.fn(async () => '1.0.0'), + getOpenCodePath: vi.fn(() => 'C:/opencode/bin/opencode'), + }; +}); + function providerExitCode( status: ThothMemSetupResult['status'], ): number | null { @@ -267,6 +302,306 @@ describe('install', () => { } }); + test('OpenCode installation rejects unresolved package identity before config mutation', async () => { + const configRoot = mkdtempSync(join(tmpdir(), 'thoth-install-identity-')); + const configDir = join(configRoot, 'opencode'); + const configPath = join(configDir, 'opencode.json'); + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + mkdirSync(configDir, { recursive: true }); + writeFileSync(configPath, '{"plugin":["user-plugin"]}'); + process.env.XDG_CONFIG_HOME = configRoot; + const resolvePackageVersion = vi.fn(() => ({ + ok: false as const, + error: { + code: 'package-version-invalid' as const, + message: 'Executing package metadata has no valid semantic version.', + }, + })); + const updateMainConfig = vi.fn(() => ({ + success: true, + configPath, + })); + const originalLog = console.log; + console.log = () => undefined; + + try { + const code = await install( + { + agent: 'opencode', + tui: false, + tmux: 'no', + dryRun: false, + reset: false, + }, + { + resolveExecutingPackageVersion: resolvePackageVersion, + updateOpenCodeMainConfig: updateMainConfig, + }, + ); + + expect(code).toBe(1); + expect(resolvePackageVersion).toHaveBeenCalledOnce(); + expect(updateMainConfig).not.toHaveBeenCalled(); + expect(readFileSync(configPath, 'utf8')).toBe( + '{"plugin":["user-plugin"]}', + ); + expect(existsSync(`${configPath}.bak`)).toBe(false); + } finally { + console.log = originalLog; + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + rmSync(configRoot, { recursive: true, force: true }); + } + }); + + test('OpenCode installation passes the approved exact version to config mutation', async () => { + const effects: string[] = []; + const updateMainConfig = vi.fn(() => { + effects.push('config'); + return { + success: false, + configPath: 'C:/opencode/opencode.json', + error: 'stop after observing config request', + }; + }); + const originalLog = console.log; + console.log = () => undefined; + + try { + const code = await install( + { + agent: 'opencode', + tui: false, + tmux: 'no', + dryRun: false, + reset: false, + }, + { + resolveExecutingPackageVersion: () => { + effects.push('identity'); + return { + ok: true, + version: '0.4.8-beta.1', + packageRoot: 'C:/thoth-agents', + }; + }, + updateOpenCodeMainConfig: updateMainConfig, + }, + ); + + expect(code).toBe(1); + expect(effects).toEqual(['identity', 'config']); + expect(updateMainConfig).toHaveBeenCalledWith({ + ensurePlugin: true, + pluginVersion: '0.4.8-beta.1', + disableDefaults: true, + }); + } finally { + console.log = originalLog; + } + }); + + test.each([ + 'codex', + 'claude', + ] as const)('%s installation rejects unresolved package identity before native mutation', async (agent) => { + vi.clearAllMocks(); + const resolvePackageVersion = vi.fn(() => ({ + ok: false as const, + error: { + code: 'package-version-invalid' as const, + message: 'Executing package metadata has no valid semantic version.', + }, + })); + const runProvider = vi.fn(() => providerResult(agent)); + const originalLog = console.log; + console.log = () => undefined; + + try { + const code = await install( + { + agent, + tui: false, + tmux: 'no', + dryRun: false, + reset: false, + }, + { + resolveExecutingPackageVersion: resolvePackageVersion, + runThothMemSetup: runProvider, + }, + ); + + expect(code).toBe(1); + expect(resolvePackageVersion).toHaveBeenCalledOnce(); + expect(runProvider).not.toHaveBeenCalled(); + if (agent === 'codex') { + expect(applyCodexPluginSetup).not.toHaveBeenCalled(); + } else { + expect(applyClaudeCodeSetup).not.toHaveBeenCalled(); + } + } finally { + console.log = originalLog; + } + }); + + test('records independent versions after every explicit harness install succeeds', async () => { + vi.clearAllMocks(); + installRequiredSkillMock.mockReturnValue({ status: 'installed' }); + const configRoot = mkdtempSync(join(tmpdir(), 'thoth-explicit-ledger-')); + const homeDir = join(configRoot, 'home'); + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR; + process.env.XDG_CONFIG_HOME = configRoot; + process.env.OPENCODE_CONFIG_DIR = join(configRoot, 'opencode'); + const originalLog = console.log; + console.log = () => undefined; + + try { + for (const agent of ['opencode', 'codex', 'claude'] as const) { + const code = await install( + { + agent, + tui: false, + tmux: 'no', + dryRun: false, + reset: false, + }, + { + homeDir, + resolveExecutingPackageVersion: () => ({ + ok: true, + version: '0.4.8', + packageRoot: process.cwd(), + }), + runThothMemSetup: () => providerResult(agent), + installLedgerOptions: { configRoot }, + }, + ); + expect(code).toBe(0); + } + + expect(readInstallLedger({ configRoot })).toEqual({ + status: 'valid', + path: getInstallLedgerPath({ configRoot }), + ledger: { + schemaVersion: 1, + harnesses: { + opencode: { version: '0.4.8' }, + codex: { version: '0.4.8' }, + claude: { version: '0.4.8' }, + }, + }, + }); + } finally { + console.log = originalLog; + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalOpenCodeConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir; + } + rmSync(configRoot, { recursive: true, force: true }); + } + }); + + test('provider failure does not advance an explicit install record', async () => { + vi.clearAllMocks(); + const configRoot = mkdtempSync(join(tmpdir(), 'thoth-provider-ledger-')); + expect( + recordCompletedInstall({ + harness: 'codex', + version: '0.4.7', + configRoot, + }).success, + ).toBe(true); + const originalLog = console.log; + console.log = () => undefined; + + try { + const code = await install( + { + agent: 'codex', + tui: false, + tmux: 'no', + dryRun: false, + reset: false, + }, + { + resolveExecutingPackageVersion: () => ({ + ok: true, + version: '0.4.8', + packageRoot: process.cwd(), + }), + runThothMemSetup: () => providerResult('codex', 'partial'), + installLedgerOptions: { configRoot }, + }, + ); + + expect(code).toBe(1); + expect(readInstallLedger({ configRoot })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { codex: { version: '0.4.7' } } }, + }); + } finally { + console.log = originalLog; + rmSync(configRoot, { recursive: true, force: true }); + } + }); + + test('ledger failure makes explicit installation fail without advancing the record', async () => { + vi.clearAllMocks(); + const configRoot = mkdtempSync(join(tmpdir(), 'thoth-ledger-failure-')); + expect( + recordCompletedInstall({ + harness: 'claude', + version: '0.4.7', + configRoot, + }).success, + ).toBe(true); + const ledgerPath = getInstallLedgerPath({ configRoot }); + mkdirSync(`${ledgerPath}.tmp`); + const originalLog = console.log; + console.log = () => undefined; + + try { + const code = await install( + { + agent: 'claude', + tui: false, + tmux: 'no', + dryRun: false, + reset: false, + }, + { + resolveExecutingPackageVersion: () => ({ + ok: true, + version: '0.4.8', + packageRoot: process.cwd(), + }), + runThothMemSetup: () => providerResult('claude'), + installLedgerOptions: { configRoot }, + }, + ); + + expect(code).toBe(1); + expect(readInstallLedger({ configRoot })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { claude: { version: '0.4.7' } } }, + }); + } finally { + console.log = originalLog; + rmSync(configRoot, { recursive: true, force: true }); + } + }); + test('OpenCode stops before provider setup when the owned bundle is incomplete', async () => { const packageRoot = mkdtempSync(join(tmpdir(), 'thoth-owned-missing-')); const lines: string[] = []; diff --git a/src/cli/install.ts b/src/cli/install.ts index e8ed9ad..a96a82f 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -25,7 +25,16 @@ import { updateOpenCodeMainConfig, writeLiteConfig, } from './config-manager'; +import { + finalizeHarnessInstall, + type HarnessInstallCompletionResult, +} from './install-completion'; +import type { InstallLedgerOptions } from './install-ledger'; import { syncOpenCodeOwnedSkills } from './owned-skills'; +import { + type ExecutingPackageVersionResult, + resolveExecutingPackageVersion, +} from './package-version'; import { getExistingLiteConfigPath } from './paths'; import { getRequiredSkillInstallCommand, @@ -33,10 +42,9 @@ import { REQUIRED_SKILLS, type SkillInstallHarness, } from './skills'; -import { - runThothMemSetup, - type ThothMemSetupOptions, - type ThothMemSetupResult, +import type { + ThothMemSetupOptions, + ThothMemSetupResult, } from './thoth-mem-install'; import type { ConfigMergeResult, InstallArgs, InstallConfig } from './types'; @@ -44,6 +52,10 @@ export interface InstallDependencies { homeDir?: string; opencodeOwnedSkillPackageRoot?: string; runThothMemSetup?: (options: ThothMemSetupOptions) => ThothMemSetupResult; + resolveExecutingPackageVersion?: () => ExecutingPackageVersionResult; + updateOpenCodeMainConfig?: typeof updateOpenCodeMainConfig; + finalizeHarnessInstall?: typeof finalizeHarnessInstall; + installLedgerOptions?: InstallLedgerOptions; } // Colors @@ -243,21 +255,42 @@ function printThothMemSetupResult( return true; } -function installThothMemForHarness( +function finalizeInstallForHarness( harness: SkillInstallHarness, dryRun: boolean | undefined, + version: string, dependencies: InstallDependencies, ): boolean { - const setup = dependencies.runThothMemSetup ?? runThothMemSetup; - return printThothMemSetupResult( - setup({ harness, dryRun, cwd: cwd() }), + const finalize = + dependencies.finalizeHarnessInstall ?? finalizeHarnessInstall; + const result: HarnessInstallCompletionResult = finalize({ + harness, + version, dryRun, - ); + cwd: cwd(), + runThothMemSetup: dependencies.runThothMemSetup, + ledgerOptions: dependencies.installLedgerOptions ?? { + homeDir: dependencies.homeDir ?? homedir(), + }, + }); + const providerComplete = printThothMemSetupResult(result.provider, dryRun); + if (!providerComplete) return false; + if (!result.success) { + printError(result.error ?? 'Failed to record the completed CLI install.'); + return false; + } + if (result.ledger.status === 'planned') { + printInfo(`CLI-managed install record planned: ${result.ledger.path}`); + } else if (result.ledger.status === 'recorded') { + printSuccess(`CLI-managed install version recorded: ${result.ledger.path}`); + } + return true; } async function runInstall( config: InstallConfig, dependencies: InstallDependencies, + pluginVersion: string, ): Promise { const detected = detectCurrentConfig(); const isUpdate = detected.isInstalled; @@ -283,8 +316,11 @@ async function runInstall( if (config.dryRun) { printInfo('Dry run mode - skipping main config update'); } else { - const mainConfigResult = updateOpenCodeMainConfig({ + const updateMainConfig = + dependencies.updateOpenCodeMainConfig ?? updateOpenCodeMainConfig; + const mainConfigResult = updateMainConfig({ ensurePlugin: true, + pluginVersion, disableDefaults: true, }); if (!handleStepResult(mainConfigResult, 'Main config updated')) return 1; @@ -337,7 +373,14 @@ async function runInstall( return 1; printStep(step++, totalSteps, 'Configuring provider-owned thoth-mem...'); - if (!installThothMemForHarness('opencode', config.dryRun, dependencies)) { + if ( + !finalizeInstallForHarness( + 'opencode', + config.dryRun, + pluginVersion, + dependencies, + ) + ) { return 1; } @@ -379,6 +422,17 @@ export async function install( dependencies: InstallDependencies = {}, ): Promise { const config = createInstallConfig(args); + const resolvePackageVersion = + dependencies.resolveExecutingPackageVersion ?? + resolveExecutingPackageVersion; + const packageVersion = resolvePackageVersion(); + if (!packageVersion.ok) { + printError( + `Could not resolve the executing thoth-agents package version: ${packageVersion.error.message}`, + ); + return 1; + } + if (config.agent === 'codex') { const projectRoot = cwd(); const pluginPlan = buildCodexPluginSetupPlan({ @@ -398,7 +452,7 @@ export async function install( reset: config.reset, scope: 'user', projectRoot, - homeDir: homedir(), + homeDir: dependencies.homeDir ?? homedir(), }); console.log(formatCodexSetupPlan(plan)); const result = applyCodexSetup(plan); @@ -407,8 +461,22 @@ export async function install( printError(`Codex install failed: ${result.error}`); return 1; } - if (!installRequiredSkillsForHarness('codex', config.dryRun)) return 1; - if (!installThothMemForHarness('codex', config.dryRun, dependencies)) { + if ( + !installRequiredSkillsForHarness( + 'codex', + config.dryRun, + dependencies.homeDir ?? homedir(), + ) + ) + return 1; + if ( + !finalizeInstallForHarness( + 'codex', + config.dryRun, + packageVersion.version, + dependencies, + ) + ) { return 1; } printSuccess( @@ -432,8 +500,22 @@ export async function install( printError(`Claude Code install failed: ${result.error}`); return 1; } - if (!installRequiredSkillsForHarness('claude', config.dryRun)) return 1; - if (!installThothMemForHarness('claude', config.dryRun, dependencies)) { + if ( + !installRequiredSkillsForHarness( + 'claude', + config.dryRun, + dependencies.homeDir ?? homedir(), + ) + ) + return 1; + if ( + !finalizeInstallForHarness( + 'claude', + config.dryRun, + packageVersion.version, + dependencies, + ) + ) { return 1; } printSuccess( @@ -443,5 +525,5 @@ export async function install( ); return 0; } - return runInstall(config, dependencies); + return runInstall(config, dependencies, packageVersion.version); } diff --git a/src/cli/operations/claude-code.test.ts b/src/cli/operations/claude-code.test.ts index 5529de4..b6f859d 100644 --- a/src/cli/operations/claude-code.test.ts +++ b/src/cli/operations/claude-code.test.ts @@ -10,10 +10,18 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { ProviderEvidenceInput } from '../../harness/types'; import type { ClaudeCommandExecutor } from '../claude-code-install'; +import { finalizeHarnessInstall } from '../install-completion'; +import { + getInstallLedgerPath, + readInstallLedger, + recordCompletedInstall, +} from '../install-ledger'; +import { resolveExecutingPackageVersion } from '../package-version'; import { applyClaudeCodePlan, buildClaudeCodeInstallPlan, buildClaudeCodeModelPlan, + buildClaudeCodeSyncPlan, buildClaudeCodeUpdatePlan, claudeCodeOperationAdapter, defaultClaudeCodeModelRoles, @@ -40,6 +48,24 @@ interface ManagerState { let home: string; let manager: ManagerState; +function claudeProviderResult() { + return { + success: true, + evidenceValid: true, + status: 'complete' as const, + changed: true, + harness: 'claude' as const, + target: 'C:/provider/claude', + steps: [{ name: 'Provider setup', outcome: 'complete' as const }], + diagnostics: ['provider complete'], + manualActions: [], + receipt: null, + command: 'npx', + args: ['thoth-mem@latest'], + exitCode: 0, + }; +} + function commandExecutor(state: ManagerState): ClaudeCommandExecutor { return (_command, args) => { const key = args.join(' '); @@ -97,9 +123,12 @@ function commandExecutor(state: ManagerState): ClaudeCommandExecutor { function context() { return { cwd: process.cwd(), + env: {}, scope: 'user' as const, homeDir: home, commandExecutor: commandExecutor(manager), + runThothMemSetup: () => claudeProviderResult(), + installLedgerOptions: { homeDir: home, env: {} }, }; } @@ -136,6 +165,172 @@ afterEach(() => { }); describe('claudeCodeOperationAdapter', () => { + function completeProviderResult() { + return claudeProviderResult(); + } + + test.each([ + ['install', buildClaudeCodeInstallPlan], + ['update', buildClaudeCodeUpdatePlan], + ] as const)('%s keeps native refresh before skills, provider, and ledger', (action, buildPlan) => { + const effects: string[] = []; + const nativeExecutor = commandExecutor(manager); + const trackedExecutor: ClaudeCommandExecutor = (command, args, options) => { + const key = args.join(' '); + if (!key.includes(' list ')) effects.push(`native:${key}`); + return nativeExecutor(command, args, options); + }; + const installSkill = vi.fn((skill: { name: string }) => { + effects.push(`external:${skill.name}`); + return { + status: 'installed' as const, + skillPath: join(home, '.claude', 'skills', skill.name), + }; + }); + const finalize = vi.fn((options) => { + effects.push('provider-ledger'); + return finalizeHarnessInstall(options); + }); + const operationContext = { + ...context(), + commandExecutor: trackedExecutor, + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version: '0.4.8', + packageRoot: process.cwd(), + }), + installRequiredSkill: installSkill, + finalizeHarnessInstall: finalize, + installLedgerOptions: { homeDir: home, env: {} }, + }; + + const plan = buildPlan(operationContext); + const titles = plan.items.map(({ title }) => title); + + expect(plan.action).toBe(action); + expect(titles).toEqual( + expect.arrayContaining([ + 'Install required external skills for Claude Code', + 'Plan provider-owned thoth-mem setup for Claude Code', + 'Record completed Claude Code CLI install', + ]), + ); + expect(effects).toEqual([]); + expect(installSkill).not.toHaveBeenCalled(); + expect(finalize).not.toHaveBeenCalled(); + + const result = applyClaudeCodePlan(plan); + + expect(result.applied).toBe(true); + expect(effects).toEqual([ + 'native:plugin marketplace add EremesNG/thoth-agents --scope user', + 'native:plugin install thoth-agents@thoth-agents --scope user', + ...[ + 'simplify', + 'tdd', + 'progressive-context-router', + 'architectural-grilling', + ].map((name) => `external:${name}`), + 'provider-ledger', + ]); + expect(readInstallLedger({ homeDir: home, env: {} })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { claude: { version: '0.4.8' } } }, + }); + }); + + test('reports incomplete provider finalization after Claude native and skill success', () => { + const finalize = vi.fn(() => ({ + success: false, + provider: { + ...completeProviderResult(), + success: false, + status: 'partial' as const, + diagnostics: ['provider partial'], + exitCode: 2, + }, + ledger: { + status: 'not-attempted' as const, + path: getInstallLedgerPath({ homeDir: home, env: {} }), + }, + error: 'provider incomplete', + })); + const operationContext = { + ...context(), + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version: '0.4.8', + packageRoot: process.cwd(), + }), + finalizeHarnessInstall: finalize, + installRequiredSkill: vi.fn(() => ({ status: 'installed' as const })), + installLedgerOptions: { homeDir: home, env: {} }, + }; + + const result = applyClaudeCodePlan( + buildClaudeCodeUpdatePlan(operationContext), + ); + + expect(result.applied).toBe(false); + expect(result.summary).toContain('provider incomplete'); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'provider partial' }), + ]), + ); + }); + + test('sync remains narrower and does not advance the Claude CLI ledger', () => { + manager.marketplace = true; + manager.plugin = true; + manager.enabled = true; + + expect(buildClaudeCodeInstallPlan(context()).items[0]?.title).toBe( + 'Verify Claude Code native marketplace and plugin state', + ); + + const result = applyClaudeCodePlan(buildClaudeCodeSyncPlan(context())); + + expect(result.applied).toBe(true); + expect(readInstallLedger({ homeDir: home, env: {} }).status).toBe( + 'missing', + ); + }); + + test('keeps the CLI-managed version authoritative across native marketplace changes', () => { + const executing = resolveExecutingPackageVersion(); + expect(executing.ok).toBe(true); + if (!executing.ok) return; + const recordedVersion = executing.version === '0.4.7' ? '0.4.6' : '0.4.7'; + expect( + recordCompletedInstall({ + harness: 'claude', + version: recordedVersion, + homeDir: home, + env: {}, + }).success, + ).toBe(true); + const versionTarget = () => + getClaudeCodeStatus(context()).targets.find( + ({ label }) => label === 'CLI-managed install version', + ); + + expect(versionTarget()).toMatchObject({ + state: 'outdated', + expected: `executing ${executing.version}`, + observed: `recorded ${recordedVersion}`, + }); + + manager.marketplace = true; + manager.plugin = true; + manager.enabled = true; + expect(getClaudeCodeStatus(context()).state).toBe('missing'); + expect(versionTarget()).toMatchObject({ + state: 'outdated', + observed: `recorded ${recordedVersion}`, + }); + }); + test('exposes native plugin operations and disables cache model rewrites', () => { expect(claudeCodeOperationAdapter.id).toBe('claude'); expect(claudeCodeOperationAdapter.available).toBe(true); diff --git a/src/cli/operations/claude-code.ts b/src/cli/operations/claude-code.ts index 5298e84..6aabc9a 100644 --- a/src/cli/operations/claude-code.ts +++ b/src/cli/operations/claude-code.ts @@ -12,12 +12,25 @@ import { isClaudeCodeModelAlias, } from '../claude-code-install'; import type { ClaudeCodeInstallScope } from '../claude-code-paths'; +import { + type FinalizeHarnessInstallOptions, + finalizeHarnessInstall, +} from '../install-completion'; +import { + getInstallLedgerPath, + type InstallLedgerOptions, +} from '../install-ledger'; +import { + type ExecutingPackageVersionResult, + resolveExecutingPackageVersion, +} from '../package-version'; import { getRequiredSkillInstallCommand, getRequiredSkillPath, installRequiredSkill, REQUIRED_SKILLS, } from '../skills'; +import { getThothMemSetupCommand } from '../thoth-mem-install'; import type { HarnessAction, HarnessOperationAdapter, @@ -33,19 +46,36 @@ import type { OperationPlanItem, OperationWarning, } from './types'; -import { classifyProviderCapabilityEvidence } from './types'; +import { + classifyProviderCapabilityEvidence, + getCliManagedInstallVersionTarget, + getInstallCompletionEvidence, +} from './types'; export interface ClaudeCodeOperationContext extends OperationContext { scope?: ClaudeCodeInstallScope; homeDir?: string; packageRoot?: string; commandExecutor?: ClaudeCommandExecutor; + resolveExecutingPackageVersion?: () => ExecutingPackageVersionResult; + buildClaudeCodeSetupPlan?: typeof buildClaudeCodeSetupPlan; + applyClaudeCodeSetup?: typeof applyClaudeCodeSetup; + installRequiredSkill?: typeof installRequiredSkill; + finalizeHarnessInstall?: ( + options: FinalizeHarnessInstallOptions, + ) => ReturnType; + runThothMemSetup?: FinalizeHarnessInstallOptions['runThothMemSetup']; + installLedgerOptions?: InstallLedgerOptions; } const CLAUDE_CODE_DISPLAY_NAME = 'Claude Code'; const claudeCodePlanSources = new WeakMap< OperationPlan, - { setupPlan: ClaudeCodeSetupPlan; context: ClaudeCodeOperationContext } + { + setupPlan: ClaudeCodeSetupPlan; + context: ClaudeCodeOperationContext; + version?: string; + } >(); const CLAUDE_CODE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); @@ -367,10 +397,16 @@ export function getClaudeCodeStatus( evidence: ProviderEvidenceInput = {}, ): HarnessStatusReport { const providerCapability = classifyProviderCapabilityEvidence(evidence); + const installVersionTarget = getCliManagedInstallVersionTarget('claude', { + env: context.env, + homeDir: context.homeDir, + }); try { const plan = buildClaudeCodeSetupPlan(claudeCodeConfig(context, true)); + const status = statusFromSetupPlan(plan, context); return { - ...statusFromSetupPlan(plan, context), + ...status, + targets: [...status.targets, installVersionTarget], providerCapability, }; } catch (error) { @@ -380,7 +416,7 @@ export function getClaudeCodeStatus( displayName: CLAUDE_CODE_DISPLAY_NAME, state: 'unknown', summary: `Claude Code setup plan could not be built: ${message}`, - targets: [], + targets: [installVersionTarget], diagnostics: [ { severity: 'critical', @@ -404,6 +440,17 @@ function planItemFromSetup(item: ClaudeCodeSetupPlanItem): OperationPlanItem { }; } +function claudeLedgerOptions( + context: ClaudeCodeOperationContext, +): InstallLedgerOptions { + return ( + context.installLedgerOptions ?? { + env: context.env, + homeDir: context.homeDir, + } + ); +} + function planFromSetup( id: string, action: OperationPlan['action'], @@ -411,11 +458,51 @@ function planFromSetup( summary: string, setupPlan: ClaudeCodeSetupPlan, context: ClaudeCodeOperationContext, + version?: string, ): OperationPlan { const status = statusFromSetupPlan(setupPlan, context); const missingSkills = status.targets.some( (target) => target.kind === 'skill' && target.state === 'missing', ); + const providerCommand = getThothMemSetupCommand('claude', true); + const nativeSetupItems: OperationPlanItem[] = + setupPlan.items.length > 0 + ? setupPlan.items.map(planItemFromSetup) + : [ + { + title: 'Verify Claude Code native marketplace and plugin state', + target: { + kind: 'package', + path: setupPlan.pluginRoot, + label: 'Claude Code native thoth-agents plugin', + }, + preview: + 'Inspect and verify the canonical marketplace and enabled native plugin.', + backup: { required: false, strategy: 'external' }, + }, + ]; + const completionItems: OperationPlanItem[] = version + ? [ + { + title: 'Plan provider-owned thoth-mem setup for Claude Code', + target: { + kind: 'surface', + label: 'Provider-owned thoth-mem setup', + }, + preview: `${providerCommand.command} ${providerCommand.args.join(' ')}`, + }, + { + title: 'Record completed Claude Code CLI install', + target: { + kind: 'file', + path: getInstallLedgerPath(claudeLedgerOptions(context)), + label: 'CLI-managed install version', + expected: `recorded ${version}`, + }, + preview: JSON.stringify({ harness: 'claude', version }), + }, + ] + : []; const plan: OperationPlan = { id, harness: 'claude', @@ -443,8 +530,9 @@ function planFromSetup( 'Claude Code owns its marketplace snapshots and plugin cache.', }, items: [ - ...setupPlan.items.map(planItemFromSetup), + ...nativeSetupItems, claudeCodeRequiredSkillPlanItem(), + ...completionItems, ], warnings: status.diagnostics, disclaimers: [ @@ -452,45 +540,67 @@ function planFromSetup( ...setupPlan.disclaimers.map((message) => ({ message })), ], }; - claudeCodePlanSources.set(plan, { setupPlan, context }); + claudeCodePlanSources.set(plan, { + setupPlan, + context, + ...(version ? { version } : {}), + }); return plan; } -export function buildClaudeCodeInstallPlan( - context: ClaudeCodeOperationContext = { cwd: process.cwd() }, +function buildCompleteClaudeCodePlan( + action: 'install' | 'update', + context: ClaudeCodeOperationContext, ): OperationPlan { - return planFromSetup( - 'claude-code-install-preview', - 'install', - 'Install Claude Code native plugin', - 'Preview native marketplace registration, plugin installation, and required skills.', - buildClaudeCodeSetupPlan(claudeCodeConfig(context, true)), + const resolveVersion = + context.resolveExecutingPackageVersion ?? resolveExecutingPackageVersion; + const packageVersion = resolveVersion(); + const buildSetup = + context.buildClaudeCodeSetupPlan ?? buildClaudeCodeSetupPlan; + const plan = planFromSetup( + `claude-code-${action}-preview`, + action, + `${action === 'install' ? 'Install' : 'Update'} complete Claude Code setup`, + 'Preview native marketplace/plugin refresh, required skills, provider setup, and CLI ledger commit.', + buildSetup(claudeCodeConfig(context, true, action === 'update')), context, + packageVersion.ok ? packageVersion.version : undefined, ); + if (!packageVersion.ok) { + plan.canApply = false; + plan.warnings.push( + warning( + packageVersion.error.message, + 'claude-code-package-version-unresolved', + ), + ); + } + return plan; +} + +export function buildClaudeCodeInstallPlan( + context: ClaudeCodeOperationContext = { cwd: process.cwd() }, +): OperationPlan { + return buildCompleteClaudeCodePlan('install', context); } export function buildClaudeCodeUpdatePlan( context: ClaudeCodeOperationContext = { cwd: process.cwd() }, ): OperationPlan { - return planFromSetup( - 'claude-code-update-preview', - 'update', - 'Reconcile Claude Code native plugin', - 'Preview native marketplace, enabled-plugin, and required-skill reconciliation.', - buildClaudeCodeSetupPlan(claudeCodeConfig(context, true, true)), - context, - ); + return buildCompleteClaudeCodePlan('update', context); } export function buildClaudeCodeSyncPlan( context: ClaudeCodeOperationContext = { cwd: process.cwd() }, ): OperationPlan { + const buildSetup = + context.buildClaudeCodeSetupPlan ?? buildClaudeCodeSetupPlan; return planFromSetup( 'claude-code-sync-preview', 'sync', 'Sync Claude Code native plugin', 'Preview native marketplace, enabled-plugin, and required-skill reconciliation.', - buildClaudeCodeSetupPlan(claudeCodeConfig(context, true)), + buildSetup(claudeCodeConfig(context, true)), context, ); } @@ -589,12 +699,31 @@ export function applyClaudeCodePlan(plan: OperationPlan): OperationApplyResult { ); } - const result = applyClaudeCodeSetup({ ...source.setupPlan, dryRun: false }); + if (source.version) { + const resolveVersion = + source.context.resolveExecutingPackageVersion ?? + resolveExecutingPackageVersion; + const currentVersion = resolveVersion(); + if (!currentVersion.ok || currentVersion.version !== source.version) { + return rejectPlan( + plan, + currentVersion.ok + ? `Approved package version changed from ${source.version} to ${currentVersion.version} before apply.` + : currentVersion.error.message, + ); + } + } + + const applySetup = + source.context.applyClaudeCodeSetup ?? applyClaudeCodeSetup; + const result = applySetup({ ...source.setupPlan, dryRun: false }); const requiredSkillWarnings: OperationWarning[] = []; const requiredSkillTargets: ManagedTarget[] = []; if (result.success) { + const installSkill = + source.context.installRequiredSkill ?? installRequiredSkill; for (const skill of REQUIRED_SKILLS) { - const installed = installRequiredSkill(skill, 'claude', { + const installed = installSkill(skill, 'claude', { homeDir: source.context.homeDir, }); const success = installed.status !== 'failed'; @@ -614,7 +743,7 @@ export function applyClaudeCodePlan(plan: OperationPlan): OperationApplyResult { } } } - const success = result.success && requiredSkillWarnings.length === 0; + let success = result.success && requiredSkillWarnings.length === 0; let summary = result.error ?? `Failed to apply Claude Code ${plan.action} plan.`; if (success) { @@ -623,34 +752,62 @@ export function applyClaudeCodePlan(plan: OperationPlan): OperationApplyResult { summary = 'Claude Code plugin was installed, but required skills failed to install.'; } + const changedTargets: ManagedTarget[] = [ + ...result.changed.map((path) => ({ + kind: 'package' as const, + path, + label: path.includes('/marketplaces/') + ? 'Claude Code marketplace' + : 'Claude Code plugin', + state: 'installed' as const, + })), + ...requiredSkillTargets, + ]; + const warnings: OperationWarning[] = [ + ...requiredSkillWarnings, + ...(result.success + ? [] + : [ + { + severity: 'critical' as const, + message: result.error ?? 'apply failed.', + }, + ]), + ]; + + if (success && source.version && plan.action !== 'sync') { + const finalize = + source.context.finalizeHarnessInstall ?? finalizeHarnessInstall; + const completion = finalize({ + harness: 'claude', + version: source.version, + dryRun: false, + cwd: source.context.cwd, + runThothMemSetup: source.context.runThothMemSetup, + ledgerOptions: claudeLedgerOptions(source.context), + }); + const completionEvidence = getInstallCompletionEvidence(completion, { + codePrefix: 'claude-code', + version: source.version, + fallbackError: 'Claude Code install finalization failed.', + }); + warnings.push(...completionEvidence.warnings); + changedTargets.push(...completionEvidence.targets); + if (completion.success) { + summary = `Applied complete Claude Code ${plan.action} plan.`; + } else { + success = false; + summary = completion.error ?? 'Claude Code install finalization failed.'; + } + } return { harness: 'claude', action: plan.action, applied: success, summary, - changedTargets: [ - ...result.changed.map((path) => ({ - kind: 'package' as const, - path, - label: path.includes('/marketplaces/') - ? 'Claude Code marketplace' - : 'Claude Code plugin', - state: 'installed' as const, - })), - ...requiredSkillTargets, - ], + changedTargets, backups: [], - warnings: [ - ...requiredSkillWarnings, - ...(result.success - ? [] - : [ - { - severity: 'critical' as const, - message: result.error ?? 'apply failed.', - }, - ]), - ], + warnings, disclaimers: claudeCodeDisclaimers(), }; } diff --git a/src/cli/operations/codex.test.ts b/src/cli/operations/codex.test.ts index 6625f4c..21d86a0 100644 --- a/src/cli/operations/codex.test.ts +++ b/src/cli/operations/codex.test.ts @@ -11,6 +11,10 @@ import { join } from 'node:path'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { ProviderEvidenceInput } from '../../harness/types'; import { applyCodexSetup, buildCodexSetupPlan } from '../codex-install'; +import type { CodexCommandExecutor } from '../codex-plugin-install'; +import { finalizeHarnessInstall } from '../install-completion'; +import { readInstallLedger, recordCompletedInstall } from '../install-ledger'; +import { resolveExecutingPackageVersion } from '../package-version'; import { applyCodexPlan, buildCodexInstallPlan, @@ -48,6 +52,52 @@ function writeRequiredSkills(home: string): void { } } +function codexProviderResult() { + return { + success: true, + evidenceValid: true, + status: 'complete' as const, + changed: true, + harness: 'codex' as const, + target: 'C:/provider/codex', + steps: [{ name: 'Provider setup', outcome: 'complete' as const }], + diagnostics: ['provider complete'], + manualActions: [], + receipt: null, + command: 'npx', + args: ['thoth-mem@latest'], + exitCode: 0, + }; +} + +const installedCodexManagerExecutor: CodexCommandExecutor = ( + _command, + args, +) => { + const key = args.join(' '); + if (key === 'plugin marketplace list --json') { + return { + exitCode: 0, + stdout: JSON.stringify({ + marketplaces: [ + { name: 'thoth-agents', source: 'EremesNG/thoth-agents' }, + ], + }), + stderr: '', + }; + } + if (key === 'plugin list --available --json') { + return { + exitCode: 0, + stdout: JSON.stringify({ + installed: [{ pluginId: 'thoth-agents@thoth-agents', enabled: true }], + }), + stderr: '', + }; + } + return { exitCode: 1, stdout: '', stderr: 'unexpected native mutation' }; +}; + beforeEach(() => { installRequiredSkillMock.mockReset(); installRequiredSkillMock.mockImplementation((skill, harness, options) => { @@ -61,8 +111,12 @@ beforeEach(() => { function context(dir: string, home: string) { return { cwd: dir, + env: {}, homeDir: home, packageRoot: PACKAGE_ROOT, + codexPluginCommandExecutor: installedCodexManagerExecutor, + runThothMemSetup: () => codexProviderResult(), + installLedgerOptions: { homeDir: home, env: {} }, }; } @@ -99,6 +153,221 @@ function roleModel(content: string): string | undefined { } describe('Codex operations adapter', () => { + test.each([ + ['install', buildCodexInstallPlan], + ['update', buildCodexUpdatePlan], + ] as const)('%s runs native manager before managed setup, skills, provider, and ledger', (action, buildPlan) => { + const dir = mkdtempSync(join(tmpdir(), 'thoth-codex-parity-')); + try { + const home = join(dir, 'home'); + const effects: string[] = []; + let marketplace = false; + let plugin = false; + const commandExecutor: CodexCommandExecutor = (_command, args) => { + const key = args.join(' '); + if (key === 'plugin marketplace list --json') { + return { + exitCode: 0, + stdout: JSON.stringify({ + marketplaces: marketplace + ? [ + { + name: 'thoth-agents', + source: 'EremesNG/thoth-agents', + }, + ] + : [], + }), + stderr: '', + }; + } + if (key === 'plugin list --available --json') { + return { + exitCode: 0, + stdout: JSON.stringify({ + installed: plugin + ? [ + { + pluginId: 'thoth-agents@thoth-agents', + enabled: true, + }, + ] + : [], + }), + stderr: '', + }; + } + effects.push( + key.startsWith('plugin marketplace add') + ? 'native-marketplace' + : 'native-plugin', + ); + if (key.startsWith('plugin marketplace add')) marketplace = true; + if (key.startsWith('plugin add')) plugin = true; + return { exitCode: 0, stdout: '{}', stderr: '' }; + }; + const applyManagedSetup = vi.fn(() => { + effects.push('agent-pack'); + return { success: true, changed: [], diagnostics: [] }; + }); + const installSkill = vi.fn((skill: { name: string }) => { + effects.push(`external:${skill.name}`); + return { + status: 'installed' as const, + skillPath: join(home, '.agents', 'skills', skill.name), + }; + }); + const finalize = vi.fn((options) => { + effects.push('provider-ledger'); + return finalizeHarnessInstall(options); + }); + const operationContext = { + ...context(dir, home), + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version: '0.4.8', + packageRoot: process.cwd(), + }), + codexPluginCommandExecutor: commandExecutor, + applyCodexSetup: applyManagedSetup, + installRequiredSkill: installSkill, + finalizeHarnessInstall: finalize, + installLedgerOptions: { homeDir: home, env: {} }, + }; + + const plan = buildPlan(operationContext); + const titles = plan.items.map(({ title }) => title); + + expect(plan.action).toBe(action); + expect(titles[0]).toContain('marketplace'); + expect(titles).toEqual( + expect.arrayContaining([ + 'Install required external skills for Codex', + 'Plan provider-owned thoth-mem setup for Codex', + 'Record completed Codex CLI install', + ]), + ); + expect(effects).toEqual([]); + expect(applyManagedSetup).not.toHaveBeenCalled(); + expect(installSkill).not.toHaveBeenCalled(); + expect(finalize).not.toHaveBeenCalled(); + + const result = applyCodexPlan(plan); + + expect(result.applied).toBe(true); + expect(effects).toEqual([ + 'native-marketplace', + 'native-plugin', + 'agent-pack', + ...[ + 'simplify', + 'tdd', + 'progressive-context-router', + 'architectural-grilling', + ].map((name) => `external:${name}`), + 'provider-ledger', + ]); + expect(readInstallLedger({ homeDir: home, env: {} })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { codex: { version: '0.4.8' } } }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('fails before Codex managed files when native manager apply fails', () => { + const dir = mkdtempSync(join(tmpdir(), 'thoth-codex-native-fail-')); + try { + const home = join(dir, 'home'); + const commandExecutor: CodexCommandExecutor = (_command, args) => { + const key = args.join(' '); + if (key.includes(' list')) { + return { + exitCode: 0, + stdout: key.startsWith('plugin marketplace') + ? '{"marketplaces":[]}' + : '{"installed":[]}', + stderr: '', + }; + } + return { exitCode: 1, stdout: '', stderr: 'native failure' }; + }; + const applyManagedSetup = vi.fn(); + const finalize = vi.fn(); + const operationContext = { + ...context(dir, home), + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version: '0.4.8', + packageRoot: process.cwd(), + }), + codexPluginCommandExecutor: commandExecutor, + applyCodexSetup: applyManagedSetup, + finalizeHarnessInstall: finalize, + }; + + const result = applyCodexPlan(buildCodexUpdatePlan(operationContext)); + + expect(result.applied).toBe(false); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('native failure'), + }), + ]), + ); + expect(applyManagedSetup).not.toHaveBeenCalled(); + expect(finalize).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('reports the authoritative CLI-managed version independently of native marketplace state', () => { + const dir = mkdtempSync(join(tmpdir(), 'thoth-codex-ledger-status-')); + try { + const home = join(dir, 'home'); + const executing = resolveExecutingPackageVersion(); + expect(executing.ok).toBe(true); + if (!executing.ok) return; + const versionTarget = () => + getCodexStatus(context(dir, home)).targets.find( + ({ label }) => label === 'CLI-managed install version', + ); + + expect(versionTarget()).toMatchObject({ + state: 'missing', + expected: `executing ${executing.version}`, + observed: 'recorded missing', + }); + + const recordedVersion = executing.version === '0.4.7' ? '0.4.6' : '0.4.7'; + expect( + recordCompletedInstall({ + harness: 'codex', + version: recordedVersion, + homeDir: home, + env: {}, + }).success, + ).toBe(true); + expect(versionTarget()).toMatchObject({ + state: 'outdated', + expected: `executing ${executing.version}`, + observed: `recorded ${recordedVersion}`, + }); + + setup(dir, home); + expect(getCodexStatus(context(dir, home)).state).toBe('installed'); + expect(versionTarget()).toMatchObject({ + state: 'outdated', + observed: `recorded ${recordedVersion}`, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test('resolves only exact catalog efforts in the documented Codex surface', () => { const base = { role: 'deep', @@ -207,6 +476,11 @@ describe('Codex operations adapter', () => { ), ).toBe(true); expect(existsSync(join(home, '.codex'))).toBe(false); + + expect(applyCodexPlan(sync).applied).toBe(true); + expect(readInstallLedger({ homeDir: home, env: {} }).status).toBe( + 'missing', + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -223,6 +497,9 @@ describe('Codex operations adapter', () => { expect(plan.dryRun).toBe(true); expect(plan.canApply).toBe(true); expect(plan.title).toContain('Install'); + expect(plan.items[0]?.title).toBe( + 'Verify Codex native plugin manager state', + ); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/src/cli/operations/codex.ts b/src/cli/operations/codex.ts index ab5d436..eb705a1 100644 --- a/src/cli/operations/codex.ts +++ b/src/cli/operations/codex.ts @@ -14,12 +14,31 @@ import { parseRoleTomlModel, } from '../codex-install'; import type { CodexInstallScope, CodexRoleName } from '../codex-paths'; +import { + applyCodexPluginSetup, + buildCodexPluginSetupPlan, + type CodexCommandExecutor, + type CodexPluginSetupPlan, +} from '../codex-plugin-install'; +import { + type FinalizeHarnessInstallOptions, + finalizeHarnessInstall, +} from '../install-completion'; +import { + getInstallLedgerPath, + type InstallLedgerOptions, +} from '../install-ledger'; +import { + type ExecutingPackageVersionResult, + resolveExecutingPackageVersion, +} from '../package-version'; import { getRequiredSkillInstallCommand, getRequiredSkillPath, installRequiredSkill, REQUIRED_SKILLS, } from '../skills'; +import { getThothMemSetupCommand } from '../thoth-mem-install'; import type { BackupExpectation, HarnessAction, @@ -36,7 +55,11 @@ import type { OperationPlanItem, OperationWarning, } from './types'; -import { classifyProviderCapabilityEvidence } from './types'; +import { + classifyProviderCapabilityEvidence, + getCliManagedInstallVersionTarget, + getInstallCompletionEvidence, +} from './types'; export interface CodexOperationContext extends OperationContext { scope?: CodexInstallScope; @@ -44,12 +67,29 @@ export interface CodexOperationContext extends OperationContext { codexHome?: string; packageRoot?: string; pluginId?: string; + resolveExecutingPackageVersion?: () => ExecutingPackageVersionResult; + codexPluginCommandExecutor?: CodexCommandExecutor; + buildCodexPluginSetupPlan?: typeof buildCodexPluginSetupPlan; + applyCodexPluginSetup?: typeof applyCodexPluginSetup; + buildCodexSetupPlan?: typeof buildCodexSetupPlan; + applyCodexSetup?: typeof applyCodexSetup; + installRequiredSkill?: typeof installRequiredSkill; + finalizeHarnessInstall?: ( + options: FinalizeHarnessInstallOptions, + ) => ReturnType; + runThothMemSetup?: FinalizeHarnessInstallOptions['runThothMemSetup']; + installLedgerOptions?: InstallLedgerOptions; } const CODEX_DISPLAY_NAME = 'Codex'; const codexPlanSources = new WeakMap< OperationPlan, - { setupPlan: CodexSetupPlan; context: CodexOperationContext } + { + setupPlan: CodexSetupPlan; + pluginPlan?: CodexPluginSetupPlan; + context: CodexOperationContext; + version?: string; + } >(); const codexModelSources = new WeakMap< OperationPlan, @@ -383,6 +423,10 @@ export function getCodexStatus( evidence: ProviderEvidenceInput = {}, ): HarnessStatusReport { const providerCapability = classifyProviderCapabilityEvidence(evidence); + const installVersionTarget = getCliManagedInstallVersionTarget('codex', { + env: context.env, + homeDir: context.homeDir, + }); let plan: CodexSetupPlan; try { plan = buildCodexSetupPlan(codexConfig(context, true)); @@ -393,7 +437,7 @@ export function getCodexStatus( displayName: CODEX_DISPLAY_NAME, state: 'unknown', summary: `Codex setup plan could not be built: ${message}`, - targets: [], + targets: [installVersionTarget], diagnostics: [ { severity: 'critical', @@ -432,6 +476,7 @@ export function getCodexStatus( targetForItem(item, state, observed), ), ...requiredSkills.targets, + installVersionTarget, ], diagnostics, actions: codexActions, @@ -452,6 +497,32 @@ function planItemFromSetup(item: CodexSetupPlanItem): OperationPlanItem { }; } +function planItemFromPluginSetup( + item: CodexPluginSetupPlan['items'][number], +): OperationPlanItem { + return { + title: item.description, + target: { + kind: 'package', + path: item.targetPath, + label: 'Codex native plugin manager target', + }, + preview: `${item.command.executable} ${item.command.args.join(' ')}`, + backup: { required: false, strategy: 'external' }, + }; +} + +function codexLedgerOptions( + context: CodexOperationContext, +): InstallLedgerOptions { + return ( + context.installLedgerOptions ?? { + env: context.env, + homeDir: context.homeDir, + } + ); +} + function planFromSetup( id: string, action: OperationPlan['action'], @@ -459,12 +530,56 @@ function planFromSetup( summary: string, setupPlan: CodexSetupPlan, context: CodexOperationContext, + completion?: { pluginPlan: CodexPluginSetupPlan; version: string }, ): OperationPlan { const status = getCodexStatus(context); const canApply = - status.state === 'installed' || - status.state === 'missing' || - status.state === 'outdated'; + (status.state === 'installed' || + status.state === 'missing' || + status.state === 'outdated') && + (completion?.pluginPlan.ready ?? true); + const providerCommand = getThothMemSetupCommand('codex', true); + const nativePluginItems: OperationPlanItem[] = completion + ? completion.pluginPlan.items.length > 0 + ? completion.pluginPlan.items.map(planItemFromPluginSetup) + : [ + { + title: 'Verify Codex native plugin manager state', + target: { + kind: 'package', + label: 'Codex native thoth-agents plugin', + }, + preview: + 'Inspect and post-verify the canonical thoth-agents marketplace and enabled plugin.', + backup: { required: false, strategy: 'external' }, + }, + ] + : []; + const completionItems: OperationPlanItem[] = completion + ? [ + { + title: 'Plan provider-owned thoth-mem setup for Codex', + target: { + kind: 'surface', + label: 'Provider-owned thoth-mem setup', + }, + preview: `${providerCommand.command} ${providerCommand.args.join(' ')}`, + }, + { + title: 'Record completed Codex CLI install', + target: { + kind: 'file', + path: getInstallLedgerPath(codexLedgerOptions(context)), + label: 'CLI-managed install version', + expected: `recorded ${completion.version}`, + }, + preview: JSON.stringify({ + harness: 'codex', + version: completion.version, + }), + }, + ] + : []; const plan: OperationPlan = { id, harness: 'codex', @@ -484,8 +599,10 @@ function planFromSetup( 'Codex setup apply uses the existing installer backup behavior for files that already exist.', }, items: [ + ...nativePluginItems, ...setupPlan.items.map(planItemFromSetup), codexRequiredSkillPlanItem(), + ...completionItems, ], warnings: [ ...status.diagnostics, @@ -503,28 +620,75 @@ function planFromSetup( ...setupPlan.disclaimers.map((message) => ({ message })), ], }; - codexPlanSources.set(plan, { setupPlan, context }); + codexPlanSources.set(plan, { + setupPlan, + context, + ...(completion + ? { + pluginPlan: completion.pluginPlan, + version: completion.version, + } + : {}), + }); return plan; } -export function buildCodexUpdatePlan( - context: CodexOperationContext = { cwd: process.cwd() }, +function buildCompleteCodexPlan( + action: 'install' | 'update', + context: CodexOperationContext, ): OperationPlan { - const setupPlan = buildCodexSetupPlan(codexConfig(context, true)); - return planFromSetup( - 'codex-update-preview', - 'update', - 'Update Codex managed setup', - 'Preview Codex managed setup refresh using buildCodexSetupPlan().', + const resolveVersion = + context.resolveExecutingPackageVersion ?? resolveExecutingPackageVersion; + const packageVersion = resolveVersion(); + const buildPlugin = + context.buildCodexPluginSetupPlan ?? buildCodexPluginSetupPlan; + const pluginPlan = buildPlugin({ + dryRun: true, + projectRoot: context.cwd, + commandExecutor: context.codexPluginCommandExecutor, + }); + const buildSetup = context.buildCodexSetupPlan ?? buildCodexSetupPlan; + const setupPlan = buildSetup(codexConfig(context, true)); + const plan = planFromSetup( + `codex-${action}-preview`, + action, + `${action === 'install' ? 'Install' : 'Update'} complete Codex setup`, + `Preview Codex native plugin, global agent pack, required skills, provider setup, and CLI ledger ${action}.`, setupPlan, context, + packageVersion.ok + ? { pluginPlan, version: packageVersion.version } + : undefined, ); + if (!packageVersion.ok) { + plan.canApply = false; + plan.warnings.push( + warning(packageVersion.error.message, 'codex-package-version-unresolved'), + ); + } + if (!pluginPlan.ready) { + plan.canApply = false; + plan.warnings.push( + warning( + 'Codex native plugin manager state is not safe to mutate.', + 'codex-native-plugin-not-ready', + ), + ); + } + return plan; +} + +export function buildCodexUpdatePlan( + context: CodexOperationContext = { cwd: process.cwd() }, +): OperationPlan { + return buildCompleteCodexPlan('update', context); } export function buildCodexSyncPlan( context: CodexOperationContext = { cwd: process.cwd() }, ): OperationPlan { - const setupPlan = buildCodexSetupPlan(codexConfig(context, true)); + const buildSetup = context.buildCodexSetupPlan ?? buildCodexSetupPlan; + const setupPlan = buildSetup(codexConfig(context, true)); return planFromSetup( 'codex-sync-preview', 'sync', @@ -538,15 +702,7 @@ export function buildCodexSyncPlan( export function buildCodexInstallPlan( context: CodexOperationContext = { cwd: process.cwd() }, ): OperationPlan { - const setupPlan = buildCodexSetupPlan(codexConfig(context, true)); - return planFromSetup( - 'codex-install-preview', - 'install', - 'Install Codex managed setup', - 'Preview Codex managed agent-pack setup using buildCodexSetupPlan().', - setupPlan, - context, - ); + return buildCompleteCodexPlan('install', context); } function normalizeCodexModel(input: ModelRoleInput): string { @@ -804,12 +960,74 @@ export function applyCodexPlan(plan: OperationPlan): OperationApplyResult { 'Codex setup plan was not produced by a Codex operation plan builder in this process.', ); } - const result = applyCodexSetup({ ...source.setupPlan, dryRun: false }); + if (source.version) { + const resolveVersion = + source.context.resolveExecutingPackageVersion ?? + resolveExecutingPackageVersion; + const currentVersion = resolveVersion(); + if (!currentVersion.ok || currentVersion.version !== source.version) { + return rejectPlan( + plan, + currentVersion.ok + ? `Approved package version changed from ${source.version} to ${currentVersion.version} before apply.` + : currentVersion.error.message, + ); + } + } + + const changedTargets: ManagedTarget[] = []; + const warnings: OperationWarning[] = []; + if (source.pluginPlan) { + const applyPlugin = + source.context.applyCodexPluginSetup ?? applyCodexPluginSetup; + const nativeResult = applyPlugin({ + ...source.pluginPlan, + dryRun: false, + }); + warnings.push( + ...nativeResult.diagnostics.map((message) => ({ + severity: 'minor' as const, + code: 'codex-native-plugin-diagnostic', + message, + })), + ); + changedTargets.push( + ...nativeResult.changed.map((path) => ({ + kind: 'package' as const, + path, + label: 'Codex native plugin manager target', + state: 'installed' as const, + })), + ); + if (!nativeResult.success) { + const message = nativeResult.error ?? 'Codex native plugin setup failed.'; + warnings.push({ + severity: 'critical', + code: 'codex-native-plugin-failed', + message, + }); + return { + harness: 'codex', + action: plan.action, + applied: false, + summary: message, + changedTargets, + backups: [], + warnings, + disclaimers: codexDisclaimers(), + }; + } + } + + const applySetup = source.context.applyCodexSetup ?? applyCodexSetup; + const result = applySetup({ ...source.setupPlan, dryRun: false }); const requiredSkillWarnings: OperationWarning[] = []; const requiredSkillTargets: ManagedTarget[] = []; if (result.success) { + const installSkill = + source.context.installRequiredSkill ?? installRequiredSkill; for (const skill of REQUIRED_SKILLS) { - const installed = installRequiredSkill(skill, 'codex', { + const installed = installSkill(skill, 'codex', { homeDir: source.context.homeDir, }); const success = installed.status !== 'failed'; @@ -829,41 +1047,67 @@ export function applyCodexPlan(plan: OperationPlan): OperationApplyResult { } } } - const success = result.success && requiredSkillWarnings.length === 0; + let success = result.success && requiredSkillWarnings.length === 0; + let summary = success + ? `Applied Codex managed ${plan.action} plan.` + : requiredSkillWarnings.length > 0 + ? `Codex setup was written, but required skills failed to install.` + : (result.error ?? `Failed to apply Codex ${plan.action} plan.`); + warnings.push(...requiredSkillWarnings); + if (!result.success) { + warnings.push({ + severity: 'critical', + message: result.error ?? 'Codex setup apply failed.', + }); + } + changedTargets.push( + ...result.changed.map((path) => ({ + kind: path.endsWith('.json') + ? ('memory-state' as const) + : ('generated-artifact' as const), + path, + label: basename(path), + state: 'installed' as const, + })), + ...requiredSkillTargets, + ); + + if (success && source.version && plan.action !== 'sync') { + const finalize = + source.context.finalizeHarnessInstall ?? finalizeHarnessInstall; + const completion = finalize({ + harness: 'codex', + version: source.version, + dryRun: false, + cwd: source.context.cwd, + runThothMemSetup: source.context.runThothMemSetup, + ledgerOptions: codexLedgerOptions(source.context), + }); + const completionEvidence = getInstallCompletionEvidence(completion, { + codePrefix: 'codex', + version: source.version, + fallbackError: 'Codex install finalization failed.', + }); + warnings.push(...completionEvidence.warnings); + changedTargets.push(...completionEvidence.targets); + if (completion.success) { + summary = `Applied complete Codex ${plan.action} plan.`; + } else { + success = false; + summary = completion.error ?? 'Codex install finalization failed.'; + } + } + return { harness: 'codex', action: plan.action, applied: success, - summary: success - ? `Applied Codex managed ${plan.action} plan.` - : requiredSkillWarnings.length > 0 - ? `Codex setup was written, but required skills failed to install.` - : (result.error ?? `Failed to apply Codex ${plan.action} plan.`), - changedTargets: [ - ...result.changed.map((path) => ({ - kind: path.endsWith('.json') - ? ('memory-state' as const) - : ('generated-artifact' as const), - path, - label: basename(path), - state: 'installed' as const, - })), - ...requiredSkillTargets, - ], + summary, + changedTargets, backups: result.changed .filter((path) => existsSync(`${path}.bak`)) .map((path) => ({ path: `${path}.bak`, label: 'managed backup' })), - warnings: [ - ...requiredSkillWarnings, - ...(result.success - ? [] - : [ - { - severity: 'critical' as const, - message: result.error ?? 'Codex setup apply failed.', - }, - ]), - ], + warnings, disclaimers: codexDisclaimers(), }; } diff --git a/src/cli/operations/opencode.test.ts b/src/cli/operations/opencode.test.ts index bdb894b..0a25650 100644 --- a/src/cli/operations/opencode.test.ts +++ b/src/cli/operations/opencode.test.ts @@ -11,6 +11,13 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ALL_AGENT_NAMES } from '../../config'; import { THOTH_OWNED_SKILL_NAMES } from '../../harness/core/owned-skills'; +import { finalizeHarnessInstall } from '../install-completion'; +import { + getInstallLedgerPath, + readInstallLedger, + recordCompletedInstall, +} from '../install-ledger'; +import { resolveExecutingPackageVersion } from '../package-version'; import { generateLiteConfig } from '../providers'; import { getOpenCodeModelRoles } from '../tui/operations'; @@ -61,6 +68,12 @@ import { getOpenCodeStatus, } from './opencode'; +const EXECUTING_PACKAGE = resolveExecutingPackageVersion(); +if (!EXECUTING_PACKAGE.ok) { + throw new Error(EXECUTING_PACKAGE.error.message); +} +const EXECUTING_PLUGIN = `thoth-agents@${EXECUTING_PACKAGE.version}`; + describe('OpenCode operations adapter v0.3', () => { let configRoot: string; let tempRoot: string; @@ -95,7 +108,12 @@ describe('OpenCode operations adapter v0.3', () => { const context = () => ({ cwd: configRoot, - env: { HOME: join(configRoot, 'home') }, + env: { + HOME: join(configRoot, 'home'), + XDG_CONFIG_HOME: tempRoot, + }, + runThothMemSetup: () => completeProviderResult(), + installLedgerOptions: { configRoot: tempRoot }, }); const mainConfigPath = () => join(configRoot, 'opencode.json'); @@ -115,7 +133,7 @@ describe('OpenCode operations adapter v0.3', () => { } function writeManagedConfig(): void { - writeJson(mainConfigPath(), { plugin: ['thoth-agents@latest'] }); + writeJson(mainConfigPath(), { plugin: [EXECUTING_PLUGIN] }); writeJson(liteConfigPath(), validLiteConfig()); } @@ -155,6 +173,246 @@ describe('OpenCode operations adapter v0.3', () => { ); } + function completeProviderResult() { + return { + success: true, + evidenceValid: true, + status: 'complete' as const, + changed: true, + harness: 'opencode' as const, + target: 'C:/provider/opencode', + steps: [{ name: 'Provider setup', outcome: 'complete' as const }], + diagnostics: ['provider complete'], + manualActions: [], + receipt: null, + command: 'npx', + args: ['thoth-mem@latest'], + exitCode: 0, + }; + } + + test.each([ + ['install', buildOpenCodeInstallPlan], + ['update', buildOpenCodeUpdatePlan], + ] as const)('%s preview is complete and apply preserves required effect order', (action, buildPlan) => { + const effects: string[] = []; + const updateMainConfig = vi.fn(() => { + effects.push('config'); + return { success: true, configPath: mainConfigPath() }; + }); + const writeLite = vi.fn(() => { + effects.push('lite-config'); + return { success: true, configPath: liteConfigPath() }; + }); + const syncOwnedSkills = vi.fn((options: { dryRun?: boolean }) => { + if (!options.dryRun) effects.push('owned-skills'); + return { + success: true, + status: options.dryRun ? ('planned' as const) : ('installed' as const), + skills: [ + { + name: 'thoth-sdd' as const, + sourcePath: 'C:/package/skills/thoth-sdd', + destinationPath: 'C:/home/skills/thoth-sdd', + }, + ], + }; + }); + const installSkill = vi.fn((skill: { name: string }) => { + effects.push(`external:${skill.name}`); + return { + status: 'installed' as const, + skillPath: `C:/skills/${skill.name}`, + }; + }); + const finalize = vi.fn((options) => { + effects.push('provider-ledger'); + return finalizeHarnessInstall(options); + }); + const operationContext = { + ...context(), + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version: '0.4.8', + packageRoot: process.cwd(), + }), + updateOpenCodeMainConfig: updateMainConfig, + writeLiteConfig: writeLite, + syncOpenCodeOwnedSkills: syncOwnedSkills, + installRequiredSkill: installSkill, + finalizeHarnessInstall: finalize, + installLedgerOptions: { configRoot: tempRoot }, + }; + + const plan = buildPlan(operationContext); + const titles = plan.items.map(({ title }) => title); + + expect(plan.action).toBe(action); + expect(titles).toEqual( + expect.arrayContaining([ + 'Ensure OpenCode plugin points at thoth-agents@0.4.8', + 'Disable OpenCode default agents', + 'Write thoth-agents seven-role config', + 'Synchronize global thoth-owned OpenCode skills', + 'Install required external skills', + 'Plan provider-owned thoth-mem setup', + 'Record completed OpenCode CLI install', + ]), + ); + expect(updateMainConfig).not.toHaveBeenCalled(); + expect(writeLite).not.toHaveBeenCalled(); + expect(installSkill).not.toHaveBeenCalled(); + expect(finalize).not.toHaveBeenCalled(); + + const result = applyOpenCodePlan(plan); + + expect(result.applied).toBe(true); + expect(updateMainConfig).toHaveBeenCalledWith({ + ensurePlugin: true, + pluginVersion: '0.4.8', + disableDefaults: true, + }); + expect(effects).toEqual([ + 'config', + 'lite-config', + 'owned-skills', + ...[ + 'simplify', + 'tdd', + 'progressive-context-router', + 'architectural-grilling', + ].map((name) => `external:${name}`), + 'provider-ledger', + ]); + expect(readInstallLedger({ configRoot: tempRoot })).toMatchObject({ + status: 'valid', + ledger: { harnesses: { opencode: { version: '0.4.8' } } }, + }); + }); + + test('rejects changed exact-version provenance before OpenCode mutation', () => { + let version = '0.4.8'; + const updateMainConfig = vi.fn(() => ({ + success: true, + configPath: mainConfigPath(), + })); + const operationContext = { + ...context(), + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version, + packageRoot: process.cwd(), + }), + updateOpenCodeMainConfig: updateMainConfig, + }; + const plan = buildOpenCodeUpdatePlan(operationContext); + version = '0.4.9'; + + const result = applyOpenCodePlan(plan); + + expect(result.applied).toBe(false); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'opencode-package-version-changed' }), + ]), + ); + expect(updateMainConfig).not.toHaveBeenCalled(); + }); + + test('reports provider failure and does not claim OpenCode update completion', () => { + const finalize = vi.fn(() => ({ + success: false, + provider: { + ...completeProviderResult(), + success: false, + status: 'partial' as const, + diagnostics: ['provider partial'], + manualActions: ['Run provider recovery.'], + receipt: 'C:/provider/partial.json', + exitCode: 2, + }, + ledger: { + status: 'not-attempted' as const, + path: getInstallLedgerPath({ configRoot: tempRoot }), + }, + error: 'provider incomplete', + })); + const operationContext = { + ...context(), + resolveExecutingPackageVersion: () => ({ + ok: true as const, + version: '0.4.8', + packageRoot: process.cwd(), + }), + finalizeHarnessInstall: finalize, + installLedgerOptions: { configRoot: tempRoot }, + }; + + const result = applyOpenCodePlan(buildOpenCodeUpdatePlan(operationContext)); + + expect(result.applied).toBe(false); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'provider partial' }), + expect.objectContaining({ message: 'Run provider recovery.' }), + ]), + ); + expect(result.summary).toContain('provider incomplete'); + }); + + test('reports matching, mismatched, missing, and invalid CLI-managed versions', () => { + const executing = resolveExecutingPackageVersion(); + expect(executing.ok).toBe(true); + if (!executing.ok) return; + const versionTarget = () => + getOpenCodeStatus(context()).targets.find( + ({ label }) => label === 'CLI-managed install version', + ); + const ledgerOptions = { configRoot: tempRoot }; + + expect(versionTarget()).toMatchObject({ + kind: 'file', + path: getInstallLedgerPath(ledgerOptions), + state: 'missing', + expected: `executing ${executing.version}`, + observed: 'recorded missing', + }); + + expect( + recordCompletedInstall({ + harness: 'opencode', + version: executing.version, + ...ledgerOptions, + }).success, + ).toBe(true); + expect(versionTarget()).toMatchObject({ + state: 'installed', + expected: `executing ${executing.version}`, + observed: `recorded ${executing.version}`, + }); + + const priorVersion = executing.version === '0.4.7' ? '0.4.6' : '0.4.7'; + expect( + recordCompletedInstall({ + harness: 'opencode', + version: priorVersion, + ...ledgerOptions, + }).success, + ).toBe(true); + expect(versionTarget()).toMatchObject({ + state: 'outdated', + expected: `executing ${executing.version}`, + observed: `recorded ${priorVersion}`, + }); + + writeFileSync(getInstallLedgerPath(ledgerOptions), '{ malformed'); + expect(versionTarget()).toMatchObject({ + state: 'unknown', + expected: `executing ${executing.version}`, + observed: 'recorded unknown (invalid ledger)', + }); + }); + test('classifies missing required skills as managed drift', () => { writeManagedConfig(); const status = getOpenCodeStatus(context()); @@ -303,6 +561,7 @@ describe('OpenCode operations adapter v0.3', () => { expect(result.applied).toBe(true); expect(written.preset).toBe('openai'); expect(Object.keys(written.presets.openai)).toEqual(ALL_AGENT_NAMES); + expect(readInstallLedger({ configRoot: tempRoot }).status).toBe('missing'); }); test('blocks completion when a required skill cannot be installed', () => { @@ -475,7 +734,7 @@ describe('OpenCode operations adapter v0.3', () => { }; delete legacy.presets.agents; writeJson(liteConfigPath(), legacy); - writeJson(mainConfigPath(), { plugin: ['thoth-agents@latest'] }); + writeJson(mainConfigPath(), { plugin: [EXECUTING_PLUGIN] }); const rootOnlyLegacy = getOpenCodeStatus(context()); expect(rootOnlyLegacy.state).toBe('drift'); expect(rootOnlyLegacy.diagnostics).toEqual( diff --git a/src/cli/operations/opencode.ts b/src/cli/operations/opencode.ts index 9443e0b..35ae379 100644 --- a/src/cli/operations/opencode.ts +++ b/src/cli/operations/opencode.ts @@ -4,11 +4,20 @@ import { join } from 'node:path'; import { ALL_AGENT_NAMES } from '../../config'; import type { ProviderEvidenceInput } from '../../harness/types'; import { + type OpenCodeMainConfigUpdate, parseConfig, updateOpenCodeMainConfig, writeConfig, writeLiteConfig, } from '../config-io'; +import { + type FinalizeHarnessInstallOptions, + finalizeHarnessInstall, +} from '../install-completion'; +import { + getInstallLedgerPath, + type InstallLedgerOptions, +} from '../install-ledger'; import { readManagedModelState, stableJson, @@ -17,8 +26,14 @@ import { import { resolveOpenCodeEffort } from '../opencode-effort'; import { getOpenCodeOwnedSkillEntries, + type OpenCodeOwnedSkillSyncOptions, + type OpenCodeOwnedSkillSyncResult, syncOpenCodeOwnedSkills, } from '../owned-skills'; +import { + type ExecutingPackageVersionResult, + resolveExecutingPackageVersion, +} from '../package-version'; import { ensureConfigDir, getExistingConfigPath, @@ -33,7 +48,8 @@ import { installRequiredSkill, REQUIRED_SKILLS, } from '../skills'; -import type { OpenCodeConfig } from '../types'; +import { getThothMemSetupCommand } from '../thoth-mem-install'; +import type { ConfigMergeResult, OpenCodeConfig } from '../types'; import type { HarnessAction, HarnessOperationAdapter, @@ -48,10 +64,17 @@ import type { OperationPlanItem, OperationWarning, } from './types'; -import { classifyProviderCapabilityEvidence } from './types'; +import { + classifyProviderCapabilityEvidence, + getCliManagedInstallVersionTarget, + getInstallCompletionEvidence, +} from './types'; const PACKAGE_NAME = 'thoth-agents'; -const EXPECTED_PLUGIN = `${PACKAGE_NAME}@latest`; +const EXECUTING_PACKAGE_VERSION = resolveExecutingPackageVersion(); +const EXPECTED_PLUGIN = EXECUTING_PACKAGE_VERSION.ok + ? `${PACKAGE_NAME}@${EXECUTING_PACKAGE_VERSION.version}` + : null; const OPENAI_PRESET = 'openai'; const APPLIED_MODELS_PRESET = 'agents'; const ROLE_NAMES = [...ALL_AGENT_NAMES]; @@ -62,8 +85,29 @@ interface NormalizedOpenCodeRoleOverride { readonly variant: string | null; } +export interface OpenCodeOperationContext extends OperationContext { + resolveExecutingPackageVersion?: () => ExecutingPackageVersionResult; + updateOpenCodeMainConfig?: ( + update: OpenCodeMainConfigUpdate, + ) => ConfigMergeResult; + writeLiteConfig?: typeof writeLiteConfig; + syncOpenCodeOwnedSkills?: ( + options: OpenCodeOwnedSkillSyncOptions, + ) => OpenCodeOwnedSkillSyncResult; + installRequiredSkill?: typeof installRequiredSkill; + finalizeHarnessInstall?: ( + options: FinalizeHarnessInstallOptions, + ) => ReturnType; + runThothMemSetup?: FinalizeHarnessInstallOptions['runThothMemSetup']; + installLedgerOptions?: InstallLedgerOptions; +} + type IssuedOpenCodePayload = - | { readonly kind: 'fixed'; readonly action: 'install' | 'update' | 'sync' } + | { + readonly kind: 'fixed'; + readonly action: 'install' | 'update' | 'sync'; + readonly version: string; + } | { readonly kind: 'model'; readonly roles: readonly NormalizedOpenCodeRoleOverride[]; @@ -75,6 +119,7 @@ interface IssuedOpenCodePlan { readonly harness: 'opencode'; readonly action: 'install' | 'update' | 'sync' | 'model-config'; readonly context: OperationContext; + readonly runtimeContext: OpenCodeOperationContext; readonly payload: IssuedOpenCodePayload; } @@ -222,7 +267,7 @@ function immutableContext(context: OperationContext): OperationContext { function issueOpenCodePlan( plan: OperationPlan, status: HarnessStatusReport, - context: OperationContext, + context: OpenCodeOperationContext, payload: IssuedOpenCodePayload, ): OperationPlan { const planDigest = canonicalDigest(plan); @@ -237,7 +282,11 @@ function issueOpenCodePlan( } const immutablePayload: IssuedOpenCodePayload = payload.kind === 'fixed' - ? Object.freeze({ kind: 'fixed', action: payload.action }) + ? Object.freeze({ + kind: 'fixed', + action: payload.action, + version: payload.version, + }) : Object.freeze({ kind: 'model', roles: Object.freeze( @@ -250,6 +299,7 @@ function issueOpenCodePlan( harness: 'opencode', action: payload.kind === 'model' ? 'model-config' : payload.action, context: immutableContext(context), + runtimeContext: context, payload: immutablePayload, }); return plan; @@ -326,7 +376,9 @@ function targetForMainConfig(state?: ManagedState): ManagedTarget { path: getExistingConfigPath(), label: 'OpenCode config', ...(state ? { state } : {}), - expected: `plugin includes ${EXPECTED_PLUGIN}`, + expected: EXPECTED_PLUGIN + ? `plugin includes ${EXPECTED_PLUGIN}` + : 'plugin version unavailable', }; } @@ -432,7 +484,9 @@ function configPluginMarker(config: OpenCodeConfig | null): string { function hasExpectedPlugin(config: OpenCodeConfig | null): boolean { return ( - Array.isArray(config?.plugin) && config.plugin.includes(EXPECTED_PLUGIN) + EXPECTED_PLUGIN !== null && + Array.isArray(config?.plugin) && + config.plugin.includes(EXPECTED_PLUGIN) ); } @@ -665,6 +719,14 @@ function getOpenCodeManagedStatus( const diagnostics: OperationWarning[] = []; const skillStatus = openCodeSkillTargets(context); + if (!EXECUTING_PACKAGE_VERSION.ok) { + diagnostics.push({ + severity: 'critical', + message: EXECUTING_PACKAGE_VERSION.error.message, + code: 'opencode-package-version-unresolved', + }); + } + if (main.error) { diagnostics.push({ severity: 'critical', @@ -733,7 +795,7 @@ function getOpenCodeManagedStatus( displayName: 'OpenCode', state: 'drift', summary: - 'OpenCode config has a managed thoth-agents plugin entry that is not latest.', + 'OpenCode config has a managed thoth-agents plugin entry that does not match the executing CLI release.', targets: [ { ...mainTarget, state: 'drift' }, { @@ -1013,8 +1075,13 @@ export function getOpenCodeStatus( context: OperationContext = { cwd: process.cwd() }, evidence: ProviderEvidenceInput = {}, ): HarnessStatusReport { + const managedStatus = getOpenCodeManagedStatus(context); return { - ...getOpenCodeManagedStatus(context), + ...managedStatus, + targets: [ + ...managedStatus.targets, + getCliManagedInstallVersionTarget('opencode', context), + ], providerCapability: classifyProviderCapabilityEvidence(evidence), }; } @@ -1116,58 +1183,95 @@ function planFromItems( }; } -export function buildOpenCodeUpdatePlan( - _context: OperationContext = { cwd: process.cwd() }, +function packageVersionForContext( + context: OpenCodeOperationContext, +): ExecutingPackageVersionResult { + return ( + context.resolveExecutingPackageVersion ?? resolveExecutingPackageVersion + )(); +} + +function ledgerOptionsForContext( + context: OpenCodeOperationContext, +): InstallLedgerOptions { + return ( + context.installLedgerOptions ?? { + env: context.env, + homeDir: homeDirFromContext(context), + } + ); +} + +function failedPackageVersionPlan( + action: 'install' | 'update' | 'sync', + context: OpenCodeOperationContext, + error: string, ): OperationPlan { - const path = getExistingConfigPath(); - const { plan, status } = planFromItems( - 'opencode-update-preview', - 'update', - 'Update OpenCode managed plugin entry', - `Preview ensuring plugin: ["${EXPECTED_PLUGIN}"].`, + const { plan } = planFromItems( + `opencode-${action}-version-unavailable`, + action, + `${action === 'install' ? 'Install' : action === 'update' ? 'Update' : 'Sync'} OpenCode`, + 'The executing package version could not be approved.', [ { - title: 'Ensure OpenCode plugin points at thoth-agents@latest', - target: targetForMainConfig(), - state: getOpenCodeStatus(_context).state, - preview: `plugin: ["${EXPECTED_PLUGIN}"]`, - backup: defaultBackup(path), + title: 'Resolve exact thoth-agents package version', + target: { kind: 'package', label: 'Executing thoth-agents package' }, + state: 'unknown', + preview: error, }, ], - _context, + context, ); - return issueOpenCodePlan(plan, status, _context, { - kind: 'fixed', - action: 'update', + plan.canApply = false; + plan.warnings.push({ + severity: 'critical', + code: 'opencode-package-version-unresolved', + message: error, }); + return plan; } -export function buildOpenCodeSyncPlan( - context: OperationContext = { cwd: process.cwd() }, +function buildOpenCodeCompletePlan( + action: 'install' | 'update', + context: OpenCodeOperationContext, ): OperationPlan { + const packageVersion = packageVersionForContext(context); + if (!packageVersion.ok) { + return failedPackageVersionPlan( + action, + context, + packageVersion.error.message, + ); + } + const version = packageVersion.version; + const expectedPlugin = `${PACKAGE_NAME}@${version}`; const generatedConfig = generateLiteConfig({ agent: 'opencode', hasTmux: false, dryRun: true, reset: false, }); - const litePath = getExistingLiteConfigPath(); - const ownedSkills = syncOpenCodeOwnedSkills({ + const syncOwned = context.syncOpenCodeOwnedSkills ?? syncOpenCodeOwnedSkills; + const ownedSkills = syncOwned({ dryRun: true, homeDir: homeDirFromContext(context), }); - + const providerCommand = getThothMemSetupCommand('opencode', true); + const ledgerPath = getInstallLedgerPath(ledgerOptionsForContext(context)); const { plan, status } = planFromItems( - 'opencode-sync-preview', - 'sync', - 'Sync OpenCode managed configuration', - 'Preview OpenCode plugin entry, default-agent disablement, and thoth-agents config sync.', + `opencode-${action}-preview`, + action, + `${action === 'install' ? 'Install' : 'Update'} complete OpenCode setup`, + `Preview complete OpenCode refresh pinned to ${expectedPlugin}.`, [ { - title: 'Ensure OpenCode plugin points at thoth-agents@latest', - target: targetForMainConfig(), + title: `Ensure OpenCode plugin points at ${expectedPlugin}`, + target: { + ...targetForMainConfig(), + expected: `plugin includes ${expectedPlugin}`, + }, state: getOpenCodeStatus(context).state, - preview: `plugin: ["${EXPECTED_PLUGIN}"]`, + preview: `plugin: ["${expectedPlugin}"]`, backup: defaultBackup(getExistingConfigPath()), }, { @@ -1180,7 +1284,7 @@ export function buildOpenCodeSyncPlan( title: 'Write thoth-agents seven-role config', target: targetForLiteConfig(), preview: JSON.stringify(generatedConfig, null, 2), - backup: defaultBackup(litePath), + backup: defaultBackup(getExistingLiteConfigPath()), }, { title: 'Synchronize global thoth-owned OpenCode skills', @@ -1207,63 +1311,75 @@ export function buildOpenCodeSyncPlan( 2, ), }, + { + title: 'Plan provider-owned thoth-mem setup', + target: { + kind: 'surface', + label: 'Provider-owned thoth-mem setup', + }, + preview: `${providerCommand.command} ${providerCommand.args.join(' ')}`, + }, + { + title: 'Record completed OpenCode CLI install', + target: { + kind: 'file', + path: ledgerPath, + label: 'CLI-managed install version', + expected: `recorded ${version}`, + }, + preview: JSON.stringify({ harness: 'opencode', version }), + }, ], context, ); + plan.canApply = canApplyToManagedHealth('install', status); return issueOpenCodePlan(plan, status, context, { kind: 'fixed', - action: 'sync', + action, + version, }); } -function tuiInstallConfig() { - return { - agent: 'opencode' as const, - hasTmux: false, - dryRun: true, - reset: false, - }; +export function buildOpenCodeUpdatePlan( + context: OpenCodeOperationContext = { cwd: process.cwd() }, +): OperationPlan { + return buildOpenCodeCompletePlan('update', context); } -export function buildOpenCodeInstallPlan( - context: OperationContext = { cwd: process.cwd() }, +export function buildOpenCodeSyncPlan( + context: OpenCodeOperationContext = { cwd: process.cwd() }, ): OperationPlan { + const packageVersion = packageVersionForContext(context); + if (!packageVersion.ok) { + return failedPackageVersionPlan( + 'sync', + context, + packageVersion.error.message, + ); + } + const generatedConfig = generateLiteConfig({ + agent: 'opencode', + hasTmux: false, + dryRun: true, + reset: false, + }); + const litePath = getExistingLiteConfigPath(); const ownedSkills = syncOpenCodeOwnedSkills({ dryRun: true, homeDir: homeDirFromContext(context), }); - const generatedConfig = generateLiteConfig(tuiInstallConfig()); - const installPreview = { - noTui: true, - hasTmux: false, - ownedSkills: ownedSkills.skills.map((skill) => skill.name), - requiredSkills: REQUIRED_SKILLS.map((skill) => skill.name), - equivalentCommand: 'install --agent=opencode --no-tui --tmux=no', - }; - const litePath = getExistingLiteConfigPath(); const { plan, status } = planFromItems( - 'opencode-install-preview', - 'install', - 'Preview install', - 'Preview OpenCode install with the required global skills.', + 'opencode-sync-preview', + 'sync', + 'Sync OpenCode managed configuration', + 'Preview OpenCode plugin entry, default-agent disablement, and thoth-agents config sync.', [ { - title: 'Apply OpenCode TUI install options', - target: { - kind: 'config', - path: getExistingConfigPath(), - label: 'OpenCode install options', - state: getOpenCodeStatus(context).state, - expected: '--no-tui --tmux=no plus required global skills', - }, - preview: JSON.stringify(installPreview, null, 2), - }, - { - title: 'Ensure OpenCode plugin points at thoth-agents@latest', + title: `Ensure OpenCode plugin points at ${PACKAGE_NAME}@${packageVersion.version}`, target: targetForMainConfig(), state: getOpenCodeStatus(context).state, - preview: `plugin: ["${EXPECTED_PLUGIN}"]`, + preview: `plugin: ["${PACKAGE_NAME}@${packageVersion.version}"]`, backup: defaultBackup(getExistingConfigPath()), }, { @@ -1308,10 +1424,17 @@ export function buildOpenCodeInstallPlan( ); return issueOpenCodePlan(plan, status, context, { kind: 'fixed', - action: 'install', + action: 'sync', + version: packageVersion.version, }); } +export function buildOpenCodeInstallPlan( + context: OpenCodeOperationContext = { cwd: process.cwd() }, +): OperationPlan { + return buildOpenCodeCompletePlan('install', context); +} + function normalizeRoleModel(input: ModelRoleInput): string { if (input.provider && !input.model.includes('/')) { return `${input.provider}/${input.model}`; @@ -1372,7 +1495,7 @@ export function buildOpenCodeModelPlan( } if (input.harness !== 'opencode') { - const rejectedPlan = { + return { ...plan, canApply: false, warnings: [ @@ -1384,14 +1507,13 @@ export function buildOpenCodeModelPlan( }, ], }; - return rejectedPlan; } const unsupportedRoles = input.roles.filter( (role) => !ROLE_NAMES.includes(role.role as (typeof ROLE_NAMES)[number]), ); if (input.roles.length === 0 || unsupportedRoles.length > 0) { - const rejectedPlan = { + return { ...plan, canApply: false, warnings: [ @@ -1408,7 +1530,6 @@ export function buildOpenCodeModelPlan( }, ], }; - return rejectedPlan; } if (effortWarnings.length > 0) { @@ -1726,15 +1847,16 @@ function applyModelPlan( }; } -function applyRequiredSkills(context: OperationContext): { +function applyRequiredSkills(context: OpenCodeOperationContext): { success: boolean; warnings: OperationWarning[]; } { const warnings: OperationWarning[] = []; const homeDir = homeDirFromContext(context); + const installSkill = context.installRequiredSkill ?? installRequiredSkill; for (const skill of REQUIRED_SKILLS) { try { - const result = installRequiredSkill(skill, 'opencode', { homeDir }); + const result = installSkill(skill, 'opencode', { homeDir }); if (result.status !== 'failed') continue; warnings.push({ severity: 'critical', @@ -1753,11 +1875,12 @@ function applyRequiredSkills(context: OperationContext): { return { success: warnings.length === 0, warnings }; } -function applyOwnedSkills(context: OperationContext): { +function applyOwnedSkills(context: OpenCodeOperationContext): { success: boolean; warnings: OperationWarning[]; } { - const result = syncOpenCodeOwnedSkills({ + const syncOwned = context.syncOpenCodeOwnedSkills ?? syncOpenCodeOwnedSkills; + const result = syncOwned({ homeDir: homeDirFromContext(context), }); if (result.success) return { success: true, warnings: [] }; @@ -1786,8 +1909,37 @@ export function applyOpenCodePlan(plan: OperationPlan): OperationApplyResult { ); } + if (issued.payload.kind === 'fixed') { + const currentPackageVersion = packageVersionForContext( + issued.runtimeContext, + ); + if (!currentPackageVersion.ok) { + return rejectPlan( + plan, + currentPackageVersion.error.message, + 'critical', + 'opencode-package-version-unresolved', + issued.context, + ); + } + if (currentPackageVersion.version !== issued.payload.version) { + return rejectPlan( + plan, + `Approved package version changed from ${issued.payload.version} to ${currentPackageVersion.version} before apply.`, + 'critical', + 'opencode-package-version-changed', + issued.context, + ); + } + } + const status = getOpenCodeStatus(issued.context); - if (!canApplyToManagedHealth(plan.action, status)) { + const healthAction = + issued.payload.kind === 'fixed' && + (issued.payload.action === 'install' || issued.payload.action === 'update') + ? 'install' + : plan.action; + if (!canApplyToManagedHealth(healthAction, status)) { return rejectPlan( plan, `OpenCode state is ${status.state}; refusing to apply plan without a safe status.`, @@ -1815,10 +1967,12 @@ export function applyOpenCodePlan(plan: OperationPlan): OperationApplyResult { const backups = []; const warnings: OperationWarning[] = []; - const mainConfigResult = updateOpenCodeMainConfig({ + const updateMainConfig = + issued.runtimeContext.updateOpenCodeMainConfig ?? updateOpenCodeMainConfig; + const mainConfigResult = updateMainConfig({ ensurePlugin: true, - disableDefaults: - issued.payload.action === 'sync' || issued.payload.action === 'install', + pluginVersion: issued.payload.version, + disableDefaults: true, }); if (!mainConfigResult.success) { return rejectPlan( @@ -1831,7 +1985,7 @@ export function applyOpenCodePlan(plan: OperationPlan): OperationApplyResult { } changedTargets.push({ ...targetForMainConfig('installed'), - observed: `plugin includes ${EXPECTED_PLUGIN}`, + observed: `plugin includes ${PACKAGE_NAME}@${issued.payload.version}`, }); if (existsSync(`${mainConfigResult.configPath}.bak`)) { backups.push({ @@ -1840,76 +1994,108 @@ export function applyOpenCodePlan(plan: OperationPlan): OperationApplyResult { }); } - if (issued.payload.action === 'sync' || issued.payload.action === 'install') { - const liteResult = writeLiteConfig( - { - agent: 'opencode', - hasTmux: false, - dryRun: false, - reset: false, - }, - getExistingLiteConfigPath(), + const writeLite = issued.runtimeContext.writeLiteConfig ?? writeLiteConfig; + const liteResult = writeLite( + { + agent: 'opencode', + hasTmux: false, + dryRun: false, + reset: false, + }, + getExistingLiteConfigPath(), + ); + if (!liteResult.success) { + return rejectPlan( + plan, + liteResult.error ?? 'Failed to write thoth-agents config.', ); - if (!liteResult.success) { - return rejectPlan( - plan, - liteResult.error ?? 'Failed to write thoth-agents config.', - ); - } - changedTargets.push({ - ...targetForLiteConfig('installed'), - observed: 'seven-role roster written', + } + changedTargets.push({ + ...targetForLiteConfig('installed'), + observed: 'seven-role roster written', + }); + if (existsSync(`${liteResult.configPath}.bak`)) { + backups.push({ + path: `${liteResult.configPath}.bak`, + label: 'thoth-agents config backup', }); - if (existsSync(`${liteResult.configPath}.bak`)) { - backups.push({ - path: `${liteResult.configPath}.bak`, - label: 'thoth-agents config backup', - }); - } } - if (issued.payload.action === 'install' || issued.payload.action === 'sync') { - const ownedSkills = applyOwnedSkills(issued.context); - warnings.push(...ownedSkills.warnings); - changedTargets.push({ - kind: 'skill', - label: 'Thoth-owned OpenCode skills', - state: ownedSkills.success ? 'installed' : 'drift', - observed: ownedSkills.success - ? 'thoth-owned global skills synchronized' - : 'thoth-owned global skill synchronization failed', - }); - if (!ownedSkills.success) { - return { - harness: 'opencode', - action: plan.action, - applied: false, - summary: - 'OpenCode configuration was written, but thoth-owned skills failed to synchronize.', - changedTargets, - backups, - warnings, - disclaimers: defaultDisclaimers(), - }; - } + const ownedSkills = applyOwnedSkills(issued.runtimeContext); + warnings.push(...ownedSkills.warnings); + changedTargets.push({ + kind: 'skill', + label: 'Thoth-owned OpenCode skills', + state: ownedSkills.success ? 'installed' : 'drift', + observed: ownedSkills.success + ? 'thoth-owned global skills synchronized' + : 'thoth-owned global skill synchronization failed', + }); + if (!ownedSkills.success) { + return { + harness: 'opencode', + action: plan.action, + applied: false, + summary: + 'OpenCode configuration was written, but thoth-owned skills failed to synchronize.', + changedTargets, + backups, + warnings, + disclaimers: defaultDisclaimers(), + }; + } - const requiredSkills = applyRequiredSkills(issued.context); - warnings.push(...requiredSkills.warnings); - changedTargets.push({ - kind: 'skill', - label: 'Required OpenCode skills', - state: requiredSkills.success ? 'installed' : 'drift', - observed: requiredSkills.success - ? 'required external skills installed' - : 'required external skill installation failed', + const requiredSkills = applyRequiredSkills(issued.runtimeContext); + warnings.push(...requiredSkills.warnings); + changedTargets.push({ + kind: 'skill', + label: 'Required OpenCode skills', + state: requiredSkills.success ? 'installed' : 'drift', + observed: requiredSkills.success + ? 'required external skills installed' + : 'required external skill installation failed', + }); + if (!requiredSkills.success) { + return { + harness: 'opencode', + action: plan.action, + applied: false, + summary: + 'OpenCode configuration was written, but required skills failed to install.', + changedTargets, + backups, + warnings, + disclaimers: defaultDisclaimers(), + }; + } + + if ( + issued.payload.action === 'install' || + issued.payload.action === 'update' + ) { + const finalize = + issued.runtimeContext.finalizeHarnessInstall ?? finalizeHarnessInstall; + const completion = finalize({ + harness: 'opencode', + version: issued.payload.version, + dryRun: false, + cwd: issued.runtimeContext.cwd, + runThothMemSetup: issued.runtimeContext.runThothMemSetup, + ledgerOptions: ledgerOptionsForContext(issued.runtimeContext), }); - if (!requiredSkills.success) { + const completionEvidence = getInstallCompletionEvidence(completion, { + codePrefix: 'opencode', + version: issued.payload.version, + fallbackError: 'OpenCode install finalization failed.', + }); + warnings.push(...completionEvidence.warnings); + changedTargets.push(...completionEvidence.targets); + if (!completion.success) { return { harness: 'opencode', action: plan.action, applied: false, - summary: - 'OpenCode configuration was written, but required skills failed to install.', + summary: completion.error ?? 'OpenCode install finalization failed.', changedTargets, backups, warnings, @@ -1927,7 +2113,7 @@ export function applyOpenCodePlan(plan: OperationPlan): OperationApplyResult { ? 'Applied OpenCode install plan.' : plan.action === 'sync' ? 'Applied OpenCode managed configuration sync.' - : 'Applied OpenCode plugin update.', + : 'Applied complete OpenCode update plan.', changedTargets, backups, warnings, diff --git a/src/cli/operations/types.ts b/src/cli/operations/types.ts index 54ae1f5..5fef71a 100644 --- a/src/cli/operations/types.ts +++ b/src/cli/operations/types.ts @@ -3,7 +3,10 @@ import type { ProviderCapabilityEvidence, ProviderEvidenceInput, } from '../../harness/types'; +import type { HarnessInstallCompletionResult } from '../install-completion'; +import { type InstallHarnessId, readInstallLedger } from '../install-ledger'; import type { EffortSelection } from '../model-effort'; +import { resolveExecutingPackageVersion } from '../package-version'; export type ManagedState = | 'installed' @@ -61,6 +64,98 @@ export interface ManagedTarget { description?: string; } +export function getInstallCompletionEvidence( + completion: HarnessInstallCompletionResult, + options: { codePrefix: string; version: string; fallbackError: string }, +): { targets: ManagedTarget[]; warnings: OperationWarning[] } { + const targets: ManagedTarget[] = [ + { + kind: 'surface', + label: 'Provider-owned thoth-mem setup', + state: completion.provider.success ? 'installed' : 'drift', + observed: completion.provider.status, + }, + ]; + const warnings: OperationWarning[] = [ + ...completion.provider.diagnostics.map((message) => ({ + severity: 'minor' as const, + code: `${options.codePrefix}-provider-diagnostic`, + message, + })), + ...completion.provider.manualActions.map((message) => ({ + severity: 'important' as const, + code: `${options.codePrefix}-provider-manual-action`, + message, + })), + ]; + if (completion.provider.receipt) { + targets.push({ + kind: 'file', + path: completion.provider.receipt, + label: 'thoth-mem setup receipt', + observed: 'provider-owned receipt', + }); + } + if (completion.success) { + targets.push({ + kind: 'file', + path: completion.ledger.path, + label: 'CLI-managed install version', + state: 'installed', + observed: `recorded ${options.version}`, + }); + } else { + warnings.push({ + severity: 'critical', + code: `${options.codePrefix}-install-finalization-failed`, + message: completion.error ?? options.fallbackError, + }); + } + return { targets, warnings }; +} + +export function getCliManagedInstallVersionTarget( + harness: InstallHarnessId, + context: Pick & { homeDir?: string } = {}, +): ManagedTarget { + const executing = resolveExecutingPackageVersion(); + const ledger = readInstallLedger({ + env: context.env, + homeDir: context.homeDir, + }); + const recordedVersion = + ledger.status === 'valid' + ? ledger.ledger.harnesses[harness]?.version + : undefined; + + let state: ManagedState; + if (!executing.ok || ledger.status === 'invalid') { + state = 'unknown'; + } else if (!recordedVersion) { + state = 'missing'; + } else { + state = recordedVersion === executing.version ? 'installed' : 'outdated'; + } + + return { + kind: 'file', + path: ledger.path, + label: 'CLI-managed install version', + state, + expected: executing.ok + ? `executing ${executing.version}` + : 'executing unknown', + observed: + ledger.status === 'invalid' + ? 'recorded unknown (invalid ledger)' + : recordedVersion + ? `recorded ${recordedVersion}` + : 'recorded missing', + description: + 'Authoritative last complete CLI-managed install version; native marketplace state is independent.', + }; +} + export interface ManagedSurface { id: string; label: string; diff --git a/src/cli/package-version.test.ts b/src/cli/package-version.test.ts new file mode 100644 index 0000000..566628e --- /dev/null +++ b/src/cli/package-version.test.ts @@ -0,0 +1,110 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, test } from 'vitest'; +import { resolveExecutingPackageVersion } from './package-version'; + +describe('resolveExecutingPackageVersion', () => { + const fixtureRoots: string[] = []; + + function createFixture( + metadata: string | Record | null, + layout: 'source' | 'published' = 'source', + ): string { + const packageRoot = mkdtempSync(join(tmpdir(), 'thoth-package-version-')); + fixtureRoots.push(packageRoot); + const modulePath = + layout === 'source' + ? join(packageRoot, 'src', 'cli', 'package-version.ts') + : join(packageRoot, 'dist', 'cli', 'index.js'); + mkdirSync(join(modulePath, '..'), { recursive: true }); + if (metadata !== null) { + writeFileSync( + join(packageRoot, 'package.json'), + typeof metadata === 'string' ? metadata : JSON.stringify(metadata), + ); + } + return pathToFileURL(modulePath).href; + } + + afterEach(() => { + for (const fixtureRoot of fixtureRoots.splice(0)) { + if (existsSync(fixtureRoot)) { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + } + }); + + test('resolves a stable version from a source layout', () => { + const moduleUrl = createFixture({ + name: 'thoth-agents', + version: '0.4.8', + }); + + expect(resolveExecutingPackageVersion({ moduleUrl })).toEqual({ + ok: true, + version: '0.4.8', + packageRoot: expect.any(String), + }); + }); + + test('resolves a prerelease version from a published layout', () => { + const moduleUrl = createFixture( + { name: 'thoth-agents', version: '0.4.8-beta.1' }, + 'published', + ); + + expect(resolveExecutingPackageVersion({ moduleUrl })).toEqual({ + ok: true, + version: '0.4.8-beta.1', + packageRoot: expect.any(String), + }); + }); + + test.each([ + { + name: 'missing package metadata', + metadata: null, + code: 'package-root-not-found', + }, + { + name: 'malformed package metadata', + metadata: '{ malformed', + code: 'package-metadata-malformed', + }, + { + name: 'mismatched package identity', + metadata: { name: 'another-package', version: '0.4.8' }, + code: 'package-name-mismatch', + }, + { + name: 'missing package version', + metadata: { name: 'thoth-agents' }, + code: 'package-version-invalid', + }, + { + name: 'empty package version', + metadata: { name: 'thoth-agents', version: ' ' }, + code: 'package-version-invalid', + }, + { + name: 'invalid package version', + metadata: { name: 'thoth-agents', version: 'latest' }, + code: 'package-version-invalid', + }, + ])('returns a typed failure for $name', ({ metadata, code }) => { + const result = resolveExecutingPackageVersion({ + moduleUrl: createFixture(metadata), + }); + + expect(result).toMatchObject({ ok: false, error: { code } }); + expect(result).not.toHaveProperty('version'); + }); +}); diff --git a/src/cli/package-version.ts b/src/cli/package-version.ts new file mode 100644 index 0000000..370e766 --- /dev/null +++ b/src/cli/package-version.ts @@ -0,0 +1,123 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { findPackageRoot } from './package-root'; + +const EXPECTED_PACKAGE_NAME = 'thoth-agents'; +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +export type PackageVersionErrorCode = + | 'package-root-not-found' + | 'package-metadata-unreadable' + | 'package-metadata-malformed' + | 'package-name-mismatch' + | 'package-version-invalid'; + +export interface PackageVersionError { + code: PackageVersionErrorCode; + message: string; + packageJsonPath?: string; +} + +export type ExecutingPackageVersionResult = + | { ok: true; version: string; packageRoot: string } + | { ok: false; error: PackageVersionError }; + +export interface ResolveExecutingPackageVersionOptions { + moduleUrl?: string | URL; +} + +export function isValidPackageVersion(version: unknown): version is string { + return typeof version === 'string' && SEMVER_PATTERN.test(version); +} + +export function resolveExecutingPackageVersion( + options: ResolveExecutingPackageVersionOptions = {}, +): ExecutingPackageVersionResult { + const moduleUrl = options.moduleUrl ?? import.meta.url; + const packageRoot = findPackageRoot(dirname(fileURLToPath(moduleUrl))); + if (!packageRoot) { + return { + ok: false, + error: { + code: 'package-root-not-found', + message: 'Could not locate the executing thoth-agents package root.', + }, + }; + } + + const packageJsonPath = join(packageRoot, 'package.json'); + let serializedMetadata: string; + try { + serializedMetadata = readFileSync(packageJsonPath, 'utf8'); + } catch (error) { + return { + ok: false, + error: { + code: 'package-metadata-unreadable', + message: `Could not read executing package metadata: ${error}`, + packageJsonPath, + }, + }; + } + + let metadata: unknown; + try { + metadata = JSON.parse(serializedMetadata); + } catch (error) { + return { + ok: false, + error: { + code: 'package-metadata-malformed', + message: `Could not parse executing package metadata: ${error}`, + packageJsonPath, + }, + }; + } + + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return { + ok: false, + error: { + code: 'package-metadata-malformed', + message: 'Executing package metadata must be a JSON object.', + packageJsonPath, + }, + }; + } + + const { name, version } = metadata as Record; + if (typeof name !== 'string' || name.length === 0) { + return { + ok: false, + error: { + code: 'package-metadata-malformed', + message: 'Executing package metadata has no valid package name.', + packageJsonPath, + }, + }; + } + if (name !== EXPECTED_PACKAGE_NAME) { + return { + ok: false, + error: { + code: 'package-name-mismatch', + message: `Expected package ${EXPECTED_PACKAGE_NAME}, received ${name}.`, + packageJsonPath, + }, + }; + } + if (!isValidPackageVersion(version)) { + return { + ok: false, + error: { + code: 'package-version-invalid', + message: 'Executing package metadata has no valid semantic version.', + packageJsonPath, + }, + }; + } + + return { ok: true, version, packageRoot }; +} diff --git a/src/cli/tui/App.test.tsx b/src/cli/tui/App.test.tsx index d4ed4ff..136c5db 100644 --- a/src/cli/tui/App.test.tsx +++ b/src/cli/tui/App.test.tsx @@ -31,7 +31,7 @@ function status(summary = 'OpenCode ready'): HarnessStatusReport { label: 'OpenCode config', path: longWindowsPath, state: 'installed', - observed: 'plugin includes thoth-agents@latest', + observed: 'plugin includes thoth-agents@0.4.8', }, ], diagnostics: [], @@ -170,7 +170,7 @@ function plan( { title: 'Ensure managed config', target: status().targets[0], - preview: 'plugin: ["thoth-agents@latest"]', + preview: 'plugin: ["thoth-agents@0.4.8"]', }, ], warnings: [ @@ -419,6 +419,7 @@ describe('interactive TUI', () => { expect(lastFrame()).toContain('Status'); expect(lastFrame()).toContain('Manage Harnesses'); expect(lastFrame()).toContain('Sync / Update'); + expect(lastFrame()).toContain('complete CLI-managed refresh'); expect(lastFrame()).toContain('Exit'); expect(lastFrame()).not.toContain('Configure Models'); expect(lastFrame()).not.toContain('OpenCode Status'); @@ -699,6 +700,86 @@ describe('interactive TUI', () => { expect(lastFrame()).toContain('Preview update'); expect(lastFrame()).toContain('No writes until explicit apply.'); + expect(lastFrame()).toContain('Complete CLI-managed refresh'); + expect(lastFrame()).toContain('only after explicit confirmation'); + expect(lastFrame()).toContain('[Apply complete refresh]'); + expect(lastFrame()).toContain('thoth-agents@0.4.8'); + expect(lastFrame()).not.toContain('thoth-agents@latest'); + }); + + test('TUI failed Update reports a failed complete refresh without claiming native marketplace ownership', async () => { + const base = operations(); + const ops: TuiOperations = { + ...base, + apply(operationPlan) { + base.applied.push(operationPlan); + return { + harness: operationPlan.harness, + action: operationPlan.action, + applied: false, + summary: 'Required skill installation failed.', + changedTargets: [], + backups: [], + warnings: [], + disclaimers: [], + }; + }, + }; + const { lastFrame, stdin } = render( + , + ); + + await openUpdatePreview(stdin); + expect(base.applied).toHaveLength(0); + await press(stdin, 'a'); + + const frame = lastFrame() ?? ''; + expect(base.applied).toHaveLength(1); + expect(frame).toContain( + 'Complete CLI refresh result: failed — Required skill installation failed.', + ); + expect(frame).toContain( + 'Codex and Claude native marketplace versions remain independent.', + ); + expect(frame).not.toContain('marketplace cache'); + }); + + test('TUI status clearly identifies executing and last complete recorded CLI versions', async () => { + const ledgerStatus: HarnessStatusReport = { + ...status('OpenCode needs a complete CLI refresh.'), + state: 'outdated', + targets: [ + ...status().targets, + { + kind: 'file', + label: 'CLI-managed install version', + state: 'outdated', + expected: 'executing 0.4.8', + observed: 'recorded 0.4.7', + description: + 'Authoritative last complete CLI-managed install version; native marketplace state is independent.', + }, + ], + }; + const { lastFrame, stdin } = render( + , + ); + + await openStatus(stdin); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('CLI-managed version'); + expect(frame).toContain('Executing: 0.4.8'); + expect(frame).toContain('Recorded: 0.4.7'); + expect(frame).toContain( + 'Recorded is the last complete CLI-managed version.', + ); + expect(frame).toContain( + 'Codex/Claude native marketplace versions are independent.', + ); }); test('TUI preview and apply keep consumer actions usable beside unsupported provider evidence', async () => { @@ -741,7 +822,9 @@ describe('interactive TUI', () => { frame = lastFrame() ?? ''; expect(base.applied).toHaveLength(1); - expect(frame).toContain('Consumer result: Applied test plan.'); + expect(frame).toContain( + 'Complete CLI refresh result: completed — Applied test plan.', + ); expect(frame).toContain('Provider capability: unsupported'); expectProviderNeutralLanguage(frame); }); diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index adfc1ee..2b2f617 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -82,7 +82,7 @@ const rootItems: RootMenuItem[] = [ id: 'sync', action: 'sync', label: 'Sync / Update', - detail: 'Preview managed setup changes', + detail: 'Preview sync or a complete CLI-managed refresh', }, { id: 'exit', @@ -97,7 +97,7 @@ const actionItems: ActionMenuItem[] = [ id: 'update', action: 'update', label: 'Update', - detail: 'Refresh managed plugin/setup entries', + detail: 'Preview a complete CLI-managed refresh', }, { id: 'sync', @@ -113,7 +113,7 @@ const installManageItems: ManageMenuItem[] = [ id: 'install', action: 'install', label: 'Install', - detail: 'Preview managed setup install', + detail: 'Preview the complete CLI-managed install', }, { id: 'back', action: 'back', label: 'Back', detail: 'Choose harness' }, ]; @@ -129,7 +129,7 @@ const manageItems: ManageMenuItem[] = [ id: 'update', action: 'update', label: 'Update preview', - detail: 'Preview managed plugin/setup refresh', + detail: 'Preview a complete CLI-managed refresh', }, { id: 'sync', diff --git a/src/cli/tui/__snapshots__/App.test.tsx.snap b/src/cli/tui/__snapshots__/App.test.tsx.snap index f5696a6..0ba401d 100644 --- a/src/cli/tui/__snapshots__/App.test.tsx.snap +++ b/src/cli/tui/__snapshots__/App.test.tsx.snap @@ -7,7 +7,7 @@ Use arrows and Enter. Escape goes back. q exits. > Status - Inspect a harness Manage Harnesses - List managed surfaces and actions - Sync / Update - Preview managed setup changes + Sync / Update - Preview sync or a complete CLI-managed refresh Exit - Close the interactive setup" `; diff --git a/src/cli/tui/components/PlanPreview.tsx b/src/cli/tui/components/PlanPreview.tsx index a1d2bdf..0fbdc14 100644 --- a/src/cli/tui/components/PlanPreview.tsx +++ b/src/cli/tui/components/PlanPreview.tsx @@ -41,6 +41,15 @@ export function PlanPreview({ }: PlanPreviewProps) { const blockerTargets = plan.blockerTargets ?? []; const providerEvidence = providerEvidenceFor(result ?? plan); + const completeOperation = + plan.action === 'update' + ? 'refresh' + : plan.action === 'install' + ? 'install' + : undefined; + const applyLabel = completeOperation + ? `Apply complete ${completeOperation}` + : 'Apply'; return ( {plan.title} @@ -51,6 +60,19 @@ export function PlanPreview({ Managed action: {plan.action} Can apply: {plan.canApply ? 'yes' : 'no'} + {completeOperation ? ( + + + Complete CLI-managed {completeOperation} + + + The selected harness changes only after explicit confirmation. + + + Codex and Claude native marketplace versions remain independent. + + + ) : null} {providerEvidence ? ( ) : null} @@ -111,7 +133,7 @@ export function PlanPreview({ ))} - [Apply] + [{applyLabel}] @@ -122,7 +144,9 @@ export function PlanPreview({ {result ? ( - Consumer result: {result.summary} + {completeOperation + ? `Complete CLI ${completeOperation} result: ${result.applied ? 'completed' : 'failed'} — ${result.summary}` + : `Consumer result: ${result.summary}`} {!result.applied && ((result.diagnosticTargets?.length ?? 0) > 0 || diff --git a/src/cli/tui/components/StatusView.tsx b/src/cli/tui/components/StatusView.tsx index cba3094..f786102 100644 --- a/src/cli/tui/components/StatusView.tsx +++ b/src/cli/tui/components/StatusView.tsx @@ -102,9 +102,11 @@ interface CategorizedTarget { label: string; state: ManagedState | undefined; detail?: string; + notes?: string[]; } const categoryOrder = [ + 'CLI-managed version', 'Agents', 'Skills', 'Plugin/MCP', @@ -150,6 +152,22 @@ function categorizeTarget(target: ManagedTarget): CategorizedTarget { const agent = path.match(/thoth-agents-([a-z0-9_-]+)\.toml$/i); const skill = path.match(/skills\/([^/]+)\/SKILL\.md$/i); + if (/CLI-managed install version/i.test(label)) { + const executing = + target.expected?.replace(/^executing\s+/i, '') ?? 'unknown'; + const recorded = target.observed?.replace(/^recorded\s+/i, '') ?? 'unknown'; + return { + category: 'CLI-managed version', + label: 'Last complete install', + state: target.state, + detail: `Executing: ${executing} · Recorded: ${recorded}`, + notes: [ + 'Recorded is the last complete CLI-managed version.', + 'Codex/Claude native marketplace versions are independent.', + ], + }; + } + if (agent?.[1]) { return { category: 'Agents', @@ -310,17 +328,28 @@ export function StatusView({ {category} {visible.map((target) => ( - - - {target.label} - {target.state ? ( - - : [{target.state}] + + + - {target.label} + {target.state ? ( + + : [{target.state}] + + ) : null} + {target.detail ? ( + - {target.detail} + ) : null} + + {target.notes?.map((note) => ( + + {' '} + {note} - ) : null} - {target.detail ? ( - - {target.detail} - ) : null} - + ))} + ))} {hidden > 0 ? +{hidden} more : null} diff --git a/src/cli/tui/operations.test.ts b/src/cli/tui/operations.test.ts index d33329a..a24fc4a 100644 --- a/src/cli/tui/operations.test.ts +++ b/src/cli/tui/operations.test.ts @@ -3,13 +3,86 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { + HarnessId, ProviderCapabilityEvidence, ProviderEvidenceInput, } from '../../harness/types'; import { loadModelsDevCatalog } from '../model-catalog'; -import type { HarnessStatusReport } from '../operations'; +import type { + HarnessStatusReport, + OperationApplyResult, + OperationPlan, +} from '../operations'; const parseConfigMock = vi.hoisted(() => vi.fn()); +const operationDispatchSpies = vi.hoisted(() => ({ + opencodeUpdate: vi.fn(), + codexUpdate: vi.fn(), + claudeUpdate: vi.fn(), + opencodeApply: vi.fn(), + codexApply: vi.fn(), + claudeApply: vi.fn(), +})); + +vi.mock('../operations/opencode', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + buildOpenCodeUpdatePlan: () => { + operationDispatchSpies.opencodeUpdate(); + return completeDispatchPlan('opencode'); + }, + applyOpenCodePlan: ( + plan: Parameters[0], + ) => { + operationDispatchSpies.opencodeApply(plan); + if (plan.id === 'tui-dispatch-test') { + return dispatchApplyResult('opencode', 'OpenCode'); + } + return original.applyOpenCodePlan(plan); + }, + }; +}); + +vi.mock('../operations/codex', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + buildCodexUpdatePlan: () => { + operationDispatchSpies.codexUpdate(); + return completeDispatchPlan('codex'); + }, + applyCodexPlan: (plan: Parameters[0]) => { + operationDispatchSpies.codexApply(plan); + if (plan.id === 'tui-dispatch-test') { + return dispatchApplyResult('codex', 'Codex'); + } + return original.applyCodexPlan(plan); + }, + }; +}); + +vi.mock('../operations/claude-code', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + buildClaudeCodeUpdatePlan: () => { + operationDispatchSpies.claudeUpdate(); + return completeDispatchPlan('claude'); + }, + applyClaudeCodePlan: ( + plan: Parameters[0], + ) => { + operationDispatchSpies.claudeApply(plan); + if (plan.id === 'tui-dispatch-test') { + return dispatchApplyResult('claude', 'Claude'); + } + return original.applyClaudeCodePlan(plan); + }, + }; +}); vi.mock('../paths', async (importOriginal) => ({ ...(await importOriginal()), @@ -25,6 +98,55 @@ vi.mock('../model-catalog', () => ({ loadModelsDevCatalog: vi.fn(), })); +function completeDispatchPlan(harness: HarnessId): OperationPlan { + return { + id: `${harness}-complete-update`, + harness, + action: 'update', + title: `Update complete ${harness} setup`, + summary: 'Preview the complete shared operation update.', + dryRun: true, + canApply: true, + targets: [], + surfaces: [], + backup: { required: false, strategy: 'none' }, + items: [ + { + title: 'Plan provider setup', + target: { + kind: 'surface', + label: 'Provider-owned thoth-mem setup', + }, + }, + { + title: 'Record completed CLI install', + target: { + kind: 'file', + label: 'CLI-managed install version', + }, + }, + ], + warnings: [], + disclaimers: [], + }; +} + +function dispatchApplyResult( + harness: HarnessId, + displayName: string, +): OperationApplyResult { + return { + harness, + action: 'update', + applied: false, + summary: `${displayName} shared apply boundary reached.`, + changedTargets: [], + backups: [], + warnings: [], + disclaimers: [], + }; +} + const checkedAt = '2026-07-11T00:00:00.000Z'; function useOpenCodeConfig(config: unknown): void { @@ -383,6 +505,37 @@ describe('TUI operations', () => { ]); }); + test.each([ + ['opencode', 'opencodeUpdate', 'opencodeApply'], + ['codex', 'codexUpdate', 'codexApply'], + ['claude', 'claudeUpdate', 'claudeApply'], + ] as const)('routes %s Update planning and apply through its complete shared operation service', async (harness, planSpy, applySpy) => { + const { defaultTuiOperations } = await import('./operations'); + + const updatePlan = defaultTuiOperations.plan(harness, 'update'); + expect(operationDispatchSpies[planSpy]).toHaveBeenCalledTimes(1); + expect(updatePlan.title).toContain('complete'); + expect(updatePlan.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: expect.objectContaining({ + label: 'Provider-owned thoth-mem setup', + }), + }), + expect.objectContaining({ + target: expect.objectContaining({ + label: 'CLI-managed install version', + }), + }), + ]), + ); + + const dispatchPlan = { ...updatePlan, id: 'tui-dispatch-test' }; + const result = defaultTuiOperations.apply(dispatchPlan); + expect(operationDispatchSpies[applySpy]).toHaveBeenCalledWith(dispatchPlan); + expect(result.summary).toContain('shared apply boundary reached'); + }); + test.each([ { label: 'supported provider evidence', diff --git a/src/harness/provider-boundary.test.ts b/src/harness/provider-boundary.test.ts index 5c23fc7..d94c818 100644 --- a/src/harness/provider-boundary.test.ts +++ b/src/harness/provider-boundary.test.ts @@ -41,6 +41,7 @@ const PROVIDER_BOUNDARY_TARGETS = { 'src/harness/writers/codex-toml.ts', 'src/harness/writers/claude-code-plugin-package.ts', 'src/cli/install.ts', + 'src/cli/install-completion.ts', 'src/cli/thoth-mem-install.ts', 'src/hooks/index.ts', 'src/mcp/index.ts', @@ -138,7 +139,7 @@ describe('provider boundary', () => { test('reads the complete closed manifest and rejects deleted paths, bundled assets, and consumer protocols', async () => { const targets = await readTargets(); - expect(targets).toHaveLength(33); + expect(targets).toHaveLength(34); expect( targets.filter(({ group }) => group === 'documentationAndMetadata'), ).toHaveLength(19); @@ -147,7 +148,7 @@ describe('provider boundary', () => { ).toHaveLength(2); expect( targets.filter(({ group }) => group === 'consumerSurfaces'), - ).toHaveLength(12); + ).toHaveLength(13); for (const target of targets) { for (const rule of DELETED_PATH_RULES) { @@ -225,6 +226,9 @@ describe('provider boundary', () => { ({ path }) => path === 'src/cli/thoth-mem-install.ts', ); const install = targets.find(({ path }) => path === 'src/cli/install.ts'); + const completion = targets.find( + ({ path }) => path === 'src/cli/install-completion.ts', + ); expect(setup?.content).toMatch(/thoth-mem@latest[\s\S]*setup/); expect(setup?.content).toMatch(/--scope[\s\S]*global[\s\S]*--json/); @@ -234,11 +238,31 @@ describe('provider boundary', () => { /writeFile|appendFile|mkdir|rmSync|unlink|renameSync/, ); + const sharedFinalizer = install?.content.match( + /function\s+([A-Za-z]\w*)\s*\(\s*harness\s*:\s*SkillInstallHarness[\s\S]*?dependencies\.finalizeHarnessInstall\s*\?\?\s*finalizeHarnessInstall/, + )?.[1]; + expect(sharedFinalizer).toBeDefined(); + if (!sharedFinalizer) return; + for (const harness of ['opencode', 'codex', 'claude']) { - expect(install?.content).toContain( - `installThothMemForHarness('${harness}'`, + expect(install?.content).toMatch( + new RegExp(`${sharedFinalizer}\\s*\\(\\s*['"]${harness}['"]`), ); } + + const providerImports = [ + ...(completion?.content.matchAll( + /from\s*['"]([^'"]*thoth-mem[^'"]*)['"]/g, + ) ?? []), + ].map((match) => match[1]); + expect(providerImports).toEqual(['./thoth-mem-install']); + expect(completion?.content).toMatch( + /options\.runThothMemSetup\s*\?\?\s*runThothMemSetup/, + ); + expect(completion?.content).not.toMatch(/--force|rollback/i); + expect(completion?.content).not.toMatch( + /writeFile|appendFile|mkdir|rmSync|unlink|renameSync/, + ); }); test('documents mandatory provider setup without transferring provider ownership', async () => { diff --git a/src/hooks/auto-update-checker/cache.test.ts b/src/hooks/auto-update-checker/cache.test.ts deleted file mode 100644 index 23d4b64..0000000 --- a/src/hooks/auto-update-checker/cache.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import * as fs from 'node:fs'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { invalidatePackage } from './cache'; - -// Mock internal dependencies -vi.mock('./constants', () => ({ - CACHE_DIR: '/mock/cache', - PACKAGE_NAME: 'thoth-agents', -})); - -vi.mock('../../utils/logger', () => ({ - log: vi.fn(() => {}), -})); - -// Mock fs and path -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => false), - rmSync: vi.fn(() => {}), - readFileSync: vi.fn(() => ''), - writeFileSync: vi.fn(() => {}), -})); - -vi.mock('../../cli/config-manager', () => ({ - stripJsonComments: (s: string) => s, -})); - -describe('auto-update-checker/cache', () => { - beforeEach(() => { - (fs.existsSync as any).mockReset(); - (fs.existsSync as any).mockImplementation(() => false); - (fs.rmSync as any).mockReset(); - (fs.rmSync as any).mockImplementation(() => {}); - (fs.readFileSync as any).mockReset(); - (fs.readFileSync as any).mockImplementation(() => ''); - (fs.writeFileSync as any).mockReset(); - (fs.writeFileSync as any).mockImplementation(() => {}); - }); - - describe('invalidatePackage', () => { - test('returns false when nothing to invalidate', () => { - const existsMock = fs.existsSync as any; - existsMock.mockReturnValue(false); - - const result = invalidatePackage(); - expect(result).toBe(false); - }); - - test('returns true and removes directory if node_modules path exists', () => { - const existsMock = fs.existsSync as any; - const rmSyncMock = fs.rmSync as any; - - existsMock.mockImplementation((p: string) => p.includes('node_modules')); - - const result = invalidatePackage(); - - expect(rmSyncMock).toHaveBeenCalled(); - expect(result).toBe(true); - }); - - test('removes dependency from package.json if present', () => { - const existsMock = fs.existsSync as any; - const readMock = fs.readFileSync as any; - const writeMock = fs.writeFileSync as any; - - existsMock.mockImplementation((p: string) => p.includes('package.json')); - readMock.mockReturnValue( - JSON.stringify({ - dependencies: { - 'thoth-agents': '1.0.0', - 'other-pkg': '1.0.0', - }, - }), - ); - - const result = invalidatePackage(); - - expect(result).toBe(true); - const callArgs = writeMock.mock.calls[0]; - const savedJson = JSON.parse(callArgs[1]); - expect(savedJson.dependencies['thoth-agents']).toBeUndefined(); - expect(savedJson.dependencies['other-pkg']).toBe('1.0.0'); - }); - - test('invalidates the pnpm cache package directory without touching lockfiles', () => { - const existsMock = fs.existsSync as any; - const rmSyncMock = fs.rmSync as any; - const writeMock = fs.writeFileSync as any; - - existsMock.mockImplementation( - (p: string) => - p.endsWith('/node_modules/thoth-agents') || - p.endsWith('\\node_modules\\thoth-agents'), - ); - - const result = invalidatePackage(); - - expect(result).toBe(true); - expect(rmSyncMock).toHaveBeenCalledWith( - expect.stringContaining('node_modules'), - { recursive: true, force: true }, - ); - expect(writeMock).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/hooks/auto-update-checker/cache.ts b/src/hooks/auto-update-checker/cache.ts deleted file mode 100644 index eb23bf1..0000000 --- a/src/hooks/auto-update-checker/cache.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { stripJsonComments } from '../../cli/config-manager'; -import { log } from '../../utils/logger'; -import { CACHE_DIR, PACKAGE_NAME } from './constants'; - -/** - * Invalidates the current package by removing its directory and dependency entries. - * This forces pnpm to resolve the package again without editing pnpm-lock.yaml. - * @param packageName The name of the package to invalidate. - */ -export function invalidatePnpmPackageCache( - packageName: string = PACKAGE_NAME, -): boolean { - try { - const pkgDir = path.join(CACHE_DIR, 'node_modules', packageName); - const pkgJsonPath = path.join(CACHE_DIR, 'package.json'); - - let packageRemoved = false; - let dependencyRemoved = false; - - if (fs.existsSync(pkgDir)) { - fs.rmSync(pkgDir, { recursive: true, force: true }); - log(`[auto-update-checker] Package removed: ${pkgDir}`); - packageRemoved = true; - } - - if (fs.existsSync(pkgJsonPath)) { - try { - const content = fs.readFileSync(pkgJsonPath, 'utf-8'); - const pkgJson = JSON.parse(stripJsonComments(content)); - if (pkgJson.dependencies?.[packageName]) { - delete pkgJson.dependencies[packageName]; - fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); - log( - `[auto-update-checker] Dependency removed from package.json: ${packageName}`, - ); - dependencyRemoved = true; - } - } catch (err) { - log( - `[auto-update-checker] Failed to update package.json for invalidation:`, - err, - ); - } - } - - if (!packageRemoved && !dependencyRemoved) { - log( - `[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`, - ); - return false; - } - - return true; - } catch (err) { - log('[auto-update-checker] Failed to invalidate package:', err); - return false; - } -} - -export { invalidatePnpmPackageCache as invalidatePackage }; diff --git a/src/hooks/auto-update-checker/checker.test.ts b/src/hooks/auto-update-checker/checker.test.ts index 1a581c1..2c114ef 100644 --- a/src/hooks/auto-update-checker/checker.test.ts +++ b/src/hooks/auto-update-checker/checker.test.ts @@ -1,11 +1,6 @@ import * as fs from 'node:fs'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { - extractChannel, - findPluginEntry, - getLocalDevVersion, - updatePinnedVersion, -} from './checker'; +import { extractChannel, findPluginEntry, getLocalDevVersion } from './checker'; // Mock the dependencies vi.mock('./constants', () => ({ @@ -19,7 +14,6 @@ vi.mock('node:fs', () => ({ existsSync: vi.fn((_p: string) => false), readFileSync: vi.fn((_p: string) => ''), statSync: vi.fn((_p: string) => ({ isDirectory: () => true })), - writeFileSync: vi.fn(() => {}), })); describe('auto-update-checker/checker', () => { @@ -32,8 +26,6 @@ describe('auto-update-checker/checker', () => { (fs.statSync as any).mockImplementation((_p: string) => ({ isDirectory: () => true, })); - (fs.writeFileSync as any).mockReset(); - (fs.writeFileSync as any).mockImplementation(() => {}); }); describe('extractChannel', () => { @@ -130,27 +122,4 @@ describe('auto-update-checker/checker', () => { expect(entry?.pinnedVersion).toBe('1.0.0'); }); }); - - describe('updatePinnedVersion', () => { - test('updates pinned package-manager entry without rewriting config shape', () => { - const existsMock = fs.existsSync as any; - const readMock = fs.readFileSync as any; - const writeMock = fs.writeFileSync as any; - const configPath = '/mock/config/opencode.json'; - const originalConfig = - '{\n "plugin": [\n "thoth-agents@1.0.0"\n ]\n}\n'; - - existsMock.mockImplementation((p: string) => p === configPath); - readMock.mockReturnValue(originalConfig); - - expect( - updatePinnedVersion(configPath, 'thoth-agents@1.0.0', '1.2.3'), - ).toBe(true); - expect(writeMock).toHaveBeenCalledWith( - configPath, - originalConfig.replace('thoth-agents@1.0.0', 'thoth-agents@1.2.3'), - 'utf-8', - ); - }); - }); }); diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts index 73bcaeb..f29108a 100644 --- a/src/hooks/auto-update-checker/checker.ts +++ b/src/hooks/auto-update-checker/checker.ts @@ -169,7 +169,6 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null { return null; } -const _cachedLocalVersion: string | null = null; let cachedPackageVersion: string | null = null; /** @@ -212,53 +211,6 @@ export function getCachedVersion(): string | null { return null; } -/** - * Safely updates a pinned version in the configuration file. - * It attempts to replace the exact plugin string to preserve comments and formatting. - */ -export function updatePinnedVersion( - configPath: string, - oldEntry: string, - newVersion: string, -): boolean { - try { - if (!fs.existsSync(configPath)) return false; - - const content = fs.readFileSync(configPath, 'utf-8'); - const newEntry = `${PACKAGE_NAME}@${newVersion}`; - - // Check if the old entry actually exists as a quoted string - const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, 'g'); - - if (!entryRegex.test(content)) { - log( - `[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`, - ); - return false; - } - - // Perform the replacement - const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`); - - if (updatedContent === content) { - return false; - } - - fs.writeFileSync(configPath, updatedContent, 'utf-8'); - log( - `[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`, - ); - return true; - } catch (err) { - log( - `[auto-update-checker] Failed to update config file ${configPath}:`, - err, - ); - return false; - } -} - /** * Fetches the latest version for a specific channel from the NPM registry. */ diff --git a/src/hooks/auto-update-checker/index.test.ts b/src/hooks/auto-update-checker/index.test.ts new file mode 100644 index 0000000..91f864e --- /dev/null +++ b/src/hooks/auto-update-checker/index.test.ts @@ -0,0 +1,69 @@ +import * as fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('./checker', () => ({ + extractChannel: vi.fn(() => 'latest'), + findPluginEntry: vi.fn(() => ({ + entry: 'thoth-agents@1.0.0', + isPinned: true, + pinnedVersion: '1.0.0', + configPath: '/mock/opencode.json', + })), + getCachedVersion: vi.fn(() => '1.0.0'), + getLatestVersion: vi.fn(async () => '2.0.0'), + getLocalDevVersion: vi.fn(() => null), + updatePinnedVersion: vi.fn(() => true), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ dependencies: { 'thoth-agents': '1.0.0' } }), + ), + rmSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('../../utils/logger', () => ({ log: vi.fn() })); + +import { createAutoUpdateCheckerHook } from './index'; + +describe('auto-update-checker notification-only runtime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('newer release from a non-Node workspace only notifies and never runs the package manager', async () => { + const showToast = vi.fn(async () => undefined); + const shell = vi.fn(async () => undefined); + const context = { + directory: '/unity-project', + client: { tui: { showToast } }, + $: shell, + }; + const hook = Reflect.apply(createAutoUpdateCheckerHook, undefined, [ + context, + { showStartupToast: false }, + shell, + ]); + + hook.event({ event: { type: 'session.created' } }); + await vi.runAllTimersAsync(); + + expect(showToast).toHaveBeenCalledOnce(); + const notification = showToast.mock.calls[0]?.[0]?.body; + expect(notification?.message).toContain( + 'npx thoth-agents@latest install --agent=opencode', + ); + expect(notification?.message).toContain('interactive CLI Update'); + expect(notification?.message).not.toContain('Restart to apply'); + expect(shell).not.toHaveBeenCalled(); + expect(fs.rmSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/auto-update-checker/index.ts b/src/hooks/auto-update-checker/index.ts index 2eba191..3604caf 100644 --- a/src/hooks/auto-update-checker/index.ts +++ b/src/hooks/auto-update-checker/index.ts @@ -1,30 +1,25 @@ import type { PluginInput } from '@opencode-ai/plugin'; import { log } from '../../utils/logger'; -import { invalidatePackage } from './cache'; import { extractChannel, findPluginEntry, getCachedVersion, getLatestVersion, getLocalDevVersion, - updatePinnedVersion, } from './checker'; -import { PACKAGE_NAME } from './constants'; import type { AutoUpdateCheckerOptions } from './types'; /** * Creates an OpenCode hook that checks for plugin updates when a new session is created. * @param ctx The plugin input context. * @param options Configuration options for the update checker. - * @param shell The shell instance for running commands. * @returns A hook object for the session.created event. */ export function createAutoUpdateCheckerHook( ctx: PluginInput, options: AutoUpdateCheckerOptions = {}, - shell: PluginInput['$'], ) { - const { showStartupToast = true, autoUpdate = true } = options; + const { showStartupToast = true } = options; let hasChecked = false; @@ -67,7 +62,7 @@ export function createAutoUpdateCheckerHook( ); } - runBackgroundUpdateCheck(ctx, shell, autoUpdate).catch((err) => { + runBackgroundUpdateCheck(ctx).catch((err) => { log('[auto-update-checker] Background update check failed:', err); }); }, 0); @@ -76,16 +71,10 @@ export function createAutoUpdateCheckerHook( } /** - * Orchestrates the version comparison and update process in the background. + * Checks registry availability and notifies without mutating installed state. * @param ctx The plugin input context. - * @param shell The shell instance for running commands. - * @param autoUpdate Whether to automatically install updates. */ -async function runBackgroundUpdateCheck( - ctx: PluginInput, - shell: PluginInput['$'], - autoUpdate: boolean, -): Promise { +async function runBackgroundUpdateCheck(ctx: PluginInput): Promise { const pluginInfo = findPluginEntry(ctx.directory); if (!pluginInfo) { log('[auto-update-checker] Plugin not found in config'); @@ -121,104 +110,14 @@ async function runBackgroundUpdateCheck( `[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`, ); - if (!autoUpdate) { - showToast( - ctx, - `thoth-agents ${latestVersion}`, - `v${latestVersion} available. Restart to apply.`, - 'info', - 8000, - ); - log('[auto-update-checker] Auto-update disabled, notification only'); - return; - } - - if (pluginInfo.isPinned) { - const updated = updatePinnedVersion( - pluginInfo.configPath, - pluginInfo.entry, - latestVersion, - ); - if (!updated) { - showToast( - ctx, - `thoth-agents ${latestVersion}`, - `v${latestVersion} available. Restart to apply.`, - 'info', - 8000, - ); - log('[auto-update-checker] Failed to update pinned version in config'); - return; - } - log( - `[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`, - ); - } - - invalidatePackage(PACKAGE_NAME); - - const installSuccess = await runPnpmInstallSafe(ctx, shell); - - if (installSuccess) { - showToast( - ctx, - 'thoth-agents Updated!', - `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`, - 'success', - 8000, - ); - log( - `[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`, - ); - } else { - showToast( - ctx, - `thoth-agents ${latestVersion}`, - `v${latestVersion} available. Restart to apply.`, - 'info', - 8000, - ); - log('[auto-update-checker] pnpm install failed; update not installed'); - } -} - -/** - * Runs 'pnpm install' using the OpenCode shell with a 60-second timeout. - * Includes a timeout to prevent stalling OpenCode. - * @param ctx The plugin input context. - * @param shell The shell instance for running commands. - * @returns True if the installation succeeded within the timeout. - */ -async function runPnpmInstallSafe( - ctx: PluginInput, - shell: PluginInput['$'], -): Promise { - try { - const timeoutPromise = new Promise<'timeout'>((resolve) => - setTimeout(() => resolve('timeout'), 60_000), - ); - - const installPromise = (async () => { - try { - await shell`cd ${ctx.directory} && pnpm install`; - return 'completed' as const; - } catch { - return 'failed' as const; - } - })(); - - const result = await Promise.race([installPromise, timeoutPromise]); - - if (result === 'timeout') { - log('[auto-update-checker] pnpm install timed out after 60 seconds'); - return false; - } - - return result === 'completed'; - } catch (err) { - log('[auto-update-checker] pnpm install error:', err); - return false; - } + showToast( + ctx, + `thoth-agents ${latestVersion} available`, + `v${currentVersion} → v${latestVersion}. Run npx thoth-agents@latest install --agent=opencode or use interactive CLI Update.`, + 'info', + 10_000, + ); + log('[auto-update-checker] Notification only; explicit CLI update required'); } /** diff --git a/src/hooks/auto-update-checker/types.ts b/src/hooks/auto-update-checker/types.ts index 3b1e256..14ea5f8 100644 --- a/src/hooks/auto-update-checker/types.ts +++ b/src/hooks/auto-update-checker/types.ts @@ -16,7 +16,6 @@ export interface PackageJson { export interface AutoUpdateCheckerOptions { showStartupToast?: boolean; - autoUpdate?: boolean; } export interface PluginEntryInfo { diff --git a/src/index.ts b/src/index.ts index c6fad7a..56fa35e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ import { log } from './utils/logger'; import { TmuxSessionManager } from './utils/tmux-session-manager'; const ThothAgents: Plugin = async (ctx, _options?: Record) => { - const { client, directory, $: shell } = ctx; + const { client, directory } = ctx; const config = loadPluginConfig(directory); const agentDefs = createAgents(config); @@ -98,14 +98,9 @@ const ThothAgents: Plugin = async (ctx, _options?: Record) => { const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig); // Initialize auto-update checker hook - const autoUpdateChecker = createAutoUpdateCheckerHook( - ctx, - { - showStartupToast: true, - autoUpdate: true, - }, - shell, - ); + const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, { + showStartupToast: true, + }); // Initialize delegate-task retry guidance hook const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);