diff --git a/.context/AGENT_PLAYBOOK.md b/.context/AGENT_PLAYBOOK.md index ef97eea04..11ec8ec8e 100644 --- a/.context/AGENT_PLAYBOOK.md +++ b/.context/AGENT_PLAYBOOK.md @@ -74,6 +74,15 @@ go run ./cmd/ctx # ✗ avoid unless developing ctx itself ``` Check with `which ctx` if unsure whether it's installed. +### Optional AI Backends + +`ctx ai` is optional and fail-closed. Use `ctx setup --backend ` to +configure a backend, `ctx ai ping` to verify it, and `ctx ai propose +--emit ...` only to write reviewable artifacts under `.context/proposals/ai/`. +Do not wire deterministic commands, ceremonies, or hooks to backend +availability; `ctx status`, `ctx agent`, and hooks must keep working with no +backend configured. + ### When ctx Returns an Error Triage the error before reacting: diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index 4ec0606a1..95bf50aac 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -3,6 +3,7 @@ | Date | Decision | |----|--------| +| 2026-06-19 | `ctx ai` is a separate namespace with proposed-patch output only | | 2026-06-07 | ctx-dream executor is a documented contract, not a hardcoded cron/claude assumption | | 2026-06-07 | Output belongs in write/ — taxonomy and emission style (consolidated) | | 2026-06-07 | Package taxonomy and shared-code placement (consolidated) | @@ -107,6 +108,51 @@ For significant decisions: --> +## [2026-06-19-064500] `ctx ai` is a separate namespace with proposed-patch output only + +**Status**: Accepted + +**Context**: GitHub issue #92 (`ctx ai backend`) introduces an optional, +local-first AI backend layer. The governing spec left several Block A choices +open: whether AI use should live under a new `ctx ai ` namespace or as +flags on existing commands, where the validation consumer should write +proposals, whether Block A needs a companion skill, and how to reconcile the +spec's TOML examples with the repo's existing YAML `.ctxrc` parser. + +**Decision**: Block A uses a new top-level `ctx ai ` namespace. Backend +configuration uses the existing YAML `.ctxrc` shape under `backends:` rather +than TOML-style `[backends]` tables. `ctx setup --backend ` is a distinct +setup mode that can run without the current `` positional argument. The +provisional proposal queue is `.context/proposals/ai/`, and the Block A +validation consumer is `ctx ai propose --emit ...`, a generic +validation-only proposer that writes proposed-patch JSON artifacts and never +mutates `.context/*.md`. No new companion skill ships in Block A; command +assets, setup docs, the `ctx ai` CLI reference, the vLLM recipe, and the agent +playbook note cover the user-facing surface. + +**Rationale**: A separate `ctx ai` namespace keeps optional AI behavior out of +deterministic commands (`ctx status`, `ctx agent`, ceremonies, and hooks), makes +fail-closed behavior explicit, and avoids smuggling backend availability into +existing deterministic verbs through flags like `--use-ai`. YAML config matches +the actual `internal/rc` implementation and avoids landing a spec that cannot +be implemented without replacing the config parser. `.context/proposals/ai/` +keeps AI-produced suggestions inside the cognitive substrate while preserving +the human gate before canonical memory changes. A generic `propose` verb proves +backend dispatch and artifact writing without pretending to settle the final +Block B taxonomy; later `ctx ai compact` or `ctx ai ingest` commands remain +available once the extraction-and-recall spec is promoted. + +**Consequence**: Implementations must add a deterministic-boundary guard so +agent/status/ceremony paths cannot import `internal/backend` or invoke `ctx ai`. +Multiple configured backends require `--backend ` or `backends.default`; +there is no implicit selection and no deterministic fallback. Proposed-patch +artifacts need a minimal schema with backend, model, input reference, emit +kinds, proposed rows, source spans or citations when available, and status +metadata. Documentation and command assets are part of the same deliverable +because `ctx ai` is a user-facing surface. Spec: `specs/ctx-ai-backend.md`. + +--- + ## [2026-06-07-112203] ctx-dream executor is a documented contract, not a hardcoded cron/claude assumption **Status**: Accepted @@ -1300,4 +1346,3 @@ inherits it, and cleanup is automatic at test end. One line replaces the helper. to maintain. Pattern reusable for other subprocess tests. --- - diff --git a/.context/TASKS.md b/.context/TASKS.md index 8c1dfd60d..932e6ffa7 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -32,6 +32,181 @@ TASK STATUS LABELS: `--phase` flag too, and we can have a auditor/normalizer for the current task document; or a skill that does a semantic pass, or both too. +## Phase AI-A: `ctx ai` Backend Foundation + +Spec: `specs/ctx-ai-backend.md`. Read the spec before starting any AI-A task. +Tracks GitHub issue #92. + +- [x] Decide the AI command CLI namespace: `ctx ai ` (new top-level) + vs. flags on existing commands (`--use-ai`, `--emit`, etc.). Record the + call as a `.context/DECISIONS.md` entry naming the chosen shape and the + rejected alternative with rationale. Blocks every other task in this group. + Spec: `specs/ctx-ai-backend.md` Open Question #1. #priority:medium + #added:2026-06-19 #completed:2026-06-19 + +- [x] Implement the backend contract and registry: new `internal/backend/` + package with `Backend` interface (`Name`, `Ping`, `Complete`), + `Request`/`Response` types, and a `Registry` (`Register`, `Resolve`, + `Default`). No per-backend implementations yet; this is the abstraction + surface that later tasks plug into. Unit tests cover single, multiple, + default, and missing backend resolution. Spec: `specs/ctx-ai-backend.md` + §Implementation. #priority:medium #added:2026-06-19 + #completed:2026-06-19 + Shipped: added `internal/backend/` with zero-value-safe registry, public + `SetDefault`, `Register`, `Resolve`, and `Default`; `Config`, `Request`, + `Response`, `Backend`, and `Factory` types; typed errors under + `internal/err/backend`; and structural error text constants under + `internal/config/backend`. Tests cover single default resolution, ambiguous + multiple-backend default, explicit default, missing default, empty registry, + missing backend, duplicate registration, and factory-error wrapping. Verified: + `go test ./internal/backend ./internal/err/backend ./internal/config/backend`, + `go test ./internal/audit`, and `git diff --check`. + +- [x] Extend `internal/rc/` to parse and validate the `.ctxrc` `backends:` + YAML mapping per the spec's Configuration section: per-backend `endpoint`, + `api_key_env`, `timeout`, `default_model`, plus optional + `backends.default`. Refuse malformed mappings with a clear parse error + naming the offending key. Add fixtures and round-trip tests. Spec: + `specs/ctx-ai-backend.md` §Configuration. #priority:medium + #added:2026-06-19 + #completed:2026-06-19 + Shipped: added `BackendsRC` / `BackendRC` parsing for mixed YAML mapping shape + (`backends.default` plus named backend definitions), strict backend-field + decoding, semantic validation for missing endpoint and default references, + and config constants for backend rc keys/messages. Multiple backends without a + default intentionally parse successfully because dispatch owns that error. + Tests cover well-formed config, missing endpoint, malformed shape, missing + default target, multiple without default, unknown nested fields, and empty + backends. Verified: `go test ./internal/rc ./internal/audit`, + `go test ./internal/backend ./internal/err/backend ./internal/config/backend`, + and `git diff --check`. + +- [x] Implement the minimum viable backend set: `vllm` (canonical local) and + generic `openai-compatible` (the contract floor) in + `internal/backend/vllm.go` and `internal/backend/openaicompat.go`. Both must + implement `Ping` (HTTP GET on `/v1/models`) and `Complete` (POST + `/v1/chat/completions`). Fail closed on unreachable, 4xx, 5xx, and timeout; + never retry with a different model and never fall back to deterministic + behavior. Spec: `specs/ctx-ai-backend.md` §Approach and §Edge Cases. + #priority:medium #added:2026-06-19 + #completed:2026-06-19 + Shipped: added generic OpenAI-compatible HTTP backend and vLLM wrapper over the + same contract. `Ping` calls `/v1/models`; `Complete` posts to + `/v1/chat/completions`, applies default model fallback / request model + override, optionally reads `api_key_env` for a bearer token, validates HTTP(S) + endpoints, uses configured timeout with a safe default, and fails closed on + transport, timeout, 4xx, 5xx, decode, and request errors. Tests cover ping, + completion, upstream body propagation, unreachable server, timeout, invalid + endpoint scheme, model fallback/override, auth header, and vLLM naming. Factory + functions remain private until a real cross-package caller lands, per the dead + export audit. Verified: `go test ./internal/backend ./internal/err/backend + ./internal/config/backend ./internal/rc ./internal/audit` and + `git diff --check`. + +- [x] Add the named-backend implementations: `openai`, `anthropic`, `ollama`, + `lmstudio` in `internal/backend/`. Each is a thin wrapper over + `openaicompat` with backend-specific defaults (endpoint, auth header shape, + env-var name). Anthropic uses the Messages API endpoint where supported but + inherits the OpenAI-compatible floor for `/v1/chat/completions`. Spec: + `specs/ctx-ai-backend.md` §Approach. #priority:medium #added:2026-06-19 + #completed:2026-06-19 + Shipped: added unexported named factories for `openai`, `anthropic`, `ollama`, + `lmstudio`, plus vLLM default endpoint handling. Each factory returns the + OpenAI-compatible implementation with backend-specific name, default endpoint, + and default API key env var where applicable. Anthropic is explicitly a floor + implementation for now; Messages API specialization waits for capability + detection. Factories remain private until real registry wiring lands to avoid + dead exports. Tests cover each factory's name/default behavior and preservation + of configured endpoint/env overrides. Verified: `go test ./internal/backend + ./internal/config/backend ./internal/err/backend ./internal/audit`, + `go test ./internal/rc`, and `git diff --check`. + +- [x] Extend the `ctx setup` family with `--backend ` as a distinct setup + mode that can run without the existing `` positional argument: + templates endpoint + auth wiring into `.ctxrc` and, where applicable, + downstream AI-tool configs (`ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`). Honour + existing env-var values: warn but do not overwrite. Lives in new + `internal/cli/setup/core/backend/` subpackage. Spec: + `specs/ctx-ai-backend.md` §Implementation. #priority:medium + #added:2026-06-19 + #completed:2026-06-19 + Shipped: `ctx setup --backend ` now runs without a positional tool, + supports dry-run YAML output, and writes/merges `.ctxrc` with `--write` while + preserving unrelated YAML fields. Added `--endpoint`, `--api-key-env`, + `--model`, and `--timeout` setup flags; backend name validation; backend mode + precedence when a positional tool is also present; default endpoint/env values; + and env-var conflict warnings. Tests cover no-arg backend mode, missing backend + rejection, existing tool compatibility, backend-mode precedence, unsupported + backend rejection, dry-run output, `.ctxrc` creation, unrelated-field + preservation, and env warning. Verified: `go test ./internal/cli/setup/... + ./internal/rc ./internal/backend ./internal/audit` and `git diff --check`. + +- [x] Build the `ctx ai` command surface. Minimum verbs: `ping` (reachability + + first model listed) plus `propose` as the Block A validation-only generic + proposer. All AI commands honour `--backend` flag (falling back to + `backends.default`), fail closed when no backend is configured, fail closed + when no backend is reachable, require explicit selection when multiple + backends are configured without a default, and surface upstream errors + verbatim. Spec: `specs/ctx-ai-backend.md` §Interface. #priority:medium + #added:2026-06-19 + #completed:2026-06-19 + Shipped: added the `ctx ai` command group with `ping` and `propose`, backend + dispatch through `.ctxrc` `backends:`, `--backend` selection, fail-closed empty + and ambiguous backend handling, built-in backend registration, and command / flag + assets. `ctx ai ping` reports the resolved backend, defaulted endpoint, and + first listed model from `/v1/models`. Verified: `go test ./internal/cli/ai/... + ./internal/backend ./internal/rc ./internal/audit ./internal/compliance` and + `git diff --check`. + +- [x] Add the deterministic-core boundary guard: a unit test or lint check that + fails if `internal/cli/agent/`, `internal/cli/status/`, or any + deterministic-ceremony hook imports or invokes `internal/backend/` or `ctx ai`. + This structurally enforces that `ctx status`, `ctx agent`, ceremonies, and + hooks remain additive-only and keep working with no backend configured. Spec: + `specs/ctx-ai-backend.md` §Validation Rules and §Testing. #priority:medium + #added:2026-06-19 + #completed:2026-06-19 + Shipped: added `internal/compliance/ai_boundary_test.go`, which scans + non-test Go files under `internal/cli/agent/`, `internal/cli/status/`, and + `internal/cli/hook/` and fails on `internal/backend` imports or `ctx ai` + invocation/documentation. Verified: `go test ./internal/compliance + ./internal/audit`. + +- [x] Ship the Block A validation consumer: `ctx ai propose --emit + decisions,learnings,tasks,open-questions`. This is a validation-only generic + proposer and must not foreclose later `ctx ai compact` or `ctx ai ingest` + command taxonomy. It performs schema-constrained dispatch through the backend, + validates JSON, and writes one proposed-patch JSON artifact to + `.context/proposals/ai/` with backend, model, input reference, emit kinds, + proposed rows, source spans/citations when available, and status metadata. + It must never directly mutate `.context/*.md`. Integration test confirms the + round-trip against a fake OpenAI-compatible `httptest` server and confirms + `.context/*.md` files are unchanged. Spec: `specs/ctx-ai-backend.md` §Testing + and Open Question #5. #priority:medium #added:2026-06-19 + #completed:2026-06-19 + Shipped: `ctx ai propose` reads a user input file, sends a schema-constrained + JSON request through the selected backend, validates the response as JSON, and + writes one proposed-patch artifact under `.context/proposals/ai/` with backend, + model, input reference, emit kinds, status, and decoded response payload. Tests + confirm the fake OpenAI-compatible round-trip and that `.context/*.md` remains + unchanged. Verified with the Phase 6 command-surface test set above. + +- [x] Write the documentation deliverables: one new recipe + (`docs/recipes/local-inference-with-vllm.md` or + `docs/recipes/ai-backend-setup.md`) covering the + `ctx setup --backend vllm` flow end-to-end, plus a CLI reference page under + `docs/cli/` for `ctx ai`. Also update command assets/examples and the agent + playbook note listed in the spec. The recipe is one file, not a recipe-surface + rework. Spec: `specs/ctx-ai-backend.md` §Non-Goals. #priority:medium + #added:2026-06-19 + #completed:2026-06-19 + Shipped: added `docs/cli/ai.md`, updated `docs/cli/setup.md`, added `ctx ai` + and `backends:` coverage to `docs/cli/index.md`, added + `docs/recipes/local-inference-with-vllm.md` and linked it from the recipes + index, and documented optional/fail-closed AI backend usage in + `.context/AGENT_PLAYBOOK.md`. Verified: `go test ./internal/audit + ./internal/compliance`. + ## Phase CLI-FIX: CLI Infrastructure Fixes These have priority because other knowledge ingestion projects depend on them. diff --git a/.golangci.yml b/.golangci.yml index 78eaf855f..311985090 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -38,6 +38,22 @@ linters: - linters: [gosec] text: "G101" path: "internal/config/embed/text/" + # FlagDescKey constants are CLI flag description keys, not credentials + - linters: [gosec] + text: "G101" + path: "internal/config/embed/flag/" + # Flag name constants are CLI flag identifiers, not credentials + - linters: [gosec] + text: "G101" + path: "internal/config/flag/" + # RC key constants are YAML config key names, not credentials + - linters: [gosec] + text: "G101" + path: "internal/config/rc/" + # Default env var name constants are identifiers, not credentials + - linters: [gosec] + text: "G101" + path: "internal/config/backend/" run: timeout: 5m diff --git a/docs/cli/ai.md b/docs/cli/ai.md new file mode 100644 index 000000000..a1ec37337 --- /dev/null +++ b/docs/cli/ai.md @@ -0,0 +1,58 @@ +--- +# / ctx: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +title: AI Backends +icon: lucide/brain-circuit +--- + +![ctx](../images/ctx-banner.png) + +## `ctx ai` + +Optional AI backend commands. These commands are additive: deterministic +commands such as `ctx status`, `ctx agent`, and hooks do not require a backend. + +Configure a backend first: + +```bash +ctx setup --backend vllm --endpoint http://localhost:8000 --write +``` + +### `ctx ai ping` + +Check the selected backend by reading `/v1/models`. + +```bash +ctx ai ping [--backend ] +``` + +Output includes the resolved backend name, endpoint, and first model listed. + +### `ctx ai propose` + +Send an input file to the selected backend and write a reviewable proposal +artifact. It never edits `.context/*.md` directly. + +```bash +ctx ai propose --emit decisions,learnings,tasks,open-questions \ + [--backend ] +``` + +Artifacts are written under `.context/proposals/ai/` as JSON proposed-patch +records with backend, model, input, emit kinds, status, and the decoded response. + +### Backend selection + +`ctx ai` uses this order: + +1. `--backend ` +2. `.ctxrc` `backends.default` +3. the only configured backend, if exactly one exists + +If multiple backends exist and none is selected, the command fails closed. +If no backend is configured or the backend is unreachable, the command fails +closed; non-AI commands are unaffected. diff --git a/docs/cli/index.md b/docs/cli/index.md index 542f354f7..2959546ae 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -87,6 +87,7 @@ have been initialized by `ctx init` (otherwise commands return | Command | Description | |-----------------------------------------------|----------------------------------------------------------| +| [`ctx ai`](ai.md#ctx-ai) | Optional AI backend ping and proposal commands | | [`ctx setup`](setup.md#ctx-setup) | Generate AI tool integration configs | | [`ctx steering`](steering.md#ctx-steering) | Manage steering files (behavioral rules for AI tools) | | [`ctx trigger`](trigger.md#ctx-trigger) | Manage lifecycle triggers (scripts for automation) | @@ -198,6 +199,15 @@ dream: # ctx-dream config (opt-in; off by default) budget: 40 # Step/token ceiling per pass model: "" # Executor model ("" = session default) executor: "" # Executor command ("" = claude -p reference) +backends: # Optional ctx ai backend definitions + default: vllm # Used when --backend is omitted + vllm: + endpoint: http://localhost:8000 + timeout: 30s + default_model: Qwen/Qwen2.5-Coder-7B-Instruct + openai: + api_key_env: OPENAI_API_KEY + default_model: gpt-4.1-mini ``` | Field | Type | Default | Description | @@ -226,6 +236,13 @@ dream: # ctx-dream config (opt-in; off by default) | `hooks.dir` | `string` | `.context/hooks` | Hook scripts directory | | `hooks.timeout` | `int` | `10` | Per-hook execution timeout in seconds | | `hooks.enabled` | `bool` | `true` | Whether hook execution is enabled | +| `backends` | `object` | *(none)* | Optional backend definitions for `ctx ai` commands | +| `backends.default` | `string` | *(empty)* | Backend selected when `ctx ai` omits `--backend` | +| `type` | `string` | backend name | Backend implementation when a named entry should use a different implementation | +| `endpoint` | `string` | backend default| OpenAI-compatible endpoint URL | +| `api_key_env` | `string` | backend default| Environment variable containing the API key | +| `timeout` | `string` | `30s` | Backend request timeout duration | +| `default_model` | `string` | *(empty)* | Model used when an AI request omits a model | **Priority order:** CLI flags > Environment variables > `.ctxrc` > Defaults diff --git a/docs/cli/setup.md b/docs/cli/setup.md index 5f1ce8edd..dfaaedc02 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -17,13 +17,19 @@ Generate AI tool integration configuration. ```bash ctx setup [flags] +ctx setup --backend [flags] ``` **Flags**: -| Flag | Short | Description | -|-----------|-------|-----------------------------------------------------------------------------| -| `--write` | `-w` | Write the generated config to disk (e.g. `.github/copilot-instructions.md`) | +| Flag | Short | Description | +|-----------------|-------|-----------------------------------------------------------------------------| +| `--write` | `-w` | Write the generated config to disk (e.g. `.github/copilot-instructions.md`) | +| `--backend` | | Configure a `ctx ai` backend in `.ctxrc` | +| `--endpoint` | | Backend endpoint URL | +| `--api-key-env` | | Environment variable that contains the backend API key | +| `--model` | | Default model for backend requests | +| `--timeout` | | Backend request timeout duration | **Supported tools**: @@ -38,6 +44,17 @@ ctx setup [flags] | `opencode` | OpenCode (terminal-first AI coding agent) | | `windsurf` | Windsurf IDE | +**Supported backends**: + +| Backend | Description | +|-----------------------|--------------------------------------------------| +| `vllm` | Local vLLM OpenAI-compatible server | +| `openai-compatible` | Generic OpenAI-compatible HTTP backend | +| `openai` | OpenAI-compatible backend with OpenAI defaults | +| `anthropic` | Anthropic backend using the compatibility floor | +| `ollama` | Local Ollama OpenAI-compatible endpoint defaults | +| `lmstudio` | Local LM Studio endpoint defaults | + !!! note "Claude Code Uses the Plugin System" Claude Code integration is now provided via the `ctx` plugin. Running `ctx setup claude-code` prints plugin install instructions. @@ -59,4 +76,13 @@ ctx setup cline --write # Generate OpenCode plugin, skills, AGENTS.md, and global MCP config ctx setup opencode --write + +# Configure a local vLLM backend for ctx ai commands +ctx setup --backend vllm --endpoint http://localhost:8000 --write + +# Preview an OpenAI-compatible backend config without writing +ctx setup --backend openai-compatible \ + --endpoint https://llm.example.com \ + --api-key-env OPENAI_API_KEY \ + --model gpt-4.1-mini ``` diff --git a/docs/recipes/index.md b/docs/recipes/index.md index a1ddaeae5..d03d286b5 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -30,6 +30,15 @@ Aider, Copilot, or Windsurf. Includes **shell completion**, --- +### [Local Inference with vLLM](local-inference-with-vllm.md) + +Configure a local OpenAI-compatible vLLM server for optional `ctx ai` ping and +proposal commands without making deterministic ctx commands depend on AI. + +**Uses**: `ctx setup --backend`, `ctx ai ping`, `ctx ai propose` + +--- + ### [Multilingual Session Parsing](multilingual-sessions.md) Parse session journal entries written in **other languages**. @@ -543,4 +552,3 @@ stepdown, and the Raft-lite durability caveat. **Uses**: `ctx hub start --peers`, `ctx hub status`, `ctx hub peer add/remove`, `ctx hub stepdown` - diff --git a/docs/recipes/local-inference-with-vllm.md b/docs/recipes/local-inference-with-vllm.md new file mode 100644 index 000000000..df47de3c1 --- /dev/null +++ b/docs/recipes/local-inference-with-vllm.md @@ -0,0 +1,72 @@ +--- +# / ctx: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +title: Local Inference with vLLM +icon: lucide/server +--- + +![ctx](../images/ctx-banner.png) + +Use a local vLLM OpenAI-compatible server for optional `ctx ai` commands. +This does not change deterministic ctx behavior: `ctx status`, `ctx agent`, +and hooks keep working when no backend is configured or reachable. + +## 1. Start vLLM + +Run vLLM with its OpenAI-compatible API enabled. The exact model is your choice; +ctx only needs `/v1/models` and `/v1/chat/completions`. + +## 2. Configure ctx + +From the project root: + +```bash +ctx setup --backend vllm --endpoint http://localhost:8000 --write +``` + +This writes a `.ctxrc` `backends:` block similar to: + +```yaml +backends: + default: vllm + vllm: + endpoint: http://localhost:8000 +``` + +Add a model or timeout if needed: + +```yaml +backends: + default: vllm + vllm: + endpoint: http://localhost:8000 + default_model: Qwen/Qwen2.5-Coder-7B-Instruct + timeout: 30s +``` + +## 3. Verify reachability + +```bash +ctx ai ping +``` + +Expected output names the backend, endpoint, and first model listed by vLLM. + +## 4. Write a reviewable proposal + +```bash +ctx ai propose notes.md --emit decisions,learnings,tasks,open-questions +``` + +ctx writes one JSON proposed-patch artifact under `.context/proposals/ai/`. +Review it before changing canonical `.context/*.md` files. + +## Failure behavior + +AI commands fail closed when no backend is configured, multiple backends are +ambiguous, the backend is unreachable, or the upstream response is invalid. +They do not fall back to a different model and do not affect non-AI commands. diff --git a/internal/assets/commands/commands.yaml b/internal/assets/commands/commands.yaml index 550daee7e..9aa6ab2a7 100644 --- a/internal/assets/commands/commands.yaml +++ b/internal/assets/commands/commands.yaml @@ -62,6 +62,18 @@ agent: ctx agent --format json # JSON output for programmatic use ctx agent --session $PPID # Cooldown scoped to calling process short: Print AI-ready context packet +ai: + long: |- + Run optional AI backend commands. + short: Run AI backend commands +ai.ping: + long: |- + Check the configured AI backend and print model reachability. + short: Check AI backend reachability +ai.propose: + long: |- + Generate a validation-only proposed-patch artifact from an input file. + short: Generate AI proposal artifact change: long: |- Show changes in context files and code since the last AI session. diff --git a/internal/assets/commands/examples.yaml b/internal/assets/commands/examples.yaml index 5d6821b6f..62a24c74a 100644 --- a/internal/assets/commands/examples.yaml +++ b/internal/assets/commands/examples.yaml @@ -42,6 +42,17 @@ agent: ctx agent ctx agent --budget 4000 ctx agent --format json +ai: + short: |2- + ctx ai ping + ctx ai propose transcript.md --emit decisions,learnings +ai.ping: + short: |2- + ctx ai ping + ctx ai ping --backend vllm +ai.propose: + short: |2- + ctx ai propose transcript.md --emit decisions,learnings,tasks change: short: |2- diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index ca903042f..222bee970 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -85,6 +85,20 @@ trigger.test.tool: short: Tool name for mock input setup.write: short: Write the configuration file instead of printing +ai.backend: + short: Backend name to use +ai.emit: + short: Comma-separated proposal kinds to emit +setup.backend: + short: Configure an AI backend in .ctxrc +setup.endpoint: + short: Backend HTTP endpoint URL +setup.api-key-env: + short: Environment variable containing the backend API key +setup.model: + short: Default model for the backend +setup.timeout: + short: Backend request timeout duration initialize.reset: short: Reset an existing context (interactive only; backs up existing files to .context/.backup-init-/ before overwriting) initialize.merge: diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 000000000..471b25751 --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,104 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import errBackend "github.com/ActiveMemory/ctx/internal/err/backend" + +// SetDefault records the configured default backend name. +// +// Parameters: +// - name: configured backend name used by Default +func (registry *Registry) SetDefault(name string) { + registry.defaultName = name +} + +// RegisterBuiltin adds a built-in backend factory by type. +// +// Parameters: +// - name: configured backend name +// - config: backend configuration passed to the factory +// +// Returns: +// - error: duplicate registration or missing built-in backend +func (registry *Registry) RegisterBuiltin(name string, config Config) error { + factory, ok := builtinFactory(config.Type) + if !ok { + factory, ok = builtinFactory(name) + } + if !ok { + return errBackend.MissingBackend{Name: name} + } + return registry.Register(name, config, factory) +} + +// Register adds a backend factory and its config to the registry. +// +// Parameters: +// - name: configured backend name +// - config: backend configuration passed to the factory +// - factory: backend constructor +// +// Returns: +// - error: duplicate registration error, when name already exists +func (registry *Registry) Register( + name string, + config Config, + factory Factory, +) error { + registry.ensure() + if _, ok := registry.factories[name]; ok { + return errBackend.DuplicateRegistration{Name: name} + } + registry.factories[name] = factory + registry.configs[name] = config + return nil +} + +// Resolve returns the backend selected by name. +// +// Parameters: +// - name: configured backend name +// +// Returns: +// - Backend: resolved backend instance +// - error: missing backend or factory failure +func (registry *Registry) Resolve(name string) (Backend, error) { + registry.ensure() + factory, ok := registry.factories[name] + if !ok { + return nil, errBackend.MissingBackend{Name: name} + } + resolved, resolveErr := factory(registry.configs[name]) + if resolveErr != nil { + return nil, errBackend.Factory{Name: name, Cause: resolveErr} + } + return resolved, nil +} + +// Default resolves the registry default backend. +// +// Returns: +// - Backend: resolved default backend instance +// - error: empty registry, ambiguous registry, missing default, +// or factory failure +func (registry *Registry) Default() (Backend, error) { + registry.ensure() + if registry.defaultName != "" { + return registry.Resolve(registry.defaultName) + } + count := len(registry.factories) + if count == 0 { + return nil, errBackend.NoBackendConfigured{} + } + if count > 1 { + return nil, errBackend.MultipleBackends{} + } + for name := range registry.factories { + return registry.Resolve(name) + } + return nil, errBackend.NoBackendConfigured{} +} diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go new file mode 100644 index 000000000..0357cf390 --- /dev/null +++ b/internal/backend/backend_test.go @@ -0,0 +1,151 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "context" + "errors" + "testing" + + errBackend "github.com/ActiveMemory/ctx/internal/err/backend" +) + +type fakeBackend struct { + name string +} + +func (backend fakeBackend) Name() string { + return backend.name +} + +func (backend fakeBackend) Ping(context.Context) error { + return nil +} + +func (backend fakeBackend) Complete( + context.Context, + Request, +) (Response, error) { + return Response{Model: backend.name}, nil +} + +func TestRegistryResolvesSingleBackendAsDefault(t *testing.T) { + registry := &Registry{} + registerTestBackend(t, registry, "vllm") + + resolved, resolveErr := registry.Default() + if resolveErr != nil { + t.Fatalf("Default() error = %v", resolveErr) + } + if resolved.Name() != "vllm" { + t.Fatalf("Default().Name() = %q", resolved.Name()) + } +} + +func TestRegistryMultipleBackendsWithoutDefaultFails(t *testing.T) { + registry := &Registry{} + registerTestBackend(t, registry, "vllm") + registerTestBackend(t, registry, "openai") + + _, resolveErr := registry.Default() + var multiple errBackend.MultipleBackends + if !errors.As(resolveErr, &multiple) { + t.Fatalf("Default() error = %T, want MultipleBackends", resolveErr) + } +} + +func TestRegistryExplicitDefault(t *testing.T) { + registry := &Registry{} + registerTestBackend(t, registry, "vllm") + registerTestBackend(t, registry, "openai") + registry.SetDefault("openai") + + resolved, resolveErr := registry.Default() + if resolveErr != nil { + t.Fatalf("Default() error = %v", resolveErr) + } + if resolved.Name() != "openai" { + t.Fatalf("Default().Name() = %q", resolved.Name()) + } +} + +func TestRegistryMissingDefault(t *testing.T) { + registry := &Registry{} + registerTestBackend(t, registry, "vllm") + registry.SetDefault("openai") + + _, resolveErr := registry.Default() + var missing errBackend.MissingBackend + if !errors.As(resolveErr, &missing) { + t.Fatalf("Default() error = %T, want MissingBackend", resolveErr) + } +} + +func TestRegistryEmptyDefault(t *testing.T) { + registry := &Registry{} + + _, resolveErr := registry.Default() + var empty errBackend.NoBackendConfigured + if !errors.As(resolveErr, &empty) { + t.Fatalf("Default() error = %T, want NoBackendConfigured", resolveErr) + } +} + +func TestRegistryMissingBackend(t *testing.T) { + registry := &Registry{} + + _, resolveErr := registry.Resolve("missing") + var missing errBackend.MissingBackend + if !errors.As(resolveErr, &missing) { + t.Fatalf("Resolve() error = %T, want MissingBackend", resolveErr) + } +} + +func TestRegistryDuplicateRegistration(t *testing.T) { + registry := &Registry{} + registerTestBackend(t, registry, "vllm") + + registerErr := registry.Register("vllm", Config{}, testFactory("vllm")) + var duplicate errBackend.DuplicateRegistration + if !errors.As(registerErr, &duplicate) { + t.Fatalf("Register() error = %T, want DuplicateRegistration", registerErr) + } +} + +func TestRegistryFactoryErrors(t *testing.T) { + factoryErr := errors.New("boom") + registry := &Registry{} + registerErr := registry.Register("vllm", Config{}, func(Config) (Backend, error) { + return nil, factoryErr + }) + if registerErr != nil { + t.Fatalf("Register() error = %v", registerErr) + } + + _, resolveErr := registry.Resolve("vllm") + var wrapped errBackend.Factory + if !errors.As(resolveErr, &wrapped) { + t.Fatalf("Resolve() error = %T, want Factory", resolveErr) + } + if !errors.Is(resolveErr, factoryErr) { + t.Fatalf("Resolve() does not wrap factory error") + } +} + +func registerTestBackend(t *testing.T, registry *Registry, name string) { + t.Helper() + registerErr := registry.Register(name, Config{Name: name}, testFactory(name)) + if registerErr != nil { + t.Fatalf("Register() error = %v", registerErr) + } +} + +func testFactory(name string) Factory { + return func(Config) (Backend, error) { + return fakeBackend{name: name}, nil + } +} diff --git a/internal/backend/builtin_internal.go b/internal/backend/builtin_internal.go new file mode 100644 index 000000000..242bf4ada --- /dev/null +++ b/internal/backend/builtin_internal.go @@ -0,0 +1,36 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + +// builtinFactory returns a built-in backend factory by name. +// +// Parameters: +// - name: backend implementation name +// +// Returns: +// - Factory: backend factory +// - bool: whether the factory exists +func builtinFactory(name string) (Factory, bool) { + switch name { + case cfgBackend.NameOpenAICompatible: + return openAICompatibleFactory, true + case cfgBackend.NameVLLM: + return vllmFactory, true + case cfgBackend.NameOpenAI: + return openAIFactory, true + case cfgBackend.NameAnthropic: + return anthropicFactory, true + case cfgBackend.NameOllama: + return ollamaFactory, true + case cfgBackend.NameLMStudio: + return lmStudioFactory, true + default: + return nil, false + } +} diff --git a/internal/backend/doc.go b/internal/backend/doc.go new file mode 100644 index 000000000..8ed75593b --- /dev/null +++ b/internal/backend/doc.go @@ -0,0 +1,13 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package backend defines the optional AI backend contract and an +// internal registry for resolving configured backends. +// +// The package contains no concrete HTTP backend implementations. It is +// the dispatch seam later rc-backed configuration and ctx ai commands +// use to select a backend, ping it, and request completions. +package backend diff --git a/internal/backend/info.go b/internal/backend/info.go new file mode 100644 index 000000000..474c360ac --- /dev/null +++ b/internal/backend/info.go @@ -0,0 +1,41 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import "context" + +// PingInfo checks backend reachability and returns optional model info. +// +// Parameters: +// - ctx: request context +// - target: backend to ping +// +// Returns: +// - Response: optional ping metadata, including FirstModel when available +// - error: backend ping failure +func PingInfo(ctx context.Context, target Backend) (Response, error) { + if modelBackend, ok := target.(interface { + models(context.Context) (Response, error) + }); ok { + return modelBackend.models(ctx) + } + return Response{}, target.Ping(ctx) +} + +// EndpointInfo returns the resolved endpoint for HTTP-backed backends. +// +// Parameters: +// - target: backend to inspect +// +// Returns: +// - string: resolved endpoint when the backend exposes one +func EndpointInfo(target Backend) string { + if endpointBackend, ok := target.(interface{ endpoint() string }); ok { + return endpointBackend.endpoint() + } + return "" +} diff --git a/internal/backend/named_internal.go b/internal/backend/named_internal.go new file mode 100644 index 000000000..a7456ac6d --- /dev/null +++ b/internal/backend/named_internal.go @@ -0,0 +1,107 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + +// openAIFactory creates the named OpenAI backend wrapper. +// +// Parameters: +// - config: backend configuration +// +// Returns: +// - Backend: configured OpenAI-compatible backend +// - error: always nil; request validation happens during calls +func openAIFactory(config Config) (Backend, error) { + return openAICompatible{ + name: cfgBackend.NameOpenAI, + config: defaultConfig( + config, + cfgBackend.DefaultEndpointOpenAI, + cfgBackend.DefaultAPIKeyEnvOpenAI, + ), + }, nil +} + +// anthropicFactory creates the named Anthropic backend wrapper. +// +// Anthropic currently uses the OpenAI-compatible floor. Messages API +// specialization is deferred until backend capability detection exists. +// +// Parameters: +// - config: backend configuration +// +// Returns: +// - Backend: configured OpenAI-compatible backend +// - error: always nil; request validation happens during calls +func anthropicFactory(config Config) (Backend, error) { + return openAICompatible{ + name: cfgBackend.NameAnthropic, + config: defaultConfig( + config, + cfgBackend.DefaultEndpointAnthropic, + cfgBackend.DefaultAPIKeyEnvAnthropic, + ), + }, nil +} + +// ollamaFactory creates the named Ollama backend wrapper. +// +// Parameters: +// - config: backend configuration +// +// Returns: +// - Backend: configured OpenAI-compatible backend +// - error: always nil; request validation happens during calls +func ollamaFactory(config Config) (Backend, error) { + return openAICompatible{ + name: cfgBackend.NameOllama, + config: defaultConfig( + config, + cfgBackend.DefaultEndpointOllama, + "", + ), + }, nil +} + +// lmStudioFactory creates the named LM Studio backend wrapper. +// +// Parameters: +// - config: backend configuration +// +// Returns: +// - Backend: configured OpenAI-compatible backend +// - error: always nil; request validation happens during calls +func lmStudioFactory(config Config) (Backend, error) { + return openAICompatible{ + name: cfgBackend.NameLMStudio, + config: defaultConfig( + config, + cfgBackend.DefaultEndpointLMStudio, + "", + ), + }, nil +} + +// defaultConfig applies endpoint and auth defaults. +// +// Parameters: +// - config: caller-supplied config +// - endpoint: default endpoint when config omits one +// - apiKeyEnv: default API key env var when config omits one +// +// Returns: +// - Config: config with defaults applied +func defaultConfig(config Config, endpoint string, apiKeyEnv string) Config { + if config.Endpoint == "" { + config.Endpoint = endpoint + } + if config.APIKeyEnv == "" { + config.APIKeyEnv = apiKeyEnv + } + return config +} diff --git a/internal/backend/named_test.go b/internal/backend/named_test.go new file mode 100644 index 000000000..eb670fd3d --- /dev/null +++ b/internal/backend/named_test.go @@ -0,0 +1,123 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "testing" + + cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" +) + +func TestOpenAIFactoryDefaults(t *testing.T) { + assertNamedFactory( + t, + openAIFactory, + cfgBackend.NameOpenAI, + cfgBackend.DefaultEndpointOpenAI, + cfgBackend.DefaultAPIKeyEnvOpenAI, + ) +} + +func TestAnthropicFactoryDefaults(t *testing.T) { + assertNamedFactory( + t, + anthropicFactory, + cfgBackend.NameAnthropic, + cfgBackend.DefaultEndpointAnthropic, + cfgBackend.DefaultAPIKeyEnvAnthropic, + ) +} + +func TestOllamaFactoryDefaults(t *testing.T) { + assertNamedFactory( + t, + ollamaFactory, + cfgBackend.NameOllama, + cfgBackend.DefaultEndpointOllama, + "", + ) +} + +func TestLMStudioFactoryDefaults(t *testing.T) { + assertNamedFactory( + t, + lmStudioFactory, + cfgBackend.NameLMStudio, + cfgBackend.DefaultEndpointLMStudio, + "", + ) +} + +func TestVLLMFactoryDefaults(t *testing.T) { + assertNamedFactory( + t, + vllmFactory, + cfgBackend.NameVLLM, + cfgBackend.DefaultEndpointVLLM, + "", + ) +} + +func TestEndpointInfoReportsFactoryDefault(t *testing.T) { + backend, factoryErr := vllmFactory(Config{}) + if factoryErr != nil { + t.Fatalf("vllmFactory() error = %v", factoryErr) + } + if got := EndpointInfo(backend); got != cfgBackend.DefaultEndpointVLLM { + t.Fatalf("EndpointInfo() = %q, want %q", got, cfgBackend.DefaultEndpointVLLM) + } +} + +func TestNamedFactoryKeepsConfiguredValues(t *testing.T) { + backend, factoryErr := openAIFactory(Config{ //nolint:gosec // G101: test fixture, value is an env var name, not a credential + Endpoint: "https://example.invalid", + APIKeyEnv: "CTX_CUSTOM_KEY", + }) + if factoryErr != nil { + t.Fatalf("openAIFactory() error = %v", factoryErr) + } + wrapped := assertOpenAICompatible(t, backend) + if wrapped.config.Endpoint != "https://example.invalid" { + t.Fatalf("Endpoint = %q", wrapped.config.Endpoint) + } + if wrapped.config.APIKeyEnv != "CTX_CUSTOM_KEY" { + t.Fatalf("APIKeyEnv = %q", wrapped.config.APIKeyEnv) + } +} + +func assertNamedFactory( + t *testing.T, + factory Factory, + name string, + endpoint string, + apiKeyEnv string, +) { + t.Helper() + backend, factoryErr := factory(Config{}) + if factoryErr != nil { + t.Fatalf("factory() error = %v", factoryErr) + } + if backend.Name() != name { + t.Fatalf("Name() = %q, want %q", backend.Name(), name) + } + wrapped := assertOpenAICompatible(t, backend) + if wrapped.config.Endpoint != endpoint { + t.Fatalf("Endpoint = %q, want %q", wrapped.config.Endpoint, endpoint) + } + if wrapped.config.APIKeyEnv != apiKeyEnv { + t.Fatalf("APIKeyEnv = %q, want %q", wrapped.config.APIKeyEnv, apiKeyEnv) + } +} + +func assertOpenAICompatible(t *testing.T, backend Backend) openAICompatible { + t.Helper() + wrapped, ok := backend.(openAICompatible) + if !ok { + t.Fatalf("backend = %T, want openAICompatible", backend) + } + return wrapped +} diff --git a/internal/backend/openaicompat.go b/internal/backend/openaicompat.go new file mode 100644 index 000000000..7979c8520 --- /dev/null +++ b/internal/backend/openaicompat.go @@ -0,0 +1,89 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "context" + "encoding/json" + + cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + errBackend "github.com/ActiveMemory/ctx/internal/err/backend" +) + +// Name returns the backend implementation name. +// +// Returns: +// - string: backend name +func (backend openAICompatible) Name() string { + return backend.name +} + +// Ping checks model-list reachability. +// +// Parameters: +// - ctx: request context +// +// Returns: +// - error: unreachable, upstream, or response decode failure +func (backend openAICompatible) Ping(ctx context.Context) error { + _, pingErr := backend.models(ctx) + return pingErr +} + +// Complete requests a chat completion. +// +// Parameters: +// - ctx: request context +// - req: completion request +// +// Returns: +// - Response: completion response +// - error: unreachable, upstream, encode, or decode failure +func (backend openAICompatible) Complete( + ctx context.Context, + req Request, +) (Response, error) { + model := req.Model + if model == "" { + model = backend.config.DefaultModel + } + payload := chatRequest{ + Model: model, + Messages: []chatMessage{{ + Role: cfgBackend.RoleUser, + Content: req.Prompt, + }}, + } + body, marshalErr := json.Marshal(payload) + if marshalErr != nil { + return Response{}, errBackend.BadRequest{ + Name: backend.name, + Cause: marshalErr, + } + } + raw, doErr := backend.do( + ctx, + cfgBackend.HTTPMethodPost, + cfgBackend.ChatCompletionsPath, + body, + ) + if doErr != nil { + return Response{}, doErr + } + var decoded chatResponse + if decodeErr := json.Unmarshal(raw, &decoded); decodeErr != nil { + return Response{}, errBackend.BadRequest{ + Name: backend.name, + Cause: decodeErr, + } + } + text := "" + if len(decoded.Choices) > 0 { + text = decoded.Choices[0].Message.Content + } + return Response{Model: decoded.Model, Text: text, Raw: raw}, nil +} diff --git a/internal/backend/openaicompat_internal.go b/internal/backend/openaicompat_internal.go new file mode 100644 index 000000000..1cf264677 --- /dev/null +++ b/internal/backend/openaicompat_internal.go @@ -0,0 +1,212 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "time" + + cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + cfgHTTP "github.com/ActiveMemory/ctx/internal/config/http" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" + errBackend "github.com/ActiveMemory/ctx/internal/err/backend" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" +) + +// openAICompatibleFactory creates a generic OpenAI-compatible backend. +// +// Parameters: +// - config: backend configuration +// +// Returns: +// - Backend: configured backend +// - error: always nil; request validation happens during calls +func openAICompatibleFactory(config Config) (Backend, error) { + return openAICompatible{ + name: cfgBackend.NameOpenAICompatible, + config: config, + }, nil +} + +// models requests the model list and extracts the first model. +// +// Parameters: +// - ctx: request context +// +// Returns: +// - Response: model-list response with FirstModel set when present +// - error: unreachable, upstream, or decode failure +func (backend openAICompatible) models(ctx context.Context) (Response, error) { + raw, doErr := backend.do( + ctx, + cfgBackend.HTTPMethodGet, + cfgBackend.ModelsPath, + nil, + ) + if doErr != nil { + return Response{}, doErr + } + var decoded modelsResponse + if decodeErr := json.Unmarshal(raw, &decoded); decodeErr != nil { + return Response{}, errBackend.BadRequest{ + Name: backend.name, + Cause: decodeErr, + } + } + firstModel := "" + if len(decoded.Data) > 0 { + firstModel = decoded.Data[0].ID + } + return Response{FirstModel: firstModel, Raw: raw}, nil +} + +// do sends a backend HTTP request and returns the raw response body. +// +// Parameters: +// - ctx: request context +// - method: HTTP method constant +// - path: endpoint path constant +// - body: optional JSON request body +// +// Returns: +// - []byte: raw response body +// - error: endpoint, transport, read, or upstream status error +func (backend openAICompatible) do( + ctx context.Context, + method string, + path string, + body []byte, +) ([]byte, error) { + endpoint, endpointErr := backend.url(path) + if endpointErr != nil { + return nil, endpointErr + } + requestBody := io.Reader(nil) + if body != nil { + requestBody = bytes.NewReader(body) + } + request, requestErr := http.NewRequestWithContext( + ctx, + method, + endpoint, + requestBody, + ) + if requestErr != nil { + return nil, errBackend.InvalidEndpoint{ + Endpoint: backend.config.Endpoint, + Cause: requestErr, + } + } + backend.headers(request, body != nil) + client := http.Client{Timeout: backend.timeout()} + response, doErr := client.Do(request) + if doErr != nil { + return nil, errBackend.Unreachable{ + Name: backend.name, + Endpoint: backend.config.Endpoint, + Cause: doErr, + } + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil { + logWarn.Warn(cfgWarn.CloseResponse, closeErr) + } + }() + raw, readErr := io.ReadAll(response.Body) + if readErr != nil { + return nil, errBackend.BadRequest{Name: backend.name, Cause: readErr} + } + if response.StatusCode < http.StatusOK || + response.StatusCode >= http.StatusMultipleChoices { + return nil, errBackend.Upstream{ + Name: backend.name, + StatusCode: response.StatusCode, + Body: string(raw), + } + } + return raw, nil +} + +// headers applies optional JSON and authorization headers. +// +// Parameters: +// - request: outbound HTTP request +// - hasBody: whether the request has a JSON body +func (backend openAICompatible) headers( + request *http.Request, + hasBody bool, +) { + if hasBody { + request.Header.Set( + cfgBackend.HeaderContentType, + cfgBackend.ContentTypeJSON, + ) + } + if backend.config.APIKeyEnv == "" { + return + } + apiKey := os.Getenv(backend.config.APIKeyEnv) + if apiKey == "" { + return + } + request.Header.Set( + cfgBackend.HeaderAuthorization, + cfgBackend.AuthorizationBearerPrefix+apiKey, + ) +} + +// timeout parses the configured timeout with a safe fallback. +// +// Returns: +// - time.Duration: configured or default timeout +func (backend openAICompatible) timeout() time.Duration { + parsed, parseErr := time.ParseDuration(backend.config.Timeout) + if parseErr != nil || parsed <= 0 { + return cfgBackend.DefaultTimeout + } + return parsed +} + +// endpoint returns the fully defaulted backend endpoint. +// +// Returns: +// - string: configured endpoint after factory defaults +func (backend openAICompatible) endpoint() string { + return backend.config.Endpoint +} + +// url combines the configured endpoint with an API path. +// +// Parameters: +// - path: endpoint path constant +// +// Returns: +// - string: absolute request URL +// - error: invalid endpoint error +func (backend openAICompatible) url(path string) (string, error) { + parsed, parseErr := url.Parse(backend.config.Endpoint) + if parseErr != nil { + return "", errBackend.InvalidEndpoint{ + Endpoint: backend.config.Endpoint, + Cause: parseErr, + } + } + if parsed.Scheme != cfgHTTP.SchemeHTTP && + parsed.Scheme != cfgHTTP.SchemeHTTPS { + return "", errBackend.InvalidEndpoint{ + Endpoint: backend.config.Endpoint, + } + } + parsed.Path = path + return parsed.String(), nil +} diff --git a/internal/backend/openaicompat_test.go b/internal/backend/openaicompat_test.go new file mode 100644 index 000000000..7691e4104 --- /dev/null +++ b/internal/backend/openaicompat_test.go @@ -0,0 +1,250 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + errBackend "github.com/ActiveMemory/ctx/internal/err/backend" +) + +func TestOpenAICompatiblePingSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != cfgBackend.ModelsPath { + t.Fatalf("path = %s, want %s", r.URL.Path, cfgBackend.ModelsPath) + } + _, writeErr := w.Write([]byte(`{"data":[{"id":"first"}]}`)) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) + defer server.Close() + backend := testOpenAIBackend(t, Config{Endpoint: server.URL}) + + pingErr := backend.Ping(context.Background()) + if pingErr != nil { + t.Fatalf("Ping() error = %v", pingErr) + } +} + +func TestOpenAICompatibleCompleteSuccess(t *testing.T) { + t.Setenv("CTX_TEST_API_KEY", "token") + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != cfgBackend.ChatCompletionsPath { + t.Fatalf( + "path = %s, want %s", + r.URL.Path, + cfgBackend.ChatCompletionsPath, + ) + } + if got := r.Header.Get(cfgBackend.HeaderAuthorization); got == "" { + t.Fatalf("authorization header missing") + } + var request chatRequest + decodeErr := json.NewDecoder(r.Body).Decode(&request) + if decodeErr != nil { + t.Fatalf("Decode() error = %v", decodeErr) + } + if request.Model != "configured-model" { + t.Fatalf("model = %q", request.Model) + } + _, writeErr := w.Write([]byte( + `{"model":"configured-model","choices":[{"message":{"content":"ok"}}]}`, + )) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) + defer server.Close() + backend := testOpenAIBackend(t, Config{ //nolint:gosec // G101: test fixture, value is an env var name, not a credential + Endpoint: server.URL, + APIKeyEnv: "CTX_TEST_API_KEY", + DefaultModel: "configured-model", + }) + + response, completeErr := backend.Complete( + context.Background(), + Request{Prompt: "hello"}, + ) + if completeErr != nil { + t.Fatalf("Complete() error = %v", completeErr) + } + if response.Model != "configured-model" { + t.Fatalf("Model = %q", response.Model) + } + if response.Text != "ok" { + t.Fatalf("Text = %q", response.Text) + } +} + +func TestOpenAICompatibleUpstream4xxBody(t *testing.T) { + backend := testStatusBackend(t, http.StatusUnauthorized, "nope") + + _, completeErr := backend.Complete(context.Background(), Request{}) + assertUpstreamBody(t, completeErr, "nope") +} + +func TestOpenAICompatibleUpstream5xxBody(t *testing.T) { + backend := testStatusBackend(t, http.StatusBadGateway, "bad gateway") + + _, completeErr := backend.Complete(context.Background(), Request{}) + assertUpstreamBody(t, completeErr, "bad gateway") +} + +func TestOpenAICompatibleUnreachableServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + http.ResponseWriter, + *http.Request, + ) { + })) + endpoint := server.URL + server.Close() + backend := testOpenAIBackend(t, Config{Endpoint: endpoint}) + + pingErr := backend.Ping(context.Background()) + var unreachable errBackend.Unreachable + if !errors.As(pingErr, &unreachable) { + t.Fatalf("Ping() error = %T, want Unreachable", pingErr) + } +} + +func TestOpenAICompatibleInvalidEndpointScheme(t *testing.T) { + backend := testOpenAIBackend(t, Config{Endpoint: "file:///tmp/socket"}) + + pingErr := backend.Ping(context.Background()) + var invalid errBackend.InvalidEndpoint + if !errors.As(pingErr, &invalid) { + t.Fatalf("Ping() error = %T, want InvalidEndpoint", pingErr) + } +} + +func TestOpenAICompatibleTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + time.Sleep(20 * time.Millisecond) + _, writeErr := w.Write([]byte(`{"data":[]}`)) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) + defer server.Close() + backend := testOpenAIBackend(t, Config{ + Endpoint: server.URL, + Timeout: "1ms", + }) + + pingErr := backend.Ping(context.Background()) + var unreachable errBackend.Unreachable + if !errors.As(pingErr, &unreachable) { + t.Fatalf("Ping() error = %T, want Unreachable", pingErr) + } +} + +func TestOpenAICompatibleRequestModelOverride(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + var request chatRequest + decodeErr := json.NewDecoder(r.Body).Decode(&request) + if decodeErr != nil { + t.Fatalf("Decode() error = %v", decodeErr) + } + if request.Model != "override-model" { + t.Fatalf("model = %q", request.Model) + } + _, writeErr := w.Write([]byte( + `{"model":"override-model","choices":[{"message":{"content":"ok"}}]}`, + )) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) + defer server.Close() + backend := testOpenAIBackend(t, Config{ + Endpoint: server.URL, + DefaultModel: "configured-model", + }) + + response, completeErr := backend.Complete( + context.Background(), + Request{Model: "override-model"}, + ) + if completeErr != nil { + t.Fatalf("Complete() error = %v", completeErr) + } + if response.Model != "override-model" { + t.Fatalf("Model = %q", response.Model) + } +} + +func TestVLLMFactoryNameBehavior(t *testing.T) { + backend, factoryErr := vllmFactory(Config{}) + if factoryErr != nil { + t.Fatalf("vllmFactory() error = %v", factoryErr) + } + if backend.Name() != cfgBackend.NameVLLM { + t.Fatalf("Name() = %q", backend.Name()) + } +} + +func testOpenAIBackend(t *testing.T, config Config) Backend { + t.Helper() + backend, factoryErr := openAICompatibleFactory(config) + if factoryErr != nil { + t.Fatalf("openAICompatibleFactory() error = %v", factoryErr) + } + return backend +} + +func testStatusBackend(t *testing.T, status int, body string) Backend { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + w.WriteHeader(status) + _, writeErr := w.Write([]byte(body)) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) + t.Cleanup(server.Close) + return testOpenAIBackend(t, Config{Endpoint: server.URL}) +} + +func assertUpstreamBody(t *testing.T, targetErr error, body string) { + t.Helper() + var upstream errBackend.Upstream + if !errors.As(targetErr, &upstream) { + t.Fatalf("error = %T, want Upstream", targetErr) + } + if upstream.Body != body { + t.Fatalf("Body = %q", upstream.Body) + } +} diff --git a/internal/backend/registry_internal.go b/internal/backend/registry_internal.go new file mode 100644 index 000000000..a1c37b99b --- /dev/null +++ b/internal/backend/registry_internal.go @@ -0,0 +1,20 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +// ensure initializes the registry's maps for zero-value use. +// +// Parameters: +// - registry: registry receiver to initialize +func (registry *Registry) ensure() { + if registry.factories == nil { + registry.factories = make(map[string]Factory) + } + if registry.configs == nil { + registry.configs = make(map[string]Config) + } +} diff --git a/internal/backend/types.go b/internal/backend/types.go new file mode 100644 index 000000000..a69b707de --- /dev/null +++ b/internal/backend/types.go @@ -0,0 +1,108 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import "context" + +// Backend is the AI completion backend contract. +type Backend interface { + Name() string + Ping(ctx context.Context) error + Complete(ctx context.Context, req Request) (Response, error) +} + +// Factory builds a backend from registry configuration. +type Factory func(Config) (Backend, error) + +// Config carries rc-backed backend settings into backend factories. +// +// Fields: +// - Name: configured backend name +// - Type: registered backend implementation type +// - Endpoint: backend HTTP endpoint, when applicable +// - APIKeyEnv: environment variable name for credentials +// - Timeout: request timeout duration string from configuration +// - DefaultModel: model selected when a request omits one +type Config struct { + Name string + Type string + Endpoint string + APIKeyEnv string + Timeout string + DefaultModel string +} + +// Request describes a backend completion request. +// +// Fields: +// - Model: model override for this request +// - Prompt: input prompt text +// - Schema: optional JSON schema for structured output +type Request struct { + Model string + Prompt string + Schema string +} + +// Response describes a backend completion response. +// +// Fields: +// - Model: model that produced the response +// - Text: completion text +// - Raw: optional raw provider payload +type Response struct { + Model string + Text string + Raw []byte + FirstModel string +} + +// Registry stores backend factories and resolves configured backends. +type Registry struct { + factories map[string]Factory + configs map[string]Config + defaultName string +} + +// openAICompatible is an OpenAI-compatible HTTP backend. +type openAICompatible struct { + name string + config Config +} + +// modelsResponse is the OpenAI-compatible /v1/models response. +type modelsResponse struct { + Data []modelInfo `json:"data"` +} + +// modelInfo is a single model entry. +type modelInfo struct { + ID string `json:"id"` +} + +// chatRequest is the OpenAI-compatible chat completion request. +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` +} + +// chatMessage is a chat message in an OpenAI-compatible payload. +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// chatResponse is the OpenAI-compatible chat completion response. +type chatResponse struct { + Model string `json:"model"` + Choices []chatChoice `json:"choices"` +} + +// chatChoice is one chat completion candidate. +type chatChoice struct { + Message chatMessage `json:"message"` +} diff --git a/internal/backend/vllm.go b/internal/backend/vllm.go new file mode 100644 index 000000000..add3b7e17 --- /dev/null +++ b/internal/backend/vllm.go @@ -0,0 +1,24 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + +// vllmFactory creates the canonical local vLLM backend. +// +// Parameters: +// - config: backend configuration +// +// Returns: +// - Backend: configured vLLM backend +// - error: always nil; request validation happens during calls +func vllmFactory(config Config) (Backend, error) { + return openAICompatible{ + name: cfgBackend.NameVLLM, + config: defaultConfig(config, cfgBackend.DefaultEndpointVLLM, ""), + }, nil +} diff --git a/internal/bootstrap/group.go b/internal/bootstrap/group.go index 802894b91..766959fe9 100644 --- a/internal/bootstrap/group.go +++ b/internal/bootstrap/group.go @@ -8,6 +8,7 @@ package bootstrap import ( "github.com/ActiveMemory/ctx/internal/cli/agent" + "github.com/ActiveMemory/ctx/internal/cli/ai" "github.com/ActiveMemory/ctx/internal/cli/change" "github.com/ActiveMemory/ctx/internal/cli/compact" "github.com/ActiveMemory/ctx/internal/cli/config" @@ -151,6 +152,7 @@ func runtimeCmds() []registration { // connect, mcp, watch, and loop commands func integrations() []registration { return []registration{ + {ai.Cmd, embedCmd.GroupIntegration}, {setup.Cmd, embedCmd.GroupIntegration}, {steering.Cmd, embedCmd.GroupIntegration}, {trigger.Cmd, embedCmd.GroupIntegration}, diff --git a/internal/cli/ai/ai.go b/internal/cli/ai/ai.go new file mode 100644 index 000000000..04c77c98d --- /dev/null +++ b/internal/cli/ai/ai.go @@ -0,0 +1,21 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "github.com/spf13/cobra" + + aiRoot "github.com/ActiveMemory/ctx/internal/cli/ai/cmd/root" +) + +// Cmd returns the ctx ai command. +// +// Returns: +// - *cobra.Command: configured ai command +func Cmd() *cobra.Command { + return aiRoot.Cmd() +} diff --git a/internal/cli/ai/cmd/root/cmd.go b/internal/cli/ai/cmd/root/cmd.go new file mode 100644 index 000000000..9e34e4dc2 --- /dev/null +++ b/internal/cli/ai/cmd/root/cmd.go @@ -0,0 +1,64 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the ctx ai command tree. +// +// Returns: +// - *cobra.Command: configured ai command with subcommands +func Cmd() *cobra.Command { + short, long := desc.Command(cmd.DescKeyAI) + c := &cobra.Command{ + Use: cmd.UseAI, + Short: short, + Long: long, + } + var backendName string + pingShort, pingLong := desc.Command(cmd.DescKeyAIPing) + ping := &cobra.Command{ + Use: cmd.UseAIPing, + Short: pingShort, + Long: pingLong, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return RunPing(cmd, backendName) + }, + } + flagbind.StringFlag(ping, &backendName, cFlag.Backend, flag.DescKeyAIBackend) + var proposeBackend string + var emit string + proposeShort, proposeLong := desc.Command(cmd.DescKeyAIPropose) + propose := &cobra.Command{ + Use: cmd.UseAIPropose, + Short: proposeShort, + Long: proposeLong, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return RunPropose(cmd, args[0], proposeBackend, emit) + }, + } + flagbind.StringFlag( + propose, + &proposeBackend, + cFlag.Backend, + flag.DescKeyAIBackend, + ) + flagbind.StringFlag(propose, &emit, cFlag.Emit, flag.DescKeyAIEmit) + c.AddCommand(ping) + c.AddCommand(propose) + return c +} diff --git a/internal/cli/ai/cmd/root/doc.go b/internal/cli/ai/cmd/root/doc.go new file mode 100644 index 000000000..798398887 --- /dev/null +++ b/internal/cli/ai/cmd/root/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package root defines the ctx ai command tree. +// +// It contains only cobra command construction and Run wrappers. Backend +// resolution and proposal writing live in core subpackages. +// Subcommands are constructed inline to keep cmd/ free of helper APIs. +package root diff --git a/internal/cli/ai/cmd/root/root_test.go b/internal/cli/ai/cmd/root/root_test.go new file mode 100644 index 000000000..c74583bef --- /dev/null +++ b/internal/cli/ai/cmd/root/root_test.go @@ -0,0 +1,203 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/rc" +) + +func TestNoBackendConfiguredFails(t *testing.T) { + chdirProject(t) + + _, runErr := executeAI(t, "ping") + if runErr == nil { + t.Fatalf("ping without backend should fail") + } +} + +func TestMultipleBackendsWithoutDefaultFails(t *testing.T) { + project := chdirProject(t) + writeCtxRC(t, project, multipleBackendsYAML()) + + _, runErr := executeAI(t, "ping") + if runErr == nil { + t.Fatalf("ping with ambiguous backends should fail") + } +} + +func TestPingSucceeds(t *testing.T) { + project := chdirProject(t) + server := modelsServer(t) + t.Cleanup(server.Close) + writeCtxRC(t, project, singleBackendYAML(server.URL)) + + out, runErr := executeAI(t, "ping") + if runErr != nil { + t.Fatalf("ping failed: %v", runErr) + } + for _, want := range []string{"vllm", server.URL, "model-a"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q: %q", want, out) + } + } +} + +func TestProposeWritesArtifactOnly(t *testing.T) { + project := chdirProject(t) + server := completionServer(t) + t.Cleanup(server.Close) + writeCtxRC(t, project, singleBackendYAML(server.URL)) + contextFile := filepath.Join(project, ".context", "DECISIONS.md") + before, readErr := os.ReadFile(contextFile) + if readErr != nil { + t.Fatalf("ReadFile() error = %v", readErr) + } + input := filepath.Join(project, "input.txt") + if writeErr := os.WriteFile(input, []byte("source"), 0o644); writeErr != nil { + t.Fatalf("WriteFile() error = %v", writeErr) + } + + _, runErr := executeAI( + t, + "propose", + input, + "--emit", + "decisions,learnings,tasks,open-questions", + ) + if runErr != nil { + t.Fatalf("propose failed: %v", runErr) + } + after, readErr := os.ReadFile(contextFile) + if readErr != nil { + t.Fatalf("ReadFile() error = %v", readErr) + } + if string(before) != string(after) { + t.Fatalf("context file changed") + } + entries, readDirErr := os.ReadDir( + filepath.Join(project, ".context", "proposals", "ai"), + ) + if readDirErr != nil { + t.Fatalf("ReadDir() error = %v", readDirErr) + } + if len(entries) != 1 { + t.Fatalf("artifact count = %d", len(entries)) + } +} + +func TestCommandRegistrationPathResolves(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"ping", "--backend", "vllm"}) + leaf, _, findErr := cmd.Find([]string{"ping"}) + if findErr != nil { + t.Fatalf("Find() error = %v", findErr) + } + if leaf == nil { + t.Fatalf("ping command not registered") + } +} + +func executeAI(t *testing.T, args ...string) (string, error) { + t.Helper() + rc.Reset() + t.Cleanup(rc.Reset) + buf := new(bytes.Buffer) + cmd := Cmd() + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs(args) + runErr := cmd.Execute() + return buf.String(), runErr +} + +func chdirProject(t *testing.T) string { + t.Helper() + project := t.TempDir() + contextDir := filepath.Join(project, ".context") + if mkdirErr := os.MkdirAll(contextDir, 0o755); mkdirErr != nil { + t.Fatalf("MkdirAll() error = %v", mkdirErr) + } + if writeErr := os.WriteFile( + filepath.Join(contextDir, "DECISIONS.md"), + []byte("decisions"), + 0o644, + ); writeErr != nil { + t.Fatalf("WriteFile() error = %v", writeErr) + } + origDir, wdErr := os.Getwd() + if wdErr != nil { + t.Fatalf("Getwd() error = %v", wdErr) + } + if chdirErr := os.Chdir(project); chdirErr != nil { + t.Fatalf("Chdir() error = %v", chdirErr) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(origDir); chdirErr != nil { + t.Fatalf("Chdir() cleanup error = %v", chdirErr) + } + }) + return project +} + +func writeCtxRC(t *testing.T, project string, content string) { + t.Helper() + if writeErr := os.WriteFile( + filepath.Join(project, ".ctxrc"), + []byte(content), + 0o644, + ); writeErr != nil { + t.Fatalf("WriteFile() error = %v", writeErr) + } +} + +func modelsServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + _, writeErr := w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) +} + +func completionServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + _, writeErr := w.Write([]byte(completionResponseJSON())) + if writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + })) +} + +func singleBackendYAML(endpoint string) string { + return "backends:\n default: vllm\n vllm:\n endpoint: " + + endpoint + "\n" +} + +func multipleBackendsYAML() string { + return "backends:\n vllm:\n endpoint: http://127.0.0.1\n" + + " openai:\n endpoint: http://127.0.0.2\n" +} + +func completionResponseJSON() string { + return `{"model":"m","choices":[{"message":{"content":"{\"decisions\":[]}"}}]}` +} diff --git a/internal/cli/ai/cmd/root/run.go b/internal/cli/ai/cmd/root/run.go new file mode 100644 index 000000000..782bb9292 --- /dev/null +++ b/internal/cli/ai/cmd/root/run.go @@ -0,0 +1,55 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "github.com/spf13/cobra" + + aiRun "github.com/ActiveMemory/ctx/internal/cli/ai/core/run" + writeAI "github.com/ActiveMemory/ctx/internal/write/ai" +) + +// RunPing executes ctx ai ping. +// +// Parameters: +// - cmd: cobra command for output stream +// - backendName: optional backend selector +// +// Returns: +// - error: backend resolution or ping failure +func RunPing(cmd *cobra.Command, backendName string) error { + result, pingErr := aiRun.Ping(cmd.Context(), backendName) + if pingErr != nil { + return pingErr + } + return writeAI.Ping( + cmd.OutOrStdout(), + result.Backend, + result.Endpoint, + result.FirstModel, + ) +} + +// RunPropose executes ctx ai propose. +// +// Parameters: +// - cmd: cobra command for output stream +// - input: input file path +// - backendName: optional backend selector +// - emit: comma-separated emit kinds +// +// Returns: +// - error: backend, completion, validation, or artifact write failure +func RunPropose( + cmd *cobra.Command, + input string, + backendName string, + emit string, +) error { + _, proposeErr := aiRun.Propose(cmd.Context(), input, backendName, emit) + return proposeErr +} diff --git a/internal/cli/ai/cmd/root/testmain_test.go b/internal/cli/ai/cmd/root/testmain_test.go new file mode 100644 index 000000000..adc4e3eef --- /dev/null +++ b/internal/cli/ai/cmd/root/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/cli/ai/core/doc.go b/internal/cli/ai/core/doc.go new file mode 100644 index 000000000..a974b8296 --- /dev/null +++ b/internal/cli/ai/core/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package core groups ctx ai implementation subpackages. +// +// Direct logic lives below this directory to keep core from becoming a +// god package. The root package is documentation-only by convention. +// Command packages import the concrete subpackages directly. +package core diff --git a/internal/cli/ai/core/run/doc.go b/internal/cli/ai/core/run/doc.go new file mode 100644 index 000000000..724472d60 --- /dev/null +++ b/internal/cli/ai/core/run/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package run implements ctx ai backend resolution and actions. +// +// It builds backend registries from .ctxrc configuration, performs ping +// checks, and writes validation-only proposal artifacts. It does not own +// cobra command construction or terminal formatting. +package run diff --git a/internal/cli/ai/core/run/run.go b/internal/cli/ai/core/run/run.go new file mode 100644 index 000000000..9c909d3b6 --- /dev/null +++ b/internal/cli/ai/core/run/run.go @@ -0,0 +1,99 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package run + +import ( + "context" + "encoding/json" + + backendPkg "github.com/ActiveMemory/ctx/internal/backend" + cfgAI "github.com/ActiveMemory/ctx/internal/config/ai" + "github.com/ActiveMemory/ctx/internal/config/token" + errBackend "github.com/ActiveMemory/ctx/internal/err/backend" + ctxio "github.com/ActiveMemory/ctx/internal/io" +) + +// Ping resolves and pings a configured backend. +// +// Parameters: +// - ctx: request context +// - backendName: optional backend selector +// +// Returns: +// - PingResult: backend, endpoint, and first model +// - error: backend resolution or ping failure +func Ping(ctx context.Context, backendName string) (PingResult, error) { + resolved, resolveErr := resolve(backendName) + if resolveErr != nil { + return PingResult{}, resolveErr + } + info, pingErr := backendPkg.PingInfo(ctx, resolved.backend) + if pingErr != nil { + return PingResult{}, pingErr + } + return PingResult{ + Backend: resolved.name, + Endpoint: backendPkg.EndpointInfo(resolved.backend), + FirstModel: info.FirstModel, + }, nil +} + +// Propose generates and writes a validation-only proposal artifact. +// +// Parameters: +// - ctx: request context +// - input: input file path +// - backendName: optional backend selector +// - emit: comma-separated proposal kinds +// +// Returns: +// - string: artifact path +// - error: backend, completion, validation, or write failure +func Propose( + ctx context.Context, + input string, + backendName string, + emit string, +) (string, error) { + resolved, resolveErr := resolve(backendName) + if resolveErr != nil { + return "", resolveErr + } + data, readErr := ctxio.SafeReadUserFile(input) + if readErr != nil { + return "", readErr + } + kinds := splitEmit(emit) + response, completeErr := resolved.backend.Complete( + ctx, + backendPkg.Request{ + Prompt: cfgAI.PromptPrefix + emit + token.NewlineLF + string(data), + Schema: cfgAI.SchemaMinimal, + }, + ) + if completeErr != nil { + return "", completeErr + } + decoded := map[string]any{} + decodeErr := json.Unmarshal([]byte(response.Text), &decoded) + if decodeErr != nil { + return "", errBackend.BadRequest{ + Name: resolved.name, + Cause: decodeErr, + } + } + artifact := ProposalArtifact{ + Kind: cfgAI.KindProposedPatch, + Backend: resolved.name, + Model: response.Model, + Input: input, + Emit: kinds, + Status: cfgAI.StatusProposed, + Response: decoded, + } + return writeArtifact(artifact) +} diff --git a/internal/cli/ai/core/run/support_internal.go b/internal/cli/ai/core/run/support_internal.go new file mode 100644 index 000000000..75d49dd83 --- /dev/null +++ b/internal/cli/ai/core/run/support_internal.go @@ -0,0 +1,163 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package run + +import ( + "encoding/json" + "path/filepath" + "strings" + "time" + + backendPkg "github.com/ActiveMemory/ctx/internal/backend" + cfgAI "github.com/ActiveMemory/ctx/internal/config/ai" + "github.com/ActiveMemory/ctx/internal/config/dir" + cfgFS "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/token" + errBackend "github.com/ActiveMemory/ctx/internal/err/backend" + ctxio "github.com/ActiveMemory/ctx/internal/io" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// resolve returns the selected configured backend. +// +// Parameters: +// - backendName: optional backend selector +// +// Returns: +// - resolvedBackend: selected backend and metadata +// - error: rc or registry resolution error +func resolve(backendName string) (resolvedBackend, error) { + registry := &backendPkg.Registry{} + configs := rc.RC().Backends.Configs + if len(configs) == 0 { + _, resolveErr := registry.Default() + return resolvedBackend{}, resolveErr + } + for name, config := range configs { + backendConfig := backendPkg.Config{ + Name: name, + Type: config.Type, + Endpoint: config.Endpoint, + APIKeyEnv: config.APIKeyEnv, + Timeout: config.Timeout, + DefaultModel: config.DefaultModel, + } + registerErr := registry.RegisterBuiltin(name, backendConfig) + if registerErr != nil { + return resolvedBackend{}, registerErr + } + } + registry.SetDefault(rc.RC().Backends.Default) + selectedName := selectedBackendName(backendName, configs) + selected, resolveErr := resolveSelected(registry, selectedName) + if resolveErr != nil { + return resolvedBackend{}, resolveErr + } + return resolvedBackend{ + name: selectedName, + backend: selected, + }, nil +} + +// resolveSelected resolves either an explicit or registry default backend. +// +// Parameters: +// - registry: backend registry +// - selectedName: explicit selected backend name +// +// Returns: +// - backend.Backend: resolved backend +// - error: registry resolution failure +func resolveSelected( + registry *backendPkg.Registry, + selectedName string, +) (backendPkg.Backend, error) { + if selectedName != "" { + return registry.Resolve(selectedName) + } + return registry.Default() +} + +// selectedBackendName returns the configured backend key to resolve. +// +// Parameters: +// - backendName: explicit backend selector +// - configs: configured backend map +// +// Returns: +// - string: backend key, or empty when registry default should decide +func selectedBackendName( + backendName string, + configs map[string]rc.BackendRC, +) string { + if backendName != "" { + return backendName + } + defaultName := rc.RC().Backends.Default + if defaultName != "" { + return defaultName + } + if len(configs) != 1 { + return "" + } + for name := range configs { + return name + } + return "" +} + +// writeArtifact writes a proposed-patch artifact under .context. +// +// Parameters: +// - artifact: proposal artifact to persist +// +// Returns: +// - string: artifact path +// - error: marshal, mkdir, or write failure +func writeArtifact(artifact ProposalArtifact) (string, error) { + indent := token.Space + token.Space + data, marshalErr := json.MarshalIndent(artifact, "", indent) + if marshalErr != nil { + return "", errBackend.BadRequest{ + Name: artifact.Backend, + Cause: marshalErr, + } + } + proposalDir := filepath.Join(dir.Context, cfgAI.DirProposals, cfgAI.DirAI) + mkdirErr := ctxio.SafeMkdirAll(proposalDir, cfgFS.PermExec) + if mkdirErr != nil { + return "", mkdirErr + } + name := cfgAI.ArtifactPrefix + + time.Now().UTC().Format(cfgAI.TimestampLayout) + + cfgAI.ArtifactExtJSON + path := filepath.Join(proposalDir, name) + writeErr := ctxio.SafeWriteFile(path, data, cfgFS.PermFile) + if writeErr != nil { + return "", writeErr + } + return path, nil +} + +// splitEmit splits comma-separated emit kinds. +// +// Parameters: +// - emit: comma-separated emit kinds +// +// Returns: +// - []string: trimmed emit kinds +func splitEmit(emit string) []string { + parts := strings.Split(emit, cfgAI.EmitSeparator) + out := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/internal/cli/ai/core/run/types.go b/internal/cli/ai/core/run/types.go new file mode 100644 index 000000000..66b417ed5 --- /dev/null +++ b/internal/cli/ai/core/run/types.go @@ -0,0 +1,47 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package run + +import backendPkg "github.com/ActiveMemory/ctx/internal/backend" + +// PingResult is printable backend reachability information. +// +// Fields: +// - Backend: resolved backend name +// - Endpoint: configured backend endpoint +// - FirstModel: first model returned by backend model listing +type PingResult struct { + Backend string + Endpoint string + FirstModel string +} + +// ProposalArtifact is the validation-only proposed-patch artifact. +// +// Fields: +// - Kind: artifact kind +// - Backend: backend that generated the response +// - Model: model reported by the backend +// - Input: input file path +// - Emit: requested proposal kinds +// - Status: artifact status +// - Response: decoded backend JSON response +type ProposalArtifact struct { + Kind string `json:"kind"` + Backend string `json:"backend"` + Model string `json:"model"` + Input string `json:"input"` + Emit []string `json:"emit"` + Status string `json:"status"` + Response map[string]any `json:"response"` +} + +// resolvedBackend carries the selected backend and metadata. +type resolvedBackend struct { + name string + backend backendPkg.Backend +} diff --git a/internal/cli/ai/doc.go b/internal/cli/ai/doc.go new file mode 100644 index 000000000..f6867d5e2 --- /dev/null +++ b/internal/cli/ai/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package ai exposes optional AI backend commands. +// +// The command surface is additive and fail-closed: it only runs when a +// backend is explicitly configured in .ctxrc. Deterministic context +// assembly commands do not depend on this package. +package ai diff --git a/internal/cli/setup/cmd/root/backend_test.go b/internal/cli/setup/cmd/root/backend_test.go new file mode 100644 index 000000000..288c3c020 --- /dev/null +++ b/internal/cli/setup/cmd/root/backend_test.go @@ -0,0 +1,167 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetupBackendNoArgsAccepted(t *testing.T) { + out, runErr := executeSetup(t, + "--backend", "vllm", + "--endpoint", "http://localhost:8000", + ) + if runErr != nil { + t.Fatalf("setup --backend failed: %v", runErr) + } + if !strings.Contains(out, "backends:") { + t.Fatalf("output = %q", out) + } +} + +func TestSetupNoArgsNoBackendRejected(t *testing.T) { + _, runErr := executeSetup(t) + if runErr == nil { + t.Fatalf("setup without args should fail") + } +} + +func TestSetupExistingToolStillWorks(t *testing.T) { + _, runErr := executeSetup(t, "aider") + if runErr != nil { + t.Fatalf("setup aider failed: %v", runErr) + } +} + +func TestSetupBackendModeWinsOverToolArg(t *testing.T) { + out, runErr := executeSetup(t, + "aider", + "--backend", "vllm", + "--endpoint", "http://localhost:8000", + ) + if runErr != nil { + t.Fatalf("setup --backend with tool arg failed: %v", runErr) + } + if !strings.Contains(out, "backends:") { + t.Fatalf("output = %q", out) + } +} + +func TestSetupBackendUnsupportedRejected(t *testing.T) { + _, runErr := executeSetup(t, "--backend", "unknown") + if runErr == nil { + t.Fatalf("unsupported backend should fail") + } +} + +func TestSetupBackendDryRunPrintsYaml(t *testing.T) { + out, runErr := executeSetup(t, + "--backend", "vllm", + "--endpoint", "http://localhost:8000", + ) + if runErr != nil { + t.Fatalf("setup --backend failed: %v", runErr) + } + for _, want := range []string{"backends:", "vllm:", "endpoint:"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q: %q", want, out) + } + } +} + +func TestSetupBackendWriteCreatesCtxRC(t *testing.T) { + tmpDir := chdirTemp(t) + _, runErr := executeSetup(t, + "--backend", "vllm", + "--endpoint", "http://localhost:8000", + "--write", + ) + if runErr != nil { + t.Fatalf("setup --backend --write failed: %v", runErr) + } + data, readErr := os.ReadFile(filepath.Join(tmpDir, ".ctxrc")) + if readErr != nil { + t.Fatalf("ReadFile() error = %v", readErr) + } + if !strings.Contains(string(data), "vllm:") { + t.Fatalf(".ctxrc = %q", string(data)) + } +} + +func TestSetupBackendWritePreservesUnrelatedFields(t *testing.T) { + tmpDir := chdirTemp(t) + writeErr := os.WriteFile( + filepath.Join(tmpDir, ".ctxrc"), + []byte("token_budget: 123\n"), + 0o644, + ) + if writeErr != nil { + t.Fatalf("WriteFile() error = %v", writeErr) + } + _, runErr := executeSetup(t, + "--backend", "vllm", + "--endpoint", "http://localhost:8000", + "--write", + ) + if runErr != nil { + t.Fatalf("setup --backend --write failed: %v", runErr) + } + data, readErr := os.ReadFile(filepath.Join(tmpDir, ".ctxrc")) + if readErr != nil { + t.Fatalf("ReadFile() error = %v", readErr) + } + if !strings.Contains(string(data), "token_budget: 123") { + t.Fatalf(".ctxrc = %q", string(data)) + } +} + +func TestSetupBackendEnvConflictWarning(t *testing.T) { + t.Setenv("CTX_TEST_BACKEND_KEY", "set") + out, runErr := executeSetup(t, + "--backend", "openai", + "--api-key-env", "CTX_TEST_BACKEND_KEY", + ) + if runErr != nil { + t.Fatalf("setup --backend failed: %v", runErr) + } + if !strings.Contains(out, "warning:") { + t.Fatalf("output = %q", out) + } +} + +func executeSetup(t *testing.T, args ...string) (string, error) { + t.Helper() + buf := new(bytes.Buffer) + cmd := Cmd() + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs(args) + runErr := cmd.Execute() + return buf.String(), runErr +} + +func chdirTemp(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + origDir, wdErr := os.Getwd() + if wdErr != nil { + t.Fatalf("Getwd() error = %v", wdErr) + } + if chdirErr := os.Chdir(tmpDir); chdirErr != nil { + t.Fatalf("Chdir() error = %v", chdirErr) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(origDir); chdirErr != nil { + t.Fatalf("Chdir() cleanup error = %v", chdirErr) + } + }) + return tmpDir +} diff --git a/internal/cli/setup/cmd/root/cmd.go b/internal/cli/setup/cmd/root/cmd.go index 56fd2de1f..275e8d700 100644 --- a/internal/cli/setup/cmd/root/cmd.go +++ b/internal/cli/setup/cmd/root/cmd.go @@ -30,6 +30,11 @@ import ( // accepts a tool name argument func Cmd() *cobra.Command { var write bool + var backend string + var endpoint string + var apiKeyEnv string + var model string + var timeout string short, long := desc.Command(cmd.DescKeySetup) c := &cobra.Command{ @@ -38,9 +43,23 @@ func Cmd() *cobra.Command { Annotations: map[string]string{cli.AnnotationSkipInit: cli.AnnotationTrue}, Long: long, Example: desc.Example(cmd.DescKeySetup), - Args: cobra.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + if backend != "" && len(args) == 0 { + return nil + } + return cobra.ExactArgs(1)(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { - return Run(cmd, args, write) + return Run( + cmd, + args, + write, + backend, + endpoint, + apiKeyEnv, + model, + timeout, + ) }, } @@ -48,6 +67,26 @@ func Cmd() *cobra.Command { cFlag.Write, cFlag.ShortWrite, flag.DescKeySetupWrite, ) + flagbind.StringFlag(c, &backend, + cFlag.Backend, + flag.DescKeySetupBackend, + ) + flagbind.StringFlag(c, &endpoint, + cFlag.Endpoint, + flag.DescKeySetupEndpoint, + ) + flagbind.StringFlag(c, &apiKeyEnv, + cFlag.APIKeyEnv, + flag.DescKeySetupAPIKeyEnv, + ) + flagbind.StringFlag(c, &model, + cFlag.Model, + flag.DescKeySetupModel, + ) + flagbind.StringFlag(c, &timeout, + cFlag.Timeout, + flag.DescKeySetupTimeout, + ) return c } diff --git a/internal/cli/setup/cmd/root/run.go b/internal/cli/setup/cmd/root/run.go index 66b82d45b..85b54e8d6 100644 --- a/internal/cli/setup/cmd/root/run.go +++ b/internal/cli/setup/cmd/root/run.go @@ -13,6 +13,7 @@ import ( "github.com/ActiveMemory/ctx/internal/assets/read/desc" coreCC "github.com/ActiveMemory/ctx/internal/cli/initialize/core/claudecheck" coreAgents "github.com/ActiveMemory/ctx/internal/cli/setup/core/agents" + coreBackend "github.com/ActiveMemory/ctx/internal/cli/setup/core/backend" coreCline "github.com/ActiveMemory/ctx/internal/cli/setup/core/cline" coreCopilot "github.com/ActiveMemory/ctx/internal/cli/setup/core/copilot" coreCopCLI "github.com/ActiveMemory/ctx/internal/cli/setup/core/copilotcli" @@ -39,7 +40,26 @@ import ( // // Returns: // - error: Non-nil if the tool is not supported or file write fails -func Run(cmd *cobra.Command, args []string, writeFile bool) error { +func Run( + cmd *cobra.Command, + args []string, + writeFile bool, + backend string, + endpoint string, + apiKeyEnv string, + model string, + timeout string, +) error { + if backend != "" { + return coreBackend.Run(cmd.OutOrStdout(), coreBackend.Options{ + Name: i18n.Fold(backend), + Endpoint: endpoint, + APIKeyEnv: apiKeyEnv, + Model: model, + Timeout: timeout, + Write: writeFile, + }) + } tool := i18n.Fold(args[0]) switch tool { diff --git a/internal/cli/setup/core/backend/backend.go b/internal/cli/setup/core/backend/backend.go new file mode 100644 index 000000000..c2a2f129f --- /dev/null +++ b/internal/cli/setup/core/backend/backend.go @@ -0,0 +1,71 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "io" + "os" + + cfgFS "github.com/ActiveMemory/ctx/internal/config/fs" + cfgSetup "github.com/ActiveMemory/ctx/internal/config/setup" + setupErr "github.com/ActiveMemory/ctx/internal/err/setup" + ctxio "github.com/ActiveMemory/ctx/internal/io" +) + +// Run writes or prints backend setup configuration. +// +// Parameters: +// - out: destination for dry-run output and warnings +// - options: backend setup options +// +// Returns: +// - error: read, marshal, or write failure +func Run(out io.Writer, options Options) error { + if validateErr := validate(options); validateErr != nil { + return validateErr + } + resolved := defaults(options) + if resolved.APIKeyEnv != "" && os.Getenv(resolved.APIKeyEnv) != "" { + if _, warnErr := io.WriteString( + out, + cfgSetup.BackendEnvWarn+resolved.APIKeyEnv+ + cfgSetup.BackendEnvWarnEnd, + ); warnErr != nil { + return setupErr.WriteFile(cfgSetup.FileCtxRC, warnErr) + } + } + content, contentErr := content(resolved) + if contentErr != nil { + return contentErr + } + if !resolved.Write { + if _, writeErr := io.WriteString( + out, + cfgSetup.BackendDryRunPrefix, + ); writeErr != nil { + return setupErr.WriteFile(cfgSetup.FileCtxRC, writeErr) + } + if _, writeErr := out.Write(content); writeErr != nil { + return setupErr.WriteFile(cfgSetup.FileCtxRC, writeErr) + } + return nil + } + if writeErr := ctxio.SafeWriteFile( + cfgSetup.FileCtxRC, + content, + cfgFS.PermFile, + ); writeErr != nil { + return setupErr.WriteFile(cfgSetup.FileCtxRC, writeErr) + } + if _, doneErr := io.WriteString( + out, + cfgSetup.BackendWriteDone, + ); doneErr != nil { + return setupErr.WriteFile(cfgSetup.FileCtxRC, doneErr) + } + return nil +} diff --git a/internal/cli/setup/core/backend/config_internal.go b/internal/cli/setup/core/backend/config_internal.go new file mode 100644 index 000000000..b5fbaa3c1 --- /dev/null +++ b/internal/cli/setup/core/backend/config_internal.go @@ -0,0 +1,146 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "os" + + "gopkg.in/yaml.v3" + + cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + cfgRC "github.com/ActiveMemory/ctx/internal/config/rc" + cfgSetup "github.com/ActiveMemory/ctx/internal/config/setup" + setupErr "github.com/ActiveMemory/ctx/internal/err/setup" + ctxio "github.com/ActiveMemory/ctx/internal/io" +) + +// content returns merged .ctxrc YAML for backend setup. +// +// Parameters: +// - options: resolved backend setup options +// +// Returns: +// - []byte: YAML content +// - error: read or marshal failure +func content(options Options) ([]byte, error) { + root, readErr := readRoot() + if readErr != nil { + return nil, readErr + } + backends := mapping(root, cfgRC.BackendsKey) + if scalar(backends, cfgRC.BackendDefaultKey) == nil { + setScalar(backends, cfgRC.BackendDefaultKey, options.Name) + } + entry := mapping(backends, options.Name) + setScalar(entry, cfgRC.BackendTypeKey, options.Name) + setScalar(entry, cfgRC.BackendEndpointKey, options.Endpoint) + setScalar(entry, cfgRC.BackendAPIKeyEnvKey, options.APIKeyEnv) + setScalar(entry, cfgRC.BackendTimeoutKey, options.Timeout) + setScalar(entry, cfgRC.BackendDefaultModelKey, options.Model) + return yaml.Marshal(root) +} + +// readRoot reads .ctxrc into a YAML mapping node. +// +// Returns: +// - *yaml.Node: root document mapping +// - error: read or decode failure +func readRoot() (*yaml.Node, error) { + data, readErr := ctxio.SafeReadUserFile(cfgSetup.FileCtxRC) + if readErr != nil && !os.IsNotExist(readErr) { + return nil, readErr + } + root := &yaml.Node{Kind: yaml.MappingNode} + if len(data) == 0 { + return root, nil + } + var doc yaml.Node + if decodeErr := yaml.Unmarshal(data, &doc); decodeErr != nil { + return nil, decodeErr + } + if len(doc.Content) == 0 { + return root, nil + } + if doc.Content[0].Kind != yaml.MappingNode { + return root, nil + } + return doc.Content[0], nil +} + +// defaults applies backend-specific option defaults. +// +// Parameters: +// - options: backend setup options +// +// Returns: +// - Options: setup options with defaults applied +func defaults(options Options) Options { + if options.Endpoint == "" { + options.Endpoint = defaultEndpoint(options.Name) + } + if options.APIKeyEnv == "" { + options.APIKeyEnv = defaultAPIKeyEnv(options.Name) + } + return options +} + +// validate reports whether backend setup options name a supported backend. +// +// Parameters: +// - options: backend setup options +// +// Returns: +// - error: unsupported backend error, or nil when supported +func validate(options Options) error { + if defaultEndpoint(options.Name) == "" && + options.Name != cfgBackend.NameOpenAICompatible { + return setupErr.UnsupportedBackend(options.Name) + } + return nil +} + +// defaultEndpoint returns the default endpoint for a backend. +// +// Parameters: +// - name: backend name +// +// Returns: +// - string: default endpoint, or empty when unknown +func defaultEndpoint(name string) string { + switch name { + case cfgBackend.NameVLLM: + return cfgBackend.DefaultEndpointVLLM + case cfgBackend.NameOpenAI: + return cfgBackend.DefaultEndpointOpenAI + case cfgBackend.NameAnthropic: + return cfgBackend.DefaultEndpointAnthropic + case cfgBackend.NameOllama: + return cfgBackend.DefaultEndpointOllama + case cfgBackend.NameLMStudio: + return cfgBackend.DefaultEndpointLMStudio + default: + return "" + } +} + +// defaultAPIKeyEnv returns the default credential env var for a backend. +// +// Parameters: +// - name: backend name +// +// Returns: +// - string: default env var, or empty when none +func defaultAPIKeyEnv(name string) string { + switch name { + case cfgBackend.NameOpenAI: + return cfgBackend.DefaultAPIKeyEnvOpenAI + case cfgBackend.NameAnthropic: + return cfgBackend.DefaultAPIKeyEnvAnthropic + default: + return "" + } +} diff --git a/internal/cli/setup/core/backend/doc.go b/internal/cli/setup/core/backend/doc.go new file mode 100644 index 000000000..828431aa4 --- /dev/null +++ b/internal/cli/setup/core/backend/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package backend writes .ctxrc backend setup entries. +// +// It owns both dry-run snippet generation and safe file updates for the +// setup command's backend mode. The package deliberately does not create +// runtime backend instances or wire command dispatch. +package backend diff --git a/internal/cli/setup/core/backend/types.go b/internal/cli/setup/core/backend/types.go new file mode 100644 index 000000000..709b62654 --- /dev/null +++ b/internal/cli/setup/core/backend/types.go @@ -0,0 +1,25 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +// Options controls backend setup output. +// +// Fields: +// - Name: backend name to configure +// - Endpoint: backend HTTP endpoint +// - APIKeyEnv: environment variable containing credentials +// - Model: default model name +// - Timeout: backend request timeout duration +// - Write: whether to write .ctxrc instead of printing a snippet +type Options struct { + Name string + Endpoint string + APIKeyEnv string + Model string + Timeout string + Write bool +} diff --git a/internal/cli/setup/core/backend/yaml_internal.go b/internal/cli/setup/core/backend/yaml_internal.go new file mode 100644 index 000000000..099134802 --- /dev/null +++ b/internal/cli/setup/core/backend/yaml_internal.go @@ -0,0 +1,72 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import "gopkg.in/yaml.v3" + +// mapping returns a child mapping node, creating it when absent. +// +// Parameters: +// - parent: mapping node to search +// - key: child key +// +// Returns: +// - *yaml.Node: child mapping node +func mapping(parent *yaml.Node, key string) *yaml.Node { + for idx := 0; idx < len(parent.Content); idx += 2 { + if parent.Content[idx].Value == key { + parent.Content[idx+1].Kind = yaml.MappingNode + return parent.Content[idx+1] + } + } + child := &yaml.Node{Kind: yaml.MappingNode} + parent.Content = append(parent.Content, scalarNode(key), child) + return child +} + +// scalar returns a child scalar node by key. +// +// Parameters: +// - parent: mapping node to search +// - key: child key +// +// Returns: +// - *yaml.Node: child scalar node, or nil +func scalar(parent *yaml.Node, key string) *yaml.Node { + for idx := 0; idx < len(parent.Content); idx += 2 { + if parent.Content[idx].Value == key { + return parent.Content[idx+1] + } + } + return nil +} + +// setScalar sets a child scalar node by key. +// +// Parameters: +// - parent: mapping node to mutate +// - key: child key +// - value: scalar value +func setScalar(parent *yaml.Node, key string, value string) { + if existing := scalar(parent, key); existing != nil { + existing.Kind = yaml.ScalarNode + existing.Value = value + return + } + parent.Content = append(parent.Content, scalarNode(key), scalarNode(value)) +} + +// scalarNode creates a YAML scalar node. +// +// Parameters: +// - value: scalar value +// +// Returns: +// - *yaml.Node: scalar node +func scalarNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Value: value} +} diff --git a/internal/compliance/ai_boundary_test.go b/internal/compliance/ai_boundary_test.go new file mode 100644 index 000000000..0dbd8d5cb --- /dev/null +++ b/internal/compliance/ai_boundary_test.go @@ -0,0 +1,74 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package compliance + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDeterministicCoreDoesNotDependOnAIBackends(t *testing.T) { + root := projectRoot(t) + guardedPrefixes := []string{ + "internal/cli/agent/", + "internal/cli/status/", + "internal/cli/hook/", + } + + for _, path := range nonTestGoFiles(t, root) { + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + t.Fatalf("filepath.Rel: %v", relErr) + } + rel = filepath.ToSlash(rel) + if !hasGuardedPrefix(rel, guardedPrefixes) { + continue + } + + assertNoBackendImport(t, path, rel) + assertNoAIInvocation(t, path, rel) + } +} + +func hasGuardedPrefix(path string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func assertNoBackendImport(t *testing.T, path string, rel string) { + t.Helper() + fset := token.NewFileSet() + file, parseErr := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if parseErr != nil { + t.Fatalf("parser.ParseFile(%s): %v", rel, parseErr) + } + for _, imported := range file.Imports { + importPath := strings.Trim(imported.Path.Value, "\"") + if importPath == "github.com/ActiveMemory/ctx/internal/backend" { + t.Fatalf("%s imports internal/backend", rel) + } + } +} + +func assertNoAIInvocation(t *testing.T, path string, rel string) { + t.Helper() + data, readErr := os.ReadFile(filepath.Clean(path)) + if readErr != nil { + t.Fatalf("os.ReadFile(%s): %v", rel, readErr) + } + if strings.Contains(string(data), "ctx ai") { + t.Fatalf("%s invokes or documents ctx ai from deterministic core", rel) + } +} diff --git a/internal/config/ai/ai.go b/internal/config/ai/ai.go new file mode 100644 index 000000000..948740b73 --- /dev/null +++ b/internal/config/ai/ai.go @@ -0,0 +1,22 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package ai + +// Output labels and proposal constants. +const ( + ArtifactExtJSON = ".json" + ArtifactPrefix = "proposal-" + DirAI = "ai" + DirProposals = "proposals" + EmitSeparator = "," + KindProposedPatch = "proposed-patch" + PromptPrefix = "Return JSON for requested emit kinds: " + SchemaMinimal = `{"type":"object"}` + StatusProposed = "proposed" + TimestampLayout = "20060102T150405Z" + WritePingFormat = "backend: %s\nendpoint: %s\nfirst_model: %s\n" +) diff --git a/internal/config/ai/doc.go b/internal/config/ai/doc.go new file mode 100644 index 000000000..777edc919 --- /dev/null +++ b/internal/config/ai/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package ai defines constants for the ctx ai command surface. +// +// Constants here keep command output labels, proposal artifact names, +// and schema placeholders out of implementation packages. It is a +// constants-only package for CLI-facing AI behavior. +package ai diff --git a/internal/config/backend/backend.go b/internal/config/backend/backend.go new file mode 100644 index 000000000..9a4e4dde9 --- /dev/null +++ b/internal/config/backend/backend.go @@ -0,0 +1,64 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import "time" + +// Backend names for built-in backend factories. +const ( + NameAnthropic = "anthropic" + NameLMStudio = "lmstudio" + NameOllama = "ollama" + NameOpenAI = "openai" + NameOpenAICompatible = "openai-compatible" + NameVLLM = "vllm" +) + +// Default endpoints for built-in backend factories. +const ( + DefaultEndpointAnthropic = "https://api.anthropic.com" + DefaultEndpointLMStudio = "http://localhost:1234" + DefaultEndpointOllama = "http://localhost:11434" + DefaultEndpointOpenAI = "https://api.openai.com" + DefaultEndpointVLLM = "http://localhost:8000" +) + +// Default API key environment variables for backend factories. +const ( + DefaultAPIKeyEnvAnthropic = "ANTHROPIC_API_KEY" + DefaultAPIKeyEnvOpenAI = "OPENAI_API_KEY" +) + +// HTTP constants for OpenAI-compatible backends. +const ( + AuthorizationBearerPrefix = "Bearer " + ChatCompletionsPath = "/v1/chat/completions" + ContentTypeJSON = "application/json" + HeaderAuthorization = "Authorization" + HeaderContentType = "Content-Type" + HTTPMethodGet = "GET" + HTTPMethodPost = "POST" + ModelsPath = "/v1/models" + RoleUser = "user" +) + +// DefaultTimeout is the fallback timeout for backend HTTP calls. +const DefaultTimeout = 30 * time.Second + +// Error messages for backend registry and HTTP failures. +const ( + ErrBadRequest = "backend request failed: " + ErrDuplicateRegistration = "backend already registered: " + ErrFactory = "backend factory failed: " + ErrInvalidEndpoint = "backend endpoint invalid: " + ErrMissingBackend = "backend not registered: " + ErrMultipleBackends = "multiple backends configured; pass --backend " + + "or set backends.default" + ErrNoBackendConfigured = "no backend configured; run ctx setup --backend" + ErrUnreachable = "backend unreachable: " + ErrUpstream = "backend upstream returned: " +) diff --git a/internal/config/backend/doc.go b/internal/config/backend/doc.go new file mode 100644 index 000000000..721ad1781 --- /dev/null +++ b/internal/config/backend/doc.go @@ -0,0 +1,13 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package backend defines constants for the internal AI backend +// registry. +// +// These constants keep registry names, error fragments, and future +// configuration keys out of implementation packages. The package has no +// behavior and exists only as the canonical constants home. +package backend diff --git a/internal/config/embed/cmd/base.go b/internal/config/embed/cmd/base.go index f383d7c58..99818efd3 100644 --- a/internal/config/embed/cmd/base.go +++ b/internal/config/embed/cmd/base.go @@ -10,6 +10,8 @@ package cmd const ( // UseAgent is the cobra Use string for the agent command. UseAgent = "agent" + // UseAI is the cobra Use string for the ai command. + UseAI = "ai" // UseChange is the cobra Use string for the change command. UseChange = "change" // UseCompact is the cobra Use string for the compact command. @@ -64,12 +66,22 @@ const ( UseWatch = "watch" // UseWhy is the cobra Use string for the why command. UseWhy = "why [DOCUMENT]" + // UseAIPing is the cobra Use string for the ai ping command. + UseAIPing = "ping" + // UseAIPropose is the cobra Use string for the ai propose command. + UseAIPropose = "propose " ) // DescKeys for base commands. const ( // DescKeyAgent is the description key for the agent command. DescKeyAgent = "agent" + // DescKeyAI is the description key for the ai command. + DescKeyAI = "ai" + // DescKeyAIPing is the description key for the ai ping command. + DescKeyAIPing = "ai.ping" + // DescKeyAIPropose is the description key for the ai propose command. + DescKeyAIPropose = "ai.propose" // DescKeyChange is the description key for the change command. DescKeyChange = "change" // DescKeyCompact is the description key for the compact command. diff --git a/internal/config/embed/flag/ai.go b/internal/config/embed/flag/ai.go new file mode 100644 index 000000000..e370fa227 --- /dev/null +++ b/internal/config/embed/flag/ai.go @@ -0,0 +1,15 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package flag + +// DescKeys for ai command flags. +const ( + // DescKeyAIBackend is the description key for ai backend selection. + DescKeyAIBackend = "ai.backend" + // DescKeyAIEmit is the description key for ai propose emit kinds. + DescKeyAIEmit = "ai.emit" +) diff --git a/internal/config/embed/flag/setup.go b/internal/config/embed/flag/setup.go new file mode 100644 index 000000000..ca7cd3d45 --- /dev/null +++ b/internal/config/embed/flag/setup.go @@ -0,0 +1,21 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package flag + +// DescKeys for setup command flags. +const ( + // DescKeySetupBackend is the description key for backend setup mode. + DescKeySetupBackend = "setup.backend" + // DescKeySetupEndpoint is the description key for backend endpoint setup. + DescKeySetupEndpoint = "setup.endpoint" + // DescKeySetupAPIKeyEnv is the description key for backend API key env setup. + DescKeySetupAPIKeyEnv = "setup.api-key-env" + // DescKeySetupModel is the description key for backend default model setup. + DescKeySetupModel = "setup.model" + // DescKeySetupTimeout is the description key for backend timeout setup. + DescKeySetupTimeout = "setup.timeout" +) diff --git a/internal/config/flag/flag.go b/internal/config/flag/flag.go index d5f0b0228..942616e1f 100644 --- a/internal/config/flag/flag.go +++ b/internal/config/flag/flag.go @@ -12,6 +12,8 @@ const PrefixLong = "--" // Add command flag names: used for both flag registration and error display. const ( Application = "application" + APIKeyEnv = "api-key-env" + Backend = "backend" Branch = "branch" Commit = "commit" Consequence = "consequence" @@ -64,6 +66,8 @@ const ( Days = "days" Dir = "dir" DryRun = "dry-run" + Endpoint = "endpoint" + Emit = "emit" Event = "event" IncludeHub = "include-hub" @@ -87,6 +91,7 @@ const ( Note = "note" Message = "message" Minimal = "minimal" + Model = "model" NoPluginEnable = "no-plugin-enable" NoSteeringInit = "no-steering-init" Out = "out" @@ -109,6 +114,7 @@ const ( Skills = "skills" Tag = "tag" Tool = "tool" + Timeout = "timeout" Token = "token" Type = "type" Variant = "variant" diff --git a/internal/config/rc/rc.go b/internal/config/rc/rc.go index 630ced9cf..94a3d308a 100644 --- a/internal/config/rc/rc.go +++ b/internal/config/rc/rc.go @@ -14,4 +14,28 @@ const ( // fmt.Errorf(FmtWrapColon, ErrFoo, "tailored detail") // ↦ ": tailored detail". FmtWrapColon = "%w: %s" + + // ErrBackendsMapping reports a non-mapping backends value. + ErrBackendsMapping = "backends must be a mapping" + // ErrBackendsDefaultScalar reports a non-scalar default backend value. + ErrBackendsDefaultScalar = "backends.default must be a scalar" + // ErrBackendsDefaultMissing reports a default backend with no definition. + ErrBackendsDefaultMissing = "backends.default references missing backend: " + // ErrBackendsEndpointRequired reports a backend missing its endpoint. + ErrBackendsEndpointRequired = "backends.%s.endpoint is required" + + // BackendDefaultKey is the reserved key for default backend selection. + BackendDefaultKey = "default" + // BackendsKey is the top-level backend configuration key. + BackendsKey = "backends" + // BackendTypeKey is the backend implementation type key. + BackendTypeKey = "type" + // BackendEndpointKey is the endpoint URL key. + BackendEndpointKey = "endpoint" + // BackendAPIKeyEnvKey is the credential environment variable key. + BackendAPIKeyEnvKey = "api_key_env" + // BackendTimeoutKey is the request timeout key. + BackendTimeoutKey = "timeout" + // BackendDefaultModelKey is the default model key. + BackendDefaultModelKey = "default_model" ) diff --git a/internal/config/setup/setup.go b/internal/config/setup/setup.go index ee20e51b9..4892c7a40 100644 --- a/internal/config/setup/setup.go +++ b/internal/config/setup/setup.go @@ -6,6 +6,23 @@ package setup +import "github.com/ActiveMemory/ctx/internal/config/token" + +// Root setup file paths. +const ( + // FileCtxRC is the project rc file name. + FileCtxRC = ".ctxrc" +) + +// Backend setup output strings. +const ( + BackendDryRunPrefix = "Add this backend configuration to .ctxrc:\n" + BackendUnsupported = "unsupported backend: " + BackendWriteDone = "Updated .ctxrc backend configuration\n" + BackendEnvWarn = "warning: environment variable already set: " + BackendEnvWarnEnd = token.NewlineLF +) + // Display names for supported integration tools. const ( // DisplayKiro is the display name for Kiro. diff --git a/internal/err/backend/backend.go b/internal/err/backend/backend.go new file mode 100644 index 000000000..b58421043 --- /dev/null +++ b/internal/err/backend/backend.go @@ -0,0 +1,189 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import cfgBackend "github.com/ActiveMemory/ctx/internal/config/backend" + +// DuplicateRegistration reports a duplicate backend registration. +// +// Fields: +// - Name: duplicate backend name +type DuplicateRegistration struct { + Name string +} + +// Error formats the duplicate registration error. +// +// Returns: +// - string: formatted duplicate registration message +func (err DuplicateRegistration) Error() string { + return cfgBackend.ErrDuplicateRegistration + err.Name +} + +// MissingBackend reports a requested backend that is not registered. +// +// Fields: +// - Name: missing backend name +type MissingBackend struct { + Name string +} + +// Error formats the missing backend error. +// +// Returns: +// - string: formatted missing backend message +func (err MissingBackend) Error() string { + return cfgBackend.ErrMissingBackend + err.Name +} + +// MultipleBackends reports an ambiguous default backend selection. +type MultipleBackends struct{} + +// Error formats the multiple backends error. +// +// Returns: +// - string: formatted multiple backends message +func (err MultipleBackends) Error() string { + return cfgBackend.ErrMultipleBackends +} + +// NoBackendConfigured reports that the registry is empty. +type NoBackendConfigured struct{} + +// Error formats the empty registry error. +// +// Returns: +// - string: formatted empty registry message +func (err NoBackendConfigured) Error() string { + return cfgBackend.ErrNoBackendConfigured +} + +// Factory reports a backend factory failure. +// +// Fields: +// - Name: backend name whose factory failed +// - Cause: underlying factory error +type Factory struct { + Name string + Cause error +} + +// Error formats the factory failure. +// +// Returns: +// - string: formatted factory failure message +func (err Factory) Error() string { + return cfgBackend.ErrFactory + err.Name +} + +// Unwrap returns the underlying factory error. +// +// Returns: +// - error: underlying factory error +func (err Factory) Unwrap() error { + return err.Cause +} + +// InvalidEndpoint reports an endpoint URL that cannot be used. +// +// Fields: +// - Endpoint: configured endpoint value +// - Cause: underlying parse or request construction failure +type InvalidEndpoint struct { + Endpoint string + Cause error +} + +// Error formats the invalid endpoint error. +// +// Returns: +// - string: formatted invalid endpoint message +func (err InvalidEndpoint) Error() string { + return cfgBackend.ErrInvalidEndpoint + err.Endpoint +} + +// Unwrap returns the underlying endpoint error. +// +// Returns: +// - error: underlying endpoint error +func (err InvalidEndpoint) Unwrap() error { + return err.Cause +} + +// Unreachable reports a failed backend HTTP request. +// +// Fields: +// - Name: backend name +// - Endpoint: configured endpoint value +// - Cause: underlying HTTP error +type Unreachable struct { + Name string + Endpoint string + Cause error +} + +// Error formats the unreachable backend error. +// +// Returns: +// - string: formatted unreachable backend message +func (err Unreachable) Error() string { + return cfgBackend.ErrUnreachable + err.Name +} + +// Unwrap returns the underlying HTTP error. +// +// Returns: +// - error: underlying HTTP error +func (err Unreachable) Unwrap() error { + return err.Cause +} + +// Upstream reports a non-success status from the backend. +// +// Fields: +// - Name: backend name +// - StatusCode: upstream HTTP status code +// - Body: upstream response body +type Upstream struct { + Name string + StatusCode int + Body string +} + +// Error formats the upstream status error. +// +// Returns: +// - string: formatted upstream response message +func (err Upstream) Error() string { + return cfgBackend.ErrUpstream + err.Body +} + +// BadRequest wraps a local request encoding or decode failure. +// +// Fields: +// - Name: backend name +// - Cause: underlying request error +type BadRequest struct { + Name string + Cause error +} + +// Error formats the backend request failure. +// +// Returns: +// - string: formatted request failure message +func (err BadRequest) Error() string { + return cfgBackend.ErrBadRequest + err.Name +} + +// Unwrap returns the underlying request failure. +// +// Returns: +// - error: underlying request failure +func (err BadRequest) Unwrap() error { + return err.Cause +} diff --git a/internal/err/backend/doc.go b/internal/err/backend/doc.go new file mode 100644 index 000000000..57998e840 --- /dev/null +++ b/internal/err/backend/doc.go @@ -0,0 +1,13 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package backend provides typed errors for AI backend registry +// failures. +// +// Callers can match these errors with errors.As while still receiving +// stable user-facing messages from the config layer. The package avoids +// sentinel strings in backend implementation code. +package backend diff --git a/internal/err/setup/setup.go b/internal/err/setup/setup.go index d0299709f..264299a05 100644 --- a/internal/err/setup/setup.go +++ b/internal/err/setup/setup.go @@ -11,6 +11,8 @@ import ( "github.com/ActiveMemory/ctx/internal/assets/read/desc" "github.com/ActiveMemory/ctx/internal/config/embed/text" + cfgSetup "github.com/ActiveMemory/ctx/internal/config/setup" + "github.com/ActiveMemory/ctx/internal/config/token" ) // CreateDir wraps a failure to create a setup directory. @@ -82,3 +84,14 @@ func MissingEmbeddedAsset(name string) error { desc.Text(text.DescKeyErrSetupMissingEmbeddedAsset), name, ) } + +// UnsupportedBackend reports an unrecognized backend setup name. +// +// Parameters: +// - name: backend name passed to --backend +// +// Returns: +// - error: unsupported backend message +func UnsupportedBackend(name string) error { + return fmt.Errorf(cfgSetup.BackendUnsupported+token.FormatString, name) +} diff --git a/internal/rc/types.go b/internal/rc/types.go index 1719fcd5f..6bc5ced8f 100644 --- a/internal/rc/types.go +++ b/internal/rc/types.go @@ -6,7 +6,12 @@ package rc -import cfgMemory "github.com/ActiveMemory/ctx/internal/config/memory" +import ( + "gopkg.in/yaml.v3" + + cfgMemory "github.com/ActiveMemory/ctx/internal/config/memory" + cfgRC "github.com/ActiveMemory/ctx/internal/config/rc" +) // CtxRC represents the configuration from the .ctxrc file. // @@ -62,6 +67,7 @@ import cfgMemory "github.com/ActiveMemory/ctx/internal/config/memory" // English list to keep applying. // - Tool: Active AI tool identifier (e.g., claude, // cursor, cline, kiro, codex) +// - Backends: Optional AI backend definitions for ctx ai commands // - Steering: Steering layer configuration overrides // - Hooks: Hook system configuration overrides // - ProvenanceRequired: Per-project relaxation of @@ -93,12 +99,94 @@ type CtxRC struct { SpecNudgeMinLen int `yaml:"spec_nudge_min_len"` Placeholders []string `yaml:"placeholders"` Notify *NotifyConfig `yaml:"notify"` + Backends BackendsRC `yaml:"backends"` Steering *SteeringRC `yaml:"steering"` Hooks *HooksRC `yaml:"hooks"` ProvenanceRequired *ProvenanceConfig `yaml:"provenance_required"` Dream *DreamRC `yaml:"dream"` } +// BackendsRC holds optional AI backend configuration from .ctxrc. +// +// Fields: +// - Default: optional backend selected when a ctx ai command does not pass +// --backend +// - Configs: named backend definitions keyed by backend name +type BackendsRC struct { + Default string `yaml:"default"` + Configs map[string]BackendRC `yaml:",inline"` +} + +// UnmarshalYAML decodes the mixed backends mapping shape. +// +// Parameters: +// - value: YAML node for the backends value +// +// Returns: +// - error: decode failure, or nil when valid YAML shape was decoded +func (backends *BackendsRC) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return &yaml.TypeError{Errors: []string{cfgRC.ErrBackendsMapping}} + } + + backends.Configs = make(map[string]BackendRC) + for idx := 0; idx < len(value.Content); idx += 2 { + key := value.Content[idx] + val := value.Content[idx+1] + if key.Value == cfgRC.BackendDefaultKey { + if val.Kind != yaml.ScalarNode { + return &yaml.TypeError{Errors: []string{cfgRC.ErrBackendsDefaultScalar}} + } + backends.Default = val.Value + continue + } + + if val.Kind != yaml.MappingNode { + return &yaml.TypeError{Errors: []string{cfgRC.ErrBackendsMapping}} + } + + backend := BackendRC{} + for backendIdx := 0; backendIdx < len(val.Content); backendIdx += 2 { + backendKey := val.Content[backendIdx].Value + backendVal := val.Content[backendIdx+1].Value + switch backendKey { + case cfgRC.BackendTypeKey: + backend.Type = backendVal + case cfgRC.BackendEndpointKey: + backend.Endpoint = backendVal + case cfgRC.BackendAPIKeyEnvKey: + backend.APIKeyEnv = backendVal + case cfgRC.BackendTimeoutKey: + backend.Timeout = backendVal + case cfgRC.BackendDefaultModelKey: + backend.DefaultModel = backendVal + default: + return &yaml.TypeError{Errors: []string{backendKey}} + } + } + backends.Configs[key.Value] = backend + } + + return nil +} + +// BackendRC holds one named AI backend definition from .ctxrc. +// +// Fields: +// - Type: optional registered implementation type when it differs from the +// backend name +// - Endpoint: OpenAI-compatible HTTP endpoint URL +// - APIKeyEnv: optional environment variable name containing credentials +// - Timeout: optional request timeout duration string +// - DefaultModel: optional model selected by default for this backend +type BackendRC struct { + Type string `yaml:"type"` + Endpoint string `yaml:"endpoint"` + APIKeyEnv string `yaml:"api_key_env"` + Timeout string `yaml:"timeout"` + DefaultModel string `yaml:"default_model"` +} + // DreamRC holds the ctx-dream configuration from .ctxrc. The dream is // opt-in: nothing runs until Enabled is set true and the cron entry is // installed. An empty Executor selects the reference claude -p diff --git a/internal/rc/validate.go b/internal/rc/validate.go index cf1096abb..7ab3e13a2 100644 --- a/internal/rc/validate.go +++ b/internal/rc/validate.go @@ -38,6 +38,9 @@ func Validate(data []byte) (warnings []string, err error) { // yaml.v3 returns *yaml.TypeError for unknown fields. if te, ok := errors.AsType[*yaml.TypeError](decErr); ok { + if backendsShapeError(te.Errors) { + return nil, decErr + } return te.Errors, nil } @@ -45,5 +48,9 @@ func Validate(data []byte) (warnings []string, err error) { return nil, decErr } + if cfgErr := cfg.validateBackends(); cfgErr != nil { + return nil, cfgErr + } + return nil, nil } diff --git a/internal/rc/validate_internal.go b/internal/rc/validate_internal.go new file mode 100644 index 000000000..9c189d0ad --- /dev/null +++ b/internal/rc/validate_internal.go @@ -0,0 +1,63 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package rc + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + cfgRC "github.com/ActiveMemory/ctx/internal/config/rc" +) + +// validateBackends checks semantic requirements for .ctxrc backends. +// +// Returns: +// - error: validation failure, or nil when configuration is valid +func (cfg CtxRC) validateBackends() error { + backends := cfg.Backends + if len(backends.Configs) == 0 { + return nil + } + + if backends.Default != "" { + if _, ok := backends.Configs[backends.Default]; !ok { + return &yaml.TypeError{Errors: []string{ + cfgRC.ErrBackendsDefaultMissing + backends.Default, + }} + } + } + + for name, backend := range backends.Configs { + if backend.Endpoint == "" { + return &yaml.TypeError{Errors: []string{ + fmt.Sprintf(cfgRC.ErrBackendsEndpointRequired, name), + }} + } + } + + return nil +} + +// backendsShapeError reports whether type errors are malformed backends shape. +// +// Parameters: +// - errs: YAML type error strings +// +// Returns: +// - bool: true when the error should fail validation, not warn +func backendsShapeError(errs []string) bool { + for _, msg := range errs { + if strings.Contains(msg, cfgRC.ErrBackendsMapping) || + strings.Contains(msg, cfgRC.ErrBackendsDefaultScalar) { + return true + } + } + + return false +} diff --git a/internal/rc/validate_test.go b/internal/rc/validate_test.go index 748c32006..d55802417 100644 --- a/internal/rc/validate_test.go +++ b/internal/rc/validate_test.go @@ -110,3 +110,107 @@ notify: t.Errorf("expected no warnings for full valid config, got %v", warnings) } } + +func TestValidate_BackendsWellFormed(t *testing.T) { + data := []byte(`backends: + default: vllm + vllm: + type: openai-compatible + endpoint: http://localhost:8000 + api_key_env: "" + timeout: 30s + default_model: openai/gpt-oss-120b +`) + warnings, err := Validate(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestValidate_BackendsMissingEndpoint(t *testing.T) { + data := []byte(`backends: + vllm: + timeout: 30s +`) + _, err := Validate(data) + if err == nil { + t.Fatal("expected error for missing endpoint") + } + if !strings.Contains(err.Error(), "vllm") { + t.Errorf("error should mention backend name, got: %v", err) + } +} + +func TestValidate_BackendsMalformedShape(t *testing.T) { + data := []byte(`backends: + - vllm +`) + _, err := Validate(data) + if err == nil { + t.Fatal("expected error for malformed backends shape") + } +} + +func TestValidate_BackendsDefaultMissing(t *testing.T) { + data := []byte(`backends: + default: openai + vllm: + endpoint: http://localhost:8000 +`) + _, err := Validate(data) + if err == nil { + t.Fatal("expected error for missing default backend") + } + if !strings.Contains(err.Error(), "openai") { + t.Errorf("error should mention missing default, got: %v", err) + } +} + +func TestValidate_BackendsMultipleWithoutDefault(t *testing.T) { + data := []byte(`backends: + vllm: + endpoint: http://localhost:8000 + openai: + endpoint: https://api.openai.com + api_key_env: OPENAI_API_KEY +`) + warnings, err := Validate(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestValidate_BackendsUnknownNestedField(t *testing.T) { + data := []byte(`backends: + vllm: + endpoint: http://localhost:8000 + api_token: nope +`) + warnings, err := Validate(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) == 0 { + t.Fatal("expected warning for unknown backend field") + } + if !strings.Contains(warnings[0], "api_token") { + t.Errorf("warning should mention field name, got: %s", warnings[0]) + } +} + +func TestValidate_BackendsEmptyOK(t *testing.T) { + data := []byte("backends: {}\n") + warnings, err := Validate(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} diff --git a/internal/write/ai/ai.go b/internal/write/ai/ai.go new file mode 100644 index 000000000..97160e2ae --- /dev/null +++ b/internal/write/ai/ai.go @@ -0,0 +1,40 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "fmt" + "io" + + cfgAI "github.com/ActiveMemory/ctx/internal/config/ai" +) + +// Ping writes backend ping information. +// +// Parameters: +// - out: destination writer +// - backend: backend name +// - endpoint: configured endpoint +// - firstModel: first model from model listing +// +// Returns: +// - error: write failure +func Ping( + out io.Writer, + backend string, + endpoint string, + firstModel string, +) error { + _, writeErr := fmt.Fprintf( + out, + cfgAI.WritePingFormat, + backend, + endpoint, + firstModel, + ) + return writeErr +} diff --git a/internal/write/ai/doc.go b/internal/write/ai/doc.go new file mode 100644 index 000000000..41e5e2caa --- /dev/null +++ b/internal/write/ai/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package ai writes ctx ai command output. +// +// It owns stdout formatting for the ai command family. Command packages +// delegate here so they do not print directly. +// The package has no business logic and only formats terminal output. +package ai diff --git a/specs/ctx-ai-backend.md b/specs/ctx-ai-backend.md index 43b8b1ddc..b6d79007b 100644 --- a/specs/ctx-ai-backend.md +++ b/specs/ctx-ai-backend.md @@ -64,10 +64,11 @@ Block A delivers four things: ### Happy Path -1. User runs `ctx setup --backend vllm --endpoint http://localhost:8000` - (exact flag shape TBD). ctx writes the backend definition to - `.ctxrc` under a new `[backends.vllm]` table (or equivalent — - exact key TBD). +1. User runs `ctx setup --backend vllm --endpoint http://localhost:8000`. + This is a backend setup mode on the existing `ctx setup ` family: + implementation must relax the current exactly-one-positional tool shape so + `--backend` can run without a tool argument. ctx writes the backend + definition to `.ctxrc` under the YAML `backends:` mapping. 2. User runs `ctx ai ping` (or equivalent). ctx reads the backend definition, performs a reachability check against the endpoint (HTTP GET on `/v1/models` for OpenAI-compatible servers), and @@ -88,10 +89,10 @@ Block A delivers four things: |------|-------------------| | Backend unreachable | Fail closed with a clear error naming the configured endpoint and suggesting `ctx setup --backend --endpoint `. No fallback. | | Backend reachable but model unavailable | Surface upstream 4xx verbatim; do not retry with a different model. | -| Multiple backends configured (e.g., `vllm` + `openai`) | User must specify `--backend ` on the AI command, or set a default via `.ctxrc` `[backends].default`. No implicit selection. | +| Multiple backends configured (e.g., `vllm` + `openai`) | User must specify `--backend ` on the AI command, or set a default via `.ctxrc` `backends.default`. No implicit selection. | | No backend configured | AI commands print: "no backend configured; run `ctx setup --backend `" and exit non-zero. Non-AI commands are unaffected. | | API key missing or invalid | Surface upstream auth error verbatim; do not retry. Suggest the env-var or `.ctxrc` key the backend reads. | -| Slow backend | Respect timeout from `.ctxrc` `[backends.].timeout` (default TBD). No infinite waits. | +| Slow backend | Respect timeout from `.ctxrc` `backends..timeout` (default TBD). No infinite waits. | | AI command invoked from inside a deterministic ceremony hook | Fail closed. Coupling deterministic-core hooks to AI availability would violate Invariant 2 ("zero runtime deps for core functionality"). | | Existing `ANTHROPIC_BASE_URL` already set in user env | Honour it; do not overwrite. `ctx setup --backend` prints a warning if the env vars it would template conflict with what's already set. | | `.ctxrc` malformed (e.g., missing required key) | Refuse with a clear parse error naming the offending key; do not silently default. | @@ -103,7 +104,7 @@ Block A delivers four things: | Backend name | Alphanumeric + hyphen; must match a registered backend type | At setup time and at AI-command dispatch | | Endpoint URL | Must parse as `http://` or `https://`; localhost recommended (not required) for `vllm`-canonical backend | At setup time | | API key (if any) | Read from env-var (preferred) or `.ctxrc`; **never** allowed in a committed `.ctxrc` if the project's git config marks it as such | Setup-time warning; commit hook (out of scope here, but flagged in Non-Goals) | -| Default backend | If `[backends].default` is set, must reference a configured backend | At AI-command dispatch | +| Default backend | If `backends.default` is set, must reference a configured backend | At AI-command dispatch | | Determinism boundary | `ctx ai` commands must not be invoked by `ctx agent`, `ctx status`, or any hook that fires during deterministic ceremony paths | Unit test guard (see Testing) | ### Error Handling @@ -114,8 +115,8 @@ Block A delivers four things: | Backend unreachable | `backend \`\` unreachable at : ` | Check endpoint; verify vllm/ollama/etc. is running | | Model not found | (relay upstream 4xx body verbatim) + `backend \`\` rejected the model selection; check \`/v1/models\` on the endpoint` | Pick a listed model | | Auth failed | (relay upstream 401/403 verbatim) + `backend \`\` rejected the credential; check ` | Update credential | -| Timeout | `backend \`\` timed out after ; tune \`[backends.].timeout\` in .ctxrc` | Increase timeout or use a faster model | -| Multiple backends, none specified | `multiple backends configured; pass \`--backend \` or set \`[backends].default\` in .ctxrc` | Pass flag or set default | +| Timeout | `backend \`\` timed out after ; tune \`backends..timeout\` in .ctxrc` | Increase timeout or use a faster model | +| Multiple backends, none specified | `multiple backends configured; pass \`--backend \` or set \`backends.default\` in .ctxrc` | Pass flag or set default | | AI command called from deterministic hook | (developer-only) `ctx ai called from deterministic context; this would violate Invariant 2` | Restructure hook | ## Interface @@ -143,18 +144,17 @@ ctx ingest --extract [--backend ] | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--backend` | string | (resolved from `.ctxrc` `[backends].default`) | Selects which configured backend to dispatch through | +| `--backend` | string | (resolved from `.ctxrc` `backends.default`) | Selects which configured backend to dispatch through | | `--endpoint` (setup only) | URL | (per-backend) | Endpoint override at setup time | | `--api-key-env` (setup only) | string | (per-backend) | Name of the env-var the backend reads for auth | | (additional flags TBD with interface decision) | | | | ### Skill (if applicable) -TBD. Likely a `/ctx-ai-setup` companion skill that wraps -`ctx setup --backend`, but the brief is silent and the -existing `ctx setup` family already has skills (`/ctx-setup`, -per-tool variants) that could absorb this. Decide during -implementation. +No new companion skill ships in Block A. The user-facing surface is covered by +the `ctx setup` docs, the `ctx ai` CLI reference, the vLLM recipe, command +assets/examples, and the agent playbook note listed below. A future `/ctx-ai-*` +skill can be specified after Block A proves the backend contract. ## Implementation @@ -166,8 +166,8 @@ implementation. | `internal/cli/ai/` | **New package** (if Option 1). Command surface for the `ai` namespace | | `internal/cli/setup/cmd/root/` | Extend with `--backend` handling; templating into `.ctxrc` | | `internal/cli/setup/core/backend/` | **New subpackage.** Setup-time wiring per backend type (env-var templates, downstream-tool config writes) | -| `internal/rc/` | Add `[backends]` table parsing and validation | -| `.ctxrc` (project-init template) | Add commented-out `[backends]` skeleton | +| `internal/rc/` | Add `backends:` YAML mapping parsing and validation | +| `.ctxrc` (project-init template) | Add commented-out `backends:` skeleton | | `internal/assets/context/AGENT_PLAYBOOK.md` | (TBD) note that `ctx ai ` exists and when agents should call it vs. hand-rolling against the AI tool | | `docs/recipes/` | New recipe `local-inference-with-vllm.md` (or `ai-backend-setup.md`); the user explicitly carved out recipe-restructuring work, so this is *one* recipe added, not a recipe-surface rework | | `docs/cli/` | New page documenting `ctx ai` (or the chosen flag surface) | @@ -197,7 +197,7 @@ type Registry interface { OpenCode, Cursor, etc.). The `--backend` extension follows the same templating-into-config-files pattern. - `internal/rc/` — `.ctxrc` parsing and validation. Adding a - `[backends]` table follows the existing TOML pattern. + `backends:` mapping follows the existing YAML pattern. - `internal/err/` — typed-string sentinels for backend errors (per the recent `entity.Sentinel` convention). - `internal/assets/commands/text/errors.yaml` — externalised @@ -205,23 +205,21 @@ type Registry interface { ## Configuration -`.ctxrc` additions (proposed shape — final key names TBD): - -```toml -[backends] -default = "vllm" # optional; required only if more than one backend is configured - -[backends.vllm] -endpoint = "http://localhost:8000" -api_key_env = "" # vllm typically runs without auth; empty means none -timeout = "30s" -default_model = "openai/gpt-oss-120b" - -[backends.openai] -endpoint = "https://api.openai.com" -api_key_env = "OPENAI_API_KEY" -timeout = "60s" -default_model = "gpt-4o" +`.ctxrc` additions use the repo's existing YAML shape: + +```yaml +backends: + default: vllm # optional; required only if more than one backend is configured + vllm: + endpoint: http://localhost:8000 + api_key_env: "" # vllm typically runs without auth; empty means none + timeout: 30s + default_model: openai/gpt-oss-120b + openai: + endpoint: https://api.openai.com + api_key_env: OPENAI_API_KEY + timeout: 60s + default_model: gpt-4o ``` Environment variables: only **read** from env-vars named in @@ -287,18 +285,20 @@ implementation: 1. **CLI namespace shape:** `ctx ai ` (Option 1) vs. flags on existing commands (Option 2). Expensive to unwind. Pick once. -2. **Proposal queue location:** `.context/proposals/`? Kb-closeout-style - under `.context/ingest/`? Belongs to B+C supplementary, but A's - validation consumer must write *somewhere* — pick a provisional - location that B+C can confirm or relocate. +2. **Proposal queue location:** Block A uses `.context/proposals/ai/` as the + provisional queue. The directory is tracked as substrate metadata; each + validation run writes one JSON proposed-patch artifact containing backend, + model, input reference, emit kinds, proposed rows, source spans/citations + when available, and status metadata. B+C may confirm or relocate this queue + after Block A ships. 3. **Default extraction model:** A-spec leaves model choice to the user; recommended models per task type can be a recipe. -4. **Companion skill:** `/ctx-ai-setup` or absorb into existing - `/ctx-setup`? -5. **Validation consumer:** `ctx compact` is the cheapest validator - per the brief, but the exact command/flag shape lands in the - B+C supplementary spec. A's implementation needs to pick *one* - B-block consumer to ship alongside A; the others wait. +4. **Companion skill:** No new Block A skill; docs/assets/playbook updates cover + the surface until a dedicated skill is specified. +5. **Validation consumer:** `ctx ai propose --emit ...` is the Block A + validation-only generic proposer. It proves backend dispatch and proposed + patch writing without claiming the final B command taxonomy or foreclosing + later `ctx ai compact` / `ctx ai ingest` commands. ## Task Breakdown @@ -324,9 +324,9 @@ later rows. The `Spec:` reference points at this file. #priority:medium #added:2026-05-21 - [ ] Extend `internal/rc/` to parse and validate the `.ctxrc` - `[backends]` table per the spec's Configuration section: + `backends:` mapping per the spec's Configuration section: per-backend `endpoint`, `api_key_env`, `timeout`, `default_model`, - plus optional `[backends].default`. Refuse malformed tables with + plus optional `backends.default`. Refuse malformed tables with a clear parse error naming the offending key. Add fixtures and round-trip tests. Spec: `specs/ctx-ai-backend.md` §Configuration. #priority:medium #added:2026-05-21 @@ -359,9 +359,10 @@ later rows. The `Spec:` reference points at this file. - [ ] Build the AI command surface per the namespace decision from the first task. Minimum verbs: `ping` (reachability + first model - listed) plus the validation consumer chosen below. All AI + listed) plus `propose` as the Block A validation-only generic + proposer. All AI commands honour `--backend` flag (falls back to - `[backends].default`), fail closed when no backend configured, + `backends.default`), fail closed when no backend configured, and surface upstream errors verbatim. Spec: `specs/ctx-ai-backend.md` §Interface. #priority:medium #added:2026-05-21 @@ -374,14 +375,15 @@ later rows. The `Spec:` reference points at this file. is honour-system only. Spec: `specs/ctx-ai-backend.md` §Validation Rules and §Testing. #priority:medium #added:2026-05-21 -- [ ] Ship the validation consumer from block B: pick *one* - extraction command (the spec recommends `ctx compact - --emit decisions,learnings,tasks,open-questions` as the cheapest - per the brief). Implements the full pattern end-to-end: +- [ ] Ship the Block A validation consumer: `ctx ai propose + --emit decisions,learnings,tasks,open-questions`. This is a + validation-only generic proposer, not the final B command taxonomy. + Implements the full pattern end-to-end: schema-constrained dispatch through the backend, JSON validation, - proposal artifact written to the provisional proposal queue - location (settled later by the B+C spec). `.context/*.md` files - must remain unchanged. Integration test confirms the round-trip + one proposed-patch JSON artifact written to `.context/proposals/ai/` + with backend, model, input reference, emit kinds, proposed rows, + source spans/citations when available, and status metadata. + `.context/*.md` files must remain unchanged. Integration test confirms the round-trip against a fake OpenAI-compatible httptest server. Spec: `specs/ctx-ai-backend.md` §Testing and Open Question #5. #priority:medium #added:2026-05-21