diff --git a/run.json b/run.json index dc1569b1a..6cabe190b 100644 --- a/run.json +++ b/run.json @@ -505,7 +505,7 @@ "kind": "running" }, "status_updated_at": "2026-05-27T16:27:37.247434Z", - "last_event_at": "2026-05-27T17:03:59.667187Z", + "last_event_at": "2026-05-27T17:12:53.414555Z", "pending_control": null, "checkpoints": [ { @@ -789,9 +789,9 @@ } }, { - "seq": 0, + "seq": 404, "checkpoint": { - "timestamp": "2026-05-27T17:03:59.732183Z", + "timestamp": "2026-05-27T17:04:04.615480Z", "current_node": "implement", "completed_nodes": [ "start", @@ -802,30 +802,177 @@ ], "node_retries": {}, "context_values": { - "internal.retry_count.toolchain": 0, - "thread.toolchain.current_node": "preflight_compile", - "internal.retry_count.preflight_compile": 0, - "thread.preflight_compile.current_node": "preflight_lint", - "internal.retry_count.preflight_lint": 0, - "internal.node_visit_count": 1, - "last_stage": "implement", - "internal.work_dir": "/home/daytona/workspace/fabro", - "last_response": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `de", - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "failure_class": "", "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", - "thread.preflight_lint.current_node": "implement", - "internal.retry_count.implement": 0, - "internal.fidelity": "compact", - "current_node": "implement", + "thread.start.current_node": "toolchain", "failure_signature": "", - "internal.run_id": "01KSN4661TG7HFT3ATDKNGMGC0", + "thread.toolchain.current_node": "preflight_compile", + "internal.fidelity": "compact", + "internal.retry_count.start": 0, "graph.goal": "---\ntitle: Add CLI Variable Management\ntype: feat\nstatus: active\ndate: 2026-05-27\n---\n\n# Add CLI Variable Management\n\n## Overview\n\nExpose the recently added variables API through the CLI with a singular `fabro variable`\nnamespace. Variables are non-sensitive run-configuration values, so the CLI should expose\nvalues in `list` and `get`, while continuing to direct credentials and tokens to\n`fabro secret`.\n\n## Requirements Trace\n\n- R1. Provide variables management in the CLI, similar to secrets management.\n- R2. Support the full readable-variable CRUD surface: list, get, set/upsert, and remove.\n- R3. Preserve existing server/API behavior: variable names are env-style, values may be\n empty, and `set` preserves an existing description when `--description` is omitted.\n- R4. Keep generated CLI docs and help snapshots in sync with the new public command.\n\n## Context & Research\n\n- `lib/crates/fabro-cli/src/commands/secret/` is the command pattern to follow for\n namespace dispatch, JSON output, tabular list output, stdin value input, and status\n messages.\n- `lib/crates/fabro-server/src/server/handler/variables.rs` already provides\n `GET /variables`, `POST /variables`, `GET /variables/{name}`,\n `PUT /variables/{name}`, and `DELETE /variables/{name}`.\n- `lib/crates/fabro-types/src/variable.rs` defines the canonical API/request types and\n validates env-style names.\n- `lib/crates/fabro-api/tests/variable_round_trip.rs` already proves OpenAPI generated\n types reuse the canonical variable types.\n- `docs/public/workflows/variables.mdx` currently explains workflow template variables\n but does not yet document how server-managed `{{ vars.NAME }}` values are configured.\n\n## Key Technical Decisions\n\n- Use `fabro variable`, not `fabro variables`, to match existing singular CLI namespaces\n such as `fabro secret`, `fabro model`, and `fabro repo`.\n- Add `get` because variables are intentionally readable; secrets remain write-only.\n- Make `set` an upsert using the API's create/upsert endpoint, matching the mental model\n of `fabro secret set`.\n- Reuse `--value-stdin` from secrets but allow empty stdin values for variables after\n trimming trailing newlines.\n- Plain `list` should include a `VALUE` column. Do not add truncation or redaction in\n this first pass; exact retrieval is available through JSON output and `get`.\n\n## Implementation Units\n\n- [ ] **Unit 1: Add fabro-client variable wrappers**\n\n**Goal:** Give CLI code stable methods over the generated OpenAPI client.\n\n**Requirements:** R2, R3\n\n**Dependencies:** Existing variables API and generated `fabro-api` client.\n\n**Files:**\n- Modify: `lib/crates/fabro-client/src/client.rs`\n\n**Approach:**\n- Add wrappers for `list_variables`, `get_variable`, `create_variable`,\n `update_variable`, and `delete_variable`.\n- Return `Vec` from `list_variables` by unwrapping the API response's\n `data`, matching `list_secrets`.\n- Use the generated path-parameter operations for `get`, `update`, and `delete`.\n\n**Patterns to follow:**\n- `list_secrets`, `create_secret`, and `delete_secret_by_name` in the same file.\n\n**Test scenarios:**\n- Happy path: CLI integration tests in later units exercise each wrapper through the\n shared server client path.\n- Error path: missing and invalid variable operations propagate the server's API errors.\n\n**Verification:**\n- The CLI can compile against these wrapper methods without importing generated client\n builders directly.\n\n- [ ] **Unit 2: Add CLI args, dispatch, and command module**\n\n**Goal:** Register the new top-level namespace and route subcommands to implementation\nmodules.\n\n**Requirements:** R1, R2, R4\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-cli/src/args.rs`\n- Modify: `lib/crates/fabro-cli/src/main.rs`\n- Modify: `lib/crates/fabro-cli/src/commands/mod.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/mod.rs`\n\n**Approach:**\n- Add `Commands::Variable(VariableNamespace)` with description\n `Manage server-owned variables`.\n- Add `VariableNamespace` with `ServerTargetArgs`, matching `SecretNamespace`.\n- Add `VariableCommand::{List, Get, Rm, Set}`; give `list` the `ls` alias.\n- Add command-name mapping for analytics/logging: `variable list`, `variable get`,\n `variable rm`, and `variable set`.\n- Dispatch through `commands::variable::dispatch`, deriving the target context with\n `base_ctx.with_target(&ns.target)`.\n\n**Patterns to follow:**\n- `SecretNamespace`, `SecretCommand`, and `commands::secret::dispatch`.\n\n**Test scenarios:**\n- Happy path: `fabro --help` lists `variable`.\n- Happy path: `fabro variable --help` shows `list`, `get`, `rm`, and `set`.\n- Happy path: command-name mapping covers all subcommands.\n\n**Verification:**\n- The new namespace is reachable through clap and main dispatch without affecting\n existing commands.\n\n- [ ] **Unit 3: Implement variable list/get/set/rm behavior**\n\n**Goal:** Provide the full user-facing variables management workflow.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Units 1 and 2\n\n**Files:**\n- Create: `lib/crates/fabro-cli/src/commands/variable/list.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/get.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/set.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/rm.rs`\n\n**Approach:**\n- `list`: fetch all variables, print JSON array when JSON output is active, otherwise\n print a table with `NAME`, `VALUE`, and `UPDATED`.\n- `get`: fetch one variable, print the full variable object for JSON output, otherwise\n print only the raw value to stdout.\n- `set`: accept ` [VALUE]`, `--value-stdin`, and `--description`; call the upsert\n API wrapper and print the stored variable for JSON output or `Set NAME` otherwise.\n- `rm`: call the delete API wrapper and print `{ \"name\": NAME }` for JSON output or\n `Removed NAME` otherwise.\n- For `set`, allow empty explicit values and empty stdin values. Only error when no value\n is provided and stdin is not being used.\n\n**Patterns to follow:**\n- `commands/secret/list.rs` for table style and age formatting.\n- `commands/secret/set.rs` for argument precedence and stdin handling, adjusted so empty\n values are valid.\n- `commands/secret/rm.rs` for delete output shape.\n\n**Test scenarios:**\n- Happy path: `set DEPLOY_ENV staging --description \"Deployment target\"` then `list`\n shows `DEPLOY_ENV`, `staging`, and an updated age.\n- Happy path: `get DEPLOY_ENV` prints exactly `staging\\n` in plain output.\n- Happy path: `set DEPLOY_ENV production` updates the value and preserves the existing\n description through API behavior.\n- Happy path: `set EMPTY \"\"` stores an empty value.\n- Happy path: `printf '\\n' | fabro variable set EMPTY --value-stdin` stores an empty\n value instead of failing.\n- Error path: `get MISSING` and `rm MISSING` fail with `variable not found: MISSING`.\n- Error path: `set 1BAD value` fails with the server invalid-name error.\n\n**Verification:**\n- The command works against the default test server and does not write directly to\n local `variables.json`.\n\n- [ ] **Unit 4: Add test harness support and CLI integration tests**\n\n**Goal:** Lock the public CLI surface and expected behavior with integration coverage.\n\n**Requirements:** R1, R2, R3, R4\n\n**Dependencies:** Units 1-3\n\n**Files:**\n- Modify: `lib/crates/fabro-test/src/lib.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/mod.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/fabro.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_list.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_get.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_set.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs`\n\n**Approach:**\n- Add `TestContext::variable()` helper mirroring `TestContext::secret()`.\n- Add help snapshots for the namespace and each subcommand.\n- Add lifecycle tests for set/list/get/update/rm, `ls` alias, empty value support, JSON\n output, missing variable errors, and invalid-name errors.\n- Update root help and curated landing snapshots only if the final clap/landing output\n changes.\n\n**Patterns to follow:**\n- `secret.rs`, `secret_list.rs`, `secret_set.rs`, and `secret_rm.rs`.\n\n**Test scenarios:**\n- Happy path: JSON `list` returns an array of full variable objects including `value`.\n- Happy path: JSON `get` and `set` return full variable objects.\n- Happy path: global JSON config makes `variable list` emit JSON, matching the\n `secret list` config test.\n- Error path: missing variables and invalid names produce nonzero exits and readable\n errors.\n\n**Verification:**\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n\n- [ ] **Unit 5: Update generated and conceptual docs**\n\n**Goal:** Keep public documentation aligned with the new command and clarify how variables\nrelate to secrets.\n\n**Requirements:** R1, R4\n\n**Dependencies:** Units 2-4\n\n**Files:**\n- Modify: `docs/public/reference/cli.mdx`\n- Modify: `docs/public/workflows/variables.mdx`\n\n**Approach:**\n- Regenerate the CLI reference with `cargo dev docs refresh`.\n- Add a short section to `docs/public/workflows/variables.mdx` explaining that\n server-managed run config variables can be set with `fabro variable set NAME VALUE`\n and referenced as `{{ vars.NAME }}` in run config interpolation.\n- State that variables are non-sensitive and readable; tokens, keys, and credentials\n should use `fabro secret set`.\n\n**Patterns to follow:**\n- Existing generated docs workflow in `lib/crates/fabro-dev/src/commands/docs.rs`.\n- Existing CLI references to `fabro secret set` in administration docs.\n\n**Test scenarios:**\n- Happy path: generated CLI docs include `fabro variable` and its subcommands.\n- Documentation check: `cargo dev docs check` succeeds after regeneration.\n\n**Verification:**\n- The docs describe the CLI surface without implying variables are secret storage.\n\n## System-Wide Impact\n\n- **API surface parity:** No server or OpenAPI changes are planned; the CLI consumes the\n existing variables API.\n- **Error propagation:** Invalid names, missing variables, and write failures should flow\n through the existing `fabro-client` API error classification.\n- **State lifecycle risks:** CLI commands must use the server API rather than editing\n `variables.json` locally, so behavior remains correct for remote and socket-backed\n servers.\n- **Security boundary:** Values are intentionally visible for variables. Documentation\n must clearly distinguish variables from secrets to avoid accidental credential storage.\n- **Unchanged invariants:** `fabro secret` remains write-only and unchanged.\n\n## Risks & Dependencies\n\n| Risk | Mitigation |\n| --- | --- |\n| Users put credentials in variables because the command looks like secrets | Document variables as non-sensitive and keep secret guidance explicit. |\n| Empty values accidentally fail because secret handling rejects empties | Test explicit empty strings and newline-only stdin for `variable set`. |\n| CLI docs drift after adding clap args | Regenerate with `cargo dev docs refresh` and verify with `cargo dev docs check`. |\n| Plain `list` becomes awkward for long values | Accept for v1; `get` and JSON output provide exact machine-readable retrieval. |\n\n## Verification Plan\n\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n- `cargo dev docs check`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n\n## Assumptions\n\n- The chosen CLI surface is full CRUD with readable values.\n- The namespace is singular: `fabro variable`.\n- No TypeScript client regeneration is required for this CLI-only change.\n- No server API, OpenAPI schema, or storage migration changes are required.", + "internal.retry_count.implement": 0, + "internal.retry_count.preflight_compile": 0, + "internal.node_visit_count": 1, + "internal.retry_count.toolchain": 0, + "current_node": "implement", "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", "graph.rankdir": "LR", - "internal.retry_count.start": 0, - "outcome": "succeeded", - "failure_class": "", "internal.thread_id": "preflight_lint", + "last_stage": "implement", + "internal.run_id": "01KSN4661TG7HFT3ATDKNGMGC0", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "thread.preflight_compile.current_node": "preflight_lint", + "thread.preflight_lint.current_node": "implement", + "outcome": "succeeded", + "internal.work_dir": "/home/daytona/workspace/fabro", + "internal.retry_count.preflight_lint": 0, + "last_response": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `de" + }, + "node_outcomes": { + "start": { + "status": "succeeded", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 1445, + "active_time_ms": 1445 + } + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 128996, + "active_time_ms": 128996 + } + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 141353, + "active_time_ms": 141353 + } + }, + "implement": { + "status": "succeeded", + "context_updates": { + "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", + "last_stage": "implement", + "last_response": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `de" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 2883761, + "output_tokens": 16358, + "reasoning_tokens": 8084, + "cache_read_tokens": 6571520, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 18437825 + }, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 1484989, + "tool_time_ms": 408080, + "active_time_ms": 1893069 + } + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "67bd3cd7b5dfa175921a3054572f5ca3157fbf4c", + "node_visits": { + "toolchain": 1, + "implement": 1, + "preflight_compile": 1, + "preflight_lint": 1, + "start": 1 + } + }, + "diff": { + "patch": "diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx\nindex 67e390987..f264f7bab 100644\n--- a/docs/public/reference/cli.mdx\n+++ b/docs/public/reference/cli.mdx\n@@ -104,6 +104,7 @@ fabro [OPTIONS] [COMMAND]\n | `fabro uninstall` | Uninstall Fabro from this machine |\n | `fabro upgrade` | Upgrade fabro to the latest version |\n | `fabro validate` | Validate a workflow |\n+| `fabro variable` | Manage server-owned variables |\n | `fabro version` | Show client and server version information |\n | `fabro wait` | Block until a workflow run completes |\n | `fabro workflow` | Workflow operations |\n@@ -1571,6 +1572,87 @@ fabro validate [OPTIONS] \n | --- | --- |\n | `WORKFLOW` | Path to the .fabro workflow file |\n \n+### `fabro variable`\n+\n+Manage server-owned variables\n+\n+```bash\n+fabro variable [OPTIONS] \n+```\n+\n+#### Options\n+\n+| Option | Description |\n+| --- | --- |\n+| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path |\n+\n+#### Subcommands\n+\n+| Command | Description |\n+| --- | --- |\n+| `fabro variable get` | Get a variable value |\n+| `fabro variable list` | List variables |\n+| `fabro variable rm` | Remove a variable |\n+| `fabro variable set` | Set a variable value |\n+\n+#### `fabro variable get`\n+\n+Get a variable value\n+\n+```bash\n+fabro variable get [OPTIONS] \n+```\n+\n+#### Arguments\n+\n+| Name | Description |\n+| --- | --- |\n+| `NAME` | Name of the variable to get |\n+\n+#### `fabro variable list`\n+\n+List variables\n+\n+```bash\n+fabro variable list [OPTIONS]\n+```\n+\n+#### `fabro variable rm`\n+\n+Remove a variable\n+\n+```bash\n+fabro variable rm [OPTIONS] \n+```\n+\n+#### Arguments\n+\n+| Name | Description |\n+| --- | --- |\n+| `NAME` | Name of the variable to remove |\n+\n+#### `fabro variable set`\n+\n+Set a variable value\n+\n+```bash\n+fabro variable set [OPTIONS] [VALUE]\n+```\n+\n+#### Arguments\n+\n+| Name | Description |\n+| --- | --- |\n+| `NAME` | Name of the variable |\n+| `VALUE` | Value to store |\n+\n+#### Options\n+\n+| Option | Description |\n+| --- | --- |\n+| `--description ` | Optional human-readable description |\n+| `--value-stdin` | Read the variable value from stdin |\n+\n ### `fabro version`\n \n Show client and server version information\ndiff --git a/docs/public/workflows/variables.mdx b/docs/public/workflows/variables.mdx\nindex 9869a16af..c3a7be4e9 100644\n--- a/docs/public/workflows/variables.mdx\n+++ b/docs/public/workflows/variables.mdx\n@@ -59,6 +59,25 @@ fabro run .fabro/workflows/check/workflow.toml -I repo_name=fabro-2 --input lang\n \n CLI input values use TOML scalar parsing when possible. Quoted strings, booleans, integers, and floats keep their typed values; unquoted bare text falls back to a string. Empty values such as `foo=` are accepted as empty strings. Arrays, inline tables, and datetimes are rejected.\n \n+## Server-managed run config variables\n+\n+Use server-managed variables for non-sensitive values that should be shared across runs, such as deployment environments, default branches, regions, or image tags:\n+\n+```bash\n+fabro variable set DEPLOY_ENV staging --description \"Deployment target\"\n+```\n+\n+Run configuration strings can reference these values with `{{ vars.NAME }}`:\n+\n+```toml title=\"workflow.toml\"\n+_version = 1\n+\n+[run]\n+goal = \"Deploy {{ vars.DEPLOY_ENV }}\"\n+```\n+\n+Variables are intentionally readable: `fabro variable list` and `fabro variable get DEPLOY_ENV` show stored values. Do not store tokens, API keys, or credentials as variables; use `fabro secret set` for sensitive values.\n+\n ## `goal`\n \n Agent and prompt nodes also receive the workflow goal at runtime:\ndiff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs\nindex 72aa82b52..f560b62e6 100644\n--- a/lib/crates/fabro-cli/src/args.rs\n+++ b/lib/crates/fabro-cli/src/args.rs\n@@ -676,6 +676,35 @@ pub(crate) struct SecretSetArgs {\n pub(crate) description: Option,\n }\n \n+#[derive(Args)]\n+pub(crate) struct VariableListArgs;\n+\n+#[derive(Args)]\n+pub(crate) struct VariableGetArgs {\n+ /// Name of the variable to get\n+ pub(crate) name: String,\n+}\n+\n+#[derive(Args)]\n+pub(crate) struct VariableRmArgs {\n+ /// Name of the variable to remove\n+ pub(crate) name: String,\n+}\n+\n+#[derive(Args)]\n+pub(crate) struct VariableSetArgs {\n+ /// Name of the variable\n+ pub(crate) name: String,\n+ /// Value to store\n+ pub(crate) value: Option,\n+ /// Read the variable value from stdin\n+ #[arg(long, conflicts_with = \"value\")]\n+ pub(crate) value_stdin: bool,\n+ /// Optional human-readable description\n+ #[arg(long)]\n+ pub(crate) description: Option,\n+}\n+\n #[derive(Debug, Args)]\n pub(crate) struct ResumeArgs {\n #[command(flatten)]\n@@ -1261,6 +1290,8 @@ pub(crate) enum Commands {\n Parent(ParentNamespace),\n /// Manage server-owned secrets\n Secret(SecretNamespace),\n+ /// Manage server-owned variables\n+ Variable(VariableNamespace),\n /// Inspect effective settings\n Settings(SettingsArgs),\n /// Workflow operations\n@@ -1376,6 +1407,12 @@ impl Commands {\n SecretCommand::Rm(_) => \"secret rm\",\n SecretCommand::Set(_) => \"secret set\",\n },\n+ Self::Variable(ns) => match &ns.command {\n+ VariableCommand::List(_) => \"variable list\",\n+ VariableCommand::Get(_) => \"variable get\",\n+ VariableCommand::Rm(_) => \"variable rm\",\n+ VariableCommand::Set(_) => \"variable set\",\n+ },\n Self::Settings(_) => \"settings\",\n Self::Workflow(ns) => match &ns.command {\n WorkflowCommand::List(_) => \"workflow list\",\n@@ -1479,6 +1516,28 @@ pub(crate) enum SecretCommand {\n Set(SecretSetArgs),\n }\n \n+#[derive(Args)]\n+pub(crate) struct VariableNamespace {\n+ #[command(flatten)]\n+ pub(crate) target: ServerTargetArgs,\n+\n+ #[command(subcommand)]\n+ pub(crate) command: VariableCommand,\n+}\n+\n+#[derive(Subcommand)]\n+pub(crate) enum VariableCommand {\n+ /// List variables\n+ #[command(alias = \"ls\")]\n+ List(VariableListArgs),\n+ /// Get a variable value\n+ Get(VariableGetArgs),\n+ /// Remove a variable\n+ Rm(VariableRmArgs),\n+ /// Set a variable value\n+ Set(VariableSetArgs),\n+}\n+\n #[derive(Args)]\n pub(crate) struct ServerNamespace {\n #[command(subcommand)]\ndiff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs\nindex 71d66bd08..18e22d5f7 100644\n--- a/lib/crates/fabro-cli/src/commands/mod.rs\n+++ b/lib/crates/fabro-cli/src/commands/mod.rs\n@@ -25,6 +25,7 @@ pub(crate) mod system;\n pub(crate) mod uninstall;\n pub(crate) mod upgrade;\n pub(crate) mod validate;\n+pub(crate) mod variable;\n pub(crate) mod version;\n pub(crate) mod workflow;\n \ndiff --git a/lib/crates/fabro-cli/src/commands/variable/get.rs b/lib/crates/fabro-cli/src/commands/variable/get.rs\nnew file mode 100644\nindex 000000000..bdd588c9c\n--- /dev/null\n+++ b/lib/crates/fabro-cli/src/commands/variable/get.rs\n@@ -0,0 +1,16 @@\n+use anyhow::Result;\n+\n+use crate::args::VariableGetArgs;\n+use crate::command_context::CommandContext;\n+use crate::shared::print_json_pretty;\n+\n+pub(super) async fn get_command(args: &VariableGetArgs, ctx: &CommandContext) -> Result<()> {\n+ let client = ctx.server().await?;\n+ let variable = client.get_variable(&args.name).await?;\n+ if ctx.json_output() {\n+ print_json_pretty(&variable)?;\n+ } else {\n+ fabro_util::printout!(ctx.printer(), \"{}\", variable.value);\n+ }\n+ Ok(())\n+}\ndiff --git a/lib/crates/fabro-cli/src/commands/variable/list.rs b/lib/crates/fabro-cli/src/commands/variable/list.rs\nnew file mode 100644\nindex 000000000..f57dd2f42\n--- /dev/null\n+++ b/lib/crates/fabro-cli/src/commands/variable/list.rs\n@@ -0,0 +1,71 @@\n+use anyhow::Result;\n+use chrono::{DateTime, Utc};\n+use cli_table::format::{Border, Separator};\n+use cli_table::{Cell, CellStruct, Style, Table};\n+use fabro_util::terminal::Styles;\n+\n+use crate::args::VariableListArgs;\n+use crate::command_context::CommandContext;\n+use crate::shared::print_json_pretty;\n+\n+fn format_age(dt: DateTime, now: DateTime) -> String {\n+ let dur = now.signed_duration_since(dt);\n+ if dur.num_days() > 0 {\n+ format!(\"{}d ago\", dur.num_days())\n+ } else if dur.num_hours() > 0 {\n+ format!(\"{}h ago\", dur.num_hours())\n+ } else {\n+ format!(\"{}m ago\", dur.num_minutes().max(1))\n+ }\n+}\n+\n+pub(super) async fn list_command(_args: &VariableListArgs, ctx: &CommandContext) -> Result<()> {\n+ let client = ctx.server().await?;\n+ let printer = ctx.printer();\n+ let variables = client.list_variables().await?;\n+ if ctx.json_output() {\n+ print_json_pretty(&variables)?;\n+ return Ok(());\n+ }\n+\n+ if variables.is_empty() {\n+ fabro_util::printerr!(printer, \"No variables found.\");\n+ return Ok(());\n+ }\n+\n+ let styles = Styles::detect_stdout();\n+ let use_color = styles.use_color;\n+ let now = Utc::now();\n+\n+ let title: Vec = vec![\n+ \"NAME\".cell().bold(use_color),\n+ \"VALUE\".cell().bold(use_color),\n+ \"UPDATED\".cell().bold(use_color),\n+ ];\n+\n+ let rows: Vec> = variables\n+ .iter()\n+ .map(|variable| {\n+ vec![\n+ variable.name.clone().cell().bold(use_color),\n+ variable.value.clone().cell(),\n+ format_age(variable.updated_at, now).cell(),\n+ ]\n+ })\n+ .collect();\n+\n+ let color_choice = if use_color {\n+ cli_table::ColorChoice::Auto\n+ } else {\n+ cli_table::ColorChoice::Never\n+ };\n+ let table = rows\n+ .table()\n+ .title(title)\n+ .color_choice(color_choice)\n+ .border(Border::builder().build())\n+ .separator(Separator::builder().build());\n+ fabro_util::printout!(printer, \"{}\", table.display()?);\n+\n+ Ok(())\n+}\ndiff --git a/lib/crates/fabro-cli/src/commands/variable/mod.rs b/lib/crates/fabro-cli/src/commands/variable/mod.rs\nnew file mode 100644\nindex 000000000..cc38b32c8\n--- /dev/null\n+++ b/lib/crates/fabro-cli/src/commands/variable/mod.rs\n@@ -0,0 +1,19 @@\n+mod get;\n+mod list;\n+mod rm;\n+mod set;\n+\n+use anyhow::Result;\n+\n+use crate::args::{VariableCommand, VariableNamespace};\n+use crate::command_context::CommandContext;\n+\n+pub(crate) async fn dispatch(ns: VariableNamespace, base_ctx: &CommandContext) -> Result<()> {\n+ let ctx = base_ctx.with_target(&ns.target)?;\n+ match ns.command {\n+ VariableCommand::List(args) => list::list_command(&args, &ctx).await,\n+ VariableCommand::Get(args) => get::get_command(&args, &ctx).await,\n+ VariableCommand::Rm(args) => rm::rm_command(&args, &ctx).await,\n+ VariableCommand::Set(args) => set::set_command(&args, &ctx).await,\n+ }\n+}\ndiff --git a/lib/crates/fabro-cli/src/commands/variable/rm.rs b/lib/crates/fabro-cli/src/commands/variable/rm.rs\nnew file mode 100644\nindex 000000000..b5904cab8\n--- /dev/null\n+++ b/lib/crates/fabro-cli/src/commands/variable/rm.rs\n@@ -0,0 +1,16 @@\n+use anyhow::Result;\n+\n+use crate::args::VariableRmArgs;\n+use crate::command_context::CommandContext;\n+use crate::shared::print_json_pretty;\n+\n+pub(super) async fn rm_command(args: &VariableRmArgs, ctx: &CommandContext) -> Result<()> {\n+ let client = ctx.server().await?;\n+ client.delete_variable(&args.name).await?;\n+ if ctx.json_output() {\n+ print_json_pretty(&serde_json::json!({ \"name\": args.name }))?;\n+ } else {\n+ fabro_util::printerr!(ctx.printer(), \"Removed {}\", args.name);\n+ }\n+ Ok(())\n+}\ndiff --git a/lib/crates/fabro-cli/src/commands/variable/set.rs b/lib/crates/fabro-cli/src/commands/variable/set.rs\nnew file mode 100644\nindex 000000000..0a999eb71\n--- /dev/null\n+++ b/lib/crates/fabro-cli/src/commands/variable/set.rs\n@@ -0,0 +1,56 @@\n+#![expect(\n+ clippy::disallowed_types,\n+ reason = \"sync CLI `variable set` command: reads variable value from stdin via blocking std::io::Read\"\n+)]\n+#![expect(\n+ clippy::disallowed_methods,\n+ reason = \"sync CLI `variable set` command: reads variable value from std::io::stdin\"\n+)]\n+\n+use std::io::Read as _;\n+\n+use anyhow::{Context as _, Result, bail};\n+use fabro_api::types;\n+use tokio::task::spawn_blocking;\n+\n+use crate::args::VariableSetArgs;\n+use crate::command_context::CommandContext;\n+use crate::shared::print_json_pretty;\n+\n+async fn resolve_value(args: &VariableSetArgs) -> Result {\n+ if let Some(value) = &args.value {\n+ return Ok(value.clone());\n+ }\n+\n+ if args.value_stdin {\n+ let value = spawn_blocking(|| {\n+ let mut raw = String::new();\n+ std::io::stdin()\n+ .read_to_string(&mut raw)\n+ .context(\"failed to read variable value from stdin\")?;\n+ Ok::(raw.trim_end_matches(['\\r', '\\n']).to_string())\n+ })\n+ .await??;\n+ return Ok(value);\n+ }\n+\n+ bail!(\"variable value required: pass or use --value-stdin\")\n+}\n+\n+pub(super) async fn set_command(args: &VariableSetArgs, ctx: &CommandContext) -> Result<()> {\n+ let value = resolve_value(args).await?;\n+ let client = ctx.server().await?;\n+ let variable = client\n+ .create_variable(types::CreateVariableRequest {\n+ name: args.name.clone(),\n+ value,\n+ description: args.description.clone(),\n+ })\n+ .await?;\n+ if ctx.json_output() {\n+ print_json_pretty(&variable)?;\n+ } else {\n+ fabro_util::printerr!(ctx.printer(), \"Set {}\", variable.name);\n+ }\n+ Ok(())\n+}\ndiff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs\nindex c3402f1a7..3081e1ee3 100644\n--- a/lib/crates/fabro-cli/src/main.rs\n+++ b/lib/crates/fabro-cli/src/main.rs\n@@ -363,6 +363,9 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) {\n Commands::Secret(ns) => {\n commands::secret::dispatch(ns, &base_ctx).await?;\n }\n+ Commands::Variable(ns) => {\n+ commands::variable::dispatch(ns, &base_ctx).await?;\n+ }\n Commands::Settings(args) => {\n Box::pin(commands::config::execute(&args, &base_ctx)).await?;\n }\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs\nindex 3fe9c2bf7..d4950c196 100644\n--- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs\n+++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs\n@@ -46,6 +46,7 @@ fn help() {\n pr Pull request operations\n parent Manage run parent links\n secret Manage server-owned secrets\n+ variable Manage server-owned variables\n settings Inspect effective settings\n workflow Workflow operations\n discord Open the Discord community in the browser\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs\nindex 5c52cded3..9a9992806 100644\n--- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs\n+++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs\n@@ -92,6 +92,26 @@ fn secret_list_uses_json_output_format_from_home_config() {\n assert!(value.is_array(), \"secret list JSON should be an array\");\n }\n \n+#[test]\n+fn variable_list_uses_json_output_format_from_home_config() {\n+ let context = test_context!();\n+ context.write_home(\n+ \".fabro/settings.toml\",\n+ \"_version = 1\\n\\n[cli.output]\\nformat = \\\"json\\\"\\n\",\n+ );\n+\n+ let output = context\n+ .command()\n+ .args([\"variable\", \"list\"])\n+ .output()\n+ .expect(\"command should run\");\n+\n+ assert!(output.status.success());\n+ let value: Value =\n+ serde_json::from_slice(&output.stdout).expect(\"variable list config JSON should parse\");\n+ assert!(value.is_array(), \"variable list JSON should be an array\");\n+}\n+\n #[test]\n fn completion_succeeds_with_json_output_format_from_home_config() {\n let context = test_context!();\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs\nindex fbd02b92d..e80e79562 100644\n--- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs\n+++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs\n@@ -75,6 +75,11 @@ mod unarchive;\n mod uninstall;\n mod upgrade;\n mod validate;\n+mod variable;\n+mod variable_get;\n+mod variable_list;\n+mod variable_rm;\n+mod variable_set;\n mod version;\n mod wait;\n mod worker_auth;\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/variable.rs b/lib/crates/fabro-cli/tests/it/cmd/variable.rs\nnew file mode 100644\nindex 000000000..ba637c587\n--- /dev/null\n+++ b/lib/crates/fabro-cli/tests/it/cmd/variable.rs\n@@ -0,0 +1,97 @@\n+use fabro_test::{fabro_snapshot, test_context};\n+\n+#[test]\n+fn help() {\n+ let context = test_context!();\n+ let mut cmd = context.variable();\n+ cmd.arg(\"--help\");\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: true\n+ exit_code: 0\n+ ----- stdout -----\n+ Manage server-owned variables\n+\n+ Usage: fabro variable [OPTIONS] \n+\n+ Commands:\n+ list List variables\n+ get Get a variable value\n+ rm Remove a variable\n+ set Set a variable value\n+ help Print this message or the help of the given subcommand(s)\n+\n+ Options:\n+ --json Output as JSON [env: FABRO_JSON=]\n+ --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]\n+ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]\n+ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]\n+ --quiet Suppress non-essential output [env: FABRO_QUIET=]\n+ --verbose Enable verbose output [env: FABRO_VERBOSE=]\n+ -h, --help Print help\n+ ----- stderr -----\n+ \");\n+}\n+\n+#[test]\n+fn variable_lifecycle() {\n+ let context = test_context!();\n+ let name = format!(\"DEPLOY_ENV_{}\", context.test_case_id());\n+\n+ context\n+ .variable()\n+ .args([\n+ \"set\",\n+ &name,\n+ \"staging\",\n+ \"--description\",\n+ \"Deployment target\",\n+ ])\n+ .assert()\n+ .success()\n+ .stderr(format!(\"Set {name}\\n\"));\n+\n+ context\n+ .variable()\n+ .args([\"list\"])\n+ .assert()\n+ .success()\n+ .stdout(predicates::str::contains(&name))\n+ .stdout(predicates::str::contains(\"staging\"))\n+ .stdout(predicates::str::contains(\"UPDATED\"));\n+\n+ context\n+ .variable()\n+ .args([\"get\", &name])\n+ .assert()\n+ .success()\n+ .stdout(\"staging\\n\");\n+\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"production\"])\n+ .assert()\n+ .success();\n+\n+ context\n+ .variable()\n+ .args([\"get\", &name])\n+ .assert()\n+ .success()\n+ .stdout(\"production\\n\");\n+\n+ context\n+ .variable()\n+ .args([\"rm\", &name])\n+ .assert()\n+ .success()\n+ .stderr(format!(\"Removed {name}\\n\"));\n+\n+ context\n+ .variable()\n+ .args([\"get\", &name])\n+ .assert()\n+ .failure()\n+ .stderr(predicates::str::contains(format!(\n+ \"variable not found: {name}\"\n+ )));\n+}\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs\nnew file mode 100644\nindex 000000000..bdb529d3d\n--- /dev/null\n+++ b/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs\n@@ -0,0 +1,91 @@\n+use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};\n+use serde_json::Value;\n+\n+#[test]\n+fn help() {\n+ let context = test_context!();\n+ let mut cmd = context.variable();\n+ cmd.args([\"get\", \"--help\"]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: true\n+ exit_code: 0\n+ ----- stdout -----\n+ Get a variable value\n+\n+ Usage: fabro variable get [OPTIONS] \n+\n+ Arguments:\n+ Name of the variable to get\n+\n+ Options:\n+ --json Output as JSON [env: FABRO_JSON=]\n+ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]\n+ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]\n+ --quiet Suppress non-essential output [env: FABRO_QUIET=]\n+ --verbose Enable verbose output [env: FABRO_VERBOSE=]\n+ -h, --help Print help\n+ ----- stderr -----\n+ \");\n+}\n+\n+#[test]\n+fn variable_get_plain_outputs_raw_value() {\n+ let context = test_context!();\n+ let name = format!(\"GET_RAW_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"staging\"])\n+ .assert()\n+ .success();\n+\n+ context\n+ .variable()\n+ .args([\"get\", &name])\n+ .assert()\n+ .success()\n+ .stdout(\"staging\\n\");\n+}\n+\n+#[test]\n+fn variable_get_json_returns_full_variable() {\n+ let context = test_context!();\n+ let name = format!(\"GET_JSON_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"json-value\", \"--description\", \"Readable\"])\n+ .assert()\n+ .success();\n+\n+ let output = context\n+ .variable()\n+ .args([\"--json\", \"get\", &name])\n+ .output()\n+ .expect(\"command should run\");\n+\n+ assert!(output.status.success());\n+ let value: Value = serde_json::from_slice(&output.stdout).expect(\"variable get should parse\");\n+ fabro_json_snapshot!(context, &value, @r#\"\n+ {\n+ \"name\": \"GET_JSON_[TEST_CASE]\",\n+ \"value\": \"json-value\",\n+ \"description\": \"Readable\",\n+ \"created_at\": \"[TIMESTAMP]\",\n+ \"updated_at\": \"[TIMESTAMP]\"\n+ }\n+ \"#);\n+}\n+\n+#[test]\n+fn variable_get_missing_fails() {\n+ let context = test_context!();\n+ let name = format!(\"GET_MISSING_{}\", context.test_case_id());\n+ let mut cmd = context.variable();\n+ cmd.args([\"get\", &name]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: false\n+ exit_code: 1\n+ ----- stdout -----\n+ ----- stderr -----\n+ × variable not found: GET_MISSING_[TEST_CASE]\n+ \");\n+}\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs\nnew file mode 100644\nindex 000000000..f86620dcc\n--- /dev/null\n+++ b/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs\n@@ -0,0 +1,87 @@\n+use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};\n+use serde_json::Value;\n+\n+#[test]\n+fn help() {\n+ let context = test_context!();\n+ let mut cmd = context.variable();\n+ cmd.args([\"list\", \"--help\"]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: true\n+ exit_code: 0\n+ ----- stdout -----\n+ List variables\n+\n+ Usage: fabro variable list [OPTIONS]\n+\n+ Options:\n+ --json Output as JSON [env: FABRO_JSON=]\n+ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]\n+ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]\n+ --quiet Suppress non-essential output [env: FABRO_QUIET=]\n+ --verbose Enable verbose output [env: FABRO_VERBOSE=]\n+ -h, --help Print help\n+ ----- stderr -----\n+ \");\n+}\n+\n+#[test]\n+fn variable_list_json_returns_full_variables() {\n+ let context = test_context!();\n+ let name = format!(\"LIST_JSON_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\n+ \"set\",\n+ &name,\n+ \"staging\",\n+ \"--description\",\n+ \"Deployment target\",\n+ ])\n+ .assert()\n+ .success();\n+\n+ let output = context\n+ .variable()\n+ .args([\"--json\", \"list\"])\n+ .output()\n+ .expect(\"command should run\");\n+\n+ assert!(output.status.success());\n+ let value: Value = serde_json::from_slice(&output.stdout).expect(\"variable list should parse\");\n+ let entry = value\n+ .as_array()\n+ .expect(\"variable list should be an array\")\n+ .iter()\n+ .find(|entry| entry[\"name\"] == name)\n+ .expect(\"variable list should include the saved variable\");\n+ fabro_json_snapshot!(context, entry, @r#\"\n+ {\n+ \"name\": \"LIST_JSON_[TEST_CASE]\",\n+ \"value\": \"staging\",\n+ \"description\": \"Deployment target\",\n+ \"created_at\": \"[TIMESTAMP]\",\n+ \"updated_at\": \"[TIMESTAMP]\"\n+ }\n+ \"#);\n+}\n+\n+#[test]\n+fn variable_list_alias_ls_includes_values() {\n+ let context = test_context!();\n+ let name = format!(\"LIST_ALIAS_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"visible-value\"])\n+ .assert()\n+ .success();\n+\n+ context\n+ .variable()\n+ .args([\"ls\"])\n+ .assert()\n+ .success()\n+ .stdout(predicates::str::contains(&name))\n+ .stdout(predicates::str::contains(\"visible-value\"))\n+ .stdout(predicates::str::contains(\"VALUE\"));\n+}\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs\nnew file mode 100644\nindex 000000000..a09ddb032\n--- /dev/null\n+++ b/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs\n@@ -0,0 +1,65 @@\n+use fabro_test::{fabro_snapshot, test_context};\n+use serde_json::Value;\n+\n+#[test]\n+fn help() {\n+ let context = test_context!();\n+ let mut cmd = context.variable();\n+ cmd.args([\"rm\", \"--help\"]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: true\n+ exit_code: 0\n+ ----- stdout -----\n+ Remove a variable\n+\n+ Usage: fabro variable rm [OPTIONS] \n+\n+ Arguments:\n+ Name of the variable to remove\n+\n+ Options:\n+ --json Output as JSON [env: FABRO_JSON=]\n+ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]\n+ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]\n+ --quiet Suppress non-essential output [env: FABRO_QUIET=]\n+ --verbose Enable verbose output [env: FABRO_VERBOSE=]\n+ -h, --help Print help\n+ ----- stderr -----\n+ \");\n+}\n+\n+#[test]\n+fn variable_rm_json_outputs_removed_name() {\n+ let context = test_context!();\n+ let name = format!(\"RM_JSON_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"remove-me\"])\n+ .assert()\n+ .success();\n+\n+ let output = context\n+ .variable()\n+ .args([\"--json\", \"rm\", &name])\n+ .output()\n+ .expect(\"command should run\");\n+\n+ assert!(output.status.success());\n+ let value: Value = serde_json::from_slice(&output.stdout).expect(\"variable rm should parse\");\n+ assert_eq!(value, serde_json::json!({ \"name\": name }));\n+}\n+\n+#[test]\n+fn variable_rm_missing_fails() {\n+ let context = test_context!();\n+ let name = format!(\"RM_MISSING_{}\", context.test_case_id());\n+ let mut cmd = context.variable();\n+ cmd.args([\"rm\", &name]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: false\n+ exit_code: 1\n+ ----- stdout -----\n+ ----- stderr -----\n+ × variable not found: RM_MISSING_[TEST_CASE]\n+ \");\n+}\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs\nnew file mode 100644\nindex 000000000..1402226d8\n--- /dev/null\n+++ b/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs\n@@ -0,0 +1,156 @@\n+use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};\n+use serde_json::Value;\n+\n+#[test]\n+fn help() {\n+ let context = test_context!();\n+ let mut cmd = context.variable();\n+ cmd.args([\"set\", \"--help\"]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: true\n+ exit_code: 0\n+ ----- stdout -----\n+ Set a variable value\n+\n+ Usage: fabro variable set [OPTIONS] [VALUE]\n+\n+ Arguments:\n+ Name of the variable\n+ [VALUE] Value to store\n+\n+ Options:\n+ --json Output as JSON [env: FABRO_JSON=]\n+ --value-stdin Read the variable value from stdin\n+ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]\n+ --description Optional human-readable description\n+ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]\n+ --quiet Suppress non-essential output [env: FABRO_QUIET=]\n+ --verbose Enable verbose output [env: FABRO_VERBOSE=]\n+ -h, --help Print help\n+ ----- stderr -----\n+ \");\n+}\n+\n+#[test]\n+fn variable_set_json_returns_full_variable() {\n+ let context = test_context!();\n+ let name = format!(\"SET_JSON_{}\", context.test_case_id());\n+ let output = context\n+ .variable()\n+ .args([\n+ \"--json\",\n+ \"set\",\n+ &name,\n+ \"json-value\",\n+ \"--description\",\n+ \"Deployment target\",\n+ ])\n+ .output()\n+ .expect(\"command should run\");\n+\n+ assert!(output.status.success());\n+ let value: Value = serde_json::from_slice(&output.stdout).expect(\"variable set should parse\");\n+ fabro_json_snapshot!(context, &value, @r#\"\n+ {\n+ \"name\": \"SET_JSON_[TEST_CASE]\",\n+ \"value\": \"json-value\",\n+ \"description\": \"Deployment target\",\n+ \"created_at\": \"[TIMESTAMP]\",\n+ \"updated_at\": \"[TIMESTAMP]\"\n+ }\n+ \"#);\n+}\n+\n+#[test]\n+fn variable_set_update_preserves_description_when_omitted() {\n+ let context = test_context!();\n+ let name = format!(\"SET_PRESERVE_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\n+ \"set\",\n+ &name,\n+ \"staging\",\n+ \"--description\",\n+ \"Deployment target\",\n+ ])\n+ .assert()\n+ .success();\n+\n+ let output = context\n+ .variable()\n+ .args([\"--json\", \"set\", &name, \"production\"])\n+ .output()\n+ .expect(\"command should run\");\n+\n+ assert!(output.status.success());\n+ let value: Value = serde_json::from_slice(&output.stdout).expect(\"variable set should parse\");\n+ assert_eq!(value[\"value\"], \"production\");\n+ assert_eq!(value[\"description\"], \"Deployment target\");\n+}\n+\n+#[test]\n+fn variable_set_accepts_explicit_empty_value() {\n+ let context = test_context!();\n+ let name = format!(\"SET_EMPTY_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"\"])\n+ .assert()\n+ .success();\n+\n+ context\n+ .variable()\n+ .args([\"get\", &name])\n+ .assert()\n+ .success()\n+ .stdout(\"\\n\");\n+}\n+\n+#[test]\n+fn variable_set_accepts_empty_stdin_value() {\n+ let context = test_context!();\n+ let name = format!(\"SET_STDIN_EMPTY_{}\", context.test_case_id());\n+ context\n+ .variable()\n+ .args([\"set\", &name, \"--value-stdin\"])\n+ .write_stdin(\"\\n\")\n+ .assert()\n+ .success();\n+\n+ context\n+ .variable()\n+ .args([\"get\", &name])\n+ .assert()\n+ .success()\n+ .stdout(\"\\n\");\n+}\n+\n+#[test]\n+fn variable_set_invalid_name_fails() {\n+ let context = test_context!();\n+ let mut cmd = context.variable();\n+ cmd.args([\"set\", \"1BAD\", \"value\"]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: false\n+ exit_code: 1\n+ ----- stdout -----\n+ ----- stderr -----\n+ × invalid variable name\n+ \");\n+}\n+\n+#[test]\n+fn variable_set_requires_value_or_stdin() {\n+ let context = test_context!();\n+ let name = format!(\"SET_MISSING_VALUE_{}\", context.test_case_id());\n+ let mut cmd = context.variable();\n+ cmd.args([\"set\", &name]);\n+ fabro_snapshot!(context.filters(), cmd, @\"\n+ success: false\n+ exit_code: 1\n+ ----- stdout -----\n+ ----- stderr -----\n+ × variable value required: pass or use --value-stdin\n+ \");\n+}\ndiff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs\nindex b9a512c1e..89caa3a48 100644\n--- a/lib/crates/fabro-client/src/client.rs\n+++ b/lib/crates/fabro-client/src/client.rs\n@@ -715,6 +715,60 @@ impl Client {\n Ok(())\n }\n \n+ pub async fn list_variables(&self) -> Result> {\n+ let response = self\n+ .send_api(|client| async move { client.list_variables().send().await })\n+ .await?;\n+ Ok(response.into_inner().data)\n+ }\n+\n+ pub async fn get_variable(&self, name: &str) -> Result {\n+ let response = self\n+ .send_api(\n+ |client| async move { client.get_variable().name(name.to_string()).send().await },\n+ )\n+ .await?;\n+ Ok(response.into_inner())\n+ }\n+\n+ pub async fn create_variable(\n+ &self,\n+ body: types::CreateVariableRequest,\n+ ) -> Result {\n+ let response = self\n+ .send_api(\n+ |client| async move { client.create_variable().body(body.clone()).send().await },\n+ )\n+ .await?;\n+ Ok(response.into_inner())\n+ }\n+\n+ pub async fn update_variable(\n+ &self,\n+ name: &str,\n+ body: types::UpdateVariableRequest,\n+ ) -> Result {\n+ let response = self\n+ .send_api(|client| async move {\n+ client\n+ .update_variable()\n+ .name(name.to_string())\n+ .body(body.clone())\n+ .send()\n+ .await\n+ })\n+ .await?;\n+ Ok(response.into_inner())\n+ }\n+\n+ pub async fn delete_variable(&self, name: &str) -> Result<()> {\n+ self.send_api(|client| async move {\n+ client.delete_variable().name(name.to_string()).send().await\n+ })\n+ .await?;\n+ Ok(())\n+ }\n+\n pub async fn list_models(\n &self,\n provider: Option<&str>,\ndiff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs\nindex 48c6ef4fc..cd8b041f0 100644\n--- a/lib/crates/fabro-test/src/lib.rs\n+++ b/lib/crates/fabro-test/src/lib.rs\n@@ -1454,6 +1454,13 @@ impl TestContext {\n cmd\n }\n \n+ /// Build a `variable` subcommand.\n+ pub fn variable(&self) -> Command {\n+ let mut cmd = self.command();\n+ cmd.arg(\"variable\");\n+ cmd\n+ }\n+\n /// Build a `doctor` subcommand.\n pub fn doctor(&self) -> Command {\n let mut cmd = self.command();\n", + "summary": { + "files_changed": 20, + "additions": 925, + "deletions": 0 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-27T17:12:53.470772Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { + "last_stage": "simplify_opus", + "internal.retry_count.simplify_opus": 0, + "internal.work_dir": "/home/daytona/workspace/fabro", + "last_response": "All tests pass. Let me summarize.\n\n## Summary\n\nReviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency).\n\n### Findings reviewed\n- **Code reuse:** flagged exact-", + "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", + "internal.retry_count.preflight_compile": 0, + "internal.fidelity": "compact", + "outcome": "succeeded", + "thread.toolchain.current_node": "preflight_compile", + "graph.rankdir": "LR", + "thread.preflight_compile.current_node": "preflight_lint", + "internal.retry_count.toolchain": 0, + "internal.thread_id": "implement", + "thread.implement.current_node": "simplify_opus", + "response.simplify_opus": "All tests pass. Let me summarize.\n\n## Summary\n\nReviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency).\n\n### Findings reviewed\n- **Code reuse:** flagged exact-duplicate `format_age` between `secret/list.rs` and `variable/list.rs`; also noted broader patterns (table boilerplate, color_choice) pre-existing in 8+ places.\n- **Code quality:** flagged unused `Client::update_variable`, the duplicated `format_age`, divergent JSON envelope keys (`{name}` vs `{key}`), and intentional empty-value-acceptance for stdin.\n- **Efficiency:** no meaningful issues — each subcommand makes a single API call.\n\n### Fixed\n1. Extracted `format_age` to `lib/crates/fabro-cli/src/shared/utilities.rs` and updated both `secret/list.rs` and `variable/list.rs` to import via `crate::shared::format_age`. Removes 20 lines of byte-identical duplication.\n\n### Deliberately skipped (intentional per plan)\n- **Empty value handling for `variable set`:** the plan (R3) explicitly requires empty values be allowed for variables, unlike secrets.\n- **`set` as upsert via `POST`:** the plan explicitly chose this to match `fabro secret set`'s mental model.\n- **`update_variable` Rust client wrapper:** the plan listed all five wrappers (list/get/create/update/delete) for API surface parity. Used by the web client; kept the Rust wrapper for completeness.\n- **JSON envelope `{\"name\": ...}` for `variable rm`:** specified by the plan and matches the API field (the secret `{\"key\": ...}` is the outlier).\n- **Broader cli_table/color_choice duplication:** pre-existing across 8+ commands; out of scope.\n- **Project-wide `use func` import style nit:** pre-existing in secret too; not introduced by this change.\n\n### Verification\n- `cargo check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 fmt --check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 clippy -p fabro-cli --all-targets -- -D warnings`: clean\n- `cargo nextest run -p fabro-cli -E 'test(/variable/)'`: 20/20 passed\n- `cargo nextest run -p fabro-cli -E 'test(/secret/)'`: 30/30 passed (no regression from `format_age` extraction)", + "internal.node_visit_count": 1, + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "thread.preflight_lint.current_node": "implement", + "current_node": "simplify_opus", + "failure_signature": "", + "internal.run_id": "01KSN4661TG7HFT3ATDKNGMGC0", + "internal.retry_count.implement": 0, + "graph.goal": "---\ntitle: Add CLI Variable Management\ntype: feat\nstatus: active\ndate: 2026-05-27\n---\n\n# Add CLI Variable Management\n\n## Overview\n\nExpose the recently added variables API through the CLI with a singular `fabro variable`\nnamespace. Variables are non-sensitive run-configuration values, so the CLI should expose\nvalues in `list` and `get`, while continuing to direct credentials and tokens to\n`fabro secret`.\n\n## Requirements Trace\n\n- R1. Provide variables management in the CLI, similar to secrets management.\n- R2. Support the full readable-variable CRUD surface: list, get, set/upsert, and remove.\n- R3. Preserve existing server/API behavior: variable names are env-style, values may be\n empty, and `set` preserves an existing description when `--description` is omitted.\n- R4. Keep generated CLI docs and help snapshots in sync with the new public command.\n\n## Context & Research\n\n- `lib/crates/fabro-cli/src/commands/secret/` is the command pattern to follow for\n namespace dispatch, JSON output, tabular list output, stdin value input, and status\n messages.\n- `lib/crates/fabro-server/src/server/handler/variables.rs` already provides\n `GET /variables`, `POST /variables`, `GET /variables/{name}`,\n `PUT /variables/{name}`, and `DELETE /variables/{name}`.\n- `lib/crates/fabro-types/src/variable.rs` defines the canonical API/request types and\n validates env-style names.\n- `lib/crates/fabro-api/tests/variable_round_trip.rs` already proves OpenAPI generated\n types reuse the canonical variable types.\n- `docs/public/workflows/variables.mdx` currently explains workflow template variables\n but does not yet document how server-managed `{{ vars.NAME }}` values are configured.\n\n## Key Technical Decisions\n\n- Use `fabro variable`, not `fabro variables`, to match existing singular CLI namespaces\n such as `fabro secret`, `fabro model`, and `fabro repo`.\n- Add `get` because variables are intentionally readable; secrets remain write-only.\n- Make `set` an upsert using the API's create/upsert endpoint, matching the mental model\n of `fabro secret set`.\n- Reuse `--value-stdin` from secrets but allow empty stdin values for variables after\n trimming trailing newlines.\n- Plain `list` should include a `VALUE` column. Do not add truncation or redaction in\n this first pass; exact retrieval is available through JSON output and `get`.\n\n## Implementation Units\n\n- [ ] **Unit 1: Add fabro-client variable wrappers**\n\n**Goal:** Give CLI code stable methods over the generated OpenAPI client.\n\n**Requirements:** R2, R3\n\n**Dependencies:** Existing variables API and generated `fabro-api` client.\n\n**Files:**\n- Modify: `lib/crates/fabro-client/src/client.rs`\n\n**Approach:**\n- Add wrappers for `list_variables`, `get_variable`, `create_variable`,\n `update_variable`, and `delete_variable`.\n- Return `Vec` from `list_variables` by unwrapping the API response's\n `data`, matching `list_secrets`.\n- Use the generated path-parameter operations for `get`, `update`, and `delete`.\n\n**Patterns to follow:**\n- `list_secrets`, `create_secret`, and `delete_secret_by_name` in the same file.\n\n**Test scenarios:**\n- Happy path: CLI integration tests in later units exercise each wrapper through the\n shared server client path.\n- Error path: missing and invalid variable operations propagate the server's API errors.\n\n**Verification:**\n- The CLI can compile against these wrapper methods without importing generated client\n builders directly.\n\n- [ ] **Unit 2: Add CLI args, dispatch, and command module**\n\n**Goal:** Register the new top-level namespace and route subcommands to implementation\nmodules.\n\n**Requirements:** R1, R2, R4\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-cli/src/args.rs`\n- Modify: `lib/crates/fabro-cli/src/main.rs`\n- Modify: `lib/crates/fabro-cli/src/commands/mod.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/mod.rs`\n\n**Approach:**\n- Add `Commands::Variable(VariableNamespace)` with description\n `Manage server-owned variables`.\n- Add `VariableNamespace` with `ServerTargetArgs`, matching `SecretNamespace`.\n- Add `VariableCommand::{List, Get, Rm, Set}`; give `list` the `ls` alias.\n- Add command-name mapping for analytics/logging: `variable list`, `variable get`,\n `variable rm`, and `variable set`.\n- Dispatch through `commands::variable::dispatch`, deriving the target context with\n `base_ctx.with_target(&ns.target)`.\n\n**Patterns to follow:**\n- `SecretNamespace`, `SecretCommand`, and `commands::secret::dispatch`.\n\n**Test scenarios:**\n- Happy path: `fabro --help` lists `variable`.\n- Happy path: `fabro variable --help` shows `list`, `get`, `rm`, and `set`.\n- Happy path: command-name mapping covers all subcommands.\n\n**Verification:**\n- The new namespace is reachable through clap and main dispatch without affecting\n existing commands.\n\n- [ ] **Unit 3: Implement variable list/get/set/rm behavior**\n\n**Goal:** Provide the full user-facing variables management workflow.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Units 1 and 2\n\n**Files:**\n- Create: `lib/crates/fabro-cli/src/commands/variable/list.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/get.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/set.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/rm.rs`\n\n**Approach:**\n- `list`: fetch all variables, print JSON array when JSON output is active, otherwise\n print a table with `NAME`, `VALUE`, and `UPDATED`.\n- `get`: fetch one variable, print the full variable object for JSON output, otherwise\n print only the raw value to stdout.\n- `set`: accept ` [VALUE]`, `--value-stdin`, and `--description`; call the upsert\n API wrapper and print the stored variable for JSON output or `Set NAME` otherwise.\n- `rm`: call the delete API wrapper and print `{ \"name\": NAME }` for JSON output or\n `Removed NAME` otherwise.\n- For `set`, allow empty explicit values and empty stdin values. Only error when no value\n is provided and stdin is not being used.\n\n**Patterns to follow:**\n- `commands/secret/list.rs` for table style and age formatting.\n- `commands/secret/set.rs` for argument precedence and stdin handling, adjusted so empty\n values are valid.\n- `commands/secret/rm.rs` for delete output shape.\n\n**Test scenarios:**\n- Happy path: `set DEPLOY_ENV staging --description \"Deployment target\"` then `list`\n shows `DEPLOY_ENV`, `staging`, and an updated age.\n- Happy path: `get DEPLOY_ENV` prints exactly `staging\\n` in plain output.\n- Happy path: `set DEPLOY_ENV production` updates the value and preserves the existing\n description through API behavior.\n- Happy path: `set EMPTY \"\"` stores an empty value.\n- Happy path: `printf '\\n' | fabro variable set EMPTY --value-stdin` stores an empty\n value instead of failing.\n- Error path: `get MISSING` and `rm MISSING` fail with `variable not found: MISSING`.\n- Error path: `set 1BAD value` fails with the server invalid-name error.\n\n**Verification:**\n- The command works against the default test server and does not write directly to\n local `variables.json`.\n\n- [ ] **Unit 4: Add test harness support and CLI integration tests**\n\n**Goal:** Lock the public CLI surface and expected behavior with integration coverage.\n\n**Requirements:** R1, R2, R3, R4\n\n**Dependencies:** Units 1-3\n\n**Files:**\n- Modify: `lib/crates/fabro-test/src/lib.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/mod.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/fabro.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_list.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_get.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_set.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs`\n\n**Approach:**\n- Add `TestContext::variable()` helper mirroring `TestContext::secret()`.\n- Add help snapshots for the namespace and each subcommand.\n- Add lifecycle tests for set/list/get/update/rm, `ls` alias, empty value support, JSON\n output, missing variable errors, and invalid-name errors.\n- Update root help and curated landing snapshots only if the final clap/landing output\n changes.\n\n**Patterns to follow:**\n- `secret.rs`, `secret_list.rs`, `secret_set.rs`, and `secret_rm.rs`.\n\n**Test scenarios:**\n- Happy path: JSON `list` returns an array of full variable objects including `value`.\n- Happy path: JSON `get` and `set` return full variable objects.\n- Happy path: global JSON config makes `variable list` emit JSON, matching the\n `secret list` config test.\n- Error path: missing variables and invalid names produce nonzero exits and readable\n errors.\n\n**Verification:**\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n\n- [ ] **Unit 5: Update generated and conceptual docs**\n\n**Goal:** Keep public documentation aligned with the new command and clarify how variables\nrelate to secrets.\n\n**Requirements:** R1, R4\n\n**Dependencies:** Units 2-4\n\n**Files:**\n- Modify: `docs/public/reference/cli.mdx`\n- Modify: `docs/public/workflows/variables.mdx`\n\n**Approach:**\n- Regenerate the CLI reference with `cargo dev docs refresh`.\n- Add a short section to `docs/public/workflows/variables.mdx` explaining that\n server-managed run config variables can be set with `fabro variable set NAME VALUE`\n and referenced as `{{ vars.NAME }}` in run config interpolation.\n- State that variables are non-sensitive and readable; tokens, keys, and credentials\n should use `fabro secret set`.\n\n**Patterns to follow:**\n- Existing generated docs workflow in `lib/crates/fabro-dev/src/commands/docs.rs`.\n- Existing CLI references to `fabro secret set` in administration docs.\n\n**Test scenarios:**\n- Happy path: generated CLI docs include `fabro variable` and its subcommands.\n- Documentation check: `cargo dev docs check` succeeds after regeneration.\n\n**Verification:**\n- The docs describe the CLI surface without implying variables are secret storage.\n\n## System-Wide Impact\n\n- **API surface parity:** No server or OpenAPI changes are planned; the CLI consumes the\n existing variables API.\n- **Error propagation:** Invalid names, missing variables, and write failures should flow\n through the existing `fabro-client` API error classification.\n- **State lifecycle risks:** CLI commands must use the server API rather than editing\n `variables.json` locally, so behavior remains correct for remote and socket-backed\n servers.\n- **Security boundary:** Values are intentionally visible for variables. Documentation\n must clearly distinguish variables from secrets to avoid accidental credential storage.\n- **Unchanged invariants:** `fabro secret` remains write-only and unchanged.\n\n## Risks & Dependencies\n\n| Risk | Mitigation |\n| --- | --- |\n| Users put credentials in variables because the command looks like secrets | Document variables as non-sensitive and keep secret guidance explicit. |\n| Empty values accidentally fail because secret handling rejects empties | Test explicit empty strings and newline-only stdin for `variable set`. |\n| CLI docs drift after adding clap args | Regenerate with `cargo dev docs refresh` and verify with `cargo dev docs check`. |\n| Plain `list` becomes awkward for long values | Accept for v1; `get` and JSON output provide exact machine-readable retrieval. |\n\n## Verification Plan\n\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n- `cargo dev docs check`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n\n## Assumptions\n\n- The chosen CLI surface is full CRUD with readable values.\n- The namespace is singular: `fabro variable`.\n- No TypeScript client regeneration is required for this CLI-only change.\n- No server API, OpenAPI schema, or storage migration changes are required.", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.retry_count.start": 0, + "failure_class": "", + "internal.retry_count.preflight_lint": 0, "thread.start.current_node": "toolchain" }, "node_outcomes": { @@ -910,10 +1057,54 @@ "tool_time_ms": 128996, "active_time_ms": 128996 } + }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "response.simplify_opus": "All tests pass. Let me summarize.\n\n## Summary\n\nReviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency).\n\n### Findings reviewed\n- **Code reuse:** flagged exact-duplicate `format_age` between `secret/list.rs` and `variable/list.rs`; also noted broader patterns (table boilerplate, color_choice) pre-existing in 8+ places.\n- **Code quality:** flagged unused `Client::update_variable`, the duplicated `format_age`, divergent JSON envelope keys (`{name}` vs `{key}`), and intentional empty-value-acceptance for stdin.\n- **Efficiency:** no meaningful issues — each subcommand makes a single API call.\n\n### Fixed\n1. Extracted `format_age` to `lib/crates/fabro-cli/src/shared/utilities.rs` and updated both `secret/list.rs` and `variable/list.rs` to import via `crate::shared::format_age`. Removes 20 lines of byte-identical duplication.\n\n### Deliberately skipped (intentional per plan)\n- **Empty value handling for `variable set`:** the plan (R3) explicitly requires empty values be allowed for variables, unlike secrets.\n- **`set` as upsert via `POST`:** the plan explicitly chose this to match `fabro secret set`'s mental model.\n- **`update_variable` Rust client wrapper:** the plan listed all five wrappers (list/get/create/update/delete) for API surface parity. Used by the web client; kept the Rust wrapper for completeness.\n- **JSON envelope `{\"name\": ...}` for `variable rm`:** specified by the plan and matches the API field (the secret `{\"key\": ...}` is the outlier).\n- **Broader cli_table/color_choice duplication:** pre-existing across 8+ commands; out of scope.\n- **Project-wide `use func` import style nit:** pre-existing in secret too; not introduced by this change.\n\n### Verification\n- `cargo check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 fmt --check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 clippy -p fabro-cli --all-targets -- -D warnings`: clean\n- `cargo nextest run -p fabro-cli -E 'test(/variable/)'`: 20/20 passed\n- `cargo nextest run -p fabro-cli -E 'test(/secret/)'`: 30/30 passed (no regression from `format_age` extraction)", + "last_response": "All tests pass. Let me summarize.\n\n## Summary\n\nReviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency).\n\n### Findings reviewed\n- **Code reuse:** flagged exact-", + "last_stage": "simplify_opus" + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 44166, + "output_tokens": 11092, + "reasoning_tokens": 0, + "cache_read_tokens": 1559694, + "cache_write_tokens": 212094 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 212094, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 2603564 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/secret/list.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/variable/list.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/shared/utilities.rs" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 199764, + "tool_time_ms": 328077, + "active_time_ms": 527841 + } } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { + "simplify_opus": 1, "preflight_compile": 1, "start": 1, "preflight_lint": 1, @@ -944,6 +1135,265 @@ "superseded_by": null, "pending_interviews": {}, "stages": { + "simplify_opus@1": { + "first_event_seq": 407, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-27T17:04:04.617709Z", + "handler": "agent", + "usage": { + "input_tokens": 44166, + "output_tokens": 11092, + "total_tokens": 1827046, + "reasoning_tokens": 0, + "cache_read_tokens": 1559694, + "cache_write_tokens": 212094, + "total_usd_micros": 2603564 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "subagents": [ + { + "agent_id": "8b96bcec", + "depth": 1, + "task": "You are doing a CODE REUSE review of a change that adds `fabro variable` CLI commands. The full diff vs origin is saved at `/tmp/variable_diff.patch` — read it first.\n\nWorking dir: /home/daytona/workspace/fabro\n\nLook at the changes and find places where:\n1. New code duplicates existing utilities/helpers that could be reused.\n2. Inline logic could be replaced with an existing utility.\n\nIn particular:\n- `lib/crates/fabro-cli/src/commands/variable/list.rs` is nearly a copy-paste of `lib/crates/fabro-cli/src/commands/secret/list.rs` (especially `format_age` and the color_choice/table boilerplate). Check whether `format_age` should be extracted to a shared module (e.g. `fabro-cli/src/shared/` or `fabro-util`).\n- `lib/crates/fabro-cli/src/commands/variable/set.rs` largely mirrors `commands/secret/set.rs` for stdin handling. Check for a shared helper.\n- Check `fabro-cli/src/shared/` and `fabro-util` for existing utilities that should be used (table builder, age formatter, color choice helper, stdin readers, etc).\n- Look for any other inline logic that mirrors something already in the codebase.\n\nReport findings as a concise list with file:line references and the existing utility that should be reused. Note severity (high / medium / low). Do not fix anything — just report findings. Skip nitpicks that are not worth changing.\n\nNote: This is a greenfield app, so prioritize maximizing simplicity. The plan deliberately mirrored secret/* structure, but obvious duplication of literally-identical helpers like format_age is worth flagging.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 31 + } + }, + { + "agent_id": "63b53752", + "depth": 1, + "task": "You are doing a CODE QUALITY review of a change that adds `fabro variable` CLI commands. The full diff vs origin is saved at `/tmp/variable_diff.patch` — read it first.\n\nWorking dir: /home/daytona/workspace/fabro\n\nReview for:\n1. Redundant state.\n2. Parameter sprawl.\n3. Copy-paste with slight variation.\n4. Leaky abstractions.\n5. Stringly-typed code (raw strings where enums/constants exist).\n6. Inconsistencies with the project's conventions (read `CLAUDE.md` for `rustfmt`/import conventions, error handling, etc).\n\nNote these key files in the diff:\n- `lib/crates/fabro-cli/src/commands/variable/{list,get,set,rm,mod}.rs`\n- `lib/crates/fabro-cli/src/args.rs` (added VariableCommand, VariableNamespace, etc)\n- `lib/crates/fabro-client/src/client.rs` (new wrappers list_variables/get_variable/...)\n- Tests in `lib/crates/fabro-cli/tests/it/cmd/variable*.rs`\n\nCompare against the existing secret modules in `lib/crates/fabro-cli/src/commands/secret/` to find places where the variable code is slightly inconsistent or where things diverge in a way that's suspicious (e.g. the secret rm uses `args.key` but variable rm uses `args.name`; whether that's correct for the variable concept).\n\nReport findings as a concise list with file:line references and recommended fixes. Note severity (high/medium/low). Do not fix anything — just report findings. Skip nitpicks that are not worth changing.\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 86 + } + }, + { + "agent_id": "b3e205b6", + "depth": 1, + "task": "You are doing an EFFICIENCY review of a change that adds `fabro variable` CLI commands. The full diff vs origin is saved at `/tmp/variable_diff.patch` — read it first.\n\nWorking dir: /home/daytona/workspace/fabro\n\nReview for:\n1. Unnecessary work (redundant computations, repeated reads, duplicate API calls, N+1).\n2. Missed concurrency (sequential when could be parallel).\n3. Hot-path bloat.\n4. Unnecessary existence checks / TOCTOU anti-patterns.\n5. Memory issues / unbounded data.\n6. Overly broad operations.\n\nKey files:\n- `lib/crates/fabro-cli/src/commands/variable/{list,get,set,rm,mod}.rs`\n- `lib/crates/fabro-client/src/client.rs` (new API wrappers — check for unnecessary clones/allocations)\n- Tests in `lib/crates/fabro-cli/tests/it/cmd/variable*.rs`\n\nReport findings as a concise list with file:line references and recommended fixes. Note severity (high/medium/low). Do not fix anything — just report findings. Skip nitpicks that are not worth changing. Most likely this small CLI change won't have major efficiency issues — say so plainly if nothing meaningful is found.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 6 + } + } + ], + "permission_level": "full", + "agent_tools": [ + { + "name": "AskUserQuestion", + "description": "Ask the human one or more questions and wait for their answers before continuing this stage.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskCreate", + "description": "Create pending tasks in the current session. Use concise subjects, descriptions, optional activeForm text, and metadata. Check TaskList first to avoid duplicate tasks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskGet", + "description": "Get one task by taskId, including subject, status, description, owner, blockedBy, and blocks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskList", + "description": "List tasks for the current session, including status, owner, and blocking dependencies. Use TaskGet with a taskId for full description and dependency details.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskUpdate", + "description": "Update an existing task's status, text, owner, metadata, or dependencies. Valid statuses are pending, in_progress, completed, and deleted. After completing a task, call TaskList to find newly unblocked work.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": true + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": true + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + } + ], + "context_window": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "context_window_tokens": 1000000, + "input_tokens": 56595, + "usage_percent": 5.6595, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-05-27T17:12:53.413991Z", + "event_seq": 735, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 2550, + "usage_percent": 0.255 + }, + { + "category": "tools", + "tokens": 2914, + "usage_percent": 0.2914 + }, + { + "category": "memory", + "tokens": 6227, + "usage_percent": 0.6227 + }, + { + "category": "conversation", + "tokens": 44896, + "usage_percent": 4.4896 + }, + { + "category": "other", + "tokens": 8, + "usage_percent": 0.0008 + } + ], + "warnings": [] + }, + "state": "running" + }, "toolchain@1": { "first_event_seq": 22, "prompt": null, @@ -1126,7 +1576,12 @@ "first_event_seq": 52, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-27T17:03:59.731488Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -1140,6 +1595,12 @@ "output": null, "started_at": "2026-05-27T16:32:24.741009Z", "handler": "agent", + "timing": { + "wall_time_ms": 1894970, + "inference_time_ms": 1484989, + "tool_time_ms": 408080, + "active_time_ms": 1893069 + }, "usage": { "input_tokens": 2883761, "output_tokens": 16358, @@ -1369,7 +1830,7 @@ ], "warnings": [] }, - "state": "running" + "state": "succeeded" } } } \ No newline at end of file diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..25a1fbc0c --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,1123 @@ +diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx +index 67e390987..f264f7bab 100644 +--- a/docs/public/reference/cli.mdx ++++ b/docs/public/reference/cli.mdx +@@ -104,6 +104,7 @@ fabro [OPTIONS] [COMMAND] + | `fabro uninstall` | Uninstall Fabro from this machine | + | `fabro upgrade` | Upgrade fabro to the latest version | + | `fabro validate` | Validate a workflow | ++| `fabro variable` | Manage server-owned variables | + | `fabro version` | Show client and server version information | + | `fabro wait` | Block until a workflow run completes | + | `fabro workflow` | Workflow operations | +@@ -1571,6 +1572,87 @@ fabro validate [OPTIONS] + | --- | --- | + | `WORKFLOW` | Path to the .fabro workflow file | + ++### `fabro variable` ++ ++Manage server-owned variables ++ ++```bash ++fabro variable [OPTIONS] ++``` ++ ++#### Options ++ ++| Option | Description | ++| --- | --- | ++| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ++ ++#### Subcommands ++ ++| Command | Description | ++| --- | --- | ++| `fabro variable get` | Get a variable value | ++| `fabro variable list` | List variables | ++| `fabro variable rm` | Remove a variable | ++| `fabro variable set` | Set a variable value | ++ ++#### `fabro variable get` ++ ++Get a variable value ++ ++```bash ++fabro variable get [OPTIONS] ++``` ++ ++#### Arguments ++ ++| Name | Description | ++| --- | --- | ++| `NAME` | Name of the variable to get | ++ ++#### `fabro variable list` ++ ++List variables ++ ++```bash ++fabro variable list [OPTIONS] ++``` ++ ++#### `fabro variable rm` ++ ++Remove a variable ++ ++```bash ++fabro variable rm [OPTIONS] ++``` ++ ++#### Arguments ++ ++| Name | Description | ++| --- | --- | ++| `NAME` | Name of the variable to remove | ++ ++#### `fabro variable set` ++ ++Set a variable value ++ ++```bash ++fabro variable set [OPTIONS] [VALUE] ++``` ++ ++#### Arguments ++ ++| Name | Description | ++| --- | --- | ++| `NAME` | Name of the variable | ++| `VALUE` | Value to store | ++ ++#### Options ++ ++| Option | Description | ++| --- | --- | ++| `--description ` | Optional human-readable description | ++| `--value-stdin` | Read the variable value from stdin | ++ + ### `fabro version` + + Show client and server version information +diff --git a/docs/public/workflows/variables.mdx b/docs/public/workflows/variables.mdx +index 9869a16af..c3a7be4e9 100644 +--- a/docs/public/workflows/variables.mdx ++++ b/docs/public/workflows/variables.mdx +@@ -59,6 +59,25 @@ fabro run .fabro/workflows/check/workflow.toml -I repo_name=fabro-2 --input lang + + CLI input values use TOML scalar parsing when possible. Quoted strings, booleans, integers, and floats keep their typed values; unquoted bare text falls back to a string. Empty values such as `foo=` are accepted as empty strings. Arrays, inline tables, and datetimes are rejected. + ++## Server-managed run config variables ++ ++Use server-managed variables for non-sensitive values that should be shared across runs, such as deployment environments, default branches, regions, or image tags: ++ ++```bash ++fabro variable set DEPLOY_ENV staging --description "Deployment target" ++``` ++ ++Run configuration strings can reference these values with `{{ vars.NAME }}`: ++ ++```toml title="workflow.toml" ++_version = 1 ++ ++[run] ++goal = "Deploy {{ vars.DEPLOY_ENV }}" ++``` ++ ++Variables are intentionally readable: `fabro variable list` and `fabro variable get DEPLOY_ENV` show stored values. Do not store tokens, API keys, or credentials as variables; use `fabro secret set` for sensitive values. ++ + ## `goal` + + Agent and prompt nodes also receive the workflow goal at runtime: +diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs +index 72aa82b52..f560b62e6 100644 +--- a/lib/crates/fabro-cli/src/args.rs ++++ b/lib/crates/fabro-cli/src/args.rs +@@ -676,6 +676,35 @@ pub(crate) struct SecretSetArgs { + pub(crate) description: Option, + } + ++#[derive(Args)] ++pub(crate) struct VariableListArgs; ++ ++#[derive(Args)] ++pub(crate) struct VariableGetArgs { ++ /// Name of the variable to get ++ pub(crate) name: String, ++} ++ ++#[derive(Args)] ++pub(crate) struct VariableRmArgs { ++ /// Name of the variable to remove ++ pub(crate) name: String, ++} ++ ++#[derive(Args)] ++pub(crate) struct VariableSetArgs { ++ /// Name of the variable ++ pub(crate) name: String, ++ /// Value to store ++ pub(crate) value: Option, ++ /// Read the variable value from stdin ++ #[arg(long, conflicts_with = "value")] ++ pub(crate) value_stdin: bool, ++ /// Optional human-readable description ++ #[arg(long)] ++ pub(crate) description: Option, ++} ++ + #[derive(Debug, Args)] + pub(crate) struct ResumeArgs { + #[command(flatten)] +@@ -1261,6 +1290,8 @@ pub(crate) enum Commands { + Parent(ParentNamespace), + /// Manage server-owned secrets + Secret(SecretNamespace), ++ /// Manage server-owned variables ++ Variable(VariableNamespace), + /// Inspect effective settings + Settings(SettingsArgs), + /// Workflow operations +@@ -1376,6 +1407,12 @@ impl Commands { + SecretCommand::Rm(_) => "secret rm", + SecretCommand::Set(_) => "secret set", + }, ++ Self::Variable(ns) => match &ns.command { ++ VariableCommand::List(_) => "variable list", ++ VariableCommand::Get(_) => "variable get", ++ VariableCommand::Rm(_) => "variable rm", ++ VariableCommand::Set(_) => "variable set", ++ }, + Self::Settings(_) => "settings", + Self::Workflow(ns) => match &ns.command { + WorkflowCommand::List(_) => "workflow list", +@@ -1479,6 +1516,28 @@ pub(crate) enum SecretCommand { + Set(SecretSetArgs), + } + ++#[derive(Args)] ++pub(crate) struct VariableNamespace { ++ #[command(flatten)] ++ pub(crate) target: ServerTargetArgs, ++ ++ #[command(subcommand)] ++ pub(crate) command: VariableCommand, ++} ++ ++#[derive(Subcommand)] ++pub(crate) enum VariableCommand { ++ /// List variables ++ #[command(alias = "ls")] ++ List(VariableListArgs), ++ /// Get a variable value ++ Get(VariableGetArgs), ++ /// Remove a variable ++ Rm(VariableRmArgs), ++ /// Set a variable value ++ Set(VariableSetArgs), ++} ++ + #[derive(Args)] + pub(crate) struct ServerNamespace { + #[command(subcommand)] +diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs +index 71d66bd08..18e22d5f7 100644 +--- a/lib/crates/fabro-cli/src/commands/mod.rs ++++ b/lib/crates/fabro-cli/src/commands/mod.rs +@@ -25,6 +25,7 @@ pub(crate) mod system; + pub(crate) mod uninstall; + pub(crate) mod upgrade; + pub(crate) mod validate; ++pub(crate) mod variable; + pub(crate) mod version; + pub(crate) mod workflow; + +diff --git a/lib/crates/fabro-cli/src/commands/variable/get.rs b/lib/crates/fabro-cli/src/commands/variable/get.rs +new file mode 100644 +index 000000000..bdd588c9c +--- /dev/null ++++ b/lib/crates/fabro-cli/src/commands/variable/get.rs +@@ -0,0 +1,16 @@ ++use anyhow::Result; ++ ++use crate::args::VariableGetArgs; ++use crate::command_context::CommandContext; ++use crate::shared::print_json_pretty; ++ ++pub(super) async fn get_command(args: &VariableGetArgs, ctx: &CommandContext) -> Result<()> { ++ let client = ctx.server().await?; ++ let variable = client.get_variable(&args.name).await?; ++ if ctx.json_output() { ++ print_json_pretty(&variable)?; ++ } else { ++ fabro_util::printout!(ctx.printer(), "{}", variable.value); ++ } ++ Ok(()) ++} +diff --git a/lib/crates/fabro-cli/src/commands/variable/list.rs b/lib/crates/fabro-cli/src/commands/variable/list.rs +new file mode 100644 +index 000000000..f57dd2f42 +--- /dev/null ++++ b/lib/crates/fabro-cli/src/commands/variable/list.rs +@@ -0,0 +1,71 @@ ++use anyhow::Result; ++use chrono::{DateTime, Utc}; ++use cli_table::format::{Border, Separator}; ++use cli_table::{Cell, CellStruct, Style, Table}; ++use fabro_util::terminal::Styles; ++ ++use crate::args::VariableListArgs; ++use crate::command_context::CommandContext; ++use crate::shared::print_json_pretty; ++ ++fn format_age(dt: DateTime, now: DateTime) -> String { ++ let dur = now.signed_duration_since(dt); ++ if dur.num_days() > 0 { ++ format!("{}d ago", dur.num_days()) ++ } else if dur.num_hours() > 0 { ++ format!("{}h ago", dur.num_hours()) ++ } else { ++ format!("{}m ago", dur.num_minutes().max(1)) ++ } ++} ++ ++pub(super) async fn list_command(_args: &VariableListArgs, ctx: &CommandContext) -> Result<()> { ++ let client = ctx.server().await?; ++ let printer = ctx.printer(); ++ let variables = client.list_variables().await?; ++ if ctx.json_output() { ++ print_json_pretty(&variables)?; ++ return Ok(()); ++ } ++ ++ if variables.is_empty() { ++ fabro_util::printerr!(printer, "No variables found."); ++ return Ok(()); ++ } ++ ++ let styles = Styles::detect_stdout(); ++ let use_color = styles.use_color; ++ let now = Utc::now(); ++ ++ let title: Vec = vec![ ++ "NAME".cell().bold(use_color), ++ "VALUE".cell().bold(use_color), ++ "UPDATED".cell().bold(use_color), ++ ]; ++ ++ let rows: Vec> = variables ++ .iter() ++ .map(|variable| { ++ vec![ ++ variable.name.clone().cell().bold(use_color), ++ variable.value.clone().cell(), ++ format_age(variable.updated_at, now).cell(), ++ ] ++ }) ++ .collect(); ++ ++ let color_choice = if use_color { ++ cli_table::ColorChoice::Auto ++ } else { ++ cli_table::ColorChoice::Never ++ }; ++ let table = rows ++ .table() ++ .title(title) ++ .color_choice(color_choice) ++ .border(Border::builder().build()) ++ .separator(Separator::builder().build()); ++ fabro_util::printout!(printer, "{}", table.display()?); ++ ++ Ok(()) ++} +diff --git a/lib/crates/fabro-cli/src/commands/variable/mod.rs b/lib/crates/fabro-cli/src/commands/variable/mod.rs +new file mode 100644 +index 000000000..cc38b32c8 +--- /dev/null ++++ b/lib/crates/fabro-cli/src/commands/variable/mod.rs +@@ -0,0 +1,19 @@ ++mod get; ++mod list; ++mod rm; ++mod set; ++ ++use anyhow::Result; ++ ++use crate::args::{VariableCommand, VariableNamespace}; ++use crate::command_context::CommandContext; ++ ++pub(crate) async fn dispatch(ns: VariableNamespace, base_ctx: &CommandContext) -> Result<()> { ++ let ctx = base_ctx.with_target(&ns.target)?; ++ match ns.command { ++ VariableCommand::List(args) => list::list_command(&args, &ctx).await, ++ VariableCommand::Get(args) => get::get_command(&args, &ctx).await, ++ VariableCommand::Rm(args) => rm::rm_command(&args, &ctx).await, ++ VariableCommand::Set(args) => set::set_command(&args, &ctx).await, ++ } ++} +diff --git a/lib/crates/fabro-cli/src/commands/variable/rm.rs b/lib/crates/fabro-cli/src/commands/variable/rm.rs +new file mode 100644 +index 000000000..b5904cab8 +--- /dev/null ++++ b/lib/crates/fabro-cli/src/commands/variable/rm.rs +@@ -0,0 +1,16 @@ ++use anyhow::Result; ++ ++use crate::args::VariableRmArgs; ++use crate::command_context::CommandContext; ++use crate::shared::print_json_pretty; ++ ++pub(super) async fn rm_command(args: &VariableRmArgs, ctx: &CommandContext) -> Result<()> { ++ let client = ctx.server().await?; ++ client.delete_variable(&args.name).await?; ++ if ctx.json_output() { ++ print_json_pretty(&serde_json::json!({ "name": args.name }))?; ++ } else { ++ fabro_util::printerr!(ctx.printer(), "Removed {}", args.name); ++ } ++ Ok(()) ++} +diff --git a/lib/crates/fabro-cli/src/commands/variable/set.rs b/lib/crates/fabro-cli/src/commands/variable/set.rs +new file mode 100644 +index 000000000..0a999eb71 +--- /dev/null ++++ b/lib/crates/fabro-cli/src/commands/variable/set.rs +@@ -0,0 +1,56 @@ ++#![expect( ++ clippy::disallowed_types, ++ reason = "sync CLI `variable set` command: reads variable value from stdin via blocking std::io::Read" ++)] ++#![expect( ++ clippy::disallowed_methods, ++ reason = "sync CLI `variable set` command: reads variable value from std::io::stdin" ++)] ++ ++use std::io::Read as _; ++ ++use anyhow::{Context as _, Result, bail}; ++use fabro_api::types; ++use tokio::task::spawn_blocking; ++ ++use crate::args::VariableSetArgs; ++use crate::command_context::CommandContext; ++use crate::shared::print_json_pretty; ++ ++async fn resolve_value(args: &VariableSetArgs) -> Result { ++ if let Some(value) = &args.value { ++ return Ok(value.clone()); ++ } ++ ++ if args.value_stdin { ++ let value = spawn_blocking(|| { ++ let mut raw = String::new(); ++ std::io::stdin() ++ .read_to_string(&mut raw) ++ .context("failed to read variable value from stdin")?; ++ Ok::(raw.trim_end_matches(['\r', '\n']).to_string()) ++ }) ++ .await??; ++ return Ok(value); ++ } ++ ++ bail!("variable value required: pass or use --value-stdin") ++} ++ ++pub(super) async fn set_command(args: &VariableSetArgs, ctx: &CommandContext) -> Result<()> { ++ let value = resolve_value(args).await?; ++ let client = ctx.server().await?; ++ let variable = client ++ .create_variable(types::CreateVariableRequest { ++ name: args.name.clone(), ++ value, ++ description: args.description.clone(), ++ }) ++ .await?; ++ if ctx.json_output() { ++ print_json_pretty(&variable)?; ++ } else { ++ fabro_util::printerr!(ctx.printer(), "Set {}", variable.name); ++ } ++ Ok(()) ++} +diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs +index c3402f1a7..3081e1ee3 100644 +--- a/lib/crates/fabro-cli/src/main.rs ++++ b/lib/crates/fabro-cli/src/main.rs +@@ -363,6 +363,9 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { + Commands::Secret(ns) => { + commands::secret::dispatch(ns, &base_ctx).await?; + } ++ Commands::Variable(ns) => { ++ commands::variable::dispatch(ns, &base_ctx).await?; ++ } + Commands::Settings(args) => { + Box::pin(commands::config::execute(&args, &base_ctx)).await?; + } +diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +index 3fe9c2bf7..d4950c196 100644 +--- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs ++++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +@@ -46,6 +46,7 @@ fn help() { + pr Pull request operations + parent Manage run parent links + secret Manage server-owned secrets ++ variable Manage server-owned variables + settings Inspect effective settings + workflow Workflow operations + discord Open the Discord community in the browser +diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +index 5c52cded3..9a9992806 100644 +--- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs ++++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +@@ -92,6 +92,26 @@ fn secret_list_uses_json_output_format_from_home_config() { + assert!(value.is_array(), "secret list JSON should be an array"); + } + ++#[test] ++fn variable_list_uses_json_output_format_from_home_config() { ++ let context = test_context!(); ++ context.write_home( ++ ".fabro/settings.toml", ++ "_version = 1\n\n[cli.output]\nformat = \"json\"\n", ++ ); ++ ++ let output = context ++ .command() ++ .args(["variable", "list"]) ++ .output() ++ .expect("command should run"); ++ ++ assert!(output.status.success()); ++ let value: Value = ++ serde_json::from_slice(&output.stdout).expect("variable list config JSON should parse"); ++ assert!(value.is_array(), "variable list JSON should be an array"); ++} ++ + #[test] + fn completion_succeeds_with_json_output_format_from_home_config() { + let context = test_context!(); +diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs +index fbd02b92d..e80e79562 100644 +--- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs ++++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs +@@ -75,6 +75,11 @@ mod unarchive; + mod uninstall; + mod upgrade; + mod validate; ++mod variable; ++mod variable_get; ++mod variable_list; ++mod variable_rm; ++mod variable_set; + mod version; + mod wait; + mod worker_auth; +diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable.rs b/lib/crates/fabro-cli/tests/it/cmd/variable.rs +new file mode 100644 +index 000000000..ba637c587 +--- /dev/null ++++ b/lib/crates/fabro-cli/tests/it/cmd/variable.rs +@@ -0,0 +1,97 @@ ++use fabro_test::{fabro_snapshot, test_context}; ++ ++#[test] ++fn help() { ++ let context = test_context!(); ++ let mut cmd = context.variable(); ++ cmd.arg("--help"); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: true ++ exit_code: 0 ++ ----- stdout ----- ++ Manage server-owned variables ++ ++ Usage: fabro variable [OPTIONS] ++ ++ Commands: ++ list List variables ++ get Get a variable value ++ rm Remove a variable ++ set Set a variable value ++ help Print this message or the help of the given subcommand(s) ++ ++ Options: ++ --json Output as JSON [env: FABRO_JSON=] ++ --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] ++ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] ++ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] ++ --quiet Suppress non-essential output [env: FABRO_QUIET=] ++ --verbose Enable verbose output [env: FABRO_VERBOSE=] ++ -h, --help Print help ++ ----- stderr ----- ++ "); ++} ++ ++#[test] ++fn variable_lifecycle() { ++ let context = test_context!(); ++ let name = format!("DEPLOY_ENV_{}", context.test_case_id()); ++ ++ context ++ .variable() ++ .args([ ++ "set", ++ &name, ++ "staging", ++ "--description", ++ "Deployment target", ++ ]) ++ .assert() ++ .success() ++ .stderr(format!("Set {name}\n")); ++ ++ context ++ .variable() ++ .args(["list"]) ++ .assert() ++ .success() ++ .stdout(predicates::str::contains(&name)) ++ .stdout(predicates::str::contains("staging")) ++ .stdout(predicates::str::contains("UPDATED")); ++ ++ context ++ .variable() ++ .args(["get", &name]) ++ .assert() ++ .success() ++ .stdout("staging\n"); ++ ++ context ++ .variable() ++ .args(["set", &name, "production"]) ++ .assert() ++ .success(); ++ ++ context ++ .variable() ++ .args(["get", &name]) ++ .assert() ++ .success() ++ .stdout("production\n"); ++ ++ context ++ .variable() ++ .args(["rm", &name]) ++ .assert() ++ .success() ++ .stderr(format!("Removed {name}\n")); ++ ++ context ++ .variable() ++ .args(["get", &name]) ++ .assert() ++ .failure() ++ .stderr(predicates::str::contains(format!( ++ "variable not found: {name}" ++ ))); ++} +diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs +new file mode 100644 +index 000000000..bdb529d3d +--- /dev/null ++++ b/lib/crates/fabro-cli/tests/it/cmd/variable_get.rs +@@ -0,0 +1,91 @@ ++use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; ++use serde_json::Value; ++ ++#[test] ++fn help() { ++ let context = test_context!(); ++ let mut cmd = context.variable(); ++ cmd.args(["get", "--help"]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: true ++ exit_code: 0 ++ ----- stdout ----- ++ Get a variable value ++ ++ Usage: fabro variable get [OPTIONS] ++ ++ Arguments: ++ Name of the variable to get ++ ++ Options: ++ --json Output as JSON [env: FABRO_JSON=] ++ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] ++ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] ++ --quiet Suppress non-essential output [env: FABRO_QUIET=] ++ --verbose Enable verbose output [env: FABRO_VERBOSE=] ++ -h, --help Print help ++ ----- stderr ----- ++ "); ++} ++ ++#[test] ++fn variable_get_plain_outputs_raw_value() { ++ let context = test_context!(); ++ let name = format!("GET_RAW_{}", context.test_case_id()); ++ context ++ .variable() ++ .args(["set", &name, "staging"]) ++ .assert() ++ .success(); ++ ++ context ++ .variable() ++ .args(["get", &name]) ++ .assert() ++ .success() ++ .stdout("staging\n"); ++} ++ ++#[test] ++fn variable_get_json_returns_full_variable() { ++ let context = test_context!(); ++ let name = format!("GET_JSON_{}", context.test_case_id()); ++ context ++ .variable() ++ .args(["set", &name, "json-value", "--description", "Readable"]) ++ .assert() ++ .success(); ++ ++ let output = context ++ .variable() ++ .args(["--json", "get", &name]) ++ .output() ++ .expect("command should run"); ++ ++ assert!(output.status.success()); ++ let value: Value = serde_json::from_slice(&output.stdout).expect("variable get should parse"); ++ fabro_json_snapshot!(context, &value, @r#" ++ { ++ "name": "GET_JSON_[TEST_CASE]", ++ "value": "json-value", ++ "description": "Readable", ++ "created_at": "[TIMESTAMP]", ++ "updated_at": "[TIMESTAMP]" ++ } ++ "#); ++} ++ ++#[test] ++fn variable_get_missing_fails() { ++ let context = test_context!(); ++ let name = format!("GET_MISSING_{}", context.test_case_id()); ++ let mut cmd = context.variable(); ++ cmd.args(["get", &name]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: false ++ exit_code: 1 ++ ----- stdout ----- ++ ----- stderr ----- ++ × variable not found: GET_MISSING_[TEST_CASE] ++ "); ++} +diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs +new file mode 100644 +index 000000000..f86620dcc +--- /dev/null ++++ b/lib/crates/fabro-cli/tests/it/cmd/variable_list.rs +@@ -0,0 +1,87 @@ ++use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; ++use serde_json::Value; ++ ++#[test] ++fn help() { ++ let context = test_context!(); ++ let mut cmd = context.variable(); ++ cmd.args(["list", "--help"]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: true ++ exit_code: 0 ++ ----- stdout ----- ++ List variables ++ ++ Usage: fabro variable list [OPTIONS] ++ ++ Options: ++ --json Output as JSON [env: FABRO_JSON=] ++ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] ++ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] ++ --quiet Suppress non-essential output [env: FABRO_QUIET=] ++ --verbose Enable verbose output [env: FABRO_VERBOSE=] ++ -h, --help Print help ++ ----- stderr ----- ++ "); ++} ++ ++#[test] ++fn variable_list_json_returns_full_variables() { ++ let context = test_context!(); ++ let name = format!("LIST_JSON_{}", context.test_case_id()); ++ context ++ .variable() ++ .args([ ++ "set", ++ &name, ++ "staging", ++ "--description", ++ "Deployment target", ++ ]) ++ .assert() ++ .success(); ++ ++ let output = context ++ .variable() ++ .args(["--json", "list"]) ++ .output() ++ .expect("command should run"); ++ ++ assert!(output.status.success()); ++ let value: Value = serde_json::from_slice(&output.stdout).expect("variable list should parse"); ++ let entry = value ++ .as_array() ++ .expect("variable list should be an array") ++ .iter() ++ .find(|entry| entry["name"] == name) ++ .expect("variable list should include the saved variable"); ++ fabro_json_snapshot!(context, entry, @r#" ++ { ++ "name": "LIST_JSON_[TEST_CASE]", ++ "value": "staging", ++ "description": "Deployment target", ++ "created_at": "[TIMESTAMP]", ++ "updated_at": "[TIMESTAMP]" ++ } ++ "#); ++} ++ ++#[test] ++fn variable_list_alias_ls_includes_values() { ++ let context = test_context!(); ++ let name = format!("LIST_ALIAS_{}", context.test_case_id()); ++ context ++ .variable() ++ .args(["set", &name, "visible-value"]) ++ .assert() ++ .success(); ++ ++ context ++ .variable() ++ .args(["ls"]) ++ .assert() ++ .success() ++ .stdout(predicates::str::contains(&name)) ++ .stdout(predicates::str::contains("visible-value")) ++ .stdout(predicates::str::contains("VALUE")); ++} +diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs +new file mode 100644 +index 000000000..a09ddb032 +--- /dev/null ++++ b/lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs +@@ -0,0 +1,65 @@ ++use fabro_test::{fabro_snapshot, test_context}; ++use serde_json::Value; ++ ++#[test] ++fn help() { ++ let context = test_context!(); ++ let mut cmd = context.variable(); ++ cmd.args(["rm", "--help"]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: true ++ exit_code: 0 ++ ----- stdout ----- ++ Remove a variable ++ ++ Usage: fabro variable rm [OPTIONS] ++ ++ Arguments: ++ Name of the variable to remove ++ ++ Options: ++ --json Output as JSON [env: FABRO_JSON=] ++ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] ++ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] ++ --quiet Suppress non-essential output [env: FABRO_QUIET=] ++ --verbose Enable verbose output [env: FABRO_VERBOSE=] ++ -h, --help Print help ++ ----- stderr ----- ++ "); ++} ++ ++#[test] ++fn variable_rm_json_outputs_removed_name() { ++ let context = test_context!(); ++ let name = format!("RM_JSON_{}", context.test_case_id()); ++ context ++ .variable() ++ .args(["set", &name, "remove-me"]) ++ .assert() ++ .success(); ++ ++ let output = context ++ .variable() ++ .args(["--json", "rm", &name]) ++ .output() ++ .expect("command should run"); ++ ++ assert!(output.status.success()); ++ let value: Value = serde_json::from_slice(&output.stdout).expect("variable rm should parse"); ++ assert_eq!(value, serde_json::json!({ "name": name })); ++} ++ ++#[test] ++fn variable_rm_missing_fails() { ++ let context = test_context!(); ++ let name = format!("RM_MISSING_{}", context.test_case_id()); ++ let mut cmd = context.variable(); ++ cmd.args(["rm", &name]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: false ++ exit_code: 1 ++ ----- stdout ----- ++ ----- stderr ----- ++ × variable not found: RM_MISSING_[TEST_CASE] ++ "); ++} +diff --git a/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs b/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs +new file mode 100644 +index 000000000..1402226d8 +--- /dev/null ++++ b/lib/crates/fabro-cli/tests/it/cmd/variable_set.rs +@@ -0,0 +1,156 @@ ++use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; ++use serde_json::Value; ++ ++#[test] ++fn help() { ++ let context = test_context!(); ++ let mut cmd = context.variable(); ++ cmd.args(["set", "--help"]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: true ++ exit_code: 0 ++ ----- stdout ----- ++ Set a variable value ++ ++ Usage: fabro variable set [OPTIONS] [VALUE] ++ ++ Arguments: ++ Name of the variable ++ [VALUE] Value to store ++ ++ Options: ++ --json Output as JSON [env: FABRO_JSON=] ++ --value-stdin Read the variable value from stdin ++ --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] ++ --description Optional human-readable description ++ --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] ++ --quiet Suppress non-essential output [env: FABRO_QUIET=] ++ --verbose Enable verbose output [env: FABRO_VERBOSE=] ++ -h, --help Print help ++ ----- stderr ----- ++ "); ++} ++ ++#[test] ++fn variable_set_json_returns_full_variable() { ++ let context = test_context!(); ++ let name = format!("SET_JSON_{}", context.test_case_id()); ++ let output = context ++ .variable() ++ .args([ ++ "--json", ++ "set", ++ &name, ++ "json-value", ++ "--description", ++ "Deployment target", ++ ]) ++ .output() ++ .expect("command should run"); ++ ++ assert!(output.status.success()); ++ let value: Value = serde_json::from_slice(&output.stdout).expect("variable set should parse"); ++ fabro_json_snapshot!(context, &value, @r#" ++ { ++ "name": "SET_JSON_[TEST_CASE]", ++ "value": "json-value", ++ "description": "Deployment target", ++ "created_at": "[TIMESTAMP]", ++ "updated_at": "[TIMESTAMP]" ++ } ++ "#); ++} ++ ++#[test] ++fn variable_set_update_preserves_description_when_omitted() { ++ let context = test_context!(); ++ let name = format!("SET_PRESERVE_{}", context.test_case_id()); ++ context ++ .variable() ++ .args([ ++ "set", ++ &name, ++ "staging", ++ "--description", ++ "Deployment target", ++ ]) ++ .assert() ++ .success(); ++ ++ let output = context ++ .variable() ++ .args(["--json", "set", &name, "production"]) ++ .output() ++ .expect("command should run"); ++ ++ assert!(output.status.success()); ++ let value: Value = serde_json::from_slice(&output.stdout).expect("variable set should parse"); ++ assert_eq!(value["value"], "production"); ++ assert_eq!(value["description"], "Deployment target"); ++} ++ ++#[test] ++fn variable_set_accepts_explicit_empty_value() { ++ let context = test_context!(); ++ let name = format!("SET_EMPTY_{}", context.test_case_id()); ++ context ++ .variable() ++ .args(["set", &name, ""]) ++ .assert() ++ .success(); ++ ++ context ++ .variable() ++ .args(["get", &name]) ++ .assert() ++ .success() ++ .stdout("\n"); ++} ++ ++#[test] ++fn variable_set_accepts_empty_stdin_value() { ++ let context = test_context!(); ++ let name = format!("SET_STDIN_EMPTY_{}", context.test_case_id()); ++ context ++ .variable() ++ .args(["set", &name, "--value-stdin"]) ++ .write_stdin("\n") ++ .assert() ++ .success(); ++ ++ context ++ .variable() ++ .args(["get", &name]) ++ .assert() ++ .success() ++ .stdout("\n"); ++} ++ ++#[test] ++fn variable_set_invalid_name_fails() { ++ let context = test_context!(); ++ let mut cmd = context.variable(); ++ cmd.args(["set", "1BAD", "value"]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: false ++ exit_code: 1 ++ ----- stdout ----- ++ ----- stderr ----- ++ × invalid variable name ++ "); ++} ++ ++#[test] ++fn variable_set_requires_value_or_stdin() { ++ let context = test_context!(); ++ let name = format!("SET_MISSING_VALUE_{}", context.test_case_id()); ++ let mut cmd = context.variable(); ++ cmd.args(["set", &name]); ++ fabro_snapshot!(context.filters(), cmd, @" ++ success: false ++ exit_code: 1 ++ ----- stdout ----- ++ ----- stderr ----- ++ × variable value required: pass or use --value-stdin ++ "); ++} +diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs +index b9a512c1e..89caa3a48 100644 +--- a/lib/crates/fabro-client/src/client.rs ++++ b/lib/crates/fabro-client/src/client.rs +@@ -715,6 +715,60 @@ impl Client { + Ok(()) + } + ++ pub async fn list_variables(&self) -> Result> { ++ let response = self ++ .send_api(|client| async move { client.list_variables().send().await }) ++ .await?; ++ Ok(response.into_inner().data) ++ } ++ ++ pub async fn get_variable(&self, name: &str) -> Result { ++ let response = self ++ .send_api( ++ |client| async move { client.get_variable().name(name.to_string()).send().await }, ++ ) ++ .await?; ++ Ok(response.into_inner()) ++ } ++ ++ pub async fn create_variable( ++ &self, ++ body: types::CreateVariableRequest, ++ ) -> Result { ++ let response = self ++ .send_api( ++ |client| async move { client.create_variable().body(body.clone()).send().await }, ++ ) ++ .await?; ++ Ok(response.into_inner()) ++ } ++ ++ pub async fn update_variable( ++ &self, ++ name: &str, ++ body: types::UpdateVariableRequest, ++ ) -> Result { ++ let response = self ++ .send_api(|client| async move { ++ client ++ .update_variable() ++ .name(name.to_string()) ++ .body(body.clone()) ++ .send() ++ .await ++ }) ++ .await?; ++ Ok(response.into_inner()) ++ } ++ ++ pub async fn delete_variable(&self, name: &str) -> Result<()> { ++ self.send_api(|client| async move { ++ client.delete_variable().name(name.to_string()).send().await ++ }) ++ .await?; ++ Ok(()) ++ } ++ + pub async fn list_models( + &self, + provider: Option<&str>, +diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs +index 48c6ef4fc..cd8b041f0 100644 +--- a/lib/crates/fabro-test/src/lib.rs ++++ b/lib/crates/fabro-test/src/lib.rs +@@ -1454,6 +1454,13 @@ impl TestContext { + cmd + } + ++ /// Build a `variable` subcommand. ++ pub fn variable(&self) -> Command { ++ let mut cmd = self.command(); ++ cmd.arg("variable"); ++ cmd ++ } ++ + /// Build a `doctor` subcommand. + pub fn doctor(&self) -> Command { + let mut cmd = self.command(); diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..0594a871d --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-27T17:03:59.731488Z" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/prompt.md b/stages/006-simplify_opus@1/prompt.md new file mode 100644 index 000000000..cc5edbd63 --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,346 @@ +Goal: --- +title: Add CLI Variable Management +type: feat +status: active +date: 2026-05-27 +--- + +# Add CLI Variable Management + +## Overview + +Expose the recently added variables API through the CLI with a singular `fabro variable` +namespace. Variables are non-sensitive run-configuration values, so the CLI should expose +values in `list` and `get`, while continuing to direct credentials and tokens to +`fabro secret`. + +## Requirements Trace + +- R1. Provide variables management in the CLI, similar to secrets management. +- R2. Support the full readable-variable CRUD surface: list, get, set/upsert, and remove. +- R3. Preserve existing server/API behavior: variable names are env-style, values may be + empty, and `set` preserves an existing description when `--description` is omitted. +- R4. Keep generated CLI docs and help snapshots in sync with the new public command. + +## Context & Research + +- `lib/crates/fabro-cli/src/commands/secret/` is the command pattern to follow for + namespace dispatch, JSON output, tabular list output, stdin value input, and status + messages. +- `lib/crates/fabro-server/src/server/handler/variables.rs` already provides + `GET /variables`, `POST /variables`, `GET /variables/{name}`, + `PUT /variables/{name}`, and `DELETE /variables/{name}`. +- `lib/crates/fabro-types/src/variable.rs` defines the canonical API/request types and + validates env-style names. +- `lib/crates/fabro-api/tests/variable_round_trip.rs` already proves OpenAPI generated + types reuse the canonical variable types. +- `docs/public/workflows/variables.mdx` currently explains workflow template variables + but does not yet document how server-managed `{{ vars.NAME }}` values are configured. + +## Key Technical Decisions + +- Use `fabro variable`, not `fabro variables`, to match existing singular CLI namespaces + such as `fabro secret`, `fabro model`, and `fabro repo`. +- Add `get` because variables are intentionally readable; secrets remain write-only. +- Make `set` an upsert using the API's create/upsert endpoint, matching the mental model + of `fabro secret set`. +- Reuse `--value-stdin` from secrets but allow empty stdin values for variables after + trimming trailing newlines. +- Plain `list` should include a `VALUE` column. Do not add truncation or redaction in + this first pass; exact retrieval is available through JSON output and `get`. + +## Implementation Units + +- [ ] **Unit 1: Add fabro-client variable wrappers** + +**Goal:** Give CLI code stable methods over the generated OpenAPI client. + +**Requirements:** R2, R3 + +**Dependencies:** Existing variables API and generated `fabro-api` client. + +**Files:** +- Modify: `lib/crates/fabro-client/src/client.rs` + +**Approach:** +- Add wrappers for `list_variables`, `get_variable`, `create_variable`, + `update_variable`, and `delete_variable`. +- Return `Vec` from `list_variables` by unwrapping the API response's + `data`, matching `list_secrets`. +- Use the generated path-parameter operations for `get`, `update`, and `delete`. + +**Patterns to follow:** +- `list_secrets`, `create_secret`, and `delete_secret_by_name` in the same file. + +**Test scenarios:** +- Happy path: CLI integration tests in later units exercise each wrapper through the + shared server client path. +- Error path: missing and invalid variable operations propagate the server's API errors. + +**Verification:** +- The CLI can compile against these wrapper methods without importing generated client + builders directly. + +- [ ] **Unit 2: Add CLI args, dispatch, and command module** + +**Goal:** Register the new top-level namespace and route subcommands to implementation +modules. + +**Requirements:** R1, R2, R4 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/args.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Modify: `lib/crates/fabro-cli/src/commands/mod.rs` +- Create: `lib/crates/fabro-cli/src/commands/variable/mod.rs` + +**Approach:** +- Add `Commands::Variable(VariableNamespace)` with description + `Manage server-owned variables`. +- Add `VariableNamespace` with `ServerTargetArgs`, matching `SecretNamespace`. +- Add `VariableCommand::{List, Get, Rm, Set}`; give `list` the `ls` alias. +- Add command-name mapping for analytics/logging: `variable list`, `variable get`, + `variable rm`, and `variable set`. +- Dispatch through `commands::variable::dispatch`, deriving the target context with + `base_ctx.with_target(&ns.target)`. + +**Patterns to follow:** +- `SecretNamespace`, `SecretCommand`, and `commands::secret::dispatch`. + +**Test scenarios:** +- Happy path: `fabro --help` lists `variable`. +- Happy path: `fabro variable --help` shows `list`, `get`, `rm`, and `set`. +- Happy path: command-name mapping covers all subcommands. + +**Verification:** +- The new namespace is reachable through clap and main dispatch without affecting + existing commands. + +- [ ] **Unit 3: Implement variable list/get/set/rm behavior** + +**Goal:** Provide the full user-facing variables management workflow. + +**Requirements:** R1, R2, R3 + +**Dependencies:** Units 1 and 2 + +**Files:** +- Create: `lib/crates/fabro-cli/src/commands/variable/list.rs` +- Create: `lib/crates/fabro-cli/src/commands/variable/get.rs` +- Create: `lib/crates/fabro-cli/src/commands/variable/set.rs` +- Create: `lib/crates/fabro-cli/src/commands/variable/rm.rs` + +**Approach:** +- `list`: fetch all variables, print JSON array when JSON output is active, otherwise + print a table with `NAME`, `VALUE`, and `UPDATED`. +- `get`: fetch one variable, print the full variable object for JSON output, otherwise + print only the raw value to stdout. +- `set`: accept ` [VALUE]`, `--value-stdin`, and `--description`; call the upsert + API wrapper and print the stored variable for JSON output or `Set NAME` otherwise. +- `rm`: call the delete API wrapper and print `{ "name": NAME }` for JSON output or + `Removed NAME` otherwise. +- For `set`, allow empty explicit values and empty stdin values. Only error when no value + is provided and stdin is not being used. + +**Patterns to follow:** +- `commands/secret/list.rs` for table style and age formatting. +- `commands/secret/set.rs` for argument precedence and stdin handling, adjusted so empty + values are valid. +- `commands/secret/rm.rs` for delete output shape. + +**Test scenarios:** +- Happy path: `set DEPLOY_ENV staging --description "Deployment target"` then `list` + shows `DEPLOY_ENV`, `staging`, and an updated age. +- Happy path: `get DEPLOY_ENV` prints exactly `staging\n` in plain output. +- Happy path: `set DEPLOY_ENV production` updates the value and preserves the existing + description through API behavior. +- Happy path: `set EMPTY ""` stores an empty value. +- Happy path: `printf '\n' | fabro variable set EMPTY --value-stdin` stores an empty + value instead of failing. +- Error path: `get MISSING` and `rm MISSING` fail with `variable not found: MISSING`. +- Error path: `set 1BAD value` fails with the server invalid-name error. + +**Verification:** +- The command works against the default test server and does not write directly to + local `variables.json`. + +- [ ] **Unit 4: Add test harness support and CLI integration tests** + +**Goal:** Lock the public CLI surface and expected behavior with integration coverage. + +**Requirements:** R1, R2, R3, R4 + +**Dependencies:** Units 1-3 + +**Files:** +- Modify: `lib/crates/fabro-test/src/lib.rs` +- Modify: `lib/crates/fabro-cli/tests/it/cmd/mod.rs` +- Modify: `lib/crates/fabro-cli/tests/it/cmd/fabro.rs` +- Modify: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` +- Create: `lib/crates/fabro-cli/tests/it/cmd/variable.rs` +- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_list.rs` +- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_get.rs` +- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_set.rs` +- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs` + +**Approach:** +- Add `TestContext::variable()` helper mirroring `TestContext::secret()`. +- Add help snapshots for the namespace and each subcommand. +- Add lifecycle tests for set/list/get/update/rm, `ls` alias, empty value support, JSON + output, missing variable errors, and invalid-name errors. +- Update root help and curated landing snapshots only if the final clap/landing output + changes. + +**Patterns to follow:** +- `secret.rs`, `secret_list.rs`, `secret_set.rs`, and `secret_rm.rs`. + +**Test scenarios:** +- Happy path: JSON `list` returns an array of full variable objects including `value`. +- Happy path: JSON `get` and `set` return full variable objects. +- Happy path: global JSON config makes `variable list` emit JSON, matching the + `secret list` config test. +- Error path: missing variables and invalid names produce nonzero exits and readable + errors. + +**Verification:** +- `cargo nextest run -p fabro-cli -- variable` +- `cargo nextest run -p fabro-cli -- fabro` + +- [ ] **Unit 5: Update generated and conceptual docs** + +**Goal:** Keep public documentation aligned with the new command and clarify how variables +relate to secrets. + +**Requirements:** R1, R4 + +**Dependencies:** Units 2-4 + +**Files:** +- Modify: `docs/public/reference/cli.mdx` +- Modify: `docs/public/workflows/variables.mdx` + +**Approach:** +- Regenerate the CLI reference with `cargo dev docs refresh`. +- Add a short section to `docs/public/workflows/variables.mdx` explaining that + server-managed run config variables can be set with `fabro variable set NAME VALUE` + and referenced as `{{ vars.NAME }}` in run config interpolation. +- State that variables are non-sensitive and readable; tokens, keys, and credentials + should use `fabro secret set`. + +**Patterns to follow:** +- Existing generated docs workflow in `lib/crates/fabro-dev/src/commands/docs.rs`. +- Existing CLI references to `fabro secret set` in administration docs. + +**Test scenarios:** +- Happy path: generated CLI docs include `fabro variable` and its subcommands. +- Documentation check: `cargo dev docs check` succeeds after regeneration. + +**Verification:** +- The docs describe the CLI surface without implying variables are secret storage. + +## System-Wide Impact + +- **API surface parity:** No server or OpenAPI changes are planned; the CLI consumes the + existing variables API. +- **Error propagation:** Invalid names, missing variables, and write failures should flow + through the existing `fabro-client` API error classification. +- **State lifecycle risks:** CLI commands must use the server API rather than editing + `variables.json` locally, so behavior remains correct for remote and socket-backed + servers. +- **Security boundary:** Values are intentionally visible for variables. Documentation + must clearly distinguish variables from secrets to avoid accidental credential storage. +- **Unchanged invariants:** `fabro secret` remains write-only and unchanged. + +## Risks & Dependencies + +| Risk | Mitigation | +| --- | --- | +| Users put credentials in variables because the command looks like secrets | Document variables as non-sensitive and keep secret guidance explicit. | +| Empty values accidentally fail because secret handling rejects empties | Test explicit empty strings and newline-only stdin for `variable set`. | +| CLI docs drift after adding clap args | Regenerate with `cargo dev docs refresh` and verify with `cargo dev docs check`. | +| Plain `list` becomes awkward for long values | Accept for v1; `get` and JSON output provide exact machine-readable retrieval. | + +## Verification Plan + +- `cargo nextest run -p fabro-cli -- variable` +- `cargo nextest run -p fabro-cli -- fabro` +- `cargo dev docs check` +- `cargo +nightly-2026-04-14 fmt --check --all` +- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` + +## Assumptions + +- The chosen CLI surface is full CRUD with readable values. +- The namespace is singular: `fabro variable`. +- No TypeScript client regeneration is required for this CLI-only change. +- No server API, OpenAPI schema, or storage migration changes are required. + +## Completed stages +- **toolchain**: succeeded + - Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1` + - Output: + ``` + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: succeeded + - Model: gpt-5.5, 2.9m tokens in / 24.4k out + + +# Simplify: Code Review and Cleanup + +Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead. +3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones +3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction +4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase + +Note: This is a greenfield app, so be aggressive in optimizing quality. + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. Missed concurrency: independent operations run sequentially when they could run in parallel +3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths +4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +5. Memory: unbounded data structures, missing cleanup, event listener leaks +6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). \ No newline at end of file diff --git a/stages/006-simplify_opus@1/provider_used.json b/stages/006-simplify_opus@1/provider_used.json new file mode 100644 index 000000000..672cc50e5 --- /dev/null +++ b/stages/006-simplify_opus@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/response.md b/stages/006-simplify_opus@1/response.md new file mode 100644 index 000000000..842cefcbe --- /dev/null +++ b/stages/006-simplify_opus@1/response.md @@ -0,0 +1,28 @@ +All tests pass. Let me summarize. + +## Summary + +Reviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency). + +### Findings reviewed +- **Code reuse:** flagged exact-duplicate `format_age` between `secret/list.rs` and `variable/list.rs`; also noted broader patterns (table boilerplate, color_choice) pre-existing in 8+ places. +- **Code quality:** flagged unused `Client::update_variable`, the duplicated `format_age`, divergent JSON envelope keys (`{name}` vs `{key}`), and intentional empty-value-acceptance for stdin. +- **Efficiency:** no meaningful issues — each subcommand makes a single API call. + +### Fixed +1. Extracted `format_age` to `lib/crates/fabro-cli/src/shared/utilities.rs` and updated both `secret/list.rs` and `variable/list.rs` to import via `crate::shared::format_age`. Removes 20 lines of byte-identical duplication. + +### Deliberately skipped (intentional per plan) +- **Empty value handling for `variable set`:** the plan (R3) explicitly requires empty values be allowed for variables, unlike secrets. +- **`set` as upsert via `POST`:** the plan explicitly chose this to match `fabro secret set`'s mental model. +- **`update_variable` Rust client wrapper:** the plan listed all five wrappers (list/get/create/update/delete) for API surface parity. Used by the web client; kept the Rust wrapper for completeness. +- **JSON envelope `{"name": ...}` for `variable rm`:** specified by the plan and matches the API field (the secret `{"key": ...}` is the outlier). +- **Broader cli_table/color_choice duplication:** pre-existing across 8+ commands; out of scope. +- **Project-wide `use func` import style nit:** pre-existing in secret too; not introduced by this change. + +### Verification +- `cargo check -p fabro-cli`: clean +- `cargo +nightly-2026-04-14 fmt --check -p fabro-cli`: clean +- `cargo +nightly-2026-04-14 clippy -p fabro-cli --all-targets -- -D warnings`: clean +- `cargo nextest run -p fabro-cli -E 'test(/variable/)'`: 20/20 passed +- `cargo nextest run -p fabro-cli -E 'test(/secret/)'`: 30/30 passed (no regression from `format_age` extraction) \ No newline at end of file