diff --git a/README.md b/README.md index 5a4c6327..1e1e6cce 100644 --- a/README.md +++ b/README.md @@ -231,10 +231,11 @@ a script. ## Providers -CodeAlmanac uses `almanac-yoke` as its single provider boundary. Codex runs -through app-server; Claude uses Yoke's default Claude surface (currently the -Python Agent SDK). Existing Codex or Claude Code OAuth sessions are reused, and -API credentials can be supplied through Yoke when embedding the SDK. +CodeAlmanac uses `almanac-yoke` as its provider boundary for Codex and Claude. +OpenCode runs through a first-class CLI harness adapter (`opencode run`). Codex +runs through app-server; Claude uses Yoke's default Claude surface (currently the +Python Agent SDK). Existing Codex, Claude Code, or OpenCode OAuth sessions are +reused where available. Build, ingest, and garden are packaged as a Yoke agent collection under `src/codealmanac/agents/`. Each agent uses Yoke's native folder contract: @@ -247,6 +248,8 @@ native Claude or Codex execution still decides how and when to use them. ```bash codex login claude auth login +# OpenCode: install the CLI and authenticate your preferred model provider +opencode auth login codealmanac doctor ``` diff --git a/almanac/architecture/README.md b/almanac/architecture/README.md index 5efaf871..6a82c684 100644 --- a/almanac/architecture/README.md +++ b/almanac/architecture/README.md @@ -138,7 +138,7 @@ For authored wiki contracts, read [Page identity](wiki/page-identity), [Path nor ## Edges And Interfaces -Use [CLI adapter boundary](cli/adapter-boundary) and [Terminal output](cli/terminal-output) for command entrypoints and rendering [@cli-adapter] [@terminal-output]. Use [Agent runs](agent-runs/) for normalized harness execution and the Yoke harness boundary that serves Claude and Codex behind one adapter [@agent-runs]. Use [Source resolution and runtime](sources/source-resolution-and-runtime) when changing ingest inputs or source adapters [@source-runtime]. +Use [CLI adapter boundary](cli/adapter-boundary) and [Terminal output](cli/terminal-output) for command entrypoints and rendering [@cli-adapter] [@terminal-output]. Use [Agent runs](agent-runs/) for normalized harness execution: Yoke for Claude and Codex, and the native OpenCode CLI harness [@agent-runs]. Use [Source resolution and runtime](sources/source-resolution-and-runtime) when changing ingest inputs or source adapters [@source-runtime]. [Agents and manuals](runtime-resources/prompts-and-manuals) covers the packaged Yoke agents and writing references used by lifecycle runs, [Setup automation and update](setup/automation-and-update) covers setup-owned scheduler and update behavior, and [Telemetry](telemetry) covers the narrow remote product-signal exception [@agents-manuals] [@setup-automation] [@telemetry]. [Instruction installation](setup/instruction-installation) covers the sibling setup concern: writing `CLAUDE.md`/`AGENTS.md` kernel instructions into a repository [@instruction-install]. diff --git a/almanac/architecture/agent-runs/README.md b/almanac/architecture/agent-runs/README.md index 56d4aaa6..ed778787 100644 --- a/almanac/architecture/agent-runs/README.md +++ b/almanac/architecture/agent-runs/README.md @@ -9,7 +9,11 @@ sources: - id: provider-adapters type: file path: almanac/architecture/agent-runs/provider-adapters.md - note: Architecture page for the Yoke harness boundary that implements the contract. + note: Architecture page for the Yoke harness boundary (Claude and Codex). + - id: opencode-harness + type: file + path: almanac/architecture/agent-runs/opencode-harness.md + note: Architecture page for the native OpenCode CLI harness. - id: topics type: file path: almanac/topics.yaml @@ -18,7 +22,7 @@ sources: # Agent Runs -Agent runs is the part of CodeAlmanac that executes one build, ingest, or garden task against an external coding agent and turns the result into durable, provider-neutral facts. The neighborhood has two pages: the contract lifecycle workflows depend on, and the one adapter that currently implements it [@topics]. +Agent runs is the part of CodeAlmanac that executes one build, ingest, or garden task against an external coding agent and turns the result into durable, provider-neutral facts. The neighborhood has three pages: the contract lifecycle workflows depend on, the Yoke adapter for Claude and Codex, and the native OpenCode CLI harness [@topics]. Read this hub when changing how a `RunHarnessRequest` is built, how a harness run reports readiness or results, or how provider events become normalized job-log events. @@ -26,7 +30,7 @@ Read this hub when changing how a `RunHarnessRequest` is built, how a harness ru Start with [Harness contract](harness-contract). It defines `RunHarnessRequest`, `HarnessRunResult`, and `HarnessEvent`, and states the boundary rule that lifecycle workflows may depend only on those normalized shapes [@harness-contract]. -Then read [Yoke harness boundary](provider-adapters). It explains `YokeHarnessAdapter`, the single provider integration that implements the contract for both Claude and Codex by loading a packaged Yoke agent and projecting Yoke runs and events into the contract's models [@provider-adapters]. +Then read [Yoke harness boundary](provider-adapters) for Claude and Codex, and [OpenCode harness](opencode-harness) for the separate OpenCode CLI path [@provider-adapters] [@opencode-harness]. ## Neighboring Pages diff --git a/almanac/architecture/agent-runs/harness-contract.md b/almanac/architecture/agent-runs/harness-contract.md index 714cdfda..d2bf2d96 100644 --- a/almanac/architecture/agent-runs/harness-contract.md +++ b/almanac/architecture/agent-runs/harness-contract.md @@ -24,7 +24,7 @@ sources: # Harness Contract -The harness contract is the boundary between lifecycle workflows and external agent providers. Build, ingest, and garden send one normalized `RunHarnessRequest` to a selected harness and receive one normalized `HarnessRunResult` back. The workflows do not know whether the provider is Codex or Claude; that provider-specific behavior lives behind the [Yoke harness boundary](provider-adapters) [@harness-ports] [@harness-requests]. +The harness contract is the boundary between lifecycle workflows and external agent providers. Build, ingest, and garden send one normalized `RunHarnessRequest` to a selected harness and receive one normalized `HarnessRunResult` back. The workflows do not know whether the provider is Codex, Claude, or OpenCode; provider-specific behavior lives behind adapters ([Yoke harness boundary](provider-adapters) for Codex/Claude, [OpenCode harness](opencode-harness) for OpenCode) [@harness-ports] [@harness-requests]. The contract matters because lifecycle operations need stable facts after an agent run: readiness, terminal status, output text, optional changed files, transcript references, and normalized events. Those facts feed the [operation runner](../lifecycle/operation-runner), the [run ledger](../../concepts/run-ledger), and user-facing job logs without leaking provider JSON streams into the rest of the system [@harness-results] [@harness-events]. @@ -34,7 +34,7 @@ A harness adapter has one `kind`, a `check()` method, and a `run()` method [@har `HarnessesService` indexes adapters by kind and rejects duplicate adapters [@harness-service]. Workflows call `ensure_ready` and `run_ready` as separate stages: readiness failures can include a repair hint and a command to switch harnesses, while every exception from the approved adapter invocation is a provider-execution failure. `run_ready` also wraps caller event-sink failures in `HarnessEventSinkFailed`, keeping local run-log persistence failures distinct from provider failures that occur during the same call [@harness-service]. -The supported harness kinds are currently `codex` and `claude`. The terminal run statuses are `succeeded`, `failed`, and `cancelled` [@harness-kinds]. +The supported harness kinds are currently `codex`, `claude`, and `opencode`. The terminal run statuses are `succeeded`, `failed`, and `cancelled` [@harness-kinds]. ## Run Requests diff --git a/almanac/architecture/agent-runs/opencode-harness.md b/almanac/architecture/agent-runs/opencode-harness.md new file mode 100644 index 00000000..24f9ece3 --- /dev/null +++ b/almanac/architecture/agent-runs/opencode-harness.md @@ -0,0 +1,73 @@ +--- +title: OpenCode Harness +topics: [architecture, harnesses, agent-runs] +sources: + - id: adapter + type: file + path: src/codealmanac/integrations/harnesses/opencode/adapter.py + - id: events + type: file + path: src/codealmanac/integrations/harnesses/opencode/events.py + - id: models + type: file + path: src/codealmanac/integrations/harnesses/opencode/models.py + - id: defaults + type: file + path: src/codealmanac/integrations/harnesses/__init__.py + - id: contract + type: file + path: src/codealmanac/services/harnesses/ports.py + - id: setup + type: file + path: src/codealmanac/integrations/setup/opencode.py + - id: tests + type: file + path: tests/test_opencode_harness.py +--- + +# OpenCode Harness + +## What It Owns + +`OpenCodeHarnessAdapter` is a first-class harness integration that runs +lifecycle jobs through the local OpenCode CLI (`opencode run`), not through +Yoke [@adapter] [@defaults]. It implements the same service-owned harness port +as Yoke adapters: `check()` readiness and `run()` execution returning a +normalized `HarnessRunResult` [@contract] [@adapter]. + +Unlike Codex/Claude, OpenCode model ids are dynamic `provider/model` strings +(possibly with nested segments such as `openrouter/z-ai/glm-5`). The adapter +and config accept any well-formed OpenCode model id; listing uses `opencode +models` when the CLI is present [@models]. + +## Project Agents + +Before each lifecycle run, the adapter stages a packaged primary agent under +the product-owned path `runtime_root/opencode/agents/codealmanac-.md` +(typically under `~/.codealmanac/harnesses/opencode/agents/`), never the target +repository's tree. The CLI is invoked with `OPENCODE_CONFIG_DIR` pointing at +that directory — OpenCode loads it as an additive agents/commands source after +global and project config, so user auth and providers stay on the real OpenCode +config while generated agents stay out of `git status` [@adapter]. The runtime +prompt is supplied on stdin [@adapter]. + +## Events And Transcripts + +JSON lines from `opencode run` are projected into CodeAlmanac harness events +by `integrations/harnesses/opencode/events.py` [@events]. Live runs may +surface a session id for job linkage. Separate from live runs, sync discovers +historical OpenCode sessions from OpenCode's SQLite store +(`~/.local/share/opencode/opencode.db`); see +[Source resolution and runtime](../sources/source-resolution-and-runtime). + +## Setup + +`codealmanac setup` can install guide text into OpenCode's global +`~/.config/opencode/AGENTS.md` as a managed block [@setup]. Uninstall removes +that block without touching unrelated user content [@setup]. + +## Related Pages + +See [Harness contract](harness-contract), [Yoke harness boundary](provider-adapters), +[Instruction installation](../setup/instruction-installation), and +[Controlled model catalog](../../decisions/controlled-model-catalog). diff --git a/almanac/architecture/agent-runs/provider-adapters.md b/almanac/architecture/agent-runs/provider-adapters.md index 4ae7d607..3bf4b0d7 100644 --- a/almanac/architecture/agent-runs/provider-adapters.md +++ b/almanac/architecture/agent-runs/provider-adapters.md @@ -32,14 +32,16 @@ sources: ## What It Owns -CodeAlmanac has one provider integration: `YokeHarnessAdapter`. It implements -the service-owned harness port for Claude and Codex, selects CodeAlmanac's Yoke +CodeAlmanac has two harness integrations. `YokeHarnessAdapter` implements the +service-owned harness port for Claude and Codex, selects CodeAlmanac's Yoke surface and run options, and converts Yoke runs and events into durable product -models [@adapter] [@contract]. +models [@adapter] [@contract]. OpenCode does **not** go through Yoke; it has a +separate native CLI adapter documented in [OpenCode harness](opencode-harness). +`YokeHarnessAdapter` raises if asked to run `HarnessKind.OPENCODE`. -The adapter explicitly selects Codex app-server and leaves Claude on Yoke's -default Claude surface. It loads the requested build, ingest, or garden agent -from the packaged Yoke collection, forwards the task prompt unchanged, and +The Yoke adapter explicitly selects Codex app-server and leaves Claude on +Yoke's default Claude surface. It loads the requested build, ingest, or garden +agent from the packaged Yoke collection, forwards the task prompt unchanged, and applies the trusted non-interactive permission and timeout policy [@adapter]. ## Runtime Root @@ -47,11 +49,12 @@ applies the trusted non-interactive permission and timeout policy [@adapter]. `YokeHarnessAdapter` also requires a `runtime_root: Path` at construction. `default_harness_adapters(runtime_root)` receives `LocalStatePaths.harness_runtime_dir` (`state_dir / "harnesses"`, so `~/.codealmanac/harnesses` by default) from the -composition root and passes it to both the Claude and Codex adapters [@defaults] -[@settings] [@app]. The adapter forwards `runtime_root` into `yoke.Harness(...)` -on every `check()` and `run()` call, so Yoke's native provider compilation caches -under CodeAlmanac's own local state instead of writing `.codex` or `.claude` -configuration into the target repository [@adapter]. +composition root and passes it to the Claude and Codex Yoke adapters and the +OpenCode native adapter [@defaults] [@settings] [@app]. The Yoke adapter +forwards `runtime_root` into `yoke.Harness(...)` on every `check()` and `run()` +call, so Yoke's native provider compilation caches under CodeAlmanac's own local +state instead of writing `.codex` or `.claude` configuration into the target +repository [@adapter]. `check()` cannot run a real repository task, so it uses a sibling working directory instead of the caller's `cwd`: `runtime_root.parent / "readiness"` diff --git a/almanac/architecture/composition-root.md b/almanac/architecture/composition-root.md index e85bcaa2..7bf81ffd 100644 --- a/almanac/architecture/composition-root.md +++ b/almanac/architecture/composition-root.md @@ -26,7 +26,7 @@ The composition root is `src/codealmanac/app.py`. It is the one place that build `AppAdapters` is the injection surface for outside systems. It can carry harness adapters, transcript discovery adapters, source runtime adapters, a scheduler, a worker spawner, an executor spawner, a process controller, update command providers, instruction installers, global-state removers, and package uninstallers [@app-root]. Each field is optional, so production construction can use defaults while tests can pass fakes. -This design keeps provider selection at the edge. If no harness adapters are supplied, the root uses `default_harness_adapters(local_state.harness_runtime_dir)`, giving both the Claude and Codex adapters a product-owned cache directory under local state instead of the target repository [@app-root]. If no scheduler is supplied, automation uses `LaunchdSchedulerAdapter`. If no source adapters are supplied, the source service receives the default transcript discovery and runtime adapters [@app-root]. The services receive port implementations, not provider-specific branching logic. See [Yoke harness boundary](agent-runs/provider-adapters) for what the runtime directory is used for. +This design keeps provider selection at the edge. If no harness adapters are supplied, the root uses `default_harness_adapters(local_state.harness_runtime_dir)`, giving the Claude and Codex Yoke adapters and the native OpenCode adapter a product-owned cache directory under local state instead of the target repository [@app-root]. If no scheduler is supplied, automation uses `LaunchdSchedulerAdapter`. If no source adapters are supplied, the source service receives the default transcript discovery and runtime adapters [@app-root]. The services receive port implementations, not provider-specific branching logic. See [Yoke harness boundary](agent-runs/provider-adapters) for what the runtime directory is used for. ## Service Assembly diff --git a/almanac/architecture/setup/instruction-installation.md b/almanac/architecture/setup/instruction-installation.md index eb98aae8..3e0f761d 100644 --- a/almanac/architecture/setup/instruction-installation.md +++ b/almanac/architecture/setup/instruction-installation.md @@ -22,6 +22,10 @@ sources: type: file path: src/codealmanac/integrations/setup/codex.py note: Codex installation mechanics (managed block inside AGENTS.md). + - id: opencode-installer + type: file + path: src/codealmanac/integrations/setup/opencode.py + note: OpenCode installation mechanics (managed block inside global AGENTS.md). - id: managed-blocks type: file path: src/codealmanac/integrations/setup/managed_blocks.py @@ -96,8 +100,8 @@ package stable Yoke agent instructions and expose the manuals those operations u ## Per-Target Mechanics -Claude and Codex are installed differently because their global instruction -files have different conventions. +Claude, Codex, and OpenCode are installed differently because their global +instruction files have different conventions. For Claude, the installer writes the guide verbatim to a dedicated file, `~/.claude/codealmanac.md`, and ensures `~/.claude/CLAUDE.md` contains a @@ -115,9 +119,16 @@ non-empty content, otherwise `~/.codex/AGENTS.md` [@codex-installer]. Uninstall removes the managed block from both possible paths and deletes a file that becomes empty [@codex-installer]. -Both installers report an `InstructionChange` naming the `SetupTarget`, +For OpenCode, the installer upserts the guide into OpenCode's own global +rules file, `~/.config/opencode/AGENTS.md`, using the same managed-block +markers as Codex [@opencode-installer] [@managed-blocks]. There is no +override-file convention — only that one path is targeted. Uninstall removes +the managed block and deletes the file if it becomes empty +[@opencode-installer]. + +All installers report an `InstructionChange` naming the `SetupTarget`, whether anything changed, which paths were touched, and a human-readable message, so `setup`'s JSON and interactive output can say "already installed" without rewriting untouched files [@setup-models] [@claude-installer] -[@codex-installer]. This mirrors the idempotence expectations described in +[@codex-installer] [@opencode-installer]. This mirrors the idempotence expectations described in [Setup automation and update](automation-and-update). diff --git a/almanac/architecture/sources/source-resolution-and-runtime.md b/almanac/architecture/sources/source-resolution-and-runtime.md index 39808b5a..c0b0fe0b 100644 --- a/almanac/architecture/sources/source-resolution-and-runtime.md +++ b/almanac/architecture/sources/source-resolution-and-runtime.md @@ -26,6 +26,14 @@ sources: type: file path: src/codealmanac/integrations/sources/transcripts/claude.py note: Claude transcript discovery path and metadata parsing. + - id: opencode_transcripts + type: file + path: src/codealmanac/integrations/sources/transcripts/opencode.py + note: OpenCode transcript discovery via shared SQLite session table. + - id: opencode_transcript_runtime + type: file + path: src/codealmanac/integrations/sources/transcripts/opencode_runtime.py + note: OpenCode transcript runtime reading message/part tables. - id: sync_evaluation type: file path: src/codealmanac/workflows/sync/evaluation.py @@ -54,10 +62,12 @@ Ingest uses this boundary before it renders the writing prompt. It resolves the ## Transcript Discovery -Transcript discovery is a separate source path used by sync. The default discovery set has two adapters: Claude and Codex [@transcript_adapters]. The source model has the same two transcript app values, `claude` and `codex`, so there is no separate app identity for Codex app, Claude Desktop, Claude web, or editor-specific surfaces [@source_models]. +Transcript discovery is a separate source path used by sync. The default discovery set has three adapters: Claude, Codex, and OpenCode [@transcript_adapters]. The source model has matching transcript app values `claude`, `codex`, and `opencode` [@source_models]. The Codex adapter scans `.codex/sessions` under the configured home directory, reads the first JSONL lines for session metadata, and skips transcripts whose metadata marks `thread_source` as `subagent` [@codex_transcripts]. The Claude adapter scans `.claude/projects` under the configured home directory and skips paths that contain `subagents` [@claude_transcripts]. +OpenCode has no per-session file to scan — session history lives as rows in one shared SQLite database (`~/.local/share/opencode/opencode.db`). Its discovery adapter queries that database's `session` table with `parent_id IS NULL` to exclude sub-agent sessions [@opencode_transcripts]. Because there is no file per session, `TranscriptCandidate.transcript_path` holds a synthetic identity (`opencode-session:`) rather than a filesystem path. `OpencodeTranscriptRuntimeAdapter` recognizes that scheme and resolves content from the database's `message`/`part` tables [@opencode_transcript_runtime]. It is registered ahead of the generic file-based transcript runtime adapter so dispatch order remains correct [@transcript_adapters]. + Sync does not ingest every discovered transcript. It matches each transcript `cwd` to a registered repository root, skips unregistered working directories, and skips transcripts older than the active sync window as `inactive` [@sync_evaluation]. That means a transcript can be discovered correctly but still not become ingest input for the current sync run. ## Related Reference diff --git a/almanac/concepts/lifecycle-operation.md b/almanac/concepts/lifecycle-operation.md index d2c36469..a6c6164c 100644 --- a/almanac/concepts/lifecycle-operation.md +++ b/almanac/concepts/lifecycle-operation.md @@ -42,7 +42,7 @@ Lifecycle operations meet the rest of the system at `OperationRunner`. The runne ## Sync Is Not An Operation -Sync is related, but it is not a lifecycle operation. The live agreement defines sync as a scanner that reads local Claude and Codex transcript stores, groups active transcripts by registered repository, and queues ordinary ingest runs [@live-agreement]. In other words, sync can trigger ingest, but ingest is the page-writing operation. +Sync is related, but it is not a lifecycle operation. The live agreement defines sync as a scanner that reads local Claude, Codex, and OpenCode transcript stores, groups active transcripts by registered repository, and queues ordinary ingest runs [@live-agreement]. In other words, sync can trigger ingest, but ingest is the page-writing operation. This distinction keeps background discovery separate from wiki authorship. If a future page explains queued runs and workers, it should treat sync as a producer of run specs, not as a fourth agent-writing operation. diff --git a/almanac/decisions/controlled-model-catalog.md b/almanac/decisions/controlled-model-catalog.md index 5482f9bd..b8362569 100644 --- a/almanac/decisions/controlled-model-catalog.md +++ b/almanac/decisions/controlled-model-catalog.md @@ -5,7 +5,11 @@ sources: - id: config-models type: file path: src/codealmanac/services/config/models.py - note: Controlled harness model list, defaults, config keys, and validation. + note: Controlled harness model list, OpenCode format validation, defaults, config keys. + - id: opencode-models + type: file + path: src/codealmanac/services/config/opencode_models.py + note: OpenCode provider/model id shape checks and fallback list constants. - id: config-service type: file path: src/codealmanac/services/config/service.py @@ -17,39 +21,73 @@ sources: - id: config-plan type: file path: docs/plans/2026-07-07-controlled-model-config.md - note: Implementation plan that records the product decision and intended setup/config behavior. + note: Original implementation plan for the closed Codex/Claude catalog. --- # Controlled Model Catalog -CodeAlmanac owns a controlled catalog of AI runner models. It does not discover arbitrary provider models during setup, accept provider defaults as product truth, or let harness adapters decide which models are supported [@config-plan]. The selected runner and model are stored in config as `harness.default` and `harness.model`, then passed toward lifecycle work as explicit settings [@config-models]. - -The decision keeps model choice small, reviewable, and stable. The harness adapter can execute a chosen model, but the product list belongs to config and setup. This constrains future work in the [Yoke harness boundary](../architecture/agent-runs/provider-adapters), [Automation and update](../architecture/setup/automation-and-update), and [Config keys](../reference/config-keys). +CodeAlmanac owns model choice for lifecycle harnesses. The selected runner and +model live in config as `harness.default` and `harness.model` [@config-models]. +Codex and Claude use a **closed product catalog**. OpenCode uses a **format +rule** over OpenCode's own `provider/model` ids so auth and live menus stay with +OpenCode while CodeAlmanac still validates shape [@opencode-models]. ## Status -Accepted. The controlled catalog is implemented in `src/codealmanac/services/config/models.py` and enforced by config tests [@config-models] [@config-tests]. +Accepted, with an OpenCode carve-out documented below. Codex/Claude catalog: +`src/codealmanac/services/config/models.py`. OpenCode ids: +`src/codealmanac/services/config/opencode_models.py` [@config-models] +[@opencode-models] [@config-tests]. ## Context -CodeAlmanac can run lifecycle jobs through more than one local harness. That creates two different choices: where agent instructions are installed, and which runner/model pair performs CodeAlmanac jobs [@config-plan]. Combining those choices would make setup confusing, because a user might install instructions for both Codex and Claude while choosing only one default runner. +CodeAlmanac runs lifecycle jobs through more than one local harness. That creates +two different choices: where agent instructions are installed, and which +runner/model pair performs CodeAlmanac jobs [@config-plan]. + +For Codex and Claude, provider CLI discovery is an unstable product surface — +experimental, renamed, or account-specific models leak into setup. The plan +rejects treating `codex debug models` or Claude discovery as product truth +[@config-plan]. -Provider discovery also creates an unstable product surface. A local provider command could expose experimental, unavailable, renamed, or account-specific models. The plan explicitly rejects `codex debug models`, Claude discovery, provider defaults, and custom model entry during onboarding [@config-plan]. +OpenCode is different: it is a **router** over many providers (OpenRouter, +provider-native APIs, Zen free models, and so on). A closed CodeAlmanac list +cannot keep pace or know what the user authenticated. OpenCode's CLI already +owns that catalog via `opencode models` and provider login. ## Decision -The config model defines the allowed model names in `CONTROLLED_HARNESS_MODELS`. It also groups them by harness in `HARNESS_MODELS` and defines recommended defaults in `DEFAULT_HARNESS_MODELS` [@config-models]. +### Codex and Claude -`HarnessConfig` validates two things. First, `harness.model` must be in the controlled catalog. Second, the model must belong to the selected harness, so a Claude default cannot use a Codex model and a Codex default cannot use a Claude model [@config-models]. The tests cover both rejection paths [@config-tests]. +- Allowed names live in `CONTROLLED_HARNESS_MODELS` and per-harness + `HARNESS_MODELS` with defaults in `DEFAULT_HARNESS_MODELS` [@config-models]. +- `HarnessConfig` requires the model to be in the catalog **and** belong to + the selected harness [@config-models] [@config-tests]. +- Adding a model is an explicit code change plus tests, not a discovery side + effect [@config-models] [@config-tests]. -The config service exposes `auto_commit`, `harness.default`, and `harness.model` through `config list`, `config get`, and `config set` [@config-service]. Changing `harness.default` resets `harness.model` to that harness's recommended default, which keeps the stored pair valid after a runner switch [@config-service] [@config-tests]. +### OpenCode -## Consequences +- Any non-empty `provider/model` id (model segment may contain further `/`, for + example `openrouter/z-ai/glm-5`) is accepted when `harness.default` is + `opencode` [@opencode-models] [@config-models]. +- Setup's model menu prefers the live list from `opencode models` and falls back + to a short curated list only when the CLI is missing [@config-models]. +- `config set harness.model` may still set any well-formed OpenCode id, even if + it is not currently in that menu — availability at run time is OpenCode's + responsibility (auth and provider config). +- Switching `harness.default` still resets `harness.model` to that harness's + default so the stored pair stays coherent [@config-service] [@config-tests]. -Adding a new model is a code change, not a provider-discovery side effect. A maintainer updates the catalog, the harness grouping, and the tests in one place [@config-models] [@config-tests]. +Config keys and tables: [Config keys](../reference/config-keys). -Setup and automation can depend on a known runner/model pair. `SetupService` writes both `harness.default` and `harness.model`, so unattended lifecycle work does not need to ask a provider what it should run [@config-service]. +## Consequences -The cost is that users cannot type an arbitrary provider model into CodeAlmanac config. That is intentional. A model becomes supported when the project accepts it into the controlled catalog. +Codex/Claude stay small, reviewable, and intentionally laggy as a product +catalog [@config-plan]. OpenCode users can point CodeAlmanac at OpenRouter or +any model their OpenCode install is connected to without waiting for a +CodeAlmanac release. Workflows still receive an explicit model string on +`RunHarnessRequest`; they do not discover models themselves. -The maintenance burden is also intentional. Because the catalog is not provider discovery, it goes stale unless a maintainer explicitly refreshes it as providers release new models. Future model support work should keep the same ownership rule: update `CONTROLLED_HARNESS_MODELS`, `HARNESS_MODELS`, defaults, setup options, and provenance tests together, rather than replacing the catalog with provider discovery [@config-models] [@config-tests]. +Related: [Yoke harness boundary](../architecture/agent-runs/provider-adapters), +[OpenCode harness](../architecture/agent-runs/opencode-harness). diff --git a/almanac/guides/add-a-harness-provider-adapter.md b/almanac/guides/add-a-harness-provider-adapter.md index 2cf103d7..ced87a80 100644 --- a/almanac/guides/add-a-harness-provider-adapter.md +++ b/almanac/guides/add-a-harness-provider-adapter.md @@ -2,72 +2,98 @@ title: Add A Harness Provider Adapter topics: [guides, harnesses, yoke] sources: - - id: adapter + - id: harness-defaults + type: file + path: src/codealmanac/integrations/harnesses/__init__.py + note: Default harness adapter registration for Claude, Codex, and OpenCode. + - id: yoke-adapter type: file path: src/codealmanac/integrations/harnesses/yoke/adapter.py - - id: defaults + note: Shared Yoke harness adapter for Claude and Codex. + - id: opencode-adapter type: file - path: src/codealmanac/integrations/harnesses/__init__.py + path: src/codealmanac/integrations/harnesses/opencode/adapter.py + note: Native OpenCode CLI harness adapter. - id: kinds type: file path: src/codealmanac/services/harnesses/kinds.py - id: config type: file path: src/codealmanac/services/config/models.py - - id: events + - id: yoke-events type: file path: src/codealmanac/integrations/harnesses/yoke/events.py - - id: tests + - id: opencode-events + type: file + path: src/codealmanac/integrations/harnesses/opencode/events.py + - id: yoke-tests type: file path: tests/test_yoke_harness_integration.py + - id: opencode-tests + type: file + path: tests/test_opencode_harness.py --- # Add A Harness Provider Adapter -Use this guide when CodeAlmanac needs to add a new local agent runner (a new -`HarnessKind`, such as a future harness alongside Codex and Claude) or change -which Yoke surface an existing runner uses. The guide has two parts because the -work usually spans two repos: provider or surface support belongs in Yoke, and -the CodeAlmanac-side product choice — the `HarnessKind`, [controlled models](../decisions/controlled-model-catalog), and -`YokeHarnessAdapter` registration — belongs here. +Use this guide when CodeAlmanac needs a new local agent runner (`HarnessKind`) +or a change to how an existing runner is selected. There are **two on-ramps**. +Pick the one that matches the integration surface; do not force every runner +through Yoke. + +## Choose An On-Ramp + +### 1. Yoke surface (Claude, Codex today) + +Use Yoke when the runner is already (or should be) a Yoke provider/surface — +shared auth, sessions, skills, and event vocabulary. -## Add Provider Support To Yoke First +1. Add or extend provider support **in Yoke first** if the surface does not + exist yet. Prove it against the real provider outside this repo before + changing CodeAlmanac. +2. In CodeAlmanac, add `HarnessKind`, models (controlled catalog for that + runner), setup/config choices, and one `YokeHarnessAdapter(kind, …)` + registration [@kinds] [@config] [@harness-defaults] [@yoke-adapter]. +3. Prefer extending `YokeEventProjector` only when CodeAlmanac must persist a + new durable Yoke fact [@yoke-events]. -If the provider or surface CodeAlmanac needs does not exist yet, add it in Yoke, -not in this repo. CodeAlmanac does not implement its own provider protocol -adapter [@adapter]. Yoke owns authentication, provider processes, native surface -options, skills, subagents, sessions, models, and normalized provider events. -Prove the feature against the real provider in Yoke before changing CodeAlmanac. -Do not reproduce SDK or JSON-RPC behavior under `integrations/harnesses/`. +See [Yoke harness boundary](../architecture/agent-runs/provider-adapters). -## Add The Product Choice In CodeAlmanac +### 2. First-class CLI (or SDK) harness (OpenCode today) -Once Yoke supports the provider or surface, wire the product-side choice: +Use a dedicated adapter under `integrations/harnesses//` when the product +should talk to the runner's own CLI/SDK **without** a Yoke pin — for example +OpenCode's `opencode run` path [@opencode-adapter]. -- For a genuinely new CodeAlmanac runner, add its `HarnessKind`, controlled - models, defaults, setup/config choices, and one `YokeHarnessAdapter` - registration [@kinds] [@config] [@defaults]. -- For a different Yoke surface on an existing runner (for example, switching - which Codex surface is selected), change the explicit surface selection in - the Yoke adapter and document why the product requires it [@adapter]. +1. Implement `HarnessAdapter` (`check` + `run`) returning product + `HarnessRunResult` / `HarnessEvent` models; project runner-specific streams + in that package (see OpenCode's event projector) [@opencode-events]. +2. Register the adapter in `default_harness_adapters` [@harness-defaults]. +3. Wire `HarnessKind`, setup target, config models, telemetry labels, and + doctor/setup readiness. +4. For model choice: either a controlled catalog (Codex/Claude style) or a + documented open format validated in config (OpenCode `provider/model` ids — + see [Controlled model catalog](../decisions/controlled-model-catalog)). +5. Do **not** stage generated agent files into the user's repository. Keep + harness scratch under product-owned local state (`runtime_root`); OpenCode + stages agents under `runtime_root/opencode/agents/` and points + `OPENCODE_CONFIG_DIR` at that additive directory [@opencode-adapter]. -See [Yoke harness boundary](../architecture/agent-runs/provider-adapters) for -what the adapter currently owns before changing it. +See [OpenCode harness](../architecture/agent-runs/opencode-harness). ## Preserve The Product Contract -The service-owned request, result, and event models stay provider-neutral. -Extend `YokeEventProjector` only when CodeAlmanac needs to persist or present a -new durable Yoke fact. Do not make workflows parse provider payloads or branch -on provider names [@events]. +Services and workflows stay provider-neutral. They call `HarnessesService` with +`RunHarnessRequest` and consume `HarnessRunResult` / `HarnessEvent`. Do not make +workflows parse provider payloads or branch on provider names +[@yoke-events] [@opencode-events]. ## Verify The Change -Add focused boundary tests for readiness, exact task forwarding, model and -agent selection, callbacks, failures, event serialization, and any new display -facts [@tests]. Then run the real provider surface, the affected lifecycle -operation, the full test suite, Ruff, wheel/sdist builds, Twine checks, and a -fresh installed-wheel smoke. +Add focused boundary tests for readiness, prompt/model forwarding, agent +selection, live callbacks, failures, and event serialization +[@yoke-tests] [@opencode-tests]. Then run the real runner surface, the affected +lifecycle operation, the full suite, Ruff, and a fresh install smoke. -Related architecture: [Yoke harness boundary](../architecture/agent-runs/provider-adapters) -and [Agents and manuals](../architecture/runtime-resources/prompts-and-manuals). +Related: [Harness contract](../architecture/agent-runs/harness-contract), +[Agents and manuals](../architecture/runtime-resources/prompts-and-manuals). diff --git a/almanac/guides/setup-local-automation.md b/almanac/guides/setup-local-automation.md index de7cbc3f..d0bf87a4 100644 --- a/almanac/guides/setup-local-automation.md +++ b/almanac/guides/setup-local-automation.md @@ -42,6 +42,10 @@ sources: type: file path: src/codealmanac/integrations/sources/transcripts/codex.py note: Discovers transcripts under ~/.codex/sessions. + - id: opencode-transcripts + type: file + path: src/codealmanac/integrations/sources/transcripts/opencode.py + note: Discovers OpenCode sessions from ~/.local/share/opencode/opencode.db. - id: automation-render type: file path: src/codealmanac/cli/render/automation.py @@ -119,14 +123,15 @@ Then check sync and run activity: ```bash codealmanac sync status --from codex +codealmanac sync status --from opencode codealmanac jobs ``` -The README presents `sync status`, `sync`, `automation status`, and `jobs` as the daily local surfaces for automation and lifecycle work [@readme]. If a scheduled run fails, inspect it with `codealmanac jobs show ` and `codealmanac jobs logs `. +The README presents `sync status`, `sync`, `automation status`, and `jobs` as the daily local surfaces for automation and lifecycle work [@readme]. If a scheduled run fails, inspect it with `codealmanac jobs show ` and `codealmanac jobs logs `. Default `sync` / `sync status` without `--from` scans every registered transcript app (`claude`, `codex`, and `opencode`). ## Troubleshoot macOS Full Disk Access Prompts -Sync reads transcripts from `~/.claude/projects` and `~/.codex/sessions` [@claude-transcripts] [@codex-transcripts], which macOS treats as data owned by other apps. On macOS Sonoma and later, this can trigger a repeating "would like to access data from other apps" prompt for the launchd-scheduled `sync` job, because the prompt targets the exact executable that opened the files rather than CodeAlmanac as a concept. +Sync reads transcripts from `~/.claude/projects`, `~/.codex/sessions`, and OpenCode's SQLite store under `~/.local/share/opencode/opencode.db` [@claude-transcripts] [@codex-transcripts] [@opencode-transcripts], which macOS may treat as data owned by other apps. On macOS Sonoma and later, this can trigger a repeating "would like to access data from other apps" prompt for the launchd-scheduled `sync` job, because the prompt targets the exact executable that opened the files rather than CodeAlmanac as a concept. Granting Full Disk Access to Codex, Terminal, or another parent app does not stop the prompt, because the scheduled job runs as its own process under the Python interpreter tied to the current install. Grant Full Disk Access (System Settings > Privacy & Security > Full Disk Access) to that specific interpreter or `codealmanac` executable, then re-apply automation: diff --git a/almanac/reference/config-keys.md b/almanac/reference/config-keys.md index 795ca11e..a8fd779a 100644 --- a/almanac/reference/config-keys.md +++ b/almanac/reference/config-keys.md @@ -61,8 +61,8 @@ written [@config-tests]. | Key | Type | Default | Valid values | | --- | --- | --- | --- | | `auto_commit` | Boolean | `true` | `true` or `false` | -| `harness.default` | Harness name | `codex` | `codex` or `claude` | -| `harness.model` | Model name | `gpt-5.5` | One model from the selected harness catalog | +| `harness.default` | Harness name | `codex` | `codex`, `claude`, or `opencode` | +| `harness.model` | Model name | `gpt-5.5` | Controlled model for Codex/Claude, or OpenCode `provider/model` id | | `telemetry.enabled` | Boolean | `true` | `true` or `false` | | `automation.sync.enabled` | Boolean | `true` | `true` or `false` | | `automation.sync.every` | Duration | `5h` | Positive human duration | @@ -73,9 +73,9 @@ written [@config-tests]. `auto_commit` is prompt policy. It means lifecycle prompts may tell the selected agent to use normal Git commands for wiki source changes; CodeAlmanac itself does not stage, split, or commit diffs [@readme]. -`harness.default` chooses the default lifecycle harness. Setting it also resets `harness.model` to that harness's default model, so switching from Codex to Claude leaves the config valid [@config-service]. +`harness.default` chooses the default lifecycle harness. Setting it also resets `harness.model` to that harness's default model, so switching between Codex, Claude, and OpenCode leaves the config valid [@config-service]. -`harness.model` must be in the global controlled model set and must also belong to the currently selected default harness [@config-models] [@config-service]. +`harness.model` for Codex/Claude must be in that harness's controlled catalog. For OpenCode, model ids are dynamic `provider/model` strings (nested providers allowed) rather than a closed product list [@config-models] [@config-service]. `telemetry.enabled` controls anonymous command, lifecycle, and sanitized crash telemetry. Setting it to `false` takes effect on the next event. The installation @@ -88,6 +88,7 @@ UUID remains local SQLite state rather than TOML configuration | --- | --- | --- | | `codex` | `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex-spark` | `gpt-5.5` | | `claude` | `claude-sonnet-5`, `claude-opus-4-7`, `claude-haiku-4-5` | `claude-sonnet-5` | +| `opencode` | Any OpenCode `provider/model` id (for example `opencode/big-pickle`) | `opencode/big-pickle` | The model validator rejects unknown model names and rejects known model names that do not match the selected harness [@config-models]. The service-level parser raises the same product errors when a user runs `config set harness.model ...` [@config-service]. diff --git a/almanac/reference/harness-event-shape.md b/almanac/reference/harness-event-shape.md index 670bda33..53a52450 100644 --- a/almanac/reference/harness-event-shape.md +++ b/almanac/reference/harness-event-shape.md @@ -15,6 +15,10 @@ sources: type: file path: src/codealmanac/integrations/harnesses/yoke/events.py note: Converts Yoke EventKind values into HarnessEventKind and builds HarnessEvent from a Yoke Event. + - id: opencode-event-projector + type: file + path: src/codealmanac/integrations/harnesses/opencode/events.py + note: Projects OpenCode run JSON lines into HarnessEvent values. - id: operation-harness type: file path: src/codealmanac/workflows/operations/harness.py @@ -23,13 +27,13 @@ sources: # Harness Event Shape -The harness event shape is CodeAlmanac's normalized transcript format for agent runs. The [Yoke harness boundary](../architecture/agent-runs/provider-adapters) converts Codex or Claude event streams into `HarnessEvent` objects, and lifecycle code records those events without parsing provider-specific JSON [@harness-events]. This page defines the event kinds, optional payload groups, tool display fields, usage counters, failures, actor data, and agent traces used by the [harness contract](../architecture/agent-runs/harness-contract). +The harness event shape is CodeAlmanac's normalized transcript format for agent runs. Adapters convert runner-specific streams into `HarnessEvent` objects, and lifecycle code records those events without parsing provider JSON [@harness-events]. Today that means the [Yoke harness boundary](../architecture/agent-runs/provider-adapters) for Codex and Claude, and the [OpenCode harness](../architecture/agent-runs/opencode-harness) JSON-line projector for OpenCode [@yoke-event-projector] [@opencode-event-projector]. This page defines the event kinds, optional payload groups, tool display fields, usage counters, failures, actor data, and agent traces used by the [harness contract](../architecture/agent-runs/harness-contract). -A harness event always has a `kind` and non-empty `message`. All other fields are optional and depend on what the provider exposed for that event [@harness-events]. The shape is intentionally broad enough for text, tools, token usage, provider sessions, failures, terminal status, and helper-agent activity, so the [Yoke harness boundary](../architecture/agent-runs/provider-adapters) can add detail without changing lifecycle workflow code. +A harness event always has a `kind` and non-empty `message`. All other fields are optional and depend on what the provider exposed for that event [@harness-events]. The shape is intentionally broad enough for text, tools, token usage, provider sessions, failures, terminal status, and helper-agent activity, so adapters can add detail without changing lifecycle workflow code. ## Event Kinds -`HarnessEventKind` is the event vocabulary. These values are stable product categories, not raw provider names [@harness-events]. The first 20 kinds mirror the Yoke package's own provider-neutral `EventKind` vocabulary value for value, since the [Yoke harness adapter](../architecture/agent-runs/provider-adapters) converts a Yoke `Event.kind` straight into a `HarnessEventKind` with the same string value, falling back to `unknown` when a provider reports a kind Yoke does not recognize [@yoke-event-projector]. The last three kinds (`agent_spawned`, `agent_wait_started`, `agent_completed`) are CodeAlmanac-only additions the adapter synthesizes for helper-agent lifecycle tracking; Yoke has no equivalent kind for them [@yoke-event-projector]. +`HarnessEventKind` is the event vocabulary. These values are stable product categories, not raw provider names [@harness-events]. The first 20 kinds mirror Yoke's provider-neutral `EventKind` vocabulary value for value for Codex/Claude runs: the Yoke adapter converts a Yoke `Event.kind` straight into a `HarnessEventKind` with the same string value, falling back to `unknown` when a provider reports a kind Yoke does not recognize [@yoke-event-projector]. The OpenCode adapter maps a smaller CLI JSON surface onto the same vocabulary (notably `text`, `tool_use`, errors, and terminal status) [@opencode-event-projector]. The last three kinds (`agent_spawned`, `agent_wait_started`, `agent_completed`) are CodeAlmanac-only additions the Yoke path synthesizes for helper-agent lifecycle tracking [@yoke-event-projector]. | Kind | Meaning | |---|---| diff --git a/almanac/reference/local-state-layout.md b/almanac/reference/local-state-layout.md index 81b93604..d7f9ebdf 100644 --- a/almanac/reference/local-state-layout.md +++ b/almanac/reference/local-state-layout.md @@ -54,7 +54,7 @@ This layout follows the local-only Python product decision: authored Markdown st | `~/.codealmanac/repos//index.db` | Derived runtime | Per-repository search and graph index. | | `~/.codealmanac/update.lock` | Local runtime | Global update lock. | | `~/.codealmanac/logs/` | Local runtime | Scheduler stdout and stderr logs. | -| `~/.codealmanac/harnesses/` | Local runtime | Product-owned cache root Yoke uses to compile Claude and Codex provider resources, so provider config is never written into a repository. | +| `~/.codealmanac/harnesses/` | Local runtime | Product-owned cache root for harness runtimes (Yoke Claude/Codex compilation; OpenCode native adapter also receives this root). | `core.paths` defines the default state directory as `Path.home() / ".codealmanac"`, the default database as `~/.codealmanac/codealmanac.db`, the default config as `~/.codealmanac/config.toml`, and scheduler logs as `~/.codealmanac/logs` [@core-paths]. @@ -66,7 +66,7 @@ This layout follows the local-only Python product decision: authored Markdown st The database path must live directly inside the state directory. `LocalStatePaths` rejects a shape where the database is nested more deeply than `state_dir / "codealmanac.db"` would imply [@settings]. -`LocalStatePaths.harness_runtime_dir` is `state_dir / "harnesses"` [@settings]. The composition root passes it to both provider adapters so Yoke's native Claude and Codex compilation caches under CodeAlmanac's own local state instead of the target repository; see [Yoke harness boundary](../architecture/agent-runs/provider-adapters) for how the adapter uses this path for run caching and readiness checks [@yoke-adapter]. +`LocalStatePaths.harness_runtime_dir` is `state_dir / "harnesses"` [@settings]. The composition root passes it to default harness adapters so Yoke caches stay under CodeAlmanac local state and OpenCode receives the same product-owned root; see [Yoke harness boundary](../architecture/agent-runs/provider-adapters) and [OpenCode harness](../architecture/agent-runs/opencode-harness). ## Per-Repository Runtime diff --git a/almanac/topics.yaml b/almanac/topics.yaml index ececcda1..1ab4816a 100644 --- a/almanac/topics.yaml +++ b/almanac/topics.yaml @@ -49,7 +49,7 @@ topics: parents: [architecture] - slug: harnesses title: Harnesses - description: Local Codex and Claude runner integration boundaries + description: Local Codex, Claude, and OpenCode runner integration boundaries parents: [agent-runs] - slug: yoke title: Yoke diff --git a/src/codealmanac/cli/dispatch/setup_tui.py b/src/codealmanac/cli/dispatch/setup_tui.py index 0172aefa..9301a225 100644 --- a/src/codealmanac/cli/dispatch/setup_tui.py +++ b/src/codealmanac/cli/dispatch/setup_tui.py @@ -1,4 +1,5 @@ import argparse +import signal from codealmanac.cli.dispatch.setup_wizard.models import ( SetupCancelled, @@ -127,7 +128,10 @@ def wizard_selections( SetupChoiceScreen( step=3, title="Agent instructions", - question="Add CodeAlmanac instructions to your AGENTS.md / CLAUDE.md:", + question=( + "Add CodeAlmanac instructions to AGENTS.md, " + "CLAUDE.md, or OpenCode AGENTS.md:" + ), options=target_options(), total_steps=total_steps, ), @@ -197,31 +201,43 @@ def wizard_selections( def choose_setup_option(screen: SetupChoiceScreen, initial_index: int) -> int: selected_index = enabled_index(screen, initial_index) - render_setup_choice_screen(screen, selected_index) - while True: - key = read_setup_key() - if key in {"\x03", "\x1b", "q"}: - raise SetupCancelled() - if key in {"\r", "\n"}: - if screen.options[selected_index].disabled: + + def redraw_on_resize(signum: int, frame: object) -> None: + render_setup_choice_screen(screen, selected_index) + + has_sigwinch = hasattr(signal, "SIGWINCH") + previous_handler = ( + signal.signal(signal.SIGWINCH, redraw_on_resize) if has_sigwinch else None + ) + try: + render_setup_choice_screen(screen, selected_index) + while True: + key = read_setup_key() + if key in {"\x03", "\x1b", "q"}: + raise SetupCancelled() + if key in {"\r", "\n"}: + if screen.options[selected_index].disabled: + render_setup_choice_screen(screen, selected_index) + continue + return selected_index + shortcut_index = shortcut_option_index(screen, key) + if shortcut_index is not None: + return shortcut_index + next_index = selected_index + if key in {"\x1b[D", "a"}: + next_index = (selected_index - 1) % len(screen.options) + elif key in {"\x1b[C", "d"}: + next_index = (selected_index + 1) % len(screen.options) + elif key in {"\x1b[A", "w"}: + next_index = (selected_index - 1) % len(screen.options) + elif key in {"\x1b[B", "s"}: + next_index = (selected_index + 1) % len(screen.options) + if next_index != selected_index: + selected_index = enabled_index(screen, next_index, selected_index) render_setup_choice_screen(screen, selected_index) - continue - return selected_index - shortcut_index = shortcut_option_index(screen, key) - if shortcut_index is not None: - return shortcut_index - next_index = selected_index - if key in {"\x1b[D", "a"}: - next_index = (selected_index - 1) % len(screen.options) - elif key in {"\x1b[C", "d"}: - next_index = (selected_index + 1) % len(screen.options) - elif key in {"\x1b[A", "w"}: - next_index = (selected_index - 1) % len(screen.options) - elif key in {"\x1b[B", "s"}: - next_index = (selected_index + 1) % len(screen.options) - if next_index != selected_index: - selected_index = enabled_index(screen, next_index, selected_index) - render_setup_choice_screen(screen, selected_index) + finally: + if has_sigwinch: + signal.signal(signal.SIGWINCH, previous_handler) def enabled_index( diff --git a/src/codealmanac/cli/dispatch/setup_wizard/options.py b/src/codealmanac/cli/dispatch/setup_wizard/options.py index a99b8dfb..f9406a85 100644 --- a/src/codealmanac/cli/dispatch/setup_wizard/options.py +++ b/src/codealmanac/cli/dispatch/setup_wizard/options.py @@ -1,4 +1,5 @@ from codealmanac.cli.render.setup import SetupChoiceOption, SetupChoiceScreen +from codealmanac.integrations.harnesses.opencode.models import models_for_selection from codealmanac.services.config.models import HARNESS_MODELS from codealmanac.services.harnesses.models import HarnessKind, HarnessReadiness from codealmanac.services.setup.models import SetupTarget @@ -7,7 +8,7 @@ def target_options() -> tuple[SetupChoiceOption, ...]: return ( SetupChoiceOption( - "Codex + Claude", + "All runners", (), ("b",), ), @@ -21,6 +22,11 @@ def target_options() -> tuple[SetupChoiceOption, ...]: (), ("l",), ), + SetupChoiceOption( + "OpenCode only", + (), + ("o",), + ), ) @@ -45,6 +51,11 @@ def runner_options( return ( runner_option(HarnessKind.CODEX, by_kind.get(HarnessKind.CODEX), ("c",)), runner_option(HarnessKind.CLAUDE, by_kind.get(HarnessKind.CLAUDE), ("l",)), + runner_option( + HarnessKind.OPENCODE, + by_kind.get(HarnessKind.OPENCODE), + ("o",), + ), ) @@ -69,13 +80,32 @@ def runner_option( def model_options(harness: HarnessKind) -> tuple[SetupChoiceOption, ...]: return tuple( SetupChoiceOption( - MODEL_LABELS[model], - (MODEL_DETAILS[model],), + model_label(model), + (model_detail(model),), ) - for model in HARNESS_MODELS[harness] + for model in models_for_harness(harness) ) +def models_for_harness(harness: HarnessKind) -> tuple[str, ...]: + if harness is HarnessKind.OPENCODE: + return models_for_selection() + return HARNESS_MODELS[harness] + + +def model_label(model: str) -> str: + return MODEL_LABELS.get(model, model) + + +def model_detail(model: str) -> str: + if model in MODEL_DETAILS: + return MODEL_DETAILS[model] + provider, separator, name = model.partition("/") + if separator and name: + return f"OpenCode · {provider}" + return "OpenCode model" + + def update_options() -> tuple[SetupChoiceOption, ...]: return ( SetupChoiceOption( @@ -134,6 +164,8 @@ def target_default_index(targets: tuple[SetupTarget, ...]) -> int: return 1 if targets == (SetupTarget.CLAUDE,): return 2 + if targets == (SetupTarget.OPENCODE,): + return 3 return 0 @@ -142,34 +174,40 @@ def targets_for_index(index: int) -> tuple[SetupTarget, ...]: return (SetupTarget.CODEX,) if index == 2: return (SetupTarget.CLAUDE,) - return (SetupTarget.CODEX, SetupTarget.CLAUDE) + if index == 3: + return (SetupTarget.OPENCODE,) + return (SetupTarget.CODEX, SetupTarget.CLAUDE, SetupTarget.OPENCODE) def runner_for_index(index: int) -> HarnessKind: if index == 1: return HarnessKind.CLAUDE + if index == 2: + return HarnessKind.OPENCODE return HarnessKind.CODEX def runner_index(harness: HarnessKind) -> int: if harness == HarnessKind.CLAUDE: return 1 + if harness == HarnessKind.OPENCODE: + return 2 return 0 def model_for_index(harness: HarnessKind, index: int) -> str: - models = HARNESS_MODELS[harness] + models = models_for_harness(harness) return models[index] if index < len(models) else models[0] def model_index(harness: HarnessKind, model: str) -> int: - models = HARNESS_MODELS[harness] + models = models_for_harness(harness) return models.index(model) if model in models else 0 def parse_setup_targets(value: str) -> tuple[SetupTarget, ...]: if value == "all": - return (SetupTarget.CODEX, SetupTarget.CLAUDE) + return (SetupTarget.CODEX, SetupTarget.CLAUDE, SetupTarget.OPENCODE) return (SetupTarget(value),) @@ -181,10 +219,17 @@ def parse_setup_targets(value: str) -> tuple[SetupTarget, ...]: "claude-sonnet-5": "Claude Sonnet 5", "claude-opus-4-7": "Claude Opus 4.7", "claude-haiku-4-5": "Claude Haiku 4.5", + "opencode/big-pickle": "OpenCode Big Pickle", } RUNNER_LABELS = { HarnessKind.CODEX: "Codex", HarnessKind.CLAUDE: "Claude", + HarnessKind.OPENCODE: "OpenCode", +} +TARGET_LABELS = { + SetupTarget.CODEX: "Codex", + SetupTarget.CLAUDE: "Claude", + SetupTarget.OPENCODE: "OpenCode", } MODEL_DETAILS = { "gpt-5.5": "recommended wiki-writing runner", @@ -194,4 +239,5 @@ def parse_setup_targets(value: str) -> tuple[SetupTarget, ...]: "claude-sonnet-5": "recommended maintenance runner", "claude-opus-4-7": "deep rebuilds and hard gardens", "claude-haiku-4-5": "small routine updates", + "opencode/big-pickle": "free default OpenCode model", } diff --git a/src/codealmanac/cli/dispatch/sync.py b/src/codealmanac/cli/dispatch/sync.py index 00c1f4fe..20a28aa4 100644 --- a/src/codealmanac/cli/dispatch/sync.py +++ b/src/codealmanac/cli/dispatch/sync.py @@ -46,15 +46,16 @@ def dispatch_sync_status(args: argparse.Namespace, app: CodeAlmanac) -> int: def parse_sync_apps(value: str | None) -> tuple[TranscriptApp, ...]: if value is None or value.strip() == "": - return (TranscriptApp.CLAUDE, TranscriptApp.CODEX) + return tuple(TranscriptApp) apps: list[TranscriptApp] = [] + allowed = ",".join(app.value for app in TranscriptApp) for raw in value.split(","): item = raw.strip() try: app = TranscriptApp(item) except ValueError as error: raise ValidationFailed( - f'invalid --from "{value}" (expected claude,codex)' + f'invalid --from "{value}" (expected {allowed})' ) from error if app not in apps: apps.append(app) diff --git a/src/codealmanac/cli/parser/setup.py b/src/codealmanac/cli/parser/setup.py index 7f220610..ab0cc270 100644 --- a/src/codealmanac/cli/parser/setup.py +++ b/src/codealmanac/cli/parser/setup.py @@ -1,6 +1,6 @@ import argparse -SETUP_TARGETS = ("all", "codex", "claude") +SETUP_TARGETS = ("all", "codex", "claude", "opencode") def add_setup_commands(subcommands: argparse._SubParsersAction) -> None: @@ -14,7 +14,7 @@ def add_setup_commands(subcommands: argparse._SubParsersAction) -> None: setup.add_argument("--yes", action="store_true", help="run without prompts") setup.add_argument( "--runner", - choices=("codex", "claude"), + choices=("codex", "claude", "opencode"), help="agent that runs CodeAlmanac jobs (default: codex)", ) setup.add_argument( diff --git a/src/codealmanac/cli/render/brand.py b/src/codealmanac/cli/render/brand.py index d54fcc91..2f4306d0 100644 --- a/src/codealmanac/cli/render/brand.py +++ b/src/codealmanac/cli/render/brand.py @@ -1,4 +1,4 @@ -from codealmanac.cli.render.terminal import write_line +from codealmanac.cli.render.terminal import terminal_width, write_line RST = "\x1b[0m" BOLD = "\x1b[1m" @@ -9,12 +9,14 @@ ACCENT_BG = "\x1b[48;5;252m\x1b[38;5;16m" CLAUDE_CORAL = "\x1b[38;5;173m" CODEX_PERIWINKLE = "\x1b[38;5;105m" +OPENCODE_GREEN = "\x1b[38;5;35m" DIFF_RED = "\x1b[38;5;203m" DIFF_GREEN = "\x1b[38;5;76m" BRAND_COLORS = { "Codex": CODEX_PERIWINKLE, "Claude": CLAUDE_CORAL, + "OpenCode": OPENCODE_GREEN, } GRADIENT = ( @@ -41,9 +43,13 @@ def print_banner(subtitle: str | None = None) -> None: write_line("") - for index, line in enumerate(SETUP_BANNER): - color = GRADIENT[index] - write_line(f"{color}{line}{RST}") + banner_width = max(len(line) for line in SETUP_BANNER) + if terminal_width() >= banner_width: + for index, line in enumerate(SETUP_BANNER): + color = GRADIENT[index] + write_line(f"{color}{line}{RST}") + else: + write_line(f"{WHITE_BOLD} CodeAlmanac{RST}") write_line("") if subtitle is not None: write_line(f"{WHITE_BOLD} {subtitle}{RST}") diff --git a/src/codealmanac/cli/render/setup/change_handling.py b/src/codealmanac/cli/render/setup/change_handling.py new file mode 100644 index 00000000..fc75f833 --- /dev/null +++ b/src/codealmanac/cli/render/setup/change_handling.py @@ -0,0 +1,84 @@ +from codealmanac.cli.render.brand import ( + BLUE, + BOLD, + DIFF_GREEN, + DIFF_RED, + DIM, + RST, + WHITE_BOLD, +) +from codealmanac.cli.render.terminal import ( + card_right_row, + card_row, + card_width_for, + content_width, + selected_indicator, + write_line, +) + + +def render_change_handling_choice(selected_index: int) -> None: + width = card_width_for(2, content_width()) + cards = ( + change_handling_commit_card(width, selected_index == 0), + change_handling_worktree_card(width, selected_index == 1), + ) + rows = max(len(lines) for lines in cards) + for row in range(rows): + left = cards[0][row] if row < len(cards[0]) else " " * (width + 2) + right = cards[1][row] if row < len(cards[1]) else " " * (width + 2) + write_line(f" {left} {right}") + left_indicator = ( + selected_indicator(width, f"{BLUE}{BOLD}", RST) + if selected_index == 0 + else " " * (width + 2) + ) + right_indicator = ( + selected_indicator(width, f"{BLUE}{BOLD}", RST) + if selected_index == 1 + else " " * (width + 2) + ) + write_line(f" {left_indicator} {right_indicator}") + + +def change_handling_commit_card(width: int, selected: bool) -> tuple[str, ...]: + border = BLUE if selected else DIM + title = WHITE_BOLD if selected else DIM + muted = RST if selected else DIM + commit = BLUE if selected else DIM + return ( + f"{border}╭{'─' * width}╮{RST}", + card_row("", width, border, RST), + card_row(f" {title}Commit changes{RST}", width, border, RST), + card_row("", width, border, RST), + card_row(f" {commit}● almanac: update wiki context{RST}", width, border, RST), + card_row(f" {muted}│ rohan · just now{RST}", width, border, RST), + card_row(f" {muted}│{RST}", width, border, RST), + card_row(f" {muted}● docs: previous repo commit{RST}", width, border, RST), + card_row(f" {muted}│ rohan · earlier{RST}", width, border, RST), + card_row("", width, border, RST), + card_row("", width, border, RST), + f"{border}╰{'─' * width}╯{RST}", + ) + + +def change_handling_worktree_card(width: int, selected: bool) -> tuple[str, ...]: + border = BLUE if selected else DIM + title = WHITE_BOLD if selected else DIM + muted = RST if selected else DIM + delete = DIFF_RED if selected else DIM + add = DIFF_GREEN if selected else DIM + return ( + f"{border}╭{'─' * width}╮{RST}", + card_row("", width, border, RST), + card_row(f" {title}Leave in worktree{RST}", width, border, RST), + card_row("", width, border, RST), + card_row(f" {muted}almanac/architecture/indexing.md{RST}", width, border, RST), + card_right_row(f"{delete}-18{RST} {add}+42{RST}", width, border, RST), + card_row(f" {muted}almanac/decisions/local-first.md{RST}", width, border, RST), + card_right_row(f"{delete}-4{RST} {add}+19{RST}", width, border, RST), + card_row(f" {muted}almanac/guides/setup.md{RST}", width, border, RST), + card_right_row(f"{delete}-2{RST} {add}+11{RST}", width, border, RST), + card_row("", width, border, RST), + f"{border}╰{'─' * width}╯{RST}", + ) diff --git a/src/codealmanac/cli/render/setup/screens.py b/src/codealmanac/cli/render/setup/screens.py index 60e17741..a4e1c1bb 100644 --- a/src/codealmanac/cli/render/setup/screens.py +++ b/src/codealmanac/cli/render/setup/screens.py @@ -4,8 +4,6 @@ BAR, BLUE, BOLD, - DIFF_GREEN, - DIFF_RED, DIM, RST, WHITE_BOLD, @@ -17,10 +15,15 @@ BackgroundItemNotice, render_selected_background_item_notice, ) +from codealmanac.cli.render.setup.change_handling import render_change_handling_choice from codealmanac.cli.render.terminal import ( card_center_row, - card_right_row, card_row, + card_width_for, + columns_for, + content_width, + selected_indicator, + wrap_text, wrap_with_prefixes, write_line, ) @@ -56,7 +59,9 @@ def render_setup_choice_screen( progress = f"{DIM}[{screen.step}/{screen.total_steps}]{RST}" write_line(f" {BLUE}◆{RST} {progress} {WHITE_BOLD}{screen.title}{RST}") write_line(BAR) - for line in wrap_with_prefixes(screen.question, f"{BAR} ", f"{BAR} ", 78): + for line in wrap_with_prefixes( + screen.question, f"{BAR} ", f"{BAR} ", content_width() + ): write_line(line) write_line(BAR) write_line("") @@ -81,22 +86,44 @@ def render_option_cards( options: tuple[SetupChoiceOption, ...], selected_index: int, ) -> None: - card_width = 21 if len(options) == 3 else 34 - description_rows = max((len(option.description) for option in options), default=0) + available = content_width() + columns = columns_for(len(options), available) + card_width = card_width_for(columns, available) + inner_width = max(1, card_width - 2) + body_height = max( + len(wrap_text(option.label, inner_width)) + + sum(len(wrap_text(line, inner_width)) for line in option.description) + for option in options + ) + start = 0 + while start < len(options): + row_options = options[start : start + columns] + render_option_card_row( + row_options, start, selected_index, card_width, body_height + ) + start += columns + + +def render_option_card_row( + row_options: tuple[SetupChoiceOption, ...], + row_start: int, + selected_index: int, + card_width: int, + body_height: int, +) -> None: card_lines = tuple( - option_card(option, card_width, description_rows, index == selected_index) - for index, option in enumerate(options) + option_card( + option, card_width, row_start + index == selected_index, body_height + ) + for index, option in enumerate(row_options) ) - for row in range(max(len(lines) for lines in card_lines)): - parts = [] - for lines in card_lines: - parts.append(lines[row] if row < len(lines) else " " * (card_width + 2)) - write_line(" " + " ".join(parts)) + for row in range(len(card_lines[0])): + write_line(" " + " ".join(lines[row] for lines in card_lines)) indicator_parts = [ - selected_indicator(card_width) - if index == selected_index and not options[index].disabled + selected_indicator(card_width, f"{BLUE}{BOLD}", RST) + if row_start + index == selected_index and not option.disabled else " " * (card_width + 2) - for index in range(len(options)) + for index, option in enumerate(row_options) ] write_line(" " + " ".join(indicator_parts)) @@ -105,7 +132,7 @@ def render_vertical_options( options: tuple[SetupChoiceOption, ...], selected_index: int, ) -> None: - width = 56 + width = min(56, content_width()) border = BLUE write_line(f" {border}╭{'─' * width}╮{RST}") for index, option in enumerate(options): @@ -127,8 +154,8 @@ def render_vertical_options( def option_card( option: SetupChoiceOption, width: int, - description_rows: int, selected: bool, + body_height: int, ) -> tuple[str, ...]: enabled = not option.disabled border = BLUE if selected and enabled else DIM @@ -139,82 +166,15 @@ def option_card( lines = [ f"{border}╭{'─' * width}╮{RST}", card_row("", width, border, RST), - card_center_row(label, width, border, RST), ] + for label_line in wrap_text(label, max(1, width - 2)): + lines.append(card_center_row(label_line, width, border, RST)) for description in option.description: - lines.append(card_center_row(f"{body}{description}{RST}", width, border, RST)) - for _ in range(description_rows - len(option.description)): + for description_line in wrap_text(description, max(1, width - 2)): + lines.append( + card_center_row(f"{body}{description_line}{RST}", width, border, RST) + ) + while len(lines) - 2 < body_height + 1: lines.append(card_row("", width, border, RST)) - lines.append(card_row("", width, border, RST)) lines.append(f"{border}╰{'─' * width}╯{RST}") return tuple(lines) - - -def selected_indicator(width: int) -> str: - text = "◆ selected" - left_padding = max(0, (width + 2 - len(text)) // 2) - right_padding = max(0, width + 2 - left_padding - len(text)) - return f"{' ' * left_padding}{BLUE}{BOLD}{text}{RST}{' ' * right_padding}" - - -def render_change_handling_choice(selected_index: int) -> None: - width = 34 - cards = ( - change_handling_commit_card(width, selected_index == 0), - change_handling_worktree_card(width, selected_index == 1), - ) - rows = max(len(lines) for lines in cards) - for row in range(rows): - left = cards[0][row] if row < len(cards[0]) else " " * (width + 2) - right = cards[1][row] if row < len(cards[1]) else " " * (width + 2) - write_line(f" {left} {right}") - left_indicator = ( - selected_indicator(width) if selected_index == 0 else " " * (width + 2) - ) - right_indicator = ( - selected_indicator(width) if selected_index == 1 else " " * (width + 2) - ) - write_line(f" {left_indicator} {right_indicator}") - - -def change_handling_commit_card(width: int, selected: bool) -> tuple[str, ...]: - border = BLUE if selected else DIM - title = WHITE_BOLD if selected else DIM - muted = RST if selected else DIM - commit = BLUE if selected else DIM - return ( - f"{border}╭{'─' * width}╮{RST}", - card_row("", width, border, RST), - card_row(f" {title}Commit changes{RST}", width, border, RST), - card_row("", width, border, RST), - card_row(f" {commit}● almanac: update wiki context{RST}", width, border, RST), - card_row(f" {muted}│ rohan · just now{RST}", width, border, RST), - card_row(f" {muted}│{RST}", width, border, RST), - card_row(f" {muted}● docs: previous repo commit{RST}", width, border, RST), - card_row(f" {muted}│ rohan · earlier{RST}", width, border, RST), - card_row("", width, border, RST), - card_row("", width, border, RST), - f"{border}╰{'─' * width}╯{RST}", - ) - - -def change_handling_worktree_card(width: int, selected: bool) -> tuple[str, ...]: - border = BLUE if selected else DIM - title = WHITE_BOLD if selected else DIM - muted = RST if selected else DIM - delete = DIFF_RED if selected else DIM - add = DIFF_GREEN if selected else DIM - return ( - f"{border}╭{'─' * width}╮{RST}", - card_row("", width, border, RST), - card_row(f" {title}Leave in worktree{RST}", width, border, RST), - card_row("", width, border, RST), - card_row(f" {muted}almanac/architecture/indexing.md{RST}", width, border, RST), - card_right_row(f"{delete}-18{RST} {add}+42{RST}", width, border, RST), - card_row(f" {muted}almanac/decisions/local-first.md{RST}", width, border, RST), - card_right_row(f"{delete}-4{RST} {add}+19{RST}", width, border, RST), - card_row(f" {muted}almanac/guides/setup.md{RST}", width, border, RST), - card_right_row(f"{delete}-2{RST} {add}+11{RST}", width, border, RST), - card_row("", width, border, RST), - f"{border}╰{'─' * width}╯{RST}", - ) diff --git a/src/codealmanac/cli/render/terminal.py b/src/codealmanac/cli/render/terminal.py index 7aa104a4..ed36bfcc 100644 --- a/src/codealmanac/cli/render/terminal.py +++ b/src/codealmanac/cli/render/terminal.py @@ -53,6 +53,29 @@ def wrap_with_prefixes( return tuple(lines) +def wrap_text(text: str, width: int) -> tuple[str, ...]: + words = tuple(_fit_word(word, width) for word in text.split(" ") if len(word) > 0) + if len(words) == 0: + return ("",) + lines: list[str] = [] + line = words[0] + for word in words[1:]: + candidate = f"{line} {word}" + if visible_length(candidate) > width: + lines.append(line) + line = word + continue + line = candidate + lines.append(line) + return tuple(lines) + + +def _fit_word(word: str, width: int) -> str: + if visible_length(word) <= width or width <= 1: + return word + return f"{word[: width - 1]}…" + + def card_row(content: str, width: int, border: str, reset: str) -> str: padding = max(0, width - visible_length(content)) return f"{border}│{reset}{content}{' ' * padding}{border}│{reset}" @@ -70,5 +93,44 @@ def card_center_row(content: str, width: int, border: str, reset: str) -> str: return f"{border}│{reset}{' ' * left}{content}{' ' * right}{border}│{reset}" +def selected_indicator(width: int, style: str, reset: str) -> str: + text = "◆ selected" + left_padding = max(0, (width + 2 - len(text)) // 2) + right_padding = max(0, width + 2 - left_padding - len(text)) + return f"{' ' * left_padding}{style}{text}{reset}{' ' * right_padding}" + + +def content_width() -> int: + # Mirrors the floor/cap convention already used by steps.py and + # result.py: shrink with the real terminal, cap at the ~80-col design + # width, never go below a legible minimum. + return min(78, max(40, terminal_width() - 2)) + + +def card_width_for(count: int, available_width: int = 78) -> int: + # A row of `count` cards is: 3 leading spaces, each card is width+2 + # (borders), plus a 3-space gap between cards. Solving + # available_width = n*(width+5) for width keeps a row of cards within + # the given budget. + min_width = 12 + if count <= 0: + return min_width + return max(min_width, available_width // count - 5) + + +def columns_for(count: int, available_width: int) -> int: + # Pick the widest row (fewest wrapped rows) that still fits inside + # available_width without overflowing — falls back to a multi-row grid + # only when even a single card can't reach card_width_for's floor at + # that column count. + if count <= 1: + return max(1, count) + for columns in range(count, 0, -1): + width = card_width_for(columns, available_width) + if columns * (width + 5) <= available_width: + return columns + return 1 + + def shell_command(command: tuple[str, ...]) -> str: return shlex.join(command) diff --git a/src/codealmanac/database/__init__.py b/src/codealmanac/database/__init__.py index 41e951c1..c14f2573 100644 --- a/src/codealmanac/database/__init__.py +++ b/src/codealmanac/database/__init__.py @@ -5,6 +5,7 @@ SQLiteRow, apply_migrations, connect_sqlite, + query_readonly_or_empty, ) __all__ = ( @@ -14,4 +15,5 @@ "apply_migrations", "connect_sqlite", "open_local_database", + "query_readonly_or_empty", ) diff --git a/src/codealmanac/database/sqlite.py b/src/codealmanac/database/sqlite.py index 736719be..eb2c8057 100644 --- a/src/codealmanac/database/sqlite.py +++ b/src/codealmanac/database/sqlite.py @@ -56,3 +56,28 @@ def apply_migrations( def user_version(connection: SQLiteConnection) -> int: return int(connection.execute("PRAGMA user_version").fetchone()[0]) + + + +def query_readonly_or_empty( + path: Path, + sql: str, + params: tuple = (), +) -> tuple[SQLiteRow, ...]: + """Run one read-only query against a SQLite file this app doesn't own + the schema for (e.g. another program's local database), returning () + on any error — missing file, missing table/column, corrupt file — + instead of raising. Unlike connect_sqlite, this never writes, never + applies migrations, and tolerates the target schema not matching what + the caller expects.""" + try: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + except sqlite3.Error: + return () + try: + return tuple(connection.execute(sql, params).fetchall()) + except sqlite3.Error: + return () + finally: + connection.close() diff --git a/src/codealmanac/integrations/harnesses/__init__.py b/src/codealmanac/integrations/harnesses/__init__.py index 2269f4ba..ab237884 100644 --- a/src/codealmanac/integrations/harnesses/__init__.py +++ b/src/codealmanac/integrations/harnesses/__init__.py @@ -1,5 +1,6 @@ from pathlib import Path +from codealmanac.integrations.harnesses.opencode import OpenCodeHarnessAdapter from codealmanac.integrations.harnesses.yoke import YokeHarnessAdapter from codealmanac.services.harnesses.models import HarnessKind from codealmanac.services.harnesses.ports import HarnessAdapter @@ -9,4 +10,5 @@ def default_harness_adapters(runtime_root: Path) -> tuple[HarnessAdapter, ...]: return ( YokeHarnessAdapter(HarnessKind.CLAUDE, runtime_root), YokeHarnessAdapter(HarnessKind.CODEX, runtime_root), + OpenCodeHarnessAdapter(runtime_root), ) diff --git a/src/codealmanac/integrations/harnesses/opencode/__init__.py b/src/codealmanac/integrations/harnesses/opencode/__init__.py new file mode 100644 index 00000000..83419036 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/__init__.py @@ -0,0 +1,19 @@ +from codealmanac.integrations.harnesses.opencode.adapter import OpenCodeHarnessAdapter +from codealmanac.integrations.harnesses.opencode.models import ( + list_opencode_models, + models_for_selection, +) +from codealmanac.services.config.opencode_models import ( + OPENCODE_DEFAULT_MODEL, + OPENCODE_FALLBACK_MODELS, + is_opencode_model_id, +) + +__all__ = [ + "OPENCODE_DEFAULT_MODEL", + "OPENCODE_FALLBACK_MODELS", + "OpenCodeHarnessAdapter", + "is_opencode_model_id", + "list_opencode_models", + "models_for_selection", +] diff --git a/src/codealmanac/integrations/harnesses/opencode/adapter.py b/src/codealmanac/integrations/harnesses/opencode/adapter.py new file mode 100644 index 00000000..f4cd6a78 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/adapter.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import threading +import time +from collections.abc import Callable, Mapping +from pathlib import Path + +from codealmanac.agents.catalog import load_agent +from codealmanac.integrations.harnesses.opencode.events import ( + collect_output_text, + project_json_line, + status_from_events, +) +from codealmanac.services.harnesses.models import ( + HarnessAgentKind, + HarnessEvent, + HarnessEventKind, + HarnessFailure, + HarnessKind, + HarnessReadiness, + HarnessRunResult, + HarnessRunStatus, + HarnessTranscriptRef, + terminal_harness_event, +) +from codealmanac.services.harnesses.ports import HarnessEventSink +from codealmanac.services.harnesses.requests import RunHarnessRequest + +OPENCODE_BINARY = "opencode" +# 0 = no cap. Builds can legitimately run for hours; the old 90-minute hard +# cap killed long opencode runs mid-write. Override with the env var below. +OPENCODE_RUN_TIMEOUT_SECONDS = 0 +OPENCODE_RUN_TIMEOUT_ENV = "CODEALMANAC_OPENCODE_RUN_TIMEOUT" +OPENCODE_AGENT_PREFIX = "codealmanac" +LineSink = Callable[[str], None] +CommandRunner = Callable[ + [tuple[str, ...], Path, int, str | None, Mapping[str, str] | None, LineSink | None], + "OpenCodeProcessResult", +] + + +class OpenCodeProcessResult: + def __init__( + self, + returncode: int, + lines: tuple[str, ...], + stderr: str = "", + ): + self.returncode = returncode + self.lines = lines + self.stderr = stderr + + +class OpenCodeHarnessAdapter: + """Run CodeAlmanac lifecycle jobs through the OpenCode CLI.""" + + kind = HarnessKind.OPENCODE + + def __init__( + self, + runtime_root: Path, + *, + binary: str = OPENCODE_BINARY, + runner: CommandRunner | None = None, + which: Callable[[str], str | None] | None = None, + timeout_seconds: int | None = None, + ): + self.runtime_root = runtime_root + self.binary = binary + self.runner = runner or run_opencode_json + self.which = which or shutil.which + self.timeout_seconds = resolve_timeout(timeout_seconds) + + def check(self) -> HarnessReadiness: + path = self.which(self.binary) + if path is None: + return HarnessReadiness( + kind=self.kind, + available=False, + message="opencode not found on PATH", + repair=( + "install OpenCode (https://opencode.ai) and ensure " + "`opencode` is on PATH" + ), + ) + try: + completed = subprocess.run( + (path, "--version"), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return HarnessReadiness( + kind=self.kind, + available=False, + message=str(error), + repair=( + "repair the OpenCode installation, then rerun " + "codealmanac doctor" + ), + ) + if completed.returncode != 0: + details = ( + first_line(completed.stderr, completed.stdout) + or "opencode --version failed" + ) + return HarnessReadiness( + kind=self.kind, + available=False, + message=details, + repair=( + "repair the OpenCode installation, then rerun " + "codealmanac doctor" + ), + ) + version = ( + first_line(completed.stdout, completed.stderr) or "opencode available" + ) + return HarnessReadiness( + kind=self.kind, + available=True, + message=version, + ) + + def run( + self, + request: RunHarnessRequest, + on_event: HarnessEventSink | None = None, + ) -> HarnessRunResult: + # Stage lifecycle agents under product-owned local state, never the + # target repo. OpenCode loads OPENCODE_CONFIG_DIR as an *additive* + # agents/commands directory (after global + project config), so + # providers/auth stay on the user's real OpenCode config while our + # agents remain outside git status. + stage_root = self.runtime_root / "opencode" + agent_name = stage_lifecycle_agent(stage_root, request.agent) + args = ( + "run", + "--format", + "json", + "--auto", + "--dir", + str(request.cwd), + "--model", + request.model, + "--agent", + agent_name, + "--title", + f"codealmanac-{request.agent.value}", + ) + events: list[HarnessEvent] = [] + session_id: str | None = None + + def on_line(line: str) -> None: + nonlocal session_id + for event in project_json_line(line, session_id=session_id): + if event.provider_session_id: + session_id = event.provider_session_id + events.append(event) + if on_event is not None and event.kind is not HarnessEventKind.DONE: + on_event(event) + + try: + process = self.runner( + (self.binary, *args), + request.cwd, + self.timeout_seconds, + request.prompt.strip(), + {"OPENCODE_CONFIG_DIR": str(stage_root)}, + on_line, + ) + except FileNotFoundError: + return failed_result( + "opencode not found on PATH", + on_event=on_event, + repair="install OpenCode and ensure `opencode` is on PATH", + ) + except subprocess.TimeoutExpired: + return failed_result( + f"opencode timed out after {self.timeout_seconds}s", + on_event=on_event, + ) + except OSError as error: + return failed_result(str(error), on_event=on_event) + + if process.returncode != 0 and not any( + event.kind is HarnessEventKind.ERROR for event in events + ): + message = ( + first_line(process.stderr) + or f"opencode exited {process.returncode}" + ) + error_event = HarnessEvent( + kind=HarnessEventKind.ERROR, + message=message, + provider_session_id=session_id, + ) + events.append(error_event) + if on_event is not None: + on_event(error_event) + + status = status_from_events(tuple(events), process.returncode) + output = collect_output_text(tuple(events)) + if status is HarnessRunStatus.FAILED and process.stderr.strip(): + stderr_line = first_line(process.stderr) + if stderr_line and stderr_line not in output: + if output == "opencode completed": + output = stderr_line + else: + output = f"{output}\n{stderr_line}" + failure = ( + HarnessFailure(provider=self.kind, message=output.splitlines()[0]) + if status is HarnessRunStatus.FAILED + else None + ) + done = terminal_harness_event(self.kind, status, output) + if failure is not None: + done = done.model_copy(update={"failure": failure}) + events.append(done) + if on_event is not None: + on_event(done) + return HarnessRunResult( + kind=self.kind, + status=status, + output_text=output, + transcript=( + HarnessTranscriptRef(kind=self.kind, session_id=session_id) + if session_id is not None + else None + ), + events=tuple(events), + ) + + +def stage_lifecycle_agent(stage_root: Path, agent: HarnessAgentKind) -> str: + """Write packaged agent markdown under stage_root/agents/ (not the repo).""" + name = f"{OPENCODE_AGENT_PREFIX}-{agent.value}" + agents_dir = stage_root / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + instructions = load_agent(agent).instructions or "" + path = agents_dir / f"{name}.md" + path.write_text( + ( + "---\n" + f"description: CodeAlmanac {agent.value} lifecycle agent\n" + "mode: primary\n" + "permission:\n" + " edit: allow\n" + " bash: allow\n" + " external_directory: allow\n" + "---\n\n" + f"{instructions.strip()}\n" + ), + encoding="utf-8", + ) + return name + + +def run_opencode_json( + command: tuple[str, ...], + cwd: Path, + timeout_seconds: int, + stdin: str | None, + env: Mapping[str, str] | None = None, + on_line: LineSink | None = None, +) -> OpenCodeProcessResult: + process_env = os.environ.copy() + # Drop a parent OPENCODE_CONFIG_DIR first, then apply caller env. Lifecycle + # runs pass our product-owned stage dir; an interactive session's value + # must not leak into the worker and displace staged agents. + process_env.pop("OPENCODE_CONFIG_DIR", None) + if env: + process_env.update(env) + process = subprocess.Popen( + command, + cwd=cwd, + stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=process_env, + bufsize=1, + ) + lines: list[str] = [] + stderr_chunks: list[str] = [] + + def read_stdout() -> None: + assert process.stdout is not None + for line in process.stdout: + stripped = line.rstrip("\n") + if stripped.strip() == "": + continue + lines.append(stripped) + if on_line is not None: + on_line(stripped) + + def read_stderr() -> None: + assert process.stderr is not None + for chunk in process.stderr: + stderr_chunks.append(chunk) + + stdout_thread = threading.Thread(target=read_stdout, daemon=True) + stderr_thread = threading.Thread(target=read_stderr, daemon=True) + stdout_thread.start() + stderr_thread.start() + if stdin is not None and process.stdin is not None: + process.stdin.write(stdin) + process.stdin.close() + deadline = ( + time.monotonic() + timeout_seconds if timeout_seconds and timeout_seconds > 0 + else None + ) + while process.poll() is None: + if deadline is not None and time.monotonic() >= deadline: + process.kill() + stdout_thread.join(timeout=5) + stderr_thread.join(timeout=5) + raise subprocess.TimeoutExpired(command, timeout_seconds) + time.sleep(0.1) + stdout_thread.join(timeout=5) + stderr_thread.join(timeout=5) + return OpenCodeProcessResult( + returncode=process.returncode or 0, + lines=tuple(lines), + stderr="".join(stderr_chunks), + ) + + +def failed_result( + message: str, + *, + on_event: HarnessEventSink | None = None, + repair: str | None = None, +) -> HarnessRunResult: + failure = HarnessFailure( + provider=HarnessKind.OPENCODE, + message=message, + fix=repair, + ) + error = HarnessEvent( + kind=HarnessEventKind.ERROR, + message=message, + failure=failure, + ) + done = terminal_harness_event( + HarnessKind.OPENCODE, + HarnessRunStatus.FAILED, + message, + ).model_copy(update={"failure": failure}) + if on_event is not None: + on_event(error) + on_event(done) + return HarnessRunResult( + kind=HarnessKind.OPENCODE, + status=HarnessRunStatus.FAILED, + output_text=message, + events=(error, done), + ) + + +def resolve_timeout(explicit: int | None) -> int: + """Resolve the OpenCode run timeout: explicit > env > default (no cap).""" + if explicit is not None: + return max(0, explicit) + env_value = os.environ.get(OPENCODE_RUN_TIMEOUT_ENV) + if env_value and env_value.strip(): + try: + return max(0, int(env_value.strip())) + except ValueError: + return OPENCODE_RUN_TIMEOUT_SECONDS + return OPENCODE_RUN_TIMEOUT_SECONDS + + +def first_line(*values: str) -> str: + for value in values: + for line in value.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" diff --git a/src/codealmanac/integrations/harnesses/opencode/events.py b/src/codealmanac/integrations/harnesses/opencode/events.py new file mode 100644 index 00000000..908c29f7 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/events.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any + +from pydantic import JsonValue + +from codealmanac.services.harnesses.models import ( + HarnessActorConfidence, + HarnessActorRole, + HarnessEvent, + HarnessEventKind, + HarnessRunActor, + HarnessRunStatus, + HarnessToolDisplay, + HarnessToolDisplayKind, + HarnessToolStatus, + HarnessUsage, +) + + +def project_json_line( + line: str, + *, + session_id: str | None = None, +) -> tuple[HarnessEvent, ...]: + payload = parse_json_object(line) + if payload is None: + return () + return project_payload(payload, session_id=session_id) + + +def project_payload( + payload: Mapping[str, Any], + *, + session_id: str | None = None, +) -> tuple[HarnessEvent, ...]: + event_type = text(payload.get("type")) + if event_type is None: + return () + resolved_session = text(payload.get("sessionID")) or session_id + actor = root_actor(resolved_session) + if event_type == "text": + part = mapping(payload.get("part")) or {} + message = text(part.get("text")) or "text" + return ( + HarnessEvent( + kind=HarnessEventKind.TEXT, + message=message, + actor=actor, + provider_session_id=resolved_session, + raw=as_json(payload), + ), + ) + if event_type == "tool_use": + part = mapping(payload.get("part")) or {} + state = mapping(part.get("state")) or {} + tool_name = text(part.get("tool")) or "tool" + tool_id = text(part.get("id")) + status = tool_status(text(state.get("status"))) + error = text(state.get("error")) + message = f"{tool_name} {status.value}" if status is not None else tool_name + if error: + message = f"{tool_name} failed: {error}" + return ( + HarnessEvent( + kind=HarnessEventKind.TOOL_USE, + message=message, + actor=actor, + tool_id=tool_id, + tool_name=tool_name, + tool_input=json_text(state.get("input")), + tool_result=as_json(state.get("output") or state.get("error")), + tool_is_error=status is HarnessToolStatus.FAILED or bool(error), + tool_display=HarnessToolDisplay( + kind=tool_display_kind(tool_name), + title=tool_name, + path=text(state.get("path")), + command=text(state.get("command") or state.get("title")), + status=status, + summary=text(state.get("title")), + ), + provider_session_id=resolved_session, + provider_event_id=tool_id, + raw=as_json(payload), + ), + ) + if event_type == "error": + error = payload.get("error") + message = error_message(error) + return ( + HarnessEvent( + kind=HarnessEventKind.ERROR, + message=message, + actor=actor, + provider_session_id=resolved_session, + raw=as_json(payload), + ), + ) + if event_type in {"step_start", "step_finish", "reasoning"}: + return ( + HarnessEvent( + kind=HarnessEventKind.STREAM_EVENT, + message=event_type, + actor=actor, + provider_session_id=resolved_session, + raw=as_json(payload), + ), + ) + return ( + HarnessEvent( + kind=HarnessEventKind.UNKNOWN, + message=event_type, + actor=actor, + provider_session_id=resolved_session, + raw=as_json(payload), + ), + ) + + +def usage_from_part(part: Mapping[str, Any]) -> HarnessUsage | None: + tokens = mapping(part.get("tokens")) or mapping(part.get("usage")) + if tokens is None: + return None + return HarnessUsage( + input_tokens=int_or_none(tokens.get("input")), + output_tokens=int_or_none(tokens.get("output")), + reasoning_output_tokens=int_or_none(tokens.get("reasoning")), + total_tokens=int_or_none(tokens.get("total")), + cached_input_tokens=int_or_none( + mapping(tokens.get("cache") or {}).get("read") + if isinstance(tokens.get("cache"), dict) + else tokens.get("cache") + ), + ) + + +def root_actor(session_id: str | None) -> HarnessRunActor: + if session_id is None: + return HarnessRunActor( + role=HarnessActorRole.UNKNOWN, + label="Unknown actor", + confidence=HarnessActorConfidence.UNKNOWN, + ) + return HarnessRunActor( + thread_id=session_id, + role=HarnessActorRole.ROOT, + label="Main", + confidence=HarnessActorConfidence.PROVIDER, + ) + + +def tool_status(value: str | None) -> HarnessToolStatus | None: + if value is None: + return None + if value in {"completed", "complete", "success"}: + return HarnessToolStatus.COMPLETED + if value in {"error", "failed"}: + return HarnessToolStatus.FAILED + if value in {"running", "pending", "started"}: + return HarnessToolStatus.STARTED + if value in {"denied", "declined"}: + return HarnessToolStatus.DECLINED + return None + + +def tool_display_kind(name: str) -> HarnessToolDisplayKind: + lowered = name.casefold() + if lowered in {"read", "view", "cat"}: + return HarnessToolDisplayKind.READ + if lowered in {"write", "create"}: + return HarnessToolDisplayKind.WRITE + if lowered in {"edit", "apply_patch", "multiedit", "patch"}: + return HarnessToolDisplayKind.EDIT + if lowered in {"grep", "glob", "search", "list"}: + return HarnessToolDisplayKind.SEARCH + if lowered in {"bash", "shell", "run"}: + return HarnessToolDisplayKind.SHELL + if lowered in {"webfetch", "websearch", "web"}: + return HarnessToolDisplayKind.WEB + if lowered in {"task", "agent"}: + return HarnessToolDisplayKind.AGENT + if lowered.startswith("mcp"): + return HarnessToolDisplayKind.MCP + return HarnessToolDisplayKind.UNKNOWN + + +def parse_json_object(line: str) -> dict[str, Any] | None: + stripped = line.strip() + if stripped == "": + return None + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + return parsed + + +def mapping(value: object) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + return None + + +def text(value: object) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def int_or_none(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def json_text(value: object) -> str | None: + if value is None: + return None + if isinstance(value, str): + return text(value) + try: + return json.dumps(value, ensure_ascii=True, separators=(",", ":")) + except (TypeError, ValueError): + return str(value) + + +def as_json(value: object) -> JsonValue | None: + if value is None: + return None + try: + return json.loads(json.dumps(value)) + except (TypeError, ValueError): + return str(value) + + +def error_message(error: object) -> str: + if isinstance(error, str) and error.strip(): + return error.strip() + mapped = mapping(error) + if mapped is None: + return "opencode error" + data = mapping(mapped.get("data")) + if data is not None: + message = text(data.get("message")) + if message is not None: + return message + name = text(mapped.get("name")) + message = text(mapped.get("message")) + if name and message: + return f"{name}: {message}" + return message or name or "opencode error" + + +def collect_output_text(events: tuple[HarnessEvent, ...]) -> str: + texts = [ + event.message + for event in events + if event.kind is HarnessEventKind.TEXT and event.message.strip() + ] + if texts: + return "\n".join(texts).strip() + errors = [ + event.message + for event in events + if event.kind is HarnessEventKind.ERROR and event.message.strip() + ] + if errors: + return errors[-1] + return "opencode completed" + + +def status_from_events( + events: tuple[HarnessEvent, ...], + returncode: int, +) -> HarnessRunStatus: + if returncode != 0: + return HarnessRunStatus.FAILED + if any(event.kind is HarnessEventKind.ERROR for event in events): + return HarnessRunStatus.FAILED + return HarnessRunStatus.SUCCEEDED diff --git a/src/codealmanac/integrations/harnesses/opencode/models.py b/src/codealmanac/integrations/harnesses/opencode/models.py new file mode 100644 index 00000000..7a40cea9 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/models.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import shutil +import subprocess +from collections.abc import Callable + +from codealmanac.services.config.opencode_models import ( + OPENCODE_FALLBACK_MODELS, + is_opencode_model_id, +) + +OPENCODE_BINARY = "opencode" +OPENCODE_MODELS_TIMEOUT_SECONDS = 20 + +CommandRunner = Callable[[tuple[str, ...], float], tuple[int, str, str]] + + +def list_opencode_models( + *, + binary: str = OPENCODE_BINARY, + timeout_seconds: float = OPENCODE_MODELS_TIMEOUT_SECONDS, + runner: CommandRunner | None = None, + which: Callable[[str], str | None] | None = None, +) -> tuple[str, ...]: + """Return model ids from `opencode models`, or ().""" + locate = which or shutil.which + path = locate(binary) + if path is None: + return () + run = runner or run_command + try: + code, stdout, _stderr = run((path, "models"), timeout_seconds) + except (OSError, subprocess.TimeoutExpired): + return () + if code != 0: + return () + return parse_opencode_models_output(stdout) + + +def models_for_selection( + *, + binary: str = OPENCODE_BINARY, + timeout_seconds: float = OPENCODE_MODELS_TIMEOUT_SECONDS, + runner: CommandRunner | None = None, + which: Callable[[str], str | None] | None = None, +) -> tuple[str, ...]: + """Prefer live catalog; fall back to a short curated list.""" + discovered = list_opencode_models( + binary=binary, + timeout_seconds=timeout_seconds, + runner=runner, + which=which, + ) + if discovered: + return discovered + return OPENCODE_FALLBACK_MODELS + + +def parse_opencode_models_output(stdout: str) -> tuple[str, ...]: + seen: set[str] = set() + models: list[str] = [] + for line in stdout.splitlines(): + token = line.strip() + if not is_opencode_model_id(token) or token in seen: + continue + seen.add(token) + models.append(token) + return tuple(models) + + +def run_command( + command: tuple[str, ...], + timeout_seconds: float, +) -> tuple[int, str, str]: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + return completed.returncode, completed.stdout or "", completed.stderr or "" diff --git a/src/codealmanac/integrations/harnesses/yoke/adapter.py b/src/codealmanac/integrations/harnesses/yoke/adapter.py index 24f42ad0..411731f5 100644 --- a/src/codealmanac/integrations/harnesses/yoke/adapter.py +++ b/src/codealmanac/integrations/harnesses/yoke/adapter.py @@ -157,6 +157,8 @@ def run_options( request: RunHarnessRequest, on_event: Callable[[Event], None], ) -> RunOptions: + if request.kind is HarnessKind.OPENCODE: + raise ValueError("OpenCode runs through OpenCodeHarnessAdapter, not Yoke") return RunOptions( model=request.model, max_turns=CLAUDE_MAX_TURNS, @@ -182,14 +184,16 @@ def provider_options(kind: HarnessKind) -> ProviderOptions: raw={"mcp_servers": {}, "strict_mcp_config": True}, ) ) - return ProviderOptions( - codex=CodexOptions( - sandbox=CodexSandbox.DANGER_FULL_ACCESS, - approval=CodexApproval.NEVER, - network=False, - app_server=CodexAppServerOptions(ephemeral=True), + if kind is HarnessKind.CODEX: + return ProviderOptions( + codex=CodexOptions( + sandbox=CodexSandbox.DANGER_FULL_ACCESS, + approval=CodexApproval.NEVER, + network=False, + app_server=CodexAppServerOptions(ephemeral=True), + ) ) - ) + raise ValueError(f"unsupported yoke harness kind: {kind.value}") def project_readiness( diff --git a/src/codealmanac/integrations/setup/instructions.py b/src/codealmanac/integrations/setup/instructions.py index 4031db12..a5a0ca06 100644 --- a/src/codealmanac/integrations/setup/instructions.py +++ b/src/codealmanac/integrations/setup/instructions.py @@ -1,7 +1,7 @@ from pathlib import Path from codealmanac.core.paths import home_dir -from codealmanac.integrations.setup import claude, codex, managed_blocks +from codealmanac.integrations.setup import claude, codex, managed_blocks, opencode from codealmanac.integrations.setup.guide import read_agent_guide from codealmanac.services.setup.models import InstructionChange, SetupTarget @@ -50,6 +50,8 @@ def install_target(target: SetupTarget, home: Path, guide: str) -> InstructionCh return codex.install_codex_instructions(home, guide) if target == SetupTarget.CLAUDE: return claude.install_claude_instructions(home, guide) + if target == SetupTarget.OPENCODE: + return opencode.install_opencode_instructions(home, guide) raise ValueError(f"unsupported setup target: {target.value}") @@ -58,4 +60,6 @@ def uninstall_target(target: SetupTarget, home: Path) -> InstructionChange: return codex.uninstall_codex_instructions(home) if target == SetupTarget.CLAUDE: return claude.uninstall_claude_instructions(home) + if target == SetupTarget.OPENCODE: + return opencode.uninstall_opencode_instructions(home) raise ValueError(f"unsupported setup target: {target.value}") diff --git a/src/codealmanac/integrations/setup/opencode.py b/src/codealmanac/integrations/setup/opencode.py new file mode 100644 index 00000000..c6424b06 --- /dev/null +++ b/src/codealmanac/integrations/setup/opencode.py @@ -0,0 +1,65 @@ +from pathlib import Path + +from codealmanac.integrations.setup.managed_blocks import ( + format_managed_block, + remove_managed_block, + upsert_managed_block, +) +from codealmanac.integrations.setup.text_files import read_text_if_present +from codealmanac.services.setup.models import InstructionChange, SetupTarget + + +def install_opencode_instructions(home: Path, guide: str) -> InstructionChange: + agents_path = opencode_agents_path(home) + agents_path.parent.mkdir(parents=True, exist_ok=True) + existing = read_text_if_present(agents_path) + block = format_managed_block(guide) + next_body = upsert_managed_block(existing, block) + if next_body == existing: + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=False, + paths=(agents_path,), + message="OpenCode instructions already installed", + ) + agents_path.write_text(next_body, encoding="utf-8") + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=True, + paths=(agents_path,), + message="Installed OpenCode AGENTS instructions", + ) + + +def uninstall_opencode_instructions(home: Path) -> InstructionChange: + agents_path = opencode_agents_path(home) + existing = read_text_if_present(agents_path) + if existing == "": + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=False, + paths=(), + message="OpenCode instructions were not installed", + ) + removed = remove_managed_block(existing) + if removed == existing: + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=False, + paths=(agents_path,), + message="OpenCode instructions were not installed", + ) + if removed.strip() == "": + agents_path.unlink(missing_ok=True) + else: + agents_path.write_text(removed, encoding="utf-8") + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=True, + paths=(agents_path,), + message="Removed OpenCode AGENTS instructions", + ) + + +def opencode_agents_path(home: Path) -> Path: + return home / ".config" / "opencode" / "AGENTS.md" diff --git a/src/codealmanac/integrations/sources/__init__.py b/src/codealmanac/integrations/sources/__init__.py index 12a90e2a..ff0249f6 100644 --- a/src/codealmanac/integrations/sources/__init__.py +++ b/src/codealmanac/integrations/sources/__init__.py @@ -4,6 +4,7 @@ from codealmanac.integrations.sources.transcripts import ( TranscriptSourceRuntimeAdapter, default_transcript_discovery_adapters, + default_transcript_runtime_adapters, ) from codealmanac.integrations.sources.web import WebSourceRuntimeAdapter from codealmanac.services.sources.ports import SourceRuntimeAdapter @@ -14,7 +15,7 @@ def default_source_runtime_adapters() -> tuple[SourceRuntimeAdapter, ...]: FilesystemSourceRuntimeAdapter(), GitSourceRuntimeAdapter(), GitHubSourceRuntimeAdapter(), - TranscriptSourceRuntimeAdapter(), + *default_transcript_runtime_adapters(), WebSourceRuntimeAdapter(), ) diff --git a/src/codealmanac/integrations/sources/transcripts/__init__.py b/src/codealmanac/integrations/sources/transcripts/__init__.py index 8b72f669..7ac8c945 100644 --- a/src/codealmanac/integrations/sources/transcripts/__init__.py +++ b/src/codealmanac/integrations/sources/transcripts/__init__.py @@ -4,6 +4,12 @@ from codealmanac.integrations.sources.transcripts.codex import ( CodexTranscriptDiscoveryAdapter, ) +from codealmanac.integrations.sources.transcripts.opencode import ( + OpencodeTranscriptDiscoveryAdapter, +) +from codealmanac.integrations.sources.transcripts.opencode_runtime import ( + OpencodeTranscriptRuntimeAdapter, +) from codealmanac.integrations.sources.transcripts.runtime import ( TranscriptSourceRuntimeAdapter, ) @@ -17,16 +23,29 @@ def default_transcript_discovery_adapters() -> tuple[TranscriptDiscoveryAdapter, return ( ClaudeTranscriptDiscoveryAdapter(), CodexTranscriptDiscoveryAdapter(), + OpencodeTranscriptDiscoveryAdapter(), ) def default_transcript_runtime_adapters() -> tuple[SourceRuntimeAdapter, ...]: - return (TranscriptSourceRuntimeAdapter(),) + # Order matters: SourcesService.inspect_runtime() dispatches to the + # first adapter whose supports() matches, and TranscriptSourceRuntimeAdapter + # claims every SourceKind.TRANSCRIPT ref (it has no way to know about + # OpenCode's non-file-backed identity scheme). OpencodeTranscriptRuntimeAdapter + # must be checked first so it can claim its own opencode-session: refs + # before the generic file-based adapter does and fails trying to open + # a synthetic path as a real file. + return ( + OpencodeTranscriptRuntimeAdapter(), + TranscriptSourceRuntimeAdapter(), + ) __all__ = [ "ClaudeTranscriptDiscoveryAdapter", "CodexTranscriptDiscoveryAdapter", + "OpencodeTranscriptDiscoveryAdapter", + "OpencodeTranscriptRuntimeAdapter", "TranscriptSourceRuntimeAdapter", "default_transcript_discovery_adapters", "default_transcript_runtime_adapters", diff --git a/src/codealmanac/integrations/sources/transcripts/opencode.py b/src/codealmanac/integrations/sources/transcripts/opencode.py new file mode 100644 index 00000000..26f8ff6b --- /dev/null +++ b/src/codealmanac/integrations/sources/transcripts/opencode.py @@ -0,0 +1,69 @@ +from datetime import UTC, datetime +from pathlib import Path + +from codealmanac.core.paths import normalize_path +from codealmanac.database import query_readonly_or_empty +from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate +from codealmanac.services.sources.requests import DiscoverTranscriptsRequest + +OPENCODE_DB_RELATIVE_PATH = Path(".local") / "share" / "opencode" / "opencode.db" +# Distinguishes an OpenCode session identity from a real filesystem path in +# TranscriptCandidate.transcript_path / SourceRef.transcript. OpenCode has no +# per-session file to point at — its transcript data lives as rows in one +# shared SQLite database, not one file per session — so this scheme prefix +# is how OpencodeTranscriptRuntimeAdapter recognizes "resolve this by +# querying the database," rather than by opening it as a file. +OPENCODE_TRANSCRIPT_SCHEME = "opencode-session:" + +_ROOT_SESSIONS_QUERY = """ +SELECT id, directory, time_updated +FROM session +WHERE parent_id IS NULL +ORDER BY time_updated DESC +""" + + +class OpencodeTranscriptDiscoveryAdapter: + app = TranscriptApp.OPENCODE + + def __init__(self, db_path: Path | None = None): + self.db_path = db_path + + def discover( + self, + request: DiscoverTranscriptsRequest, + ) -> tuple[TranscriptCandidate, ...]: + db_path = self.db_path or request.home / OPENCODE_DB_RELATIVE_PATH + rows = query_readonly_or_empty(db_path, _ROOT_SESSIONS_QUERY) + candidates: list[TranscriptCandidate] = [] + for row in rows: + directory = row["directory"] + session_id = row["id"] + time_updated = row["time_updated"] + if not directory or not session_id or time_updated is None: + continue + candidates.append( + TranscriptCandidate( + app=self.app, + session_id=session_id, + transcript_path=opencode_transcript_identity(session_id), + cwd=normalize_path(Path(directory)), + modified_at=datetime.fromtimestamp(time_updated / 1000, UTC), + # OpenCode sessions aren't files; there's no on-disk size + # to report, and nothing downstream currently displays + # this field for any app. + size_bytes=0, + ) + ) + return tuple(candidates) + + +def opencode_transcript_identity(session_id: str) -> Path: + return Path(f"{OPENCODE_TRANSCRIPT_SCHEME}{session_id}") + + +def opencode_session_id(transcript: str) -> str | None: + if not transcript.startswith(OPENCODE_TRANSCRIPT_SCHEME): + return None + session_id = transcript.removeprefix(OPENCODE_TRANSCRIPT_SCHEME) + return session_id or None diff --git a/src/codealmanac/integrations/sources/transcripts/opencode_runtime.py b/src/codealmanac/integrations/sources/transcripts/opencode_runtime.py new file mode 100644 index 00000000..3fa6b927 --- /dev/null +++ b/src/codealmanac/integrations/sources/transcripts/opencode_runtime.py @@ -0,0 +1,180 @@ +import json +from pathlib import Path + +from codealmanac.database import query_readonly_or_empty +from codealmanac.integrations.sources.transcripts.jsonl import ( + object_field, + string_field, +) +from codealmanac.integrations.sources.transcripts.models import ( + TranscriptRuntimeEntry, + TranscriptRuntimeLineKind, +) +from codealmanac.integrations.sources.transcripts.opencode import ( + OPENCODE_DB_RELATIVE_PATH, + opencode_session_id, + opencode_transcript_identity, +) +from codealmanac.integrations.sources.transcripts.rendering import ( + render_json_text, + render_transcript_runtime, +) +from codealmanac.services.sources.models import ( + SourceKind, + SourceRef, + SourceRuntime, + SourceRuntimeStatus, +) +from codealmanac.services.sources.requests import InspectSourceRuntimeRequest + +_SESSION_PARTS_QUERY = """ +SELECT part.id AS id, part.data AS part_data, message.data AS message_data +FROM part +JOIN message ON message.id = part.message_id +WHERE part.session_id = ? +ORDER BY part.time_created +""" + + +class OpencodeTranscriptRuntimeAdapter: + def __init__(self, db_path: Path | None = None): + self.db_path = db_path + + def supports(self, ref: SourceRef) -> bool: + return ( + ref.kind == SourceKind.TRANSCRIPT + and ref.transcript is not None + and opencode_session_id(ref.transcript) is not None + ) + + def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime: + ref = request.ref + session_id = opencode_session_id(ref.transcript or "") + if session_id is None: + return SourceRuntime( + ref=ref, + status=SourceRuntimeStatus.SKIPPED, + title=f"Unsupported transcript source {ref.identity}", + ) + db_path = self.db_path or _default_db_path() + rows = query_readonly_or_empty(db_path, _SESSION_PARTS_QUERY, (session_id,)) + display_path = opencode_transcript_identity(session_id) + if not rows: + return SourceRuntime( + ref=ref, + status=SourceRuntimeStatus.UNAVAILABLE, + title=f"Transcript {display_path}", + diagnostics=(f"no readable session parts found for {session_id}",), + ) + entries = tuple( + entry + for entry in ( + _entry_from_row(index, row) for index, row in enumerate(rows, start=1) + ) + if entry is not None + ) + if not entries: + return SourceRuntime( + ref=ref, + status=SourceRuntimeStatus.UNAVAILABLE, + title=f"Transcript {display_path}", + diagnostics=("no renderable parts in session",), + ) + content, truncated = render_transcript_runtime(display_path, entries, 60_000) + return SourceRuntime( + ref=ref, + status=SourceRuntimeStatus.AVAILABLE, + title=f"Transcript {display_path}", + content=content, + truncated=truncated, + ) + + +def _default_db_path() -> Path: + from codealmanac.core.paths import home_dir + + return home_dir() / OPENCODE_DB_RELATIVE_PATH + + +def _entry_from_row(line_number: int, row) -> TranscriptRuntimeEntry | None: + part = _parse_json(row["part_data"]) + if part is None: + return None + message = _parse_json(row["message_data"]) + role = (string_field(message, "role") if message is not None else None) or "unknown" + part_type = string_field(part, "type") + if part_type == "text": + text = string_field(part, "text") + if text is None: + return None + # Includes user-authored text too (the original prompt), not just + # assistant output — unlike the live-progress watchdog this reuses + # part-shape knowledge from, a past transcript read for ingest needs + # the question a session was answering, not just its answer. + return _entry(line_number, TranscriptRuntimeLineKind.MESSAGE, role, text) + if role != "assistant": + # Non-text parts (tool calls, reasoning, patches) from a non- + # assistant role aren't meaningful to render — OpenCode's own user + # turns are plain text prompts. + return None + if part_type == "reasoning": + text = string_field(part, "text") + if text is None: + return None + return _entry(line_number, TranscriptRuntimeLineKind.EVENT, "reasoning", text) + if part_type == "tool": + return _tool_entry(line_number, part) + if part_type == "patch": + files = part.get("files") + if not isinstance(files, list) or not files: + return None + names = ", ".join(str(item) for item in files) + return _entry( + line_number, + TranscriptRuntimeLineKind.EVENT, + "patch", + f"files changed: {names}", + ) + return None + + +def _tool_entry(line_number: int, part: dict) -> TranscriptRuntimeEntry | None: + tool_name = string_field(part, "tool") or "tool" + state = object_field(part, "state") or {} + status = string_field(state, "status") + if status == "running": + return None + kind = ( + TranscriptRuntimeLineKind.TOOL_RESULT + if status in ("completed", "error") + else TranscriptRuntimeLineKind.TOOL_CALL + ) + input_text = render_json_text(state.get("input")) + output_text = render_json_text(state.get("output")) + text = "\n".join(piece for piece in (input_text, output_text) if piece) or "(empty)" + return _entry(line_number, kind, tool_name, text) + + +def _entry( + line_number: int, + kind: TranscriptRuntimeLineKind, + label: str, + text: str, +) -> TranscriptRuntimeEntry: + rendered = text.strip() or "(empty)" + return TranscriptRuntimeEntry( + line_number=line_number, + kind=kind, + label=label, + text=rendered, + ) + + +def _parse_json(value: object) -> dict | None: + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/src/codealmanac/integrations/sources/transcripts/runtime.py b/src/codealmanac/integrations/sources/transcripts/runtime.py index 1ce3c8c7..87acdd9c 100644 --- a/src/codealmanac/integrations/sources/transcripts/runtime.py +++ b/src/codealmanac/integrations/sources/transcripts/runtime.py @@ -1,4 +1,5 @@ from codealmanac.integrations.sources.transcripts.errors import unavailable_runtime +from codealmanac.integrations.sources.transcripts.opencode import opencode_session_id from codealmanac.integrations.sources.transcripts.paths import transcript_path from codealmanac.integrations.sources.transcripts.reader import read_transcript_entries from codealmanac.integrations.sources.transcripts.rendering import ( @@ -20,7 +21,17 @@ def __init__(self, max_chars: int = DEFAULT_MAX_CHARS): self.max_chars = max_chars def supports(self, ref: SourceRef) -> bool: - return ref.kind == SourceKind.TRANSCRIPT + if ref.kind != SourceKind.TRANSCRIPT: + return False + # OpenCode refs use a non-file identity scheme (opencode-session:...) + # handled by OpencodeTranscriptRuntimeAdapter instead — every OpenCode + # session shares one database file, so there's no per-session path + # this adapter could open. Ruled out explicitly so this adapter's + # supports() is correct standing alone, not merely correct because + # of registration order in default_transcript_runtime_adapters(). + if ref.transcript is None: + return True + return opencode_session_id(ref.transcript) is None def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime: if request.ref.kind != SourceKind.TRANSCRIPT: diff --git a/src/codealmanac/services/config/models.py b/src/codealmanac/services/config/models.py index 24b0b051..ceb5220e 100644 --- a/src/codealmanac/services/config/models.py +++ b/src/codealmanac/services/config/models.py @@ -16,11 +16,18 @@ AutomationTask, AutomationTaskApplyResult, ) +from codealmanac.services.config.opencode_models import ( + OPENCODE_DEFAULT_MODEL, + OPENCODE_FALLBACK_MODELS, + is_opencode_model_id, +) from codealmanac.services.harnesses.models import HarnessKind DEFAULT_HARNESS = HarnessKind.CODEX DEFAULT_HARNESS_MODEL = "gpt-5.5" DEFAULT_AUTO_COMMIT = True +# Fixed allowlists for Codex/Claude. OpenCode models are dynamic (provider/model) +# from `opencode models` and are validated by format, not this set. CONTROLLED_HARNESS_MODELS = frozenset( ( "gpt-5.5", @@ -44,10 +51,13 @@ "claude-opus-4-7", "claude-haiku-4-5", ), + # Fallback picks when OpenCode CLI catalog is unavailable. + HarnessKind.OPENCODE: OPENCODE_FALLBACK_MODELS, } DEFAULT_HARNESS_MODELS = { HarnessKind.CODEX: DEFAULT_HARNESS_MODEL, HarnessKind.CLAUDE: "claude-sonnet-5", + HarnessKind.OPENCODE: OPENCODE_DEFAULT_MODEL, } @@ -70,14 +80,25 @@ class HarnessConfig(CodeAlmanacModel): @field_validator("model") @classmethod - def controlled_model(cls, value: str) -> str: - if value not in CONTROLLED_HARNESS_MODELS: - allowed = ", ".join(sorted(CONTROLLED_HARNESS_MODELS)) - raise ValueError(f"harness.model must be one of: {allowed}") - return value + def known_or_opencode_model(cls, value: str) -> str: + token = value.strip() + if token in CONTROLLED_HARNESS_MODELS or is_opencode_model_id(token): + return token + allowed = ", ".join(sorted(CONTROLLED_HARNESS_MODELS)) + raise ValueError( + f"harness.model must be one of: {allowed}, " + "or an OpenCode provider/model id" + ) @model_validator(mode="after") def model_matches_harness(self) -> "HarnessConfig": + if self.default is HarnessKind.OPENCODE: + if not is_opencode_model_id(self.model): + raise ValueError( + "harness.model for opencode must be provider/model " + "(see `opencode models`)" + ) + return self if self.model not in HARNESS_MODELS[self.default]: allowed = ", ".join(HARNESS_MODELS[self.default]) raise ValueError( diff --git a/src/codealmanac/services/config/opencode_models.py b/src/codealmanac/services/config/opencode_models.py new file mode 100644 index 00000000..60a9abda --- /dev/null +++ b/src/codealmanac/services/config/opencode_models.py @@ -0,0 +1,22 @@ +"""OpenCode model id rules (provider/model). Discovery lives in integrations.""" + +from __future__ import annotations + +OPENCODE_DEFAULT_MODEL = "opencode/big-pickle" +OPENCODE_FALLBACK_MODELS = ( + OPENCODE_DEFAULT_MODEL, + "opencode/deepseek-v4-flash-free", + "opencode-go/kimi-k2.7-code", + "anthropic/claude-sonnet-4-5", + "openai/gpt-5.4", + "google/gemini-3.1-pro-preview", +) + + +def is_opencode_model_id(value: str) -> bool: + """True for OpenCode model ids: provider/model (model may contain '/').""" + token = value.strip() + if token == "" or any(ch.isspace() for ch in token): + return False + provider, separator, model = token.partition("/") + return separator == "/" and provider != "" and model != "" diff --git a/src/codealmanac/services/config/service.py b/src/codealmanac/services/config/service.py index 42d2fba4..495a2391 100644 --- a/src/codealmanac/services/config/service.py +++ b/src/codealmanac/services/config/service.py @@ -262,6 +262,15 @@ def parse_harness_value(value: str) -> str: def parse_harness_model(value: str, harness: HarnessKind) -> str: token = value.strip() + if harness is HarnessKind.OPENCODE: + from codealmanac.services.config.opencode_models import is_opencode_model_id + + if not is_opencode_model_id(token): + raise ValidationFailed( + "harness.model for opencode must be provider/model " + "(run `opencode models` for available ids)" + ) + return token if token not in CONTROLLED_HARNESS_MODELS: allowed = ", ".join(sorted(CONTROLLED_HARNESS_MODELS)) raise ValidationFailed(f"harness.model must be one of: {allowed}") diff --git a/src/codealmanac/services/harnesses/kinds.py b/src/codealmanac/services/harnesses/kinds.py index 76123305..a6e5364b 100644 --- a/src/codealmanac/services/harnesses/kinds.py +++ b/src/codealmanac/services/harnesses/kinds.py @@ -4,6 +4,7 @@ class HarnessKind(StrEnum): CODEX = "codex" CLAUDE = "claude" + OPENCODE = "opencode" class HarnessAgentKind(StrEnum): diff --git a/src/codealmanac/services/setup/models.py b/src/codealmanac/services/setup/models.py index 7e405f2e..d1878247 100644 --- a/src/codealmanac/services/setup/models.py +++ b/src/codealmanac/services/setup/models.py @@ -17,6 +17,7 @@ class SetupTarget(StrEnum): CODEX = "codex" CLAUDE = "claude" + OPENCODE = "opencode" class SetupAutomationMode(StrEnum): diff --git a/src/codealmanac/services/setup/requests.py b/src/codealmanac/services/setup/requests.py index 7821c2f6..f4de64e8 100644 --- a/src/codealmanac/services/setup/requests.py +++ b/src/codealmanac/services/setup/requests.py @@ -13,7 +13,11 @@ from codealmanac.services.harnesses.models import HarnessKind from codealmanac.services.setup.models import SetupTarget -DEFAULT_SETUP_TARGETS = (SetupTarget.CODEX, SetupTarget.CLAUDE) +DEFAULT_SETUP_TARGETS = ( + SetupTarget.CODEX, + SetupTarget.CLAUDE, + SetupTarget.OPENCODE, +) class RunSetupRequest(CodeAlmanacModel): diff --git a/src/codealmanac/services/sources/models.py b/src/codealmanac/services/sources/models.py index 2f1e16f8..5aab55f4 100644 --- a/src/codealmanac/services/sources/models.py +++ b/src/codealmanac/services/sources/models.py @@ -40,6 +40,7 @@ class SourceRuntimeStatus(StrEnum): class TranscriptApp(StrEnum): CLAUDE = "claude" CODEX = "codex" + OPENCODE = "opencode" class SourceAddress(CodeAlmanacModel): diff --git a/src/codealmanac/services/telemetry/models.py b/src/codealmanac/services/telemetry/models.py index 68827ee2..5314ba9d 100644 --- a/src/codealmanac/services/telemetry/models.py +++ b/src/codealmanac/services/telemetry/models.py @@ -90,7 +90,7 @@ class CliCommandCompletedProperties(CodeAlmanacModel): class LifecycleRunCompletedProperties(CodeAlmanacModel): run_kind: Literal["build", "ingest", "garden"] status: Literal["done", "failed", "cancelled"] - harness: Literal["codex", "claude"] + harness: Literal["codex", "claude", "opencode"] model: str duration_bucket: DurationBucket failure_category: Literal[ @@ -105,7 +105,13 @@ class LifecycleRunCompletedProperties(CodeAlmanacModel): @model_validator(mode="after") def validate_lifecycle_contract(self) -> "LifecycleRunCompletedProperties": - if self.model not in HARNESS_MODELS[HarnessKind(self.harness)]: + kind = HarnessKind(self.harness) + if kind is HarnessKind.OPENCODE: + from codealmanac.services.config.opencode_models import is_opencode_model_id + + if not is_opencode_model_id(self.model): + raise ValueError("model is not a valid OpenCode provider/model id") + elif self.model not in HARNESS_MODELS[kind]: raise ValueError("model is not controlled for harness") if self.status == "failed" and self.failure_category is None: raise ValueError("failed lifecycle events require failure_category") diff --git a/tests/test_cli.py b/tests/test_cli.py index 6b5687d0..ba93477d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -376,7 +376,7 @@ def test_cli_setup_and_uninstall_codex_instructions( assert "Navigate to your repo of choice" in captured.out assert "codealmanac init" in captured.out assert "codealmanac automation status" not in captured.out - assert "[b] Codex + Claude" not in captured.out + assert "[b] All runners" not in captured.out assert CODEALMANAC_START in agents_path.read_text(encoding="utf-8") assert tuple(job.task for job in scheduler.installed) == ( AutomationTask.SYNC, @@ -517,9 +517,8 @@ def test_cli_setup_interactive_choices_can_disable_update_and_commits( assert "[7/7]" in output.out assert output.out.index("AI runner") < output.out.index("Runner model") assert output.out.index("Runner model") < output.out.index("Agent instructions") - assert ( - "Add CodeAlmanac instructions to your AGENTS.md / CLAUDE.md:" in output.out - ) + assert "Add CodeAlmanac instructions to AGENTS.md" in output.out + assert "or OpenCode" in output.out assert "Install CodeAlmanac instructions for:" not in output.out assert "almanac: update wiki context" in output.out assert "almanac/architecture/indexing.md" in output.out @@ -529,7 +528,7 @@ def test_cli_setup_interactive_choices_can_disable_update_and_commits( assert "macOS heads-up" in output.out assert "adds 3 CodeAlmanac background tasks: Sync, Garden, and Update" in output.out assert "They are expected and all from CodeAlmanac" in output.out - assert "[b] Codex + Claude" not in output.out + assert "[b] All runners" not in output.out assert "sync quiet agent sessions" not in output.out assert "install local scheduled updater" not in output.out assert "Product updates" in output.out @@ -751,7 +750,11 @@ def test_cli_setup_skip_instructions_json(isolated_home: Path, monkeypatch, caps item["key"]: item["value"] for item in payload["config_update"]["entries"] } assert entries["auto_commit"] == "true" - assert payload["plan"]["instruction_targets"] == ["codex", "claude"] + assert payload["plan"]["instruction_targets"] == [ + "codex", + "claude", + "opencode", + ] assert [item["task"] for item in payload["plan"]["automation"]] == [ "sync", "garden", diff --git a/tests/test_controlled_model_config.py b/tests/test_controlled_model_config.py index dd7e5642..a62d7902 100644 --- a/tests/test_controlled_model_config.py +++ b/tests/test_controlled_model_config.py @@ -33,6 +33,12 @@ def test_harness_config_rejects_deprecated_claude_models() -> None: HarnessConfig(default=HarnessKind.CLAUDE, model="claude-sonnet-4-6") +def test_harness_config_rejects_opencode_id_for_codex() -> None: + with pytest.raises(ValueError, match="harness.model for codex must be one of"): + HarnessConfig(default=HarnessKind.CODEX, model="opencode/big-pickle") + + + def test_config_set_harness_default_resets_model_to_provider_default( tmp_path: Path, ) -> None: diff --git a/tests/test_opencode_harness.py b/tests/test_opencode_harness.py new file mode 100644 index 00000000..369fb565 --- /dev/null +++ b/tests/test_opencode_harness.py @@ -0,0 +1,129 @@ +import json + +from codealmanac.integrations.harnesses.opencode.adapter import ( + OpenCodeHarnessAdapter, + OpenCodeProcessResult, + stage_lifecycle_agent, +) +from codealmanac.services.harnesses.models import ( + HarnessAgentKind, + HarnessEventKind, + HarnessKind, + HarnessRunStatus, +) +from codealmanac.services.harnesses.requests import RunHarnessRequest + + +def test_check_missing_binary(tmp_path): + readiness = OpenCodeHarnessAdapter( + tmp_path, + which=lambda _: None, + ).check() + assert readiness.available is False + assert "not found" in readiness.message + + +def test_check_reports_version(tmp_path, monkeypatch): + def fake_run(*args, **kwargs): + class Result: + returncode = 0 + stdout = "1.2.3\n" + stderr = "" + + return Result() + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.adapter.subprocess.run", + fake_run, + ) + readiness = OpenCodeHarnessAdapter( + tmp_path, + which=lambda _: "/usr/local/bin/opencode", + ).check() + assert readiness.available is True + assert readiness.message == "1.2.3" + + +def test_run_streams_json_events_uses_stdin_and_runtime_agent(tmp_path): + calls = [] + live = [] + runtime_root = tmp_path / "runtime" + project = tmp_path / "project" + project.mkdir() + + def runner(command, cwd, timeout_seconds, stdin, env, on_line): + calls.append((command, cwd, timeout_seconds, stdin, env)) + payloads = ( + { + "type": "text", + "sessionID": "ses_1", + "part": {"text": "hello wiki"}, + }, + { + "type": "tool_use", + "sessionID": "ses_1", + "part": { + "id": "call_1", + "tool": "read", + "state": {"status": "completed", "title": "README.md"}, + }, + }, + ) + lines = [] + for payload in payloads: + line = json.dumps(payload) + lines.append(line) + if on_line is not None: + on_line(line) + return OpenCodeProcessResult(returncode=0, lines=tuple(lines)) + + result = OpenCodeHarnessAdapter(runtime_root, runner=runner).run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/big-pickle", + agent=HarnessAgentKind.BUILD, + cwd=project, + prompt="Runtime context:\n{}", + ), + on_event=live.append, + ) + + assert result.status is HarnessRunStatus.SUCCEEDED + assert result.output_text == "hello wiki" + assert result.transcript is not None + assert result.transcript.session_id == "ses_1" + live_kinds = [ + event.kind for event in live if event.kind is not HarnessEventKind.DONE + ] + assert live_kinds == [ + HarnessEventKind.TEXT, + HarnessEventKind.TOOL_USE, + ] + command, cwd, timeout_seconds, stdin, env = calls[0] + assert command[0] == "opencode" + assert "--agent" in command + assert command[command.index("--agent") + 1] == "codealmanac-build" + assert "CodeAlmanac Kernel" not in " ".join(command) + assert stdin == "Runtime context:\n{}" + assert cwd == project + assert env is not None + stage_root = runtime_root / "opencode" + assert env["OPENCODE_CONFIG_DIR"] == str(stage_root) + agent_path = stage_root / "agents" / "codealmanac-build.md" + assert agent_path.is_file() + body = agent_path.read_text(encoding="utf-8") + assert "mode: primary" in body + assert "CodeAlmanac Kernel" in body + assert not (project / ".opencode").exists() + + +def test_stage_lifecycle_agent_writes_runtime_agent(tmp_path): + stage_root = tmp_path / "opencode" + name = stage_lifecycle_agent(stage_root, HarnessAgentKind.GARDEN) + assert name == "codealmanac-garden" + path = stage_root / "agents" / f"{name}.md" + assert path.is_file() + text = path.read_text(encoding="utf-8") + assert "mode: primary" in text + assert "# Garden Operation" in text + assert not (tmp_path / ".opencode").exists() diff --git a/tests/test_opencode_models.py b/tests/test_opencode_models.py new file mode 100644 index 00000000..f7ef2d82 --- /dev/null +++ b/tests/test_opencode_models.py @@ -0,0 +1,91 @@ +from pathlib import Path + +import pytest + +from codealmanac.core.errors import ValidationFailed +from codealmanac.integrations.harnesses.opencode.models import ( + list_opencode_models, + models_for_selection, + parse_opencode_models_output, +) +from codealmanac.services.config.models import ConfigKey, HarnessConfig +from codealmanac.services.config.opencode_models import is_opencode_model_id +from codealmanac.services.config.requests import SetConfigValueRequest +from codealmanac.services.config.service import ConfigService, parse_harness_model +from codealmanac.services.config.store import ConfigStore +from codealmanac.services.harnesses.models import HarnessKind + + +def test_is_opencode_model_id_allows_nested_model_paths() -> None: + assert is_opencode_model_id("openrouter/z-ai/glm-5") + assert is_opencode_model_id("opencode/big-pickle") + assert not is_opencode_model_id("gpt-5.5") + assert not is_opencode_model_id("opencode/") + assert not is_opencode_model_id("/model") + assert not is_opencode_model_id("has space/model") + + +def test_parse_opencode_models_output_dedupes() -> None: + models = parse_opencode_models_output( + "opencode/big-pickle\n" + "noise\n" + "openrouter/z-ai/glm-5\n" + "opencode/big-pickle\n" + ) + assert models == ("opencode/big-pickle", "openrouter/z-ai/glm-5") + + +def test_list_opencode_models_uses_cli(runner=None) -> None: + models = list_opencode_models( + which=lambda _: "/bin/opencode", + runner=lambda command, timeout: ( + 0, + "anthropic/claude-sonnet-4-5\nopencode/big-pickle\n", + "", + ), + ) + assert models == ("anthropic/claude-sonnet-4-5", "opencode/big-pickle") + + +def test_models_for_selection_falls_back_when_missing() -> None: + models = models_for_selection(which=lambda _: None) + assert "opencode/big-pickle" in models + + +def test_harness_config_accepts_any_opencode_model_id() -> None: + config = HarnessConfig( + default=HarnessKind.OPENCODE, + model="openrouter/z-ai/glm-5.2", + ) + assert config.model == "openrouter/z-ai/glm-5.2" + + +def test_harness_config_rejects_non_id_for_opencode() -> None: + with pytest.raises(ValueError, match="provider/model"): + HarnessConfig(default=HarnessKind.OPENCODE, model="gpt-5.5") + + +def test_config_set_accepts_live_opencode_model(tmp_path: Path) -> None: + class UnusedAutomation: + def reconcile_task(self, request): + raise AssertionError("unused") + + service = ConfigService( + store=ConfigStore(), + user_config_path=tmp_path / "config.toml", + automation=UnusedAutomation(), + ) + service.set(SetConfigValueRequest(key=ConfigKey.HARNESS_DEFAULT, value="opencode")) + service.set( + SetConfigValueRequest( + key=ConfigKey.HARNESS_MODEL, + value="openrouter/z-ai/glm-5", + ) + ) + entries = {entry.key: entry.value for entry in service.list()} + assert entries[ConfigKey.HARNESS_MODEL] == "openrouter/z-ai/glm-5" + + +def test_parse_harness_model_rejects_bad_opencode_id() -> None: + with pytest.raises(ValidationFailed, match="provider/model"): + parse_harness_model("not-a-model", HarnessKind.OPENCODE) diff --git a/tests/test_opencode_transcript_discovery.py b/tests/test_opencode_transcript_discovery.py new file mode 100644 index 00000000..1c49c911 --- /dev/null +++ b/tests/test_opencode_transcript_discovery.py @@ -0,0 +1,108 @@ +import sqlite3 +from pathlib import Path + +from codealmanac.integrations.sources.transcripts.opencode import ( + OpencodeTranscriptDiscoveryAdapter, + opencode_session_id, + opencode_transcript_identity, +) +from codealmanac.services.sources.models import TranscriptApp +from codealmanac.services.sources.requests import DiscoverTranscriptsRequest + + +def _make_db(path: Path) -> None: + connection = sqlite3.connect(path) + connection.execute( + "CREATE TABLE session (id text PRIMARY KEY, parent_id text, " + "directory text NOT NULL, time_created integer, time_updated integer)" + ) + connection.commit() + connection.close() + + +def _insert_session( + path: Path, + *, + session_id: str, + directory: str, + parent_id: str | None = None, + time_updated: int = 1_000, +) -> None: + connection = sqlite3.connect(path) + connection.execute( + "INSERT INTO session (id, parent_id, directory, time_created, time_updated) " + "VALUES (?, ?, ?, ?, ?)", + (session_id, parent_id, directory, time_updated, time_updated), + ) + connection.commit() + connection.close() + + +def test_opencode_discovery_reads_root_sessions(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + repo = tmp_path / "repo" + _insert_session(db_path, session_id="ses_root", directory=str(repo)) + + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=db_path) + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + assert len(candidates) == 1 + candidate = candidates[0] + assert candidate.app == TranscriptApp.OPENCODE + assert candidate.session_id == "ses_root" + assert candidate.cwd == repo.resolve() + assert candidate.transcript_path == opencode_transcript_identity("ses_root") + + +def test_opencode_discovery_excludes_subagent_sessions(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + repo = tmp_path / "repo" + _insert_session(db_path, session_id="ses_root", directory=str(repo)) + _insert_session( + db_path, session_id="ses_child", directory=str(repo), parent_id="ses_root" + ) + + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=db_path) + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + session_ids = {candidate.session_id for candidate in candidates} + assert session_ids == {"ses_root"} + + +def test_opencode_discovery_returns_empty_for_missing_db(tmp_path: Path) -> None: + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=tmp_path / "missing.db") + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + assert candidates == () + + +def test_opencode_discovery_soft_skips_on_schema_drift(tmp_path: Path) -> None: + # OpenCode's schema is not a documented public contract — a future + # opencode-ai upgrade renaming or dropping a column should make this + # discovery pass find nothing that run, not crash sync entirely. + db_path = tmp_path / "opencode.db" + connection = sqlite3.connect(db_path) + connection.execute("CREATE TABLE session (id text PRIMARY KEY)") + connection.commit() + connection.close() + + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=db_path) + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + assert candidates == () + + +def test_opencode_session_id_roundtrip() -> None: + identity = opencode_transcript_identity("ses_abc123") + assert opencode_session_id(str(identity)) == "ses_abc123" + assert opencode_session_id("/some/real/file/path") is None diff --git a/tests/test_opencode_transcript_runtime.py b/tests/test_opencode_transcript_runtime.py new file mode 100644 index 00000000..eb08fc5e --- /dev/null +++ b/tests/test_opencode_transcript_runtime.py @@ -0,0 +1,197 @@ +import json +import sqlite3 +from pathlib import Path + +from codealmanac.integrations.sources import default_source_runtime_adapters +from codealmanac.integrations.sources.transcripts.opencode import ( + opencode_transcript_identity, +) +from codealmanac.integrations.sources.transcripts.opencode_runtime import ( + OpencodeTranscriptRuntimeAdapter, +) +from codealmanac.integrations.sources.transcripts.runtime import ( + TranscriptSourceRuntimeAdapter, +) +from codealmanac.services.sources.models import ( + SourceKind, + SourceRef, + SourceRuntimeStatus, +) +from codealmanac.services.sources.requests import InspectSourceRuntimeRequest + + +def _make_db(path: Path) -> None: + connection = sqlite3.connect(path) + connection.execute( + "CREATE TABLE message (id text PRIMARY KEY, session_id text, " + "data text, time_created integer)" + ) + connection.execute( + "CREATE TABLE part (id text PRIMARY KEY, session_id text, " + "message_id text, data text, time_created integer)" + ) + connection.commit() + connection.close() + + +def _insert_part( + path: Path, + *, + part_id: str, + session_id: str, + message_id: str, + role: str, + part: dict, + seq: int, +) -> None: + connection = sqlite3.connect(path) + connection.execute( + "INSERT OR IGNORE INTO message (id, session_id, data, time_created) " + "VALUES (?, ?, ?, ?)", + (message_id, session_id, json.dumps({"role": role}), seq), + ) + connection.execute( + "INSERT INTO part (id, session_id, message_id, data, time_created) " + "VALUES (?, ?, ?, ?, ?)", + (part_id, session_id, message_id, json.dumps(part), seq), + ) + connection.commit() + connection.close() + + +def _ref_for(session_id: str) -> SourceRef: + transcript = str(opencode_transcript_identity(session_id)) + return SourceRef( + raw=f"transcript:{transcript}", + kind=SourceKind.TRANSCRIPT, + identity=f"transcript:{transcript}", + transcript=transcript, + ) + + +def test_runtime_reads_assistant_text_parts(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="ses_1", + message_id="m1", + role="assistant", + part={"type": "text", "text": "Lazy expiration decision recorded."}, + seq=1, + ) + + adapter = OpencodeTranscriptRuntimeAdapter(db_path=db_path) + ref = _ref_for("ses_1") + result = adapter.inspect(InspectSourceRuntimeRequest(cwd=tmp_path, ref=ref)) + + assert result.status == SourceRuntimeStatus.AVAILABLE + assert "Lazy expiration decision recorded." in (result.content or "") + + +def test_runtime_includes_user_prompt_text(tmp_path: Path) -> None: + # A past transcript read for ingest needs the question a session was + # answering, not just its answer — unlike the live-progress watchdog, + # which deliberately skips the user's own echoed prompt during a run. + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="ses_1", + message_id="m1", + role="user", + part={"type": "text", "text": "Investigate the RESP decoder."}, + seq=1, + ) + + adapter = OpencodeTranscriptRuntimeAdapter(db_path=db_path) + result = adapter.inspect( + InspectSourceRuntimeRequest(cwd=tmp_path, ref=_ref_for("ses_1")) + ) + + assert result.status == SourceRuntimeStatus.AVAILABLE + assert "Investigate the RESP decoder." in (result.content or "") + + +def test_runtime_skips_non_text_parts_from_non_assistant_roles(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + _insert_part( + db_path, + part_id="p1", + session_id="ses_1", + message_id="m1", + role="user", + part={"type": "reasoning", "text": "should not appear"}, + seq=1, + ) + + adapter = OpencodeTranscriptRuntimeAdapter(db_path=db_path) + result = adapter.inspect( + InspectSourceRuntimeRequest(cwd=tmp_path, ref=_ref_for("ses_1")) + ) + + assert result.status == SourceRuntimeStatus.UNAVAILABLE + + +def test_runtime_unavailable_for_unknown_session(tmp_path: Path) -> None: + db_path = tmp_path / "opencode.db" + _make_db(db_path) + + adapter = OpencodeTranscriptRuntimeAdapter(db_path=db_path) + result = adapter.inspect( + InspectSourceRuntimeRequest(cwd=tmp_path, ref=_ref_for("ses_missing")) + ) + + assert result.status == SourceRuntimeStatus.UNAVAILABLE + + +def test_supports_only_matches_opencode_session_refs(tmp_path: Path) -> None: + adapter = OpencodeTranscriptRuntimeAdapter(db_path=tmp_path / "opencode.db") + + assert adapter.supports(_ref_for("ses_1")) is True + real_file_ref = SourceRef( + raw="transcript:/some/real/path.jsonl", + kind=SourceKind.TRANSCRIPT, + identity="transcript:/some/real/path.jsonl", + transcript="/some/real/path.jsonl", + ) + assert adapter.supports(real_file_ref) is False + + +def test_default_source_runtime_adapters_dispatch_opencode_refs_correctly() -> None: + # Regression: SourcesService.inspect_runtime() dispatches to the FIRST + # adapter whose supports() matches. TranscriptSourceRuntimeAdapter claims + # every SourceKind.TRANSCRIPT ref unconditionally, so if it were + # registered before OpencodeTranscriptRuntimeAdapter, every opencode + # session ref would be silently misrouted to the file-based adapter and + # fail as "file not found" — confirmed live against a real CodeAlmanac + # sync/ingest run before this ordering was fixed. + adapters = default_source_runtime_adapters() + ref = _ref_for("ses_1") + matching = next(adapter for adapter in adapters if adapter.supports(ref)) + + assert type(matching).__name__ == "OpencodeTranscriptRuntimeAdapter" + + +def test_generic_transcript_runtime_adapter_rejects_opencode_refs_on_its_own() -> None: + # Defense in depth: correctness shouldn't rely solely on registration + # order. TranscriptSourceRuntimeAdapter must refuse an opencode-shaped + # ref even if it were (incorrectly, in the future) registered first. + adapter = TranscriptSourceRuntimeAdapter() + + assert adapter.supports(_ref_for("ses_1")) is False + + +def test_generic_transcript_runtime_adapter_still_accepts_real_file_refs() -> None: + adapter = TranscriptSourceRuntimeAdapter() + real_file_ref = SourceRef( + raw="transcript:/some/real/path.jsonl", + kind=SourceKind.TRANSCRIPT, + identity="transcript:/some/real/path.jsonl", + transcript="/some/real/path.jsonl", + ) + + assert adapter.supports(real_file_ref) is True diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index e5228835..c0585d1a 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -154,6 +154,7 @@ def test_setup_skip_instructions_still_returns_plan(home: Path): assert tuple(target.value for target in result.plan.instruction_targets) == ( "codex", "claude", + "opencode", ) diff --git a/tests/test_setup_wizard_brand.py b/tests/test_setup_wizard_brand.py new file mode 100644 index 00000000..e10e547b --- /dev/null +++ b/tests/test_setup_wizard_brand.py @@ -0,0 +1,25 @@ +from codealmanac.cli.dispatch.setup_wizard.options import RUNNER_LABELS, TARGET_LABELS +from codealmanac.cli.render.brand import BRAND_COLORS, option_label + + +def test_every_runner_label_has_a_brand_color() -> None: + # Regression: BRAND_COLORS was a hardcoded {"Codex": ..., "Claude": ...} + # dict that never gained an "OpenCode" entry when OpenCode was added as + # a third harness — label_word() silently falls back to no color for + # any word missing from this dict, so the setup wizard rendered + # "OpenCode" and "OpenCode only" uncolored while Codex/Claude kept their + # brand colors. Confirmed visually in the actual interactive wizard. + for label in RUNNER_LABELS.values(): + assert label in BRAND_COLORS, f"{label!r} has no brand color" + + +def test_every_target_label_has_a_brand_color() -> None: + for label in TARGET_LABELS.values(): + assert label in BRAND_COLORS, f"{label!r} has no brand color" + + +def test_opencode_label_renders_with_its_own_color() -> None: + rendered = option_label("OpenCode only", selected=False) + assert BRAND_COLORS["OpenCode"] in rendered + assert BRAND_COLORS["Codex"] not in rendered + assert BRAND_COLORS["Claude"] not in rendered diff --git a/tests/test_setup_wizard_cards.py b/tests/test_setup_wizard_cards.py new file mode 100644 index 00000000..fc033f7e --- /dev/null +++ b/tests/test_setup_wizard_cards.py @@ -0,0 +1,123 @@ +from codealmanac.cli.render.setup.screens import SetupChoiceOption, option_card +from codealmanac.cli.render.terminal import ( + card_width_for, + columns_for, + content_width, + visible_length, + wrap_text, +) + + +def test_card_width_for_matches_known_values() -> None: + # 2 harnesses -> wide cards, 3 harnesses -> narrower cards, so a row of + # cards stays close to an 80-column terminal either way. + assert card_width_for(2, 78) == 34 + assert card_width_for(3, 78) == 21 + assert card_width_for(4, 78) == 14 + + +def test_card_width_for_never_overflows_its_budget_for_real_option_counts() -> None: + # Regression: the old fixed min_width=18 floor overrode the fitted + # width whenever a row had enough options (e.g. 4 harnesses), pushing + # the total row past the 78-column budget and onto the next terminal + # row — this is the exact corruption seen in the real 80x23 wizard + # once OpenCode became a 4th runner option (Codex/Claude/OpenCode + + # "Codex + Claude + OpenCode" in the instruction-target screen). Beyond + # 4 options card_width_for's floor can still overflow on its own — + # that's what columns_for exists to protect against (see below). + for count in range(1, 5): + width = card_width_for(count, 78) + assert count * (width + 5) <= 78 + + +def test_columns_for_never_overflows_regardless_of_option_count() -> None: + for count in range(1, 8): + for available in (30, 44, 60, 78): + columns = columns_for(count, available) + width = card_width_for(columns, available) + assert columns * (width + 5) <= available + + +def test_columns_for_wraps_into_a_grid_on_narrow_terminals() -> None: + # A terminal too narrow for one row of `count` cards should fall back + # to fewer columns (a multi-row grid) rather than overflow. + columns = columns_for(4, 44) + assert columns < 4 + width = card_width_for(columns, 44) + assert columns * (width + 5) <= 44 + + +def test_columns_for_keeps_single_row_when_it_fits() -> None: + assert columns_for(4, 78) == 4 + assert columns_for(2, 78) == 2 + + +def test_content_width_stays_within_bounds() -> None: + width = content_width() + assert 40 <= width <= 78 + + +def test_wrap_text_splits_long_text_across_lines() -> None: + lines = wrap_text( + "opencode providers configured: Cloudflare AI Gateway, OpenAI", 21 + ) + assert len(lines) > 1 + for line in lines: + assert visible_length(line) <= 21 + + +def test_wrap_text_truncates_a_single_word_longer_than_width() -> None: + lines = wrap_text("supercalifragilisticexpialidocious", 10) + assert len(lines) == 1 + assert visible_length(lines[0]) <= 10 + assert lines[0].endswith("…") + + +def test_option_card_body_does_not_overflow_its_border() -> None: + # Regression: a long readiness message (e.g. OpenCode's + # "opencode providers configured: ...") used to overflow past the card's + # right border because card_center_row never wrapped or truncated + # content longer than the card width — confirmed visually in the actual + # interactive wizard once OpenCode became a third runner option. + option = SetupChoiceOption( + "OpenCode", + ( + "ready", + "opencode providers configured: Cloudflare AI Gateway, OpenAI, " + "OpenCode Zen", + ), + ) + width = card_width_for(3) + lines = option_card(option, width, selected=True, body_height=0) + + for line in lines: + assert visible_length(line) == width + 2 + + +def test_option_cards_in_one_row_share_the_same_height() -> None: + # "Unify card heights": Codex/Claude's short readiness messages and + # OpenCode's much longer one must still produce equal-height cards, so + # every card's closing border lands on the same row. + short_option = SetupChoiceOption("Codex", ("ready", "Logged in using ChatGPT")) + long_option = SetupChoiceOption( + "OpenCode", + ( + "ready", + "opencode providers configured: Cloudflare AI Gateway, OpenAI, " + "OpenCode Zen", + ), + ) + width = card_width_for(3) + inner_width = max(1, width - 2) + body_height = max( + len(wrap_text(option.label, inner_width)) + + sum(len(wrap_text(line, inner_width)) for line in option.description) + for option in (short_option, long_option) + ) + + short_lines = option_card( + short_option, width, selected=False, body_height=body_height + ) + long_lines = option_card(long_option, width, selected=True, body_height=body_height) + + assert len(short_lines) == len(long_lines) diff --git a/tests/test_yoke_harness_integration.py b/tests/test_yoke_harness_integration.py index f861363d..f2bac7ff 100644 --- a/tests/test_yoke_harness_integration.py +++ b/tests/test_yoke_harness_integration.py @@ -15,6 +15,7 @@ from codealmanac.agents.catalog import agent_collection, load_agent from codealmanac.app import create_app +from codealmanac.integrations.harnesses.opencode.adapter import OpenCodeHarnessAdapter from codealmanac.integrations.harnesses.yoke.adapter import ( CLAUDE_ALLOWED_TOOLS, YokeHarnessAdapter, @@ -33,6 +34,8 @@ from codealmanac.services.harnesses.requests import RunHarnessRequest from codealmanac.settings import AppConfig +YOKE_KINDS = (HarnessKind.CLAUDE, HarnessKind.CODEX) + class RecordingHarness: def __init__(self, run: Run): @@ -62,7 +65,7 @@ def request(kind: HarnessKind, prompt: str = " preserve me exactly "): ) -@pytest.mark.parametrize("kind", tuple(HarnessKind)) +@pytest.mark.parametrize("kind", YOKE_KINDS) def test_adapter_forwards_exact_prompt_and_model(kind, tmp_path): harness = RecordingHarness( Run(provider=Provider(kind.value), output="ok") @@ -174,13 +177,16 @@ def test_composition_root_gives_both_harnesses_local_state_runtime(tmp_path): app = create_app(config=AppConfig(database_path=database_path)) expected = database_path.parent / "harnesses" - for kind in HarnessKind: + for kind in YOKE_KINDS: adapter = app.harnesses.adapter_for(kind) assert isinstance(adapter, YokeHarnessAdapter) assert adapter.runtime_root == expected + opencode = app.harnesses.adapter_for(HarnessKind.OPENCODE) + assert isinstance(opencode, OpenCodeHarnessAdapter) + assert opencode.runtime_root == expected -@pytest.mark.parametrize("kind", tuple(HarnessKind)) +@pytest.mark.parametrize("kind", YOKE_KINDS) @pytest.mark.parametrize("event_kind", tuple(EventKind)) def test_every_yoke_event_kind_projects_and_serializes(kind, event_kind): [projected, *extra] = YokeEventProjector(kind).project(