diff --git a/justfile b/justfile index e57e5ed49..45bb30c7c 100644 --- a/justfile +++ b/justfile @@ -331,7 +331,7 @@ _bundle-unix: GOOSE_DEV_MODE=required GOOSE_BUILD_PROFILE=release ./scripts/ensure-local-goose.sh fi GOOSE_BUILD_PROFILE=release ./scripts/prepare-goose-sidecar.sh - VITE_FEEDBACK="${VITE_FEEDBACK:-0}" CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh + VITE_FEEDBACK="${VITE_FEEDBACK:-0}" VITE_AUTOMATIONS="${VITE_AUTOMATIONS:-0}" CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh ./scripts/prepare-catch-sidecar.sh CARGO_FEATURES=(berdctl) @@ -421,7 +421,7 @@ _bundle-debug-unix: GOOSE_DEV_MODE=required GOOSE_BUILD_PROFILE=debug ./scripts/ensure-local-goose.sh fi GOOSE_BUILD_PROFILE=debug ./scripts/prepare-goose-sidecar.sh - VITE_FEEDBACK="${VITE_FEEDBACK:-0}" CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh + VITE_FEEDBACK="${VITE_FEEDBACK:-0}" VITE_AUTOMATIONS="${VITE_AUTOMATIONS:-0}" CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh ./scripts/prepare-catch-sidecar.sh CARGO_FEATURES=(berdctl devtools) @@ -512,6 +512,7 @@ dev: # because tauri.dev.conf.json blanks externalBin. BERDCTL_FEATURES=() [[ "${VITE_FEEDBACK:-0}" == "1" ]] && BERDCTL_FEATURES+=(--features block-feedback) + [[ "${VITE_AUTOMATIONS:-0}" == "1" ]] && BERDCTL_FEATURES+=(--features block-automations) # ${arr[@]+...} guards the empty-array expansion, which bash 3.2 (stock # macOS) treats as an unbound variable under `set -u`. (cd src-tauri && cargo build -p berdctl ${BERDCTL_FEATURES[@]+"${BERDCTL_FEATURES[@]}"}) diff --git a/scripts/dev-e2e.sh b/scripts/dev-e2e.sh index e36dab474..0639dfa86 100755 --- a/scripts/dev-e2e.sh +++ b/scripts/dev-e2e.sh @@ -53,6 +53,7 @@ export VITE_APP_VERSION="$BERD_APP_VERSION_RICH" BERDCTL_FEATURES=() [[ "${VITE_FEEDBACK:-0}" == "1" ]] && BERDCTL_FEATURES+=(--features block-feedback) +[[ "${VITE_AUTOMATIONS:-0}" == "1" ]] && BERDCTL_FEATURES+=(--features block-automations) # ${arr[@]+...} guards the empty-array expansion, which bash 3.2 (stock # macOS) treats as an unbound variable under `set -u`. (cd src-tauri && cargo build -p berdctl ${BERDCTL_FEATURES[@]+"${BERDCTL_FEATURES[@]}"}) diff --git a/scripts/generate-berdctl-contract.mjs b/scripts/generate-berdctl-contract.mjs index 5ccd98bc0..1028aa9a9 100644 --- a/scripts/generate-berdctl-contract.mjs +++ b/scripts/generate-berdctl-contract.mjs @@ -35,7 +35,7 @@ const packageJson = JSON.parse( fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); -async function loadContracts(feedbackEnabled) { +async function loadContracts({ feedbackEnabled, automationsEnabled }) { // Mirror the app's vite resolution (the `@` alias and build-feature defines) // so each generated projection comes from the exact renderer registry that // its build will dispatch. @@ -51,6 +51,9 @@ async function loadContracts(feedbackEnabled) { "import.meta.env.VITE_FEEDBACK": JSON.stringify( feedbackEnabled ? "1" : "0", ), + "import.meta.env.VITE_AUTOMATIONS": JSON.stringify( + automationsEnabled ? "1" : "0", + ), }, server: { middlewareMode: true, hmr: false }, optimizeDeps: { noDiscovery: true }, @@ -69,8 +72,25 @@ async function loadContracts(feedbackEnabled) { } } -const publicContracts = await loadContracts(false); -const feedbackContracts = await loadContracts(true); +// One variant per gate combination: the gates are independent renderer +// build flags, and the embedded CLI artifacts must never advertise a group +// the matching renderer build filters out of TOOL_GROUPS. +const publicContracts = await loadContracts({ + feedbackEnabled: false, + automationsEnabled: false, +}); +const feedbackContracts = await loadContracts({ + feedbackEnabled: true, + automationsEnabled: false, +}); +const automationsContracts = await loadContracts({ + feedbackEnabled: false, + automationsEnabled: true, +}); +const blockContracts = await loadContracts({ + feedbackEnabled: true, + automationsEnabled: true, +}); // Resolve the repo's biome binary (same pattern as // scripts/design-system-manifest.mjs) so the emitted JSON matches the @@ -106,6 +126,10 @@ for (const [fileName, contract] of [ ["cli-surface.json", publicContracts.surface], ["api-surface-feedback.json", feedbackContracts.api], ["cli-surface-feedback.json", feedbackContracts.surface], + ["api-surface-automations.json", automationsContracts.api], + ["cli-surface-automations.json", automationsContracts.surface], + ["api-surface-block.json", blockContracts.api], + ["cli-surface-block.json", blockContracts.surface], ]) { const target = path.join(crateDir, fileName); const rendered = render(fileName, contract); diff --git a/scripts/prepare-berdctl-sidecar.sh b/scripts/prepare-berdctl-sidecar.sh index e91e8a447..0a18efe26 100755 --- a/scripts/prepare-berdctl-sidecar.sh +++ b/scripts/prepare-berdctl-sidecar.sh @@ -30,8 +30,14 @@ fi EXPLICIT_TRIPLE="${1:-${BERDCTL_TRIPLE:-}}" CARGO_ARGS=(build -p berdctl --release) -if [[ "${VITE_FEEDBACK:-0}" == "1" ]]; then - CARGO_ARGS+=(--features block-feedback) +# Each renderer gate maps to its own cargo feature so the embedded CLI +# contract always matches the renderer registry variant it ships with; the +# renderer registry still refuses at dispatch (the trust boundary). +BERDCTL_FEATURE_LIST=() +[[ "${VITE_FEEDBACK:-0}" == "1" ]] && BERDCTL_FEATURE_LIST+=(block-feedback) +[[ "${VITE_AUTOMATIONS:-0}" == "1" ]] && BERDCTL_FEATURE_LIST+=(block-automations) +if [[ ${#BERDCTL_FEATURE_LIST[@]} -gt 0 ]]; then + CARGO_ARGS+=(--features "$(IFS=,; echo "${BERDCTL_FEATURE_LIST[*]}")") fi if [[ -n "$EXPLICIT_TRIPLE" ]]; then TRIPLE="$EXPLICIT_TRIPLE" diff --git a/scripts/release/build-macos.sh b/scripts/release/build-macos.sh index b0eca5b60..2d47450ef 100755 --- a/scripts/release/build-macos.sh +++ b/scripts/release/build-macos.sh @@ -453,7 +453,7 @@ echo "+++ :hammer: pnpm tauri build (unsigned)" GOOSE_BUILD_PROFILE=release ./scripts/prepare-goose-sidecar.sh # ACP bridges are installed into the managed Node runtime on demand; they are # no longer staged as build resources. -VITE_FEEDBACK="$VITE_FEEDBACK_VALUE" ./scripts/prepare-berdctl-sidecar.sh "$TARGET_TRIPLE" +VITE_FEEDBACK="$VITE_FEEDBACK_VALUE" VITE_AUTOMATIONS="$VITE_AUTOMATIONS_VALUE" ./scripts/prepare-berdctl-sidecar.sh "$TARGET_TRIPLE" if [[ "$VITE_AGENT_TOOLS_VALUE" == "1" ]]; then ./scripts/prepare-bb-cli-resource.sh "$TARGET_TRIPLE" tmp="$(mktemp)" diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index 8deb70b0f..e201a81e1 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -494,6 +494,9 @@ describe("development Block-feature resources", () => { expect(source).toContain( `[[ "\${VITE_FEEDBACK:-0}" == "1" ]] && BERDCTL_FEATURES+=(--features block-feedback)`, ); + expect(source).toContain( + `[[ "\${VITE_AUTOMATIONS:-0}" == "1" ]] && BERDCTL_FEATURES+=(--features block-automations)`, + ); expect(source).toContain( `cargo build -p berdctl \${BERDCTL_FEATURES[@]+"\${BERDCTL_FEATURES[@]}"}`, ); @@ -502,6 +505,21 @@ describe("development Block-feature resources", () => { }); }); +describe("Windows sidecar staging Block-feature seam", () => { + it("maps each gated CLI family onto the berdctl.exe build args", async () => { + const script = await readFile( + join(repo, "scripts/windows/Stage-Sidecar-Windows.ps1"), + "utf8", + ); + expect(script).toContain('if ($env:VITE_FEEDBACK -eq "1") {'); + expect(script).toContain('$cargoArgs += @("--features", "block-feedback")'); + expect(script).toContain('if ($env:VITE_AUTOMATIONS -eq "1") {'); + expect(script).toContain( + '$cargoArgs += @("--features", "block-automations")', + ); + }); +}); + describe("build-macos Block-service feature seam", () => { it("defaults all six families off and maps each opt-in to packaging", async () => { const script = await readFile( @@ -535,7 +553,7 @@ describe("build-macos Block-service feature seam", () => { 'jq \'.bundle.resources["../resources/bb"] = "bb"\'', ); expect(script).toContain( - 'VITE_FEEDBACK="$VITE_FEEDBACK_VALUE" ./scripts/prepare-berdctl-sidecar.sh', + 'VITE_FEEDBACK="$VITE_FEEDBACK_VALUE" VITE_AUTOMATIONS="$VITE_AUTOMATIONS_VALUE" ./scripts/prepare-berdctl-sidecar.sh', ); }); diff --git a/scripts/windows/Stage-Sidecar-Windows.ps1 b/scripts/windows/Stage-Sidecar-Windows.ps1 index e7542af02..2be1f818e 100644 --- a/scripts/windows/Stage-Sidecar-Windows.ps1 +++ b/scripts/windows/Stage-Sidecar-Windows.ps1 @@ -75,6 +75,9 @@ $cargoArgs = @("build", "-p", "berdctl", "--release") if ($env:VITE_FEEDBACK -eq "1") { $cargoArgs += @("--features", "block-feedback") } +if ($env:VITE_AUTOMATIONS -eq "1") { + $cargoArgs += @("--features", "block-automations") +} if (-not [string]::IsNullOrWhiteSpace($hostTriple) -and $Triple -ne $hostTriple) { $cargoArgs += @("--target", $Triple) $berdctlReleaseDir = Join-Path (Join-Path $tauriTargetDir $Triple) "release" diff --git a/src-tauri/crates/berdctl/Cargo.toml b/src-tauri/crates/berdctl/Cargo.toml index b30b8d2a1..9b652c7bb 100644 --- a/src-tauri/crates/berdctl/Cargo.toml +++ b/src-tauri/crates/berdctl/Cargo.toml @@ -21,3 +21,4 @@ ureq = { version = "3", features = ["json"] } [features] default = [] block-feedback = [] +block-automations = [] diff --git a/src-tauri/crates/berdctl/api-surface-automations.json b/src-tauri/crates/berdctl/api-surface-automations.json new file mode 100644 index 000000000..31031b12c --- /dev/null +++ b/src-tauri/crates/berdctl/api-surface-automations.json @@ -0,0 +1,1189 @@ +{ + "$comment": "GENERATED FILE — do not hand-edit; run `pnpm generate:berdctl-contract`. Client-neutral wire surface of the Berd desktop app's control API: POST /v1/call {\"command\": \"\", \"args\": {\"action\": \"\", ...fields}} against the loopback endpoint in the berdctl discovery file. protocolVersion mirrors PROTOCOL_VERSION in both discovery.rs copies (berdctl and plugin crate tests pin them equal). Per action: description, fields (flat wire model: name, required, kind, values, description, bounds), and schema (JSON Schema 2020-12 of the args object, minus the action discriminator). Derived from the authoritative zod schemas in the colocated command modules (src/features/berdctl/commands/impl/*.ts); the renderer registry's strict zod parse — not this file — is the trust boundary. vitest asserts freshness (src/features/berdctl/__tests__/apiSurface.test.ts).", + "protocolVersion": 4, + "groups": { + "sessions": { + "description": "Manage the user's chat sessions: create (fire-and-forget, on any installed agent harness), send, open, list, get, rename, move, move to group, clear project, fork, archive.", + "actions": { + "create": { + "description": "Create a new chat session on any installed agent harness and send the prompt in it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Only check on it later (action \"get\") if the user asks.", + "fields": [ + { + "name": "prompt", + "required": true, + "kind": "string", + "description": "The message to send in the new session (1-50000 chars).", + "min": 1, + "max": 50000 + }, + { + "name": "harness_id", + "required": false, + "kind": "string", + "description": "Agent harness to run the session on (from `berdctl info harnesses`, e.g. \"goose\", \"claude-acp\", \"codex-acp\"). Defaults to the app default." + }, + { + "name": "model_id", + "required": false, + "kind": "string", + "description": "Id of the model to use (from `berdctl info models`)." + }, + { + "name": "agent_id", + "required": false, + "kind": "string", + "description": "Id of the agent (persona) to use (from `berdctl agent list`)." + }, + { + "name": "project_id", + "required": false, + "kind": "string", + "description": "Id of the project to create the session in." + }, + { + "name": "startup_name", + "required": false, + "kind": "string", + "description": "Branch/worktree name when the project's startup mode is branch or worktree; required for those modes.", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The message to send in the new session (1-50000 chars)." + }, + "harness_id": { + "description": "Agent harness to run the session on (from `berdctl info harnesses`, e.g. \"goose\", \"claude-acp\", \"codex-acp\"). Defaults to the app default.", + "type": "string" + }, + "model_id": { + "description": "Id of the model to use (from `berdctl info models`).", + "type": "string" + }, + "agent_id": { + "description": "Id of the agent (persona) to use (from `berdctl agent list`).", + "type": "string" + }, + "project_id": { + "description": "Id of the project to create the session in.", + "type": "string" + }, + "startup_name": { + "description": "Branch/worktree name when the project's startup mode is branch or worktree; required for those modes.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["prompt"], + "additionalProperties": false + } + }, + "send": { + "description": "Send a prompt into an existing chat session without opening or focusing it. Idle sends are fire-and-forget and visibly add a user message marked as sent by Berd from another session. Running sessions are refused by default; use --if-running steer to add context to the active run, or --if-running queue to send one follow-up after the current run finishes.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the existing session to send the prompt into." + }, + { + "name": "prompt", + "required": true, + "kind": "string", + "description": "The message to send in the existing session (1-50000 chars).", + "min": 1, + "max": 50000 + }, + { + "name": "startup_name", + "required": false, + "kind": "string", + "description": "Branch/worktree name when this is the first send.", + "min": 1, + "max": 200 + }, + { + "name": "if_running", + "required": false, + "kind": "string", + "description": "What to do if the target session is running: refuse, steer, or queue.", + "values": ["refuse", "steer", "queue"] + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the existing session to send the prompt into." + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The message to send in the existing session (1-50000 chars)." + }, + "startup_name": { + "description": "Branch/worktree name when this is the first send.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "if_running": { + "default": "refuse", + "description": "What to do if the target session is running: refuse, steer, or queue.", + "type": "string", + "enum": ["refuse", "steer", "queue"] + } + }, + "required": ["session_id", "prompt"], + "additionalProperties": false + } + }, + "open": { + "description": "Open an existing chat session in the app window the user is looking at.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to open in the app." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to open in the app." + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's chat sessions visible in the app (most recent first); does not change anything on screen.", + "fields": [ + { + "name": "project_id", + "required": false, + "kind": "string", + "description": "Only list sessions belonging to this project." + }, + { + "name": "query", + "required": false, + "kind": "string", + "description": "Case-insensitive substring to match against session titles." + }, + { + "name": "limit", + "required": false, + "kind": "number", + "description": "Maximum number of sessions to return (1-100). Defaults to 20.", + "min": 1, + "max": 100 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "description": "Only list sessions belonging to this project.", + "type": "string" + }, + "query": { + "description": "Case-insensitive substring to match against session titles.", + "type": "string" + }, + "limit": { + "default": 20, + "description": "Maximum number of sessions to return (1-100). Defaults to 20.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false + } + }, + "get": { + "description": "Read one session's metadata and optionally its most recent messages; does not change anything on screen. Use only when the user asks about a session's status or content.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to read." + }, + { + "name": "messages", + "required": false, + "kind": "number", + "description": "How many of the session's most recent messages to include (0-50). Defaults to 0 (metadata only).", + "min": 0, + "max": 50 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to read." + }, + "messages": { + "default": 0, + "description": "How many of the session's most recent messages to include (0-50). Defaults to 0 (metadata only).", + "type": "integer", + "minimum": 0, + "maximum": 50 + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "rename": { + "description": "Rename a chat session; the new title appears immediately in the app's session list.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to rename." + }, + { + "name": "title", + "required": true, + "kind": "string", + "description": "The new session title.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to rename." + }, + "title": { + "type": "string", + "minLength": 1, + "description": "The new session title." + } + }, + "required": ["session_id", "title"], + "additionalProperties": false + } + }, + "move": { + "description": "Move a chat session into a project; the session list in the app regroups immediately.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to move." + }, + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the destination project." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to move." + }, + "project_id": { + "type": "string", + "description": "Id of the destination project." + } + }, + "required": ["session_id", "project_id"], + "additionalProperties": false + } + }, + "move_to_group": { + "description": "Move a chat session into an existing group in its current project; the project chat list regroups immediately.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to move." + }, + { + "name": "group_id", + "required": true, + "kind": "string", + "description": "Id of the destination chat group, from `berdctl project get`." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to move." + }, + "group_id": { + "type": "string", + "description": "Id of the destination chat group, from `berdctl project get`." + } + }, + "required": ["session_id", "group_id"], + "additionalProperties": false + } + }, + "clear_project": { + "description": "Move a chat session out of any project; the session list in the app regroups immediately.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to move out of its project." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to move out of its project." + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "fork": { + "description": "Duplicate an existing chat session, copying its full conversation history into a new session the user can continue down an independent path. The fork appears in the app's session list; the user's current view does not change.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to fork (duplicate)." + }, + { + "name": "title", + "required": false, + "kind": "string", + "description": "Optional title for the forked session. Defaults to the source title.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to fork (duplicate)." + }, + "title": { + "description": "Optional title for the forked session. Defaults to the source title.", + "type": "string", + "minLength": 1 + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "archive": { + "description": "Archive a chat session, then remove eligible Goose-managed worktrees and branches. Refuses cleanup that would discard local files or changes unless --discard-changes is set.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to archive." + }, + { + "name": "discard_changes", + "required": false, + "kind": "boolean", + "description": "Discard local files and changes when removing managed worktrees or branches." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to archive." + }, + "discard_changes": { + "description": "Discard local files and changes when removing managed worktrees or branches.", + "type": "boolean" + } + }, + "required": ["session_id"], + "additionalProperties": false + } + } + } + }, + "folders": { + "description": "Manage folders attached to chat sessions: attach, detach, replace, set cwd, list.", + "actions": { + "attach": { + "description": "Register an existing folder as chat context. If the chat still uses Berd's implicit default cwd, the folder becomes cwd safely after the current turn; otherwise cwd stays unchanged. Repeating the command is safe and refreshes Git identity.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to attach to.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing absolute folder, repository, or worktree path; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to attach to." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing absolute folder, repository, or worktree path; ~ is expanded." + } + }, + "required": ["session_id", "path"], + "additionalProperties": false + } + }, + "detach": { + "description": "Remove an existing folder from chat context without deleting anything. If it is cwd, the chat safely falls back to the first remaining attachment or Berd's default cwd.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to detach from.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing attached folder, repository, or worktree path; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to detach from." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing attached folder, repository, or worktree path; ~ is expanded." + } + }, + "required": ["session_id", "path"], + "additionalProperties": false + } + }, + "replace": { + "description": "Use this for requests to switch or move the chat to a new worktree/folder. Validate first, then replace the old attachment in place without deleting anything. Replacing cwd safely moves cwd to the replacement.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to update.", + "min": 1 + }, + { + "name": "old_path", + "required": true, + "kind": "string", + "description": "Existing attached folder path to detach; ~ is expanded.", + "min": 1 + }, + { + "name": "new_path", + "required": true, + "kind": "string", + "description": "Existing replacement folder path to attach; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to update." + }, + "old_path": { + "type": "string", + "minLength": 1, + "description": "Existing attached folder path to detach; ~ is expanded." + }, + "new_path": { + "type": "string", + "minLength": 1, + "description": "Existing replacement folder path to attach; ~ is expanded." + } + }, + "required": ["session_id", "old_path", "new_path"], + "additionalProperties": false + } + }, + "set_cwd": { + "description": "Use this to select an already attached folder, or when the previous folder should remain additional context. It implicitly attaches the target and changes cwd immediately when idle or safely after the current turn.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to update.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing authorized folder to use as cwd; implicitly attached if needed.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to update." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing authorized folder to use as cwd; implicitly attached if needed." + } + }, + "required": ["session_id", "path"], + "additionalProperties": false + } + }, + "list": { + "description": "List every attached folder with its Git identity and whether it is the chat's current working directory.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to inspect.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to inspect." + } + }, + "required": ["session_id"], + "additionalProperties": false + } + } + } + }, + "projects": { + "description": "Manage the user's projects: create, list, get, set startup mode, archive.", + "actions": { + "create": { + "description": "Create a new project; it appears immediately in the app's project list.", + "fields": [ + { + "name": "name", + "required": true, + "kind": "string", + "description": "Name of the new project.", + "min": 1 + }, + { + "name": "instructions", + "required": false, + "kind": "string", + "description": "Instructions given to agents working in the project." + }, + { + "name": "working_dir", + "required": false, + "kind": "string_array", + "description": "Working directory to attach to the project; repeat for multiple directories." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the new project." + }, + "instructions": { + "description": "Instructions given to agents working in the project.", + "type": "string" + }, + "working_dir": { + "description": "Working directory to attach to the project; repeat for multiple directories.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's projects; does not change anything on screen.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "get": { + "description": "Read one project's details (instructions, working directories, per-workspace startup modes, chat groups, session count); does not change anything on screen.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to read." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Id of the project to read." + } + }, + "required": ["project_id"], + "additionalProperties": false + } + }, + "set_startup_mode": { + "description": "Set the startup behavior for new chats in a project. `auto-worktree` prompts before creating isolated worktrees, `ask-worktree` leaves worktree creation to the user, and `none` uses configured folders as-is. Legacy `worktree` and `branch` values migrate to those current modes.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to update." + }, + { + "name": "mode", + "required": true, + "kind": "string", + "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree.", + "values": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ] + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Id of the project to update." + }, + "mode": { + "type": "string", + "enum": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ], + "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree." + } + }, + "required": ["project_id", "mode"], + "additionalProperties": false + } + }, + "archive": { + "description": "Archive a project; it disappears from the app's project list (reversible from the archive). Sessions in the project are not archived and keep their project assignment.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to archive." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Id of the project to archive." + } + }, + "required": ["project_id"], + "additionalProperties": false + } + } + } + }, + "agents": { + "description": "Manage the user's agents (personas): create, list.", + "actions": { + "create": { + "description": "Create a new agent (persona); it is saved and becomes available in future chats.", + "fields": [ + { + "name": "name", + "required": true, + "kind": "string", + "description": "Name of the new agent (persona).", + "min": 1 + }, + { + "name": "system_prompt", + "required": true, + "kind": "string", + "description": "System prompt that defines the agent's behavior.", + "min": 1 + }, + { + "name": "model", + "required": false, + "kind": "string", + "description": "Model the agent should use." + }, + { + "name": "provider", + "required": false, + "kind": "string", + "description": "Provider of the model the agent should use." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the new agent (persona)." + }, + "system_prompt": { + "type": "string", + "minLength": 1, + "description": "System prompt that defines the agent's behavior." + }, + "model": { + "description": "Model the agent should use.", + "type": "string" + }, + "provider": { + "description": "Provider of the model the agent should use.", + "type": "string" + } + }, + "required": ["name", "system_prompt"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's agents (personas) usable as agent_id when creating a session; does not change anything on screen.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + } + }, + "skills": { + "description": "Manage the user's skills: create, list, get.", + "actions": { + "create": { + "description": "Create a new skill; it is saved as a SKILL.md and auto-loads into future sessions.", + "fields": [ + { + "name": "name", + "required": true, + "kind": "string", + "description": "Name of the new skill.", + "min": 1 + }, + { + "name": "description", + "required": true, + "kind": "string", + "description": "One-line description of what the skill does and when to use it.", + "min": 1 + }, + { + "name": "content", + "required": true, + "kind": "string", + "description": "The SKILL.md body content.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the new skill." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "One-line description of what the skill does and when to use it." + }, + "content": { + "type": "string", + "minLength": 1, + "description": "The SKILL.md body content." + } + }, + "required": ["name", "description", "content"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's skills (name and description only); does not change anything on screen. Read a skill's content with action \"get\".", + "fields": [ + { + "name": "project_id", + "required": false, + "kind": "string", + "description": "Also include skills from this project's working directories." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "description": "Also include skills from this project's working directories.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "get": { + "description": "Read one skill including its SKILL.md instructions; does not change anything on screen.", + "fields": [ + { + "name": "skill_id", + "required": true, + "kind": "string", + "description": "Id of the skill to read (from list)." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "skill_id": { + "type": "string", + "description": "Id of the skill to read (from list)." + } + }, + "required": ["skill_id"], + "additionalProperties": false + } + } + } + }, + "automations": { + "description": "Manage the user's scheduled automations: list, get, create, run now.", + "actions": { + "list": { + "description": "List the user's scheduled automations (id, title, schedule, status, and last-run info) as the Automations view shows them; does not change anything on screen. Read one automation's full detail with action \"get\".", + "fields": [ + { + "name": "query", + "required": false, + "kind": "string", + "description": "Case-insensitive substring filter on the automation title (1-200 chars).", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "description": "Case-insensitive substring filter on the automation title (1-200 chars).", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "additionalProperties": false + } + }, + "get": { + "description": "Read one automation's full detail — title, schedule, time zone, status, pause state, instructions, and latest-run wiring — exactly as the Automations view shows it; does not change anything on screen.", + "fields": [ + { + "name": "automation_id", + "required": true, + "kind": "string", + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars).", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "automation_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars)." + } + }, + "required": ["automation_id"], + "additionalProperties": false + } + }, + "create": { + "description": "Create a scheduled automation that runs the given instruction steps on the given cron cadence. The new automation appears in the Automations view immediately and can be edited, paused, or deleted there.", + "fields": [ + { + "name": "title", + "required": true, + "kind": "string", + "description": "Automation title, shown in the Automations view (1-200 chars).", + "min": 1, + "max": 200 + }, + { + "name": "schedule", + "required": true, + "kind": "string", + "description": "Cron schedule for the run cadence (e.g. \"0 */30 * * * *\"); the backend interprets it in --time-zone.", + "min": 1, + "max": 100 + }, + { + "name": "instruction", + "required": true, + "kind": "string_array", + "description": "One instruction step the automation runs each time (repeat the flag for multiple steps; 1-50 steps, each 1-10000 chars)." + }, + { + "name": "time_zone", + "required": false, + "kind": "string", + "description": "IANA time zone for the schedule (e.g. \"America/New_York\"); defaults to the app's current time zone.", + "min": 1, + "max": 100 + }, + { + "name": "enable_notifications", + "required": false, + "kind": "boolean", + "description": "Notify the user when runs complete; omitted means no notifications." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Automation title, shown in the Automations view (1-200 chars)." + }, + "schedule": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Cron schedule for the run cadence (e.g. \"0 */30 * * * *\"); the backend interprets it in --time-zone." + }, + "instruction": { + "minItems": 1, + "maxItems": 50, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "description": "One instruction step the automation runs each time (repeat the flag for multiple steps; 1-50 steps, each 1-10000 chars)." + }, + "time_zone": { + "description": "IANA time zone for the schedule (e.g. \"America/New_York\"); defaults to the app's current time zone.", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "enable_notifications": { + "default": false, + "description": "Notify the user when runs complete; omitted means no notifications.", + "type": "boolean" + } + }, + "required": ["title", "schedule", "instruction"], + "additionalProperties": false + } + }, + "run": { + "description": "Trigger one immediate run of an automation without changing its schedule. The run and its output appear in the Automations view like any scheduled run.", + "fields": [ + { + "name": "automation_id", + "required": true, + "kind": "string", + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars).", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "automation_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars)." + } + }, + "required": ["automation_id"], + "additionalProperties": false + } + } + } + }, + "info": { + "description": "Read-only app information: installed agent harnesses, available models, and the current app context.", + "actions": { + "list_harnesses": { + "description": "List the agent harnesses sessions can run on (goose, Claude Code, Codex, ...) with their readiness; only \"ready\" harnesses accept new sessions.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "list_models": { + "description": "List the models available per agent harness (same source as the app's model picker); omit harness_id to cover every ready harness in one call. Use a model_id (and its harness_id) when creating a session.", + "fields": [ + { + "name": "harness_id", + "required": false, + "kind": "string", + "description": "Agent harness to list models for (from list_harnesses). Omit to list models for every ready harness in one call." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "harness_id": { + "description": "Agent harness to list models for (from list_harnesses). Omit to list models for every ready harness in one call.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "get_context": { + "description": "Read the app's current context: which view and session the user is looking at, the active session's project, and the app version; does not change anything on screen.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + } + } + } +} diff --git a/src-tauri/crates/berdctl/api-surface-block.json b/src-tauri/crates/berdctl/api-surface-block.json new file mode 100644 index 000000000..a65a37dc7 --- /dev/null +++ b/src-tauri/crates/berdctl/api-surface-block.json @@ -0,0 +1,1298 @@ +{ + "$comment": "GENERATED FILE — do not hand-edit; run `pnpm generate:berdctl-contract`. Client-neutral wire surface of the Berd desktop app's control API: POST /v1/call {\"command\": \"\", \"args\": {\"action\": \"\", ...fields}} against the loopback endpoint in the berdctl discovery file. protocolVersion mirrors PROTOCOL_VERSION in both discovery.rs copies (berdctl and plugin crate tests pin them equal). Per action: description, fields (flat wire model: name, required, kind, values, description, bounds), and schema (JSON Schema 2020-12 of the args object, minus the action discriminator). Derived from the authoritative zod schemas in the colocated command modules (src/features/berdctl/commands/impl/*.ts); the renderer registry's strict zod parse — not this file — is the trust boundary. vitest asserts freshness (src/features/berdctl/__tests__/apiSurface.test.ts).", + "protocolVersion": 4, + "groups": { + "sessions": { + "description": "Manage the user's chat sessions: create (fire-and-forget, on any installed agent harness), send, open, list, get, rename, move, move to group, clear project, fork, archive.", + "actions": { + "create": { + "description": "Create a new chat session on any installed agent harness and send the prompt in it. Fire-and-forget: returns the session id immediately and the session runs in the background without changing what the user sees; the user can open it themselves. Only check on it later (action \"get\") if the user asks.", + "fields": [ + { + "name": "prompt", + "required": true, + "kind": "string", + "description": "The message to send in the new session (1-50000 chars).", + "min": 1, + "max": 50000 + }, + { + "name": "harness_id", + "required": false, + "kind": "string", + "description": "Agent harness to run the session on (from `berdctl info harnesses`, e.g. \"goose\", \"claude-acp\", \"codex-acp\"). Defaults to the app default." + }, + { + "name": "model_id", + "required": false, + "kind": "string", + "description": "Id of the model to use (from `berdctl info models`)." + }, + { + "name": "agent_id", + "required": false, + "kind": "string", + "description": "Id of the agent (persona) to use (from `berdctl agent list`)." + }, + { + "name": "project_id", + "required": false, + "kind": "string", + "description": "Id of the project to create the session in." + }, + { + "name": "startup_name", + "required": false, + "kind": "string", + "description": "Branch/worktree name when the project's startup mode is branch or worktree; required for those modes.", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The message to send in the new session (1-50000 chars)." + }, + "harness_id": { + "description": "Agent harness to run the session on (from `berdctl info harnesses`, e.g. \"goose\", \"claude-acp\", \"codex-acp\"). Defaults to the app default.", + "type": "string" + }, + "model_id": { + "description": "Id of the model to use (from `berdctl info models`).", + "type": "string" + }, + "agent_id": { + "description": "Id of the agent (persona) to use (from `berdctl agent list`).", + "type": "string" + }, + "project_id": { + "description": "Id of the project to create the session in.", + "type": "string" + }, + "startup_name": { + "description": "Branch/worktree name when the project's startup mode is branch or worktree; required for those modes.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["prompt"], + "additionalProperties": false + } + }, + "send": { + "description": "Send a prompt into an existing chat session without opening or focusing it. Idle sends are fire-and-forget and visibly add a user message marked as sent by Berd from another session. Running sessions are refused by default; use --if-running steer to add context to the active run, or --if-running queue to send one follow-up after the current run finishes.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the existing session to send the prompt into." + }, + { + "name": "prompt", + "required": true, + "kind": "string", + "description": "The message to send in the existing session (1-50000 chars).", + "min": 1, + "max": 50000 + }, + { + "name": "startup_name", + "required": false, + "kind": "string", + "description": "Branch/worktree name when this is the first send.", + "min": 1, + "max": 200 + }, + { + "name": "if_running", + "required": false, + "kind": "string", + "description": "What to do if the target session is running: refuse, steer, or queue.", + "values": ["refuse", "steer", "queue"] + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the existing session to send the prompt into." + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The message to send in the existing session (1-50000 chars)." + }, + "startup_name": { + "description": "Branch/worktree name when this is the first send.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "if_running": { + "default": "refuse", + "description": "What to do if the target session is running: refuse, steer, or queue.", + "type": "string", + "enum": ["refuse", "steer", "queue"] + } + }, + "required": ["session_id", "prompt"], + "additionalProperties": false + } + }, + "open": { + "description": "Open an existing chat session in the app window the user is looking at.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to open in the app." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to open in the app." + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's chat sessions visible in the app (most recent first); does not change anything on screen.", + "fields": [ + { + "name": "project_id", + "required": false, + "kind": "string", + "description": "Only list sessions belonging to this project." + }, + { + "name": "query", + "required": false, + "kind": "string", + "description": "Case-insensitive substring to match against session titles." + }, + { + "name": "limit", + "required": false, + "kind": "number", + "description": "Maximum number of sessions to return (1-100). Defaults to 20.", + "min": 1, + "max": 100 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "description": "Only list sessions belonging to this project.", + "type": "string" + }, + "query": { + "description": "Case-insensitive substring to match against session titles.", + "type": "string" + }, + "limit": { + "default": 20, + "description": "Maximum number of sessions to return (1-100). Defaults to 20.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false + } + }, + "get": { + "description": "Read one session's metadata and optionally its most recent messages; does not change anything on screen. Use only when the user asks about a session's status or content.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to read." + }, + { + "name": "messages", + "required": false, + "kind": "number", + "description": "How many of the session's most recent messages to include (0-50). Defaults to 0 (metadata only).", + "min": 0, + "max": 50 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to read." + }, + "messages": { + "default": 0, + "description": "How many of the session's most recent messages to include (0-50). Defaults to 0 (metadata only).", + "type": "integer", + "minimum": 0, + "maximum": 50 + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "rename": { + "description": "Rename a chat session; the new title appears immediately in the app's session list.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to rename." + }, + { + "name": "title", + "required": true, + "kind": "string", + "description": "The new session title.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to rename." + }, + "title": { + "type": "string", + "minLength": 1, + "description": "The new session title." + } + }, + "required": ["session_id", "title"], + "additionalProperties": false + } + }, + "move": { + "description": "Move a chat session into a project; the session list in the app regroups immediately.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to move." + }, + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the destination project." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to move." + }, + "project_id": { + "type": "string", + "description": "Id of the destination project." + } + }, + "required": ["session_id", "project_id"], + "additionalProperties": false + } + }, + "move_to_group": { + "description": "Move a chat session into an existing group in its current project; the project chat list regroups immediately.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to move." + }, + { + "name": "group_id", + "required": true, + "kind": "string", + "description": "Id of the destination chat group, from `berdctl project get`." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to move." + }, + "group_id": { + "type": "string", + "description": "Id of the destination chat group, from `berdctl project get`." + } + }, + "required": ["session_id", "group_id"], + "additionalProperties": false + } + }, + "clear_project": { + "description": "Move a chat session out of any project; the session list in the app regroups immediately.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to move out of its project." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to move out of its project." + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "fork": { + "description": "Duplicate an existing chat session, copying its full conversation history into a new session the user can continue down an independent path. The fork appears in the app's session list; the user's current view does not change.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to fork (duplicate)." + }, + { + "name": "title", + "required": false, + "kind": "string", + "description": "Optional title for the forked session. Defaults to the source title.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to fork (duplicate)." + }, + "title": { + "description": "Optional title for the forked session. Defaults to the source title.", + "type": "string", + "minLength": 1 + } + }, + "required": ["session_id"], + "additionalProperties": false + } + }, + "archive": { + "description": "Archive a chat session, then remove eligible Goose-managed worktrees and branches. Refuses cleanup that would discard local files or changes unless --discard-changes is set.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to archive." + }, + { + "name": "discard_changes", + "required": false, + "kind": "boolean", + "description": "Discard local files and changes when removing managed worktrees or branches." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Id of the session to archive." + }, + "discard_changes": { + "description": "Discard local files and changes when removing managed worktrees or branches.", + "type": "boolean" + } + }, + "required": ["session_id"], + "additionalProperties": false + } + } + } + }, + "folders": { + "description": "Manage folders attached to chat sessions: attach, detach, replace, set cwd, list.", + "actions": { + "attach": { + "description": "Register an existing folder as chat context. If the chat still uses Berd's implicit default cwd, the folder becomes cwd safely after the current turn; otherwise cwd stays unchanged. Repeating the command is safe and refreshes Git identity.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to attach to.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing absolute folder, repository, or worktree path; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to attach to." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing absolute folder, repository, or worktree path; ~ is expanded." + } + }, + "required": ["session_id", "path"], + "additionalProperties": false + } + }, + "detach": { + "description": "Remove an existing folder from chat context without deleting anything. If it is cwd, the chat safely falls back to the first remaining attachment or Berd's default cwd.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to detach from.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing attached folder, repository, or worktree path; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to detach from." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing attached folder, repository, or worktree path; ~ is expanded." + } + }, + "required": ["session_id", "path"], + "additionalProperties": false + } + }, + "replace": { + "description": "Use this for requests to switch or move the chat to a new worktree/folder. Validate first, then replace the old attachment in place without deleting anything. Replacing cwd safely moves cwd to the replacement.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to update.", + "min": 1 + }, + { + "name": "old_path", + "required": true, + "kind": "string", + "description": "Existing attached folder path to detach; ~ is expanded.", + "min": 1 + }, + { + "name": "new_path", + "required": true, + "kind": "string", + "description": "Existing replacement folder path to attach; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to update." + }, + "old_path": { + "type": "string", + "minLength": 1, + "description": "Existing attached folder path to detach; ~ is expanded." + }, + "new_path": { + "type": "string", + "minLength": 1, + "description": "Existing replacement folder path to attach; ~ is expanded." + } + }, + "required": ["session_id", "old_path", "new_path"], + "additionalProperties": false + } + }, + "set_cwd": { + "description": "Use this to select an already attached folder, or when the previous folder should remain additional context. It implicitly attaches the target and changes cwd immediately when idle or safely after the current turn.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to update.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing authorized folder to use as cwd; implicitly attached if needed.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to update." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing authorized folder to use as cwd; implicitly attached if needed." + } + }, + "required": ["session_id", "path"], + "additionalProperties": false + } + }, + "list": { + "description": "List every attached folder with its Git identity and whether it is the chat's current working directory.", + "fields": [ + { + "name": "session_id", + "required": true, + "kind": "string", + "description": "Id of the session to inspect.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1, + "description": "Id of the session to inspect." + } + }, + "required": ["session_id"], + "additionalProperties": false + } + } + } + }, + "projects": { + "description": "Manage the user's projects: create, list, get, set startup mode, archive.", + "actions": { + "create": { + "description": "Create a new project; it appears immediately in the app's project list.", + "fields": [ + { + "name": "name", + "required": true, + "kind": "string", + "description": "Name of the new project.", + "min": 1 + }, + { + "name": "instructions", + "required": false, + "kind": "string", + "description": "Instructions given to agents working in the project." + }, + { + "name": "working_dir", + "required": false, + "kind": "string_array", + "description": "Working directory to attach to the project; repeat for multiple directories." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the new project." + }, + "instructions": { + "description": "Instructions given to agents working in the project.", + "type": "string" + }, + "working_dir": { + "description": "Working directory to attach to the project; repeat for multiple directories.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's projects; does not change anything on screen.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "get": { + "description": "Read one project's details (instructions, working directories, per-workspace startup modes, chat groups, session count); does not change anything on screen.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to read." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Id of the project to read." + } + }, + "required": ["project_id"], + "additionalProperties": false + } + }, + "set_startup_mode": { + "description": "Set the startup behavior for new chats in a project. `auto-worktree` prompts before creating isolated worktrees, `ask-worktree` leaves worktree creation to the user, and `none` uses configured folders as-is. Legacy `worktree` and `branch` values migrate to those current modes.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to update." + }, + { + "name": "mode", + "required": true, + "kind": "string", + "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree.", + "values": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ] + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Id of the project to update." + }, + "mode": { + "type": "string", + "enum": [ + "none", + "branch", + "worktree", + "ask-worktree", + "auto-worktree" + ], + "description": "How new chats start from the project's Git workspaces: use them as-is, create a branch, or create an isolated worktree." + } + }, + "required": ["project_id", "mode"], + "additionalProperties": false + } + }, + "archive": { + "description": "Archive a project; it disappears from the app's project list (reversible from the archive). Sessions in the project are not archived and keep their project assignment.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to archive." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Id of the project to archive." + } + }, + "required": ["project_id"], + "additionalProperties": false + } + } + } + }, + "agents": { + "description": "Manage the user's agents (personas): create, list.", + "actions": { + "create": { + "description": "Create a new agent (persona); it is saved and becomes available in future chats.", + "fields": [ + { + "name": "name", + "required": true, + "kind": "string", + "description": "Name of the new agent (persona).", + "min": 1 + }, + { + "name": "system_prompt", + "required": true, + "kind": "string", + "description": "System prompt that defines the agent's behavior.", + "min": 1 + }, + { + "name": "model", + "required": false, + "kind": "string", + "description": "Model the agent should use." + }, + { + "name": "provider", + "required": false, + "kind": "string", + "description": "Provider of the model the agent should use." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the new agent (persona)." + }, + "system_prompt": { + "type": "string", + "minLength": 1, + "description": "System prompt that defines the agent's behavior." + }, + "model": { + "description": "Model the agent should use.", + "type": "string" + }, + "provider": { + "description": "Provider of the model the agent should use.", + "type": "string" + } + }, + "required": ["name", "system_prompt"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's agents (personas) usable as agent_id when creating a session; does not change anything on screen.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + } + }, + "skills": { + "description": "Manage the user's skills: create, list, get.", + "actions": { + "create": { + "description": "Create a new skill; it is saved as a SKILL.md and auto-loads into future sessions.", + "fields": [ + { + "name": "name", + "required": true, + "kind": "string", + "description": "Name of the new skill.", + "min": 1 + }, + { + "name": "description", + "required": true, + "kind": "string", + "description": "One-line description of what the skill does and when to use it.", + "min": 1 + }, + { + "name": "content", + "required": true, + "kind": "string", + "description": "The SKILL.md body content.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the new skill." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "One-line description of what the skill does and when to use it." + }, + "content": { + "type": "string", + "minLength": 1, + "description": "The SKILL.md body content." + } + }, + "required": ["name", "description", "content"], + "additionalProperties": false + } + }, + "list": { + "description": "List the user's skills (name and description only); does not change anything on screen. Read a skill's content with action \"get\".", + "fields": [ + { + "name": "project_id", + "required": false, + "kind": "string", + "description": "Also include skills from this project's working directories." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "description": "Also include skills from this project's working directories.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "get": { + "description": "Read one skill including its SKILL.md instructions; does not change anything on screen.", + "fields": [ + { + "name": "skill_id", + "required": true, + "kind": "string", + "description": "Id of the skill to read (from list)." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "skill_id": { + "type": "string", + "description": "Id of the skill to read (from list)." + } + }, + "required": ["skill_id"], + "additionalProperties": false + } + } + } + }, + "automations": { + "description": "Manage the user's scheduled automations: list, get, create, run now.", + "actions": { + "list": { + "description": "List the user's scheduled automations (id, title, schedule, status, and last-run info) as the Automations view shows them; does not change anything on screen. Read one automation's full detail with action \"get\".", + "fields": [ + { + "name": "query", + "required": false, + "kind": "string", + "description": "Case-insensitive substring filter on the automation title (1-200 chars).", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "description": "Case-insensitive substring filter on the automation title (1-200 chars).", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "additionalProperties": false + } + }, + "get": { + "description": "Read one automation's full detail — title, schedule, time zone, status, pause state, instructions, and latest-run wiring — exactly as the Automations view shows it; does not change anything on screen.", + "fields": [ + { + "name": "automation_id", + "required": true, + "kind": "string", + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars).", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "automation_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars)." + } + }, + "required": ["automation_id"], + "additionalProperties": false + } + }, + "create": { + "description": "Create a scheduled automation that runs the given instruction steps on the given cron cadence. The new automation appears in the Automations view immediately and can be edited, paused, or deleted there.", + "fields": [ + { + "name": "title", + "required": true, + "kind": "string", + "description": "Automation title, shown in the Automations view (1-200 chars).", + "min": 1, + "max": 200 + }, + { + "name": "schedule", + "required": true, + "kind": "string", + "description": "Cron schedule for the run cadence (e.g. \"0 */30 * * * *\"); the backend interprets it in --time-zone.", + "min": 1, + "max": 100 + }, + { + "name": "instruction", + "required": true, + "kind": "string_array", + "description": "One instruction step the automation runs each time (repeat the flag for multiple steps; 1-50 steps, each 1-10000 chars)." + }, + { + "name": "time_zone", + "required": false, + "kind": "string", + "description": "IANA time zone for the schedule (e.g. \"America/New_York\"); defaults to the app's current time zone.", + "min": 1, + "max": 100 + }, + { + "name": "enable_notifications", + "required": false, + "kind": "boolean", + "description": "Notify the user when runs complete; omitted means no notifications." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Automation title, shown in the Automations view (1-200 chars)." + }, + "schedule": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Cron schedule for the run cadence (e.g. \"0 */30 * * * *\"); the backend interprets it in --time-zone." + }, + "instruction": { + "minItems": 1, + "maxItems": 50, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "description": "One instruction step the automation runs each time (repeat the flag for multiple steps; 1-50 steps, each 1-10000 chars)." + }, + "time_zone": { + "description": "IANA time zone for the schedule (e.g. \"America/New_York\"); defaults to the app's current time zone.", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "enable_notifications": { + "default": false, + "description": "Notify the user when runs complete; omitted means no notifications.", + "type": "boolean" + } + }, + "required": ["title", "schedule", "instruction"], + "additionalProperties": false + } + }, + "run": { + "description": "Trigger one immediate run of an automation without changing its schedule. The run and its output appear in the Automations view like any scheduled run.", + "fields": [ + { + "name": "automation_id", + "required": true, + "kind": "string", + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars).", + "min": 1, + "max": 200 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "automation_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Automation id, as returned by `berdctl automation list` (1-200 chars)." + } + }, + "required": ["automation_id"], + "additionalProperties": false + } + } + } + }, + "feedback": { + "description": "Open an approved report in Berd's feedback form or submit it directly after explicit user approval.", + "actions": { + "open": { + "description": "Open Berd's existing feedback form with the supplied title and description ready for optional hand editing. Nothing is submitted. Logs and Doctor diagnostics are selected only when --include-logs is explicitly passed.", + "fields": [ + { + "name": "title", + "required": true, + "kind": "string", + "description": "Report title to prefill in the feedback form (1-200 chars).", + "min": 1, + "max": 200 + }, + { + "name": "description", + "required": true, + "kind": "string", + "description": "Report description to prefill in the feedback form (1-50000 chars).", + "min": 1, + "max": 50000 + }, + { + "name": "include_logs", + "required": false, + "kind": "boolean", + "description": "Prefill the explicit opt-in to attach sanitized logs and Doctor diagnostics; omitted means false." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Report title to prefill in the feedback form (1-200 chars)." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Report description to prefill in the feedback form (1-50000 chars)." + }, + "include_logs": { + "default": false, + "description": "Prefill the explicit opt-in to attach sanitized logs and Doctor diagnostics; omitted means false.", + "type": "boolean" + } + }, + "required": ["title", "description"], + "additionalProperties": false + } + }, + "submit": { + "description": "Submit the supplied approved report without opening the feedback form. Use this only after the user explicitly asks to file, send, or submit it. Berd immediately shows success or failure in the app. Logs and Doctor diagnostics are attached only when --include-logs is explicitly passed.", + "fields": [ + { + "name": "title", + "required": true, + "kind": "string", + "description": "Approved report title to submit (1-200 chars).", + "min": 1, + "max": 200 + }, + { + "name": "description", + "required": true, + "kind": "string", + "description": "Approved report description to submit (1-50000 chars).", + "min": 1, + "max": 50000 + }, + { + "name": "include_logs", + "required": false, + "kind": "boolean", + "description": "Explicitly opt in to attach sanitized logs and Doctor diagnostics; omitted means false." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Approved report title to submit (1-200 chars)." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Approved report description to submit (1-50000 chars)." + }, + "include_logs": { + "default": false, + "description": "Explicitly opt in to attach sanitized logs and Doctor diagnostics; omitted means false.", + "type": "boolean" + } + }, + "required": ["title", "description"], + "additionalProperties": false + } + } + } + }, + "info": { + "description": "Read-only app information: installed agent harnesses, available models, and the current app context.", + "actions": { + "list_harnesses": { + "description": "List the agent harnesses sessions can run on (goose, Claude Code, Codex, ...) with their readiness; only \"ready\" harnesses accept new sessions.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "list_models": { + "description": "List the models available per agent harness (same source as the app's model picker); omit harness_id to cover every ready harness in one call. Use a model_id (and its harness_id) when creating a session.", + "fields": [ + { + "name": "harness_id", + "required": false, + "kind": "string", + "description": "Agent harness to list models for (from list_harnesses). Omit to list models for every ready harness in one call." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "harness_id": { + "description": "Agent harness to list models for (from list_harnesses). Omit to list models for every ready harness in one call.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "get_context": { + "description": "Read the app's current context: which view and session the user is looking at, the active session's project, and the app version; does not change anything on screen.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + } + } + } +} diff --git a/src-tauri/crates/berdctl/cli-surface-automations.json b/src-tauri/crates/berdctl/cli-surface-automations.json new file mode 100644 index 000000000..506614943 --- /dev/null +++ b/src-tauri/crates/berdctl/cli-surface-automations.json @@ -0,0 +1,212 @@ +{ + "$comment": "GENERATED FILE — do not hand-edit; run `pnpm generate:berdctl-contract`. CLI projection of api-surface.json: the noun/verb tree mapping onto groups/actions, plus CLI-only prose (noun about lines, verb summaries, after-help footers), derived from TOOL_GROUPS' cli metadata and each command's summary/helpFooter (src/features/berdctl/commands/registry.ts and impl/*.ts). berdctl embeds this file plus api-surface.json and builds its clap tree from them at startup (tree.rs); vitest asserts freshness (src/features/berdctl/__tests__/cliSurface.test.ts).", + "nouns": { + "session": { + "group": "sessions", + "about": "Manage chat sessions: create, send, open, list, get, rename, move, move to group, clear project, fork, archive", + "verbs": { + "create": { + "action": "create", + "about": "Create a new chat session and send a prompt in it (fire-and-forget)", + "afterHelp": "Examples:\n berdctl session create --prompt \"Triage the failing nightly build\" \\\n --harness-id claude-acp --json\n berdctl session create --prompt \"Implement the fix\" \\\n --project-id --startup-name my-feature\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"send_status\": \"dispatched\"}\n The session runs in the background; the user's view does not change. Check\n progress later with `berdctl session get --session-id `." + }, + "send": { + "action": "send", + "about": "Send a prompt into an existing chat session", + "afterHelp": "Example:\n berdctl session send --session-id \\\n --prompt \"Check the latest CI failure\" --if-running queue --json\n\nResult:\n {\"session_id\": \"...\", \"send_status\": \"dispatched\"|\"steered\"|\"queued\"}\n The user's current view does not change." + }, + "open": { + "action": "open", + "about": "Open a session in the app window the user is looking at", + "afterHelp": "Example:\n berdctl session open --session-id \n\nResult:\n {\"ok\": true} — the session is now on screen." + }, + "list": { + "action": "list", + "about": "List the user's chat sessions (most recent first)", + "afterHelp": "Example:\n berdctl session list --query \"review\" --limit 10 --json\n\nResult:\n {\"sessions\": [{\"session_id\": \"...\", \"title\": \"...\",\n \"project_id\": \"...\"|null, \"updated_at\": \"...\",\n \"is_running\": false, \"chat_state\": \"idle\",\n \"message_count\": 12}, ...]}\n Most recent first; archived sessions are excluded." + }, + "get": { + "action": "get", + "about": "Read one session's metadata and optionally its latest messages", + "afterHelp": "Example:\n berdctl session get --session-id --messages 5 --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"model_id\": \"...\"|null, \"agent_id\": \"...\"|null,\n \"project_id\": \"...\"|null, \"working_dir\": \"...\"|null,\n \"created_at\": \"...\", \"updated_at\": \"...\", \"archived\": false,\n \"is_running\": false, \"is_open_in_window\": false,\n \"chat_state\": \"idle\", \"message_count\": 12,\n \"messages\": [{\"role\": \"user\"|\"assistant\"|null, \"text\": \"...\"}, ...]}\n \"messages\" is present only when --messages > 0; each message text is\n truncated to 2000 chars." + }, + "rename": { + "action": "rename", + "about": "Rename a chat session", + "afterHelp": "Example:\n berdctl session rename --session-id --title \"CI triage\"\n\nResult:\n {\"ok\": true} — the new title appears immediately in the app's session list." + }, + "move": { + "action": "move", + "about": "Move a chat session into a project", + "afterHelp": "Example:\n berdctl session move --session-id --project-id \n\nResult:\n {\"ok\": true} — the app's session list regroups immediately." + }, + "move-to-group": { + "action": "move_to_group", + "about": "Move a chat session into an existing project group", + "afterHelp": "Find the destination group id with:\n berdctl project get --project-id --json\n\nExample:\n berdctl session move-to-group --session-id \\\n --group-id \n\nResult:\n {\"ok\": true, \"project_id\": \"...\", \"group_id\": \"...\",\n \"group_name\": \"...\"} — the project chat list regroups immediately." + }, + "clear-project": { + "action": "clear_project", + "about": "Move a chat session out of any project", + "afterHelp": "Example:\n berdctl session clear-project --session-id \n\nResult:\n {\"ok\": true} — the app's session list regroups immediately." + }, + "fork": { + "action": "fork", + "about": "Fork a chat session into an independent copy with its history", + "afterHelp": "Example:\n berdctl session fork --session-id --title \"Alternate approach\"\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"source_session_id\": \"...\",\n \"message_count\": 7}\n The fork appears in the session list with a copy of the original history." + }, + "archive": { + "action": "archive", + "about": "Archive a chat and clean up its Goose-managed Git resources", + "afterHelp": "By default, the command refuses to archive when Git cleanup would discard local files or changes.\nUse --discard-changes to allow that loss. The command never opens an interactive prompt.\n\nExamples:\n berdctl session archive --session-id \n berdctl session archive --session-id --discard-changes\n\nResult:\n {\"ok\": true} — the session was archived and eligible worktrees and branches were removed." + } + } + }, + "folder": { + "group": "folders", + "about": "Manage attached chat folders: attach, detach, replace, set cwd, list", + "verbs": { + "attach": { + "action": "attach", + "about": "Attach a folder, repository, or worktree to a chat", + "afterHelp": "Use this after creating or adopting a folder inside a workspace root already authorized for the chat. Registered Git worktrees of an authorized repository are also allowed; select a new unrelated root in Berd first.\n\nExample:\n berdctl folder attach --session-id --path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null}" + }, + "detach": { + "action": "detach", + "about": "Detach a folder, repository, or worktree from a chat", + "afterHelp": "Detach the checked-out folder to detach its branch from chat context. This does not delete anything from disk or Git.\n\nExample:\n berdctl folder detach --session-id --path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"detached\": true|false}" + }, + "replace": { + "action": "replace", + "about": "Replace the chat's current folder with another", + "afterHelp": "Prefer this over set-cwd when the user says to switch or move the chat to a new worktree/folder and does not ask to retain the old folder as context. Use set-cwd to select an already attached folder or when the old attachment should remain.\n\nThe old path must be attached. For the usual switch flow, get the current cwd from `berdctl folder list` and pass it as --old-path.\n\nExample:\n berdctl folder replace --session-id --old-path ~/src/repo --new-path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"oldPath\": \"...\", \"newPath\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null}" + }, + "set-cwd": { + "action": "set_cwd", + "about": "Select a chat folder as cwd, retaining other attachments", + "afterHelp": "For a request to switch or move the chat to a new worktree/folder, prefer `folder replace` so the old folder leaves context. Use this command when selecting among attachments or intentionally retaining the old folder.\n\nThe path must satisfy the same authorization rules as folder attach. Files and Git state are not moved.\n\nExample:\n berdctl folder set-cwd --session-id --path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null, \"status\": \"applied\"|\"pending\"}" + }, + "list": { + "action": "list", + "about": "List folders attached to a chat and identify its cwd", + "afterHelp": "The top-level cwd is returned even when it is Berd's implicit default and is not an attached folder.\n\nExample:\n berdctl folder list --session-id \n\nResult:\n {\"ok\": true, \"cwd\": \"...\", \"folders\": [{\"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null, \"cwd\": true|false}]}" + } + } + }, + "project": { + "group": "projects", + "about": "Manage projects: create, list, get, set startup mode, archive", + "verbs": { + "create": { + "action": "create", + "about": "Create a new project", + "afterHelp": "Example:\n berdctl project create --name \"Code reviews\" \\\n --instructions \"Prefer small diffs\" \\\n --working-dir /Users/me/src/api --working-dir /Users/me/src/web\n\nResult:\n {\"project_id\": \"...\"} — the project appears immediately in the app's\n project list with both directories attached." + }, + "list": { + "action": "list", + "about": "List the user's projects", + "afterHelp": "Example:\n berdctl project list --json\n\nResult:\n {\"projects\": [{\"project_id\": \"...\", \"name\": \"...\",\n \"description\": \"...\", \"working_dirs\": [\"...\"]}, ...]}" + }, + "get": { + "action": "get", + "about": "Read one project's details", + "afterHelp": "Example:\n berdctl project get --project-id --json\n\nResult:\n {\"project_id\": \"...\", \"name\": \"...\", \"description\": \"...\",\n \"instructions\": \"...\", \"working_dirs\": [\"...\"], \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"worktree\"}\n ], \"archived\": false, \"session_count\": 4, \"chat_groups\": [\n {\"group_id\": \"...\", \"name\": \"Launch\", \"session_ids\": [\"...\"]}\n ]}" + }, + "set-startup-mode": { + "action": "set_startup_mode", + "about": "Set how a project's new chats start from its Git workspaces", + "afterHelp": "The mode applies to every Git workspace configured on the project. Non-Git\nfolders remain in the project but use mode \"none\". Legacy \"worktree\" becomes\n\"auto-worktree\" and legacy \"branch\" becomes \"ask-worktree\".\n\nExamples:\n berdctl project set-startup-mode --project-id --mode auto-worktree\n berdctl project set-startup-mode --project-id --mode none\n\nResult:\n {\"ok\": true, \"mode\": \"auto-worktree\", \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"auto-worktree\"}\n ]}" + }, + "archive": { + "action": "archive", + "about": "Archive a project (reversible; nothing is deleted)", + "afterHelp": "Archiving is reversible from the app's archive view; nothing is\ndeleted, and the project's sessions are left untouched.\n\nExample:\n berdctl project archive --project-id \n\nResult:\n {\"ok\": true} — the project disappears from the app's project list." + } + } + }, + "agent": { + "group": "agents", + "about": "Manage agents (personas): create, list", + "verbs": { + "create": { + "action": "create", + "about": "Create a new agent (persona)", + "afterHelp": "Example:\n berdctl agent create --name \"Reviewer\" \\\n --system-prompt \"You review diffs for correctness; be terse.\"\n\nResult:\n {\"agent_id\": \"...\"} — the agent is saved and becomes available in\n future chats; pass it as --agent-id to `berdctl session create`." + }, + "list": { + "action": "list", + "about": "List the user's agents (personas)", + "afterHelp": "Example:\n berdctl agent list --json\n\nResult:\n {\"agents\": [{\"agent_id\": \"...\", \"name\": \"...\",\n \"summary\": \"...\", \"builtin\": false}, ...]}\n Use an agent_id as --agent-id when creating a session." + } + } + }, + "skill": { + "group": "skills", + "about": "Manage skills: create, list, get", + "verbs": { + "create": { + "action": "create", + "about": "Create a new skill (saved as a SKILL.md)", + "afterHelp": "Example:\n berdctl skill create --name \"release-notes\" \\\n --description \"Draft release notes from merged PRs\" \\\n --content \"$(cat SKILL.md)\"\n\nResult:\n {\"skill_id\": \"...\"} — the skill is saved as a SKILL.md and auto-loads\n into future sessions." + }, + "list": { + "action": "list", + "about": "List the user's skills (name and description only)", + "afterHelp": "Example:\n berdctl skill list --json\n\nResult:\n {\"skills\": [{\"skill_id\": \"...\", \"name\": \"...\",\n \"description\": \"...\",\n \"source\": \"app\"|\"builtin\"|\"global\"|\"project\"}, ...]}\n Read a skill's full content with `berdctl skill get`." + }, + "get": { + "action": "get", + "about": "Read one skill including its SKILL.md instructions", + "afterHelp": "Example:\n berdctl skill get --skill-id --json\n\nResult:\n {\"skill_id\": \"...\", \"name\": \"...\", \"description\": \"...\",\n \"source\": \"app\"|\"builtin\"|\"global\"|\"project\", \"instructions\": \"...\"}" + } + } + }, + "automation": { + "group": "automations", + "about": "Manage scheduled automations: list, get, create, run", + "verbs": { + "list": { + "action": "list", + "about": "List the user's automations", + "afterHelp": "Example:\n berdctl automation list --json\n berdctl automation list --query \"review\" --json\n\nResult:\n {\"automations\": [{\"automation_id\": \"...\", \"title\": \"...\",\n \"schedule\": \"0 */30 * * * *\", \"time_zone\": \"...\",\n \"status\": \"...\", \"latest_run_status\": \"...\",\n \"schedule_paused\": false, \"last_success_at\": \"...\"}, ...]}\n Read one automation's instructions with `berdctl automation get`." + }, + "get": { + "action": "get", + "about": "Read one automation's full detail", + "afterHelp": "Example:\n berdctl automation get --automation-id --json\n\nResult:\n {\"automation_id\": \"...\", \"title\": \"...\", \"schedule\": \"0 */30 * * * *\",\n \"time_zone\": \"...\", \"status\": \"...\", \"latest_run_status\": \"...\",\n \"schedule_paused\": false, \"instructions\": [\"...\"],\n \"human_readable_instructions\": [\"...\"],\n \"latest_chat_session_id\": \"...\", \"created\": \"...\", \"updated\": \"...\"}\n Find ids with `berdctl automation list`." + }, + "create": { + "action": "create", + "about": "Create a scheduled automation", + "afterHelp": "Example:\n berdctl automation create --title \"Morning digest\" \\\n --schedule \"0 0 9 * * *\" --time-zone \"America/New_York\" \\\n --instruction \"Summarize my unread Slack messages\" \\\n --instruction \"Post the summary to my notes\" --json\n\nResult:\n {\"automation_id\": \"...\", \"title\": \"Morning digest\",\n \"schedule\": \"0 0 9 * * *\"} — visible in the Automations view.\n Inspect it with `berdctl automation get`; run it now with\n `berdctl automation run`." + }, + "run": { + "action": "run", + "about": "Run an automation now, off schedule", + "afterHelp": "Example:\n berdctl automation run --automation-id --json\n\nResult:\n {\"automation_id\": \"...\", \"run_session_id\": \"...\"} — the run's session id,\n when the backend reports one. Watch progress in the Automations view or\n read the result later with `berdctl automation get`." + } + } + }, + "info": { + "group": "info", + "about": "Look up installed harnesses, available models, and the app context", + "verbs": { + "harnesses": { + "action": "list_harnesses", + "about": "List the agent harnesses sessions can run on, with readiness", + "afterHelp": "Example:\n berdctl info harnesses --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"goose\", \"name\": \"...\",\n \"is_default\": true,\n \"status\": \"ready\"|\"not_installed\"|\"not_ready\"}, ...]}\n Only \"ready\" harnesses accept new sessions; \"not_installed\" and\n \"not_ready\" (sign-in or setup required) need the user to fix them in\n the app first." + }, + "models": { + "action": "list_models", + "about": "List the models available per agent harness", + "afterHelp": "Example:\n berdctl info models --harness-id goose --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"...\",\n \"models\": [{\"model_id\": \"...\", \"name\": \"...\",\n \"provider\": \"...\"?}],\n \"warning\": \"...\"?}]}\n Use a model_id (with its harness) as --model-id when creating a session.\n \"warning\" appears when a stale cached list was served or when the harness\n manages its model outside the app." + }, + "context": { + "action": "get_context", + "about": "Read what the user is looking at in the app right now", + "afterHelp": "Example:\n berdctl info context --json\n\nResult:\n {\"view\": \"...\", \"active_session_id\": \"...\"|null,\n \"active_project_id\": \"...\"|null, \"app_version\": \"...\"}" + } + } + } + } +} diff --git a/src-tauri/crates/berdctl/cli-surface-block.json b/src-tauri/crates/berdctl/cli-surface-block.json new file mode 100644 index 000000000..41ec7ce7e --- /dev/null +++ b/src-tauri/crates/berdctl/cli-surface-block.json @@ -0,0 +1,228 @@ +{ + "$comment": "GENERATED FILE — do not hand-edit; run `pnpm generate:berdctl-contract`. CLI projection of api-surface.json: the noun/verb tree mapping onto groups/actions, plus CLI-only prose (noun about lines, verb summaries, after-help footers), derived from TOOL_GROUPS' cli metadata and each command's summary/helpFooter (src/features/berdctl/commands/registry.ts and impl/*.ts). berdctl embeds this file plus api-surface.json and builds its clap tree from them at startup (tree.rs); vitest asserts freshness (src/features/berdctl/__tests__/cliSurface.test.ts).", + "nouns": { + "session": { + "group": "sessions", + "about": "Manage chat sessions: create, send, open, list, get, rename, move, move to group, clear project, fork, archive", + "verbs": { + "create": { + "action": "create", + "about": "Create a new chat session and send a prompt in it (fire-and-forget)", + "afterHelp": "Examples:\n berdctl session create --prompt \"Triage the failing nightly build\" \\\n --harness-id claude-acp --json\n berdctl session create --prompt \"Implement the fix\" \\\n --project-id --startup-name my-feature\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"send_status\": \"dispatched\"}\n The session runs in the background; the user's view does not change. Check\n progress later with `berdctl session get --session-id `." + }, + "send": { + "action": "send", + "about": "Send a prompt into an existing chat session", + "afterHelp": "Example:\n berdctl session send --session-id \\\n --prompt \"Check the latest CI failure\" --if-running queue --json\n\nResult:\n {\"session_id\": \"...\", \"send_status\": \"dispatched\"|\"steered\"|\"queued\"}\n The user's current view does not change." + }, + "open": { + "action": "open", + "about": "Open a session in the app window the user is looking at", + "afterHelp": "Example:\n berdctl session open --session-id \n\nResult:\n {\"ok\": true} — the session is now on screen." + }, + "list": { + "action": "list", + "about": "List the user's chat sessions (most recent first)", + "afterHelp": "Example:\n berdctl session list --query \"review\" --limit 10 --json\n\nResult:\n {\"sessions\": [{\"session_id\": \"...\", \"title\": \"...\",\n \"project_id\": \"...\"|null, \"updated_at\": \"...\",\n \"is_running\": false, \"chat_state\": \"idle\",\n \"message_count\": 12}, ...]}\n Most recent first; archived sessions are excluded." + }, + "get": { + "action": "get", + "about": "Read one session's metadata and optionally its latest messages", + "afterHelp": "Example:\n berdctl session get --session-id --messages 5 --json\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"harness_id\": \"...\",\n \"model_id\": \"...\"|null, \"agent_id\": \"...\"|null,\n \"project_id\": \"...\"|null, \"working_dir\": \"...\"|null,\n \"created_at\": \"...\", \"updated_at\": \"...\", \"archived\": false,\n \"is_running\": false, \"is_open_in_window\": false,\n \"chat_state\": \"idle\", \"message_count\": 12,\n \"messages\": [{\"role\": \"user\"|\"assistant\"|null, \"text\": \"...\"}, ...]}\n \"messages\" is present only when --messages > 0; each message text is\n truncated to 2000 chars." + }, + "rename": { + "action": "rename", + "about": "Rename a chat session", + "afterHelp": "Example:\n berdctl session rename --session-id --title \"CI triage\"\n\nResult:\n {\"ok\": true} — the new title appears immediately in the app's session list." + }, + "move": { + "action": "move", + "about": "Move a chat session into a project", + "afterHelp": "Example:\n berdctl session move --session-id --project-id \n\nResult:\n {\"ok\": true} — the app's session list regroups immediately." + }, + "move-to-group": { + "action": "move_to_group", + "about": "Move a chat session into an existing project group", + "afterHelp": "Find the destination group id with:\n berdctl project get --project-id --json\n\nExample:\n berdctl session move-to-group --session-id \\\n --group-id \n\nResult:\n {\"ok\": true, \"project_id\": \"...\", \"group_id\": \"...\",\n \"group_name\": \"...\"} — the project chat list regroups immediately." + }, + "clear-project": { + "action": "clear_project", + "about": "Move a chat session out of any project", + "afterHelp": "Example:\n berdctl session clear-project --session-id \n\nResult:\n {\"ok\": true} — the app's session list regroups immediately." + }, + "fork": { + "action": "fork", + "about": "Fork a chat session into an independent copy with its history", + "afterHelp": "Example:\n berdctl session fork --session-id --title \"Alternate approach\"\n\nResult:\n {\"session_id\": \"...\", \"title\": \"...\", \"source_session_id\": \"...\",\n \"message_count\": 7}\n The fork appears in the session list with a copy of the original history." + }, + "archive": { + "action": "archive", + "about": "Archive a chat and clean up its Goose-managed Git resources", + "afterHelp": "By default, the command refuses to archive when Git cleanup would discard local files or changes.\nUse --discard-changes to allow that loss. The command never opens an interactive prompt.\n\nExamples:\n berdctl session archive --session-id \n berdctl session archive --session-id --discard-changes\n\nResult:\n {\"ok\": true} — the session was archived and eligible worktrees and branches were removed." + } + } + }, + "folder": { + "group": "folders", + "about": "Manage attached chat folders: attach, detach, replace, set cwd, list", + "verbs": { + "attach": { + "action": "attach", + "about": "Attach a folder, repository, or worktree to a chat", + "afterHelp": "Use this after creating or adopting a folder inside a workspace root already authorized for the chat. Registered Git worktrees of an authorized repository are also allowed; select a new unrelated root in Berd first.\n\nExample:\n berdctl folder attach --session-id --path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null}" + }, + "detach": { + "action": "detach", + "about": "Detach a folder, repository, or worktree from a chat", + "afterHelp": "Detach the checked-out folder to detach its branch from chat context. This does not delete anything from disk or Git.\n\nExample:\n berdctl folder detach --session-id --path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"detached\": true|false}" + }, + "replace": { + "action": "replace", + "about": "Replace the chat's current folder with another", + "afterHelp": "Prefer this over set-cwd when the user says to switch or move the chat to a new worktree/folder and does not ask to retain the old folder as context. Use set-cwd to select an already attached folder or when the old attachment should remain.\n\nThe old path must be attached. For the usual switch flow, get the current cwd from `berdctl folder list` and pass it as --old-path.\n\nExample:\n berdctl folder replace --session-id --old-path ~/src/repo --new-path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"oldPath\": \"...\", \"newPath\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null}" + }, + "set-cwd": { + "action": "set_cwd", + "about": "Select a chat folder as cwd, retaining other attachments", + "afterHelp": "For a request to switch or move the chat to a new worktree/folder, prefer `folder replace` so the old folder leaves context. Use this command when selecting among attachments or intentionally retaining the old folder.\n\nThe path must satisfy the same authorization rules as folder attach. Files and Git state are not moved.\n\nExample:\n berdctl folder set-cwd --session-id --path ~/src/repo-worktrees/feature\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null, \"status\": \"applied\"|\"pending\"}" + }, + "list": { + "action": "list", + "about": "List folders attached to a chat and identify its cwd", + "afterHelp": "The top-level cwd is returned even when it is Berd's implicit default and is not an attached folder.\n\nExample:\n berdctl folder list --session-id \n\nResult:\n {\"ok\": true, \"cwd\": \"...\", \"folders\": [{\"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null, \"cwd\": true|false}]}" + } + } + }, + "project": { + "group": "projects", + "about": "Manage projects: create, list, get, set startup mode, archive", + "verbs": { + "create": { + "action": "create", + "about": "Create a new project", + "afterHelp": "Example:\n berdctl project create --name \"Code reviews\" \\\n --instructions \"Prefer small diffs\" \\\n --working-dir /Users/me/src/api --working-dir /Users/me/src/web\n\nResult:\n {\"project_id\": \"...\"} — the project appears immediately in the app's\n project list with both directories attached." + }, + "list": { + "action": "list", + "about": "List the user's projects", + "afterHelp": "Example:\n berdctl project list --json\n\nResult:\n {\"projects\": [{\"project_id\": \"...\", \"name\": \"...\",\n \"description\": \"...\", \"working_dirs\": [\"...\"]}, ...]}" + }, + "get": { + "action": "get", + "about": "Read one project's details", + "afterHelp": "Example:\n berdctl project get --project-id --json\n\nResult:\n {\"project_id\": \"...\", \"name\": \"...\", \"description\": \"...\",\n \"instructions\": \"...\", \"working_dirs\": [\"...\"], \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"worktree\"}\n ], \"archived\": false, \"session_count\": 4, \"chat_groups\": [\n {\"group_id\": \"...\", \"name\": \"Launch\", \"session_ids\": [\"...\"]}\n ]}" + }, + "set-startup-mode": { + "action": "set_startup_mode", + "about": "Set how a project's new chats start from its Git workspaces", + "afterHelp": "The mode applies to every Git workspace configured on the project. Non-Git\nfolders remain in the project but use mode \"none\". Legacy \"worktree\" becomes\n\"auto-worktree\" and legacy \"branch\" becomes \"ask-worktree\".\n\nExamples:\n berdctl project set-startup-mode --project-id --mode auto-worktree\n berdctl project set-startup-mode --project-id --mode none\n\nResult:\n {\"ok\": true, \"mode\": \"auto-worktree\", \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"auto-worktree\"}\n ]}" + }, + "archive": { + "action": "archive", + "about": "Archive a project (reversible; nothing is deleted)", + "afterHelp": "Archiving is reversible from the app's archive view; nothing is\ndeleted, and the project's sessions are left untouched.\n\nExample:\n berdctl project archive --project-id \n\nResult:\n {\"ok\": true} — the project disappears from the app's project list." + } + } + }, + "agent": { + "group": "agents", + "about": "Manage agents (personas): create, list", + "verbs": { + "create": { + "action": "create", + "about": "Create a new agent (persona)", + "afterHelp": "Example:\n berdctl agent create --name \"Reviewer\" \\\n --system-prompt \"You review diffs for correctness; be terse.\"\n\nResult:\n {\"agent_id\": \"...\"} — the agent is saved and becomes available in\n future chats; pass it as --agent-id to `berdctl session create`." + }, + "list": { + "action": "list", + "about": "List the user's agents (personas)", + "afterHelp": "Example:\n berdctl agent list --json\n\nResult:\n {\"agents\": [{\"agent_id\": \"...\", \"name\": \"...\",\n \"summary\": \"...\", \"builtin\": false}, ...]}\n Use an agent_id as --agent-id when creating a session." + } + } + }, + "skill": { + "group": "skills", + "about": "Manage skills: create, list, get", + "verbs": { + "create": { + "action": "create", + "about": "Create a new skill (saved as a SKILL.md)", + "afterHelp": "Example:\n berdctl skill create --name \"release-notes\" \\\n --description \"Draft release notes from merged PRs\" \\\n --content \"$(cat SKILL.md)\"\n\nResult:\n {\"skill_id\": \"...\"} — the skill is saved as a SKILL.md and auto-loads\n into future sessions." + }, + "list": { + "action": "list", + "about": "List the user's skills (name and description only)", + "afterHelp": "Example:\n berdctl skill list --json\n\nResult:\n {\"skills\": [{\"skill_id\": \"...\", \"name\": \"...\",\n \"description\": \"...\",\n \"source\": \"app\"|\"builtin\"|\"global\"|\"project\"}, ...]}\n Read a skill's full content with `berdctl skill get`." + }, + "get": { + "action": "get", + "about": "Read one skill including its SKILL.md instructions", + "afterHelp": "Example:\n berdctl skill get --skill-id --json\n\nResult:\n {\"skill_id\": \"...\", \"name\": \"...\", \"description\": \"...\",\n \"source\": \"app\"|\"builtin\"|\"global\"|\"project\", \"instructions\": \"...\"}" + } + } + }, + "automation": { + "group": "automations", + "about": "Manage scheduled automations: list, get, create, run", + "verbs": { + "list": { + "action": "list", + "about": "List the user's automations", + "afterHelp": "Example:\n berdctl automation list --json\n berdctl automation list --query \"review\" --json\n\nResult:\n {\"automations\": [{\"automation_id\": \"...\", \"title\": \"...\",\n \"schedule\": \"0 */30 * * * *\", \"time_zone\": \"...\",\n \"status\": \"...\", \"latest_run_status\": \"...\",\n \"schedule_paused\": false, \"last_success_at\": \"...\"}, ...]}\n Read one automation's instructions with `berdctl automation get`." + }, + "get": { + "action": "get", + "about": "Read one automation's full detail", + "afterHelp": "Example:\n berdctl automation get --automation-id --json\n\nResult:\n {\"automation_id\": \"...\", \"title\": \"...\", \"schedule\": \"0 */30 * * * *\",\n \"time_zone\": \"...\", \"status\": \"...\", \"latest_run_status\": \"...\",\n \"schedule_paused\": false, \"instructions\": [\"...\"],\n \"human_readable_instructions\": [\"...\"],\n \"latest_chat_session_id\": \"...\", \"created\": \"...\", \"updated\": \"...\"}\n Find ids with `berdctl automation list`." + }, + "create": { + "action": "create", + "about": "Create a scheduled automation", + "afterHelp": "Example:\n berdctl automation create --title \"Morning digest\" \\\n --schedule \"0 0 9 * * *\" --time-zone \"America/New_York\" \\\n --instruction \"Summarize my unread Slack messages\" \\\n --instruction \"Post the summary to my notes\" --json\n\nResult:\n {\"automation_id\": \"...\", \"title\": \"Morning digest\",\n \"schedule\": \"0 0 9 * * *\"} — visible in the Automations view.\n Inspect it with `berdctl automation get`; run it now with\n `berdctl automation run`." + }, + "run": { + "action": "run", + "about": "Run an automation now, off schedule", + "afterHelp": "Example:\n berdctl automation run --automation-id --json\n\nResult:\n {\"automation_id\": \"...\", \"run_session_id\": \"...\"} — the run's session id,\n when the backend reports one. Watch progress in the Automations view or\n read the result later with `berdctl automation get`." + } + } + }, + "feedback": { + "group": "feedback", + "about": "Open or submit an approved Berd feedback report", + "verbs": { + "open": { + "action": "open", + "about": "Open the feedback form with a report prefilled", + "afterHelp": "Example:\n berdctl feedback open --title \"Composer loses draft\" \\\n --description \"Steps to reproduce...\" --include-logs --json\n\nResult:\n {\"opened\":true,\"include_logs\":true} — the existing feedback form is visible\n for review and optional image attachments; nothing was submitted." + }, + "submit": { + "action": "submit", + "about": "Submit an approved report directly to the Berd team", + "afterHelp": "Example:\n berdctl feedback submit --title \"Composer loses draft\" \\\n --description \"Steps to reproduce...\" --include-logs --json\n\nResult:\n {\"submitted\":true,\"include_logs\":true,\"issue_url\":\"https://...\"}\n issue_url is omitted when the feedback service does not return one." + } + } + }, + "info": { + "group": "info", + "about": "Look up installed harnesses, available models, and the app context", + "verbs": { + "harnesses": { + "action": "list_harnesses", + "about": "List the agent harnesses sessions can run on, with readiness", + "afterHelp": "Example:\n berdctl info harnesses --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"goose\", \"name\": \"...\",\n \"is_default\": true,\n \"status\": \"ready\"|\"not_installed\"|\"not_ready\"}, ...]}\n Only \"ready\" harnesses accept new sessions; \"not_installed\" and\n \"not_ready\" (sign-in or setup required) need the user to fix them in\n the app first." + }, + "models": { + "action": "list_models", + "about": "List the models available per agent harness", + "afterHelp": "Example:\n berdctl info models --harness-id goose --json\n\nResult:\n {\"harnesses\": [{\"harness_id\": \"...\",\n \"models\": [{\"model_id\": \"...\", \"name\": \"...\",\n \"provider\": \"...\"?}],\n \"warning\": \"...\"?}]}\n Use a model_id (with its harness) as --model-id when creating a session.\n \"warning\" appears when a stale cached list was served or when the harness\n manages its model outside the app." + }, + "context": { + "action": "get_context", + "about": "Read what the user is looking at in the app right now", + "afterHelp": "Example:\n berdctl info context --json\n\nResult:\n {\"view\": \"...\", \"active_session_id\": \"...\"|null,\n \"active_project_id\": \"...\"|null, \"app_version\": \"...\"}" + } + } + } + } +} diff --git a/src-tauri/crates/berdctl/src/contract.rs b/src-tauri/crates/berdctl/src/contract.rs index 048c1d154..48a7ac49e 100644 --- a/src-tauri/crates/berdctl/src/contract.rs +++ b/src-tauri/crates/berdctl/src/contract.rs @@ -102,13 +102,31 @@ pub struct Contract { pub surface: Surface, } -pub(crate) const API_SURFACE: &str = if cfg!(feature = "block-feedback") { +// One artifact variant per gate combination, so the CLI never advertises a +// group the matching renderer build filters out of TOOL_GROUPS (each VITE_* +// flag maps to exactly one cargo feature; the renderer registry remains the +// trust boundary). +pub(crate) const API_SURFACE: &str = if cfg!(all( + feature = "block-feedback", + feature = "block-automations" +)) { + include_str!("../api-surface-block.json") +} else if cfg!(feature = "block-feedback") { include_str!("../api-surface-feedback.json") +} else if cfg!(feature = "block-automations") { + include_str!("../api-surface-automations.json") } else { include_str!("../api-surface.json") }; -pub(crate) const CLI_SURFACE: &str = if cfg!(feature = "block-feedback") { +pub(crate) const CLI_SURFACE: &str = if cfg!(all( + feature = "block-feedback", + feature = "block-automations" +)) { + include_str!("../cli-surface-block.json") +} else if cfg!(feature = "block-feedback") { include_str!("../cli-surface-feedback.json") +} else if cfg!(feature = "block-automations") { + include_str!("../cli-surface-automations.json") } else { include_str!("../cli-surface.json") }; diff --git a/src-tauri/crates/berdctl/src/main.rs b/src-tauri/crates/berdctl/src/main.rs index 5cc943846..3f6269915 100644 --- a/src-tauri/crates/berdctl/src/main.rs +++ b/src-tauri/crates/berdctl/src/main.rs @@ -307,6 +307,17 @@ mod tests { ("feedback", "open") | ("feedback", "submit") => { vec!["--title", "t", "--description", "d"] } + ("automation", "list") => vec![], + ("automation", "get") => vec!["--automation-id", "a"], + ("automation", "create") => vec![ + "--title", + "t", + "--schedule", + "0 0 9 * * *", + "--instruction", + "step", + ], + ("automation", "run") => vec!["--automation-id", "a"], ("info", "harnesses") => vec![], ("info", "models") => vec![], ("info", "context") => vec![], @@ -759,6 +770,11 @@ Result: rendered.contains("feedback"), cfg!(feature = "block-feedback") ); + assert_eq!( + rendered.contains("automation"), + cfg!(feature = "block-automations"), + "the automation noun tracks its own gate, independent of feedback" + ); for command in ["session", "folder", "project", "agent", "skill", "info"] { assert!( rendered diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index 0ae4fa33d..9e6c864c4 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -581,6 +581,14 @@ describe("action schemas", () => { "skills.get": { skill_id: "global:/skills/x" }, "feedback.open": { title: "Bug", description: "Details" }, "feedback.submit": { title: "Bug", description: "Details" }, + "automations.list": {}, + "automations.get": { automation_id: "auto-1" }, + "automations.create": { + title: "Morning digest", + schedule: "0 0 9 * * *", + instruction: ["Summarize unread messages"], + }, + "automations.run": { automation_id: "auto-1" }, "info.list_harnesses": {}, "info.list_models": {}, "info.get_context": {}, @@ -4065,6 +4073,41 @@ describe("info", () => { }); }); +describe("automation schemas", () => { + it("bounds ids, titles, schedules, and instruction steps", () => { + const create = ALL_TOOL_GROUPS.automations.actions.create.schema; + expect( + create.safeParse({ + title: "Morning digest", + schedule: "0 0 9 * * *", + instruction: ["Summarize unread messages"], + }), + ).toMatchObject({ success: true, data: { enable_notifications: false } }); + expect( + create.safeParse({ + title: "", + schedule: "0 0 9 * * *", + instruction: ["x"], + }).success, + ).toBe(false); + expect( + create.safeParse({ + title: "T", + schedule: "0 0 9 * * *", + instruction: [], + }).success, + ).toBe(false); + + const get = ALL_TOOL_GROUPS.automations.actions.get.schema; + expect(get.safeParse({ automation_id: "auto-1" }).success).toBe(true); + expect(get.safeParse({ automation_id: "" }).success).toBe(false); + + const run = ALL_TOOL_GROUPS.automations.actions.run.schema; + expect(run.safeParse({ automation_id: "auto-1" }).success).toBe(true); + expect(run.safeParse({}).success).toBe(false); + }); +}); + describe("feedback schemas", () => { it("requires bounded report content and defaults diagnostics off", () => { for (const action of ["open", "submit"] as const) { diff --git a/src/features/berdctl/commands/impl/automationCommands.test.ts b/src/features/berdctl/commands/impl/automationCommands.test.ts new file mode 100644 index 000000000..e2545483b --- /dev/null +++ b/src/features/berdctl/commands/impl/automationCommands.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createAutomationTile, + getAutomationTile, + getAutomationTiles, + refreshAutomationTile, +} from "@/features/automations/api/kgooseAutomations"; +import { getProfileCapabilitySnapshot } from "@/shared/profile/capabilities"; + +import { createAutomationCommand } from "./createAutomation"; +import { getAutomationCommand } from "./getAutomation"; +import { listAutomationsCommand } from "./listAutomations"; +import { runAutomationCommand } from "./runAutomation"; + +vi.mock("@/shared/profile/capabilities", () => ({ + getProfileCapabilitySnapshot: vi.fn(), +})); +vi.mock("@/features/automations/api/kgooseAutomations", () => ({ + createAutomationTile: vi.fn(), + getAutomationTile: vi.fn(), + getAutomationTiles: vi.fn(), + refreshAutomationTile: vi.fn(), +})); + +const reviewTile = { + id: "auto-1", + title: "Review sweep", + schedule: "0 */30 * * * *", + timeZone: "America/New_York", + status: "active", + latestRunStatus: "success", + schedulePaused: false, + instructions: ["Run the review driver"], + humanReadableInstructions: ["Run the review driver"], + enableNotifications: true, + latestChatSessionId: "session-9", + created: "2026-08-01T00:00:00Z", + updated: "2026-08-17T00:00:00Z", +}; + +describe("automation commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getProfileCapabilitySnapshot).mockReturnValue(true); + }); + + it("lists automations as wire summaries, filtered by --query", async () => { + vi.mocked(getAutomationTiles).mockResolvedValue({ + tiles: [reviewTile, { id: "auto-2", title: "Daily digest" }], + }); + + await expect( + listAutomationsCommand.execute({ query: "review" }, {}), + ).resolves.toEqual({ + automations: [ + { + automation_id: "auto-1", + title: "Review sweep", + schedule: "0 */30 * * * *", + time_zone: "America/New_York", + status: "active", + latest_run_status: "success", + schedule_paused: false, + }, + ], + }); + }); + + it("refuses every action when the automations capability is off", async () => { + vi.mocked(getProfileCapabilitySnapshot).mockReturnValue(false); + + await expect(listAutomationsCommand.execute({}, {})).rejects.toMatchObject({ + code: "automations_disabled", + }); + await expect( + getAutomationCommand.execute({ automation_id: "auto-1" }, {}), + ).rejects.toMatchObject({ code: "automations_disabled" }); + await expect( + createAutomationCommand.execute( + { + title: "T", + schedule: "0 0 9 * * *", + instruction: ["step"], + enable_notifications: false, + }, + {}, + ), + ).rejects.toMatchObject({ code: "automations_disabled" }); + await expect( + runAutomationCommand.execute({ automation_id: "auto-1" }, {}), + ).rejects.toMatchObject({ code: "automations_disabled" }); + expect(getAutomationTiles).not.toHaveBeenCalled(); + expect(createAutomationTile).not.toHaveBeenCalled(); + expect(refreshAutomationTile).not.toHaveBeenCalled(); + }); + + it("gets one automation's full detail", async () => { + vi.mocked(getAutomationTile).mockResolvedValue({ tileInfo: reviewTile }); + + await expect( + getAutomationCommand.execute({ automation_id: "auto-1" }, {}), + ).resolves.toEqual({ + automation_id: "auto-1", + title: "Review sweep", + schedule: "0 */30 * * * *", + time_zone: "America/New_York", + status: "active", + latest_run_status: "success", + schedule_paused: false, + instructions: ["Run the review driver"], + human_readable_instructions: ["Run the review driver"], + enable_notifications: true, + latest_chat_session_id: "session-9", + created: "2026-08-01T00:00:00Z", + updated: "2026-08-17T00:00:00Z", + }); + }); + + it("reports an unknown automation id with the fixing command", async () => { + vi.mocked(getAutomationTile).mockResolvedValue({}); + + await expect( + getAutomationCommand.execute({ automation_id: "nope" }, {}), + ).rejects.toMatchObject({ + code: "automation_not_found", + message: expect.stringContaining("berdctl automation list"), + }); + }); + + it("creates an automation and returns the backend id", async () => { + vi.mocked(createAutomationTile).mockResolvedValue({ + success: true, + automationId: "auto-3", + }); + + await expect( + createAutomationCommand.execute( + { + title: "Morning digest", + schedule: "0 0 9 * * *", + instruction: ["Summarize unread messages"], + time_zone: "America/New_York", + enable_notifications: true, + }, + {}, + ), + ).resolves.toEqual({ + automation_id: "auto-3", + title: "Morning digest", + schedule: "0 0 9 * * *", + }); + expect(createAutomationTile).toHaveBeenCalledWith({ + type: 4, + title: "Morning digest", + schedule: "0 0 9 * * *", + instructions: ["Summarize unread messages"], + timeZone: "America/New_York", + enableNotifications: true, + }); + }); + + it("surfaces a backend create rejection as a stable command error", async () => { + vi.mocked(createAutomationTile).mockResolvedValue({ + success: false, + errorMsg: "invalid cron", + }); + + await expect( + createAutomationCommand.execute( + { + title: "T", + schedule: "not-cron", + instruction: ["step"], + enable_notifications: false, + }, + {}, + ), + ).rejects.toMatchObject({ + code: "automation_create_failed", + message: "invalid cron", + }); + }); + + it("runs an automation now and returns the run session id", async () => { + vi.mocked(getAutomationTile).mockResolvedValue({ tileInfo: reviewTile }); + vi.mocked(refreshAutomationTile).mockResolvedValue({ + success: true, + refreshSessionId: "run-42", + }); + + await expect( + runAutomationCommand.execute({ automation_id: "auto-1" }, {}), + ).resolves.toEqual({ automation_id: "auto-1", run_session_id: "run-42" }); + expect(refreshAutomationTile).toHaveBeenCalledWith("auto-1"); + }); + + it("resolves the automation before running so bad ids read as not-found", async () => { + vi.mocked(getAutomationTile).mockResolvedValue({}); + + await expect( + runAutomationCommand.execute({ automation_id: "nope" }, {}), + ).rejects.toMatchObject({ code: "automation_not_found" }); + expect(refreshAutomationTile).not.toHaveBeenCalled(); + }); + + it("surfaces a backend run refusal as a stable command error", async () => { + vi.mocked(getAutomationTile).mockResolvedValue({ tileInfo: reviewTile }); + vi.mocked(refreshAutomationTile).mockResolvedValue({ + success: false, + errorMsg: "backend busy", + }); + + await expect( + runAutomationCommand.execute({ automation_id: "auto-1" }, {}), + ).rejects.toMatchObject({ + code: "automation_run_failed", + message: "backend busy", + }); + }); +}); diff --git a/src/features/berdctl/commands/impl/createAutomation.ts b/src/features/berdctl/commands/impl/createAutomation.ts new file mode 100644 index 000000000..0e4e186e4 --- /dev/null +++ b/src/features/berdctl/commands/impl/createAutomation.ts @@ -0,0 +1,113 @@ +import { z } from "zod/v4"; + +import { CommandError, defineCommand } from "../types"; + +const createAutomationSchema = z + .object({ + title: z + .string() + .trim() + .min(1) + .max(200) + .describe( + "Automation title, shown in the Automations view (1-200 chars).", + ), + schedule: z + .string() + .trim() + .min(1) + .max(100) + .describe( + 'Cron schedule for the run cadence (e.g. "0 */30 * * * *"); the ' + + "backend interprets it in --time-zone.", + ), + instruction: z + .array(z.string().trim().min(1).max(10_000)) + .min(1) + .max(50) + .describe( + "One instruction step the automation runs each time (repeat the flag " + + "for multiple steps; 1-50 steps, each 1-10000 chars).", + ), + time_zone: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe( + 'IANA time zone for the schedule (e.g. "America/New_York"); defaults ' + + "to the app's current time zone.", + ), + enable_notifications: z + .boolean() + .default(false) + .describe( + "Notify the user when runs complete; omitted means no notifications.", + ), + }) + .strict(); + +interface CreateAutomationResult { + automation_id: string; + title: string; + schedule: string; +} + +export const createAutomationCommand = defineCommand({ + effect: "create", + visibility: "immediate", + destructive: false, + bridgeTimeoutMs: 60_000, + summary: "Create a scheduled automation", + description: + "Create a scheduled automation that runs the given instruction steps on " + + "the given cron cadence. The new automation appears in the Automations " + + "view immediately and can be edited, paused, or deleted there.", + helpFooter: `Example: + berdctl automation create --title "Morning digest" \\ + --schedule "0 0 9 * * *" --time-zone "America/New_York" \\ + --instruction "Summarize my unread Slack messages" \\ + --instruction "Post the summary to my notes" --json + +Result: + {"automation_id": "...", "title": "Morning digest", + "schedule": "0 0 9 * * *"} — visible in the Automations view. + Inspect it with \`berdctl automation get\`; run it now with + \`berdctl automation run\`.`, + schema: createAutomationSchema, + execute: async (args): Promise => { + const [{ requireAutomationsCapability }, api] = await Promise.all([ + import("../runtime/automations"), + import("@/features/automations/api/kgooseAutomations"), + ]); + requireAutomationsCapability(); + const timeZone = + args.time_zone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const response = await api.createAutomationTile({ + // 4 is the generic "summary" automation tile type the Automations + // builder creates (automationBuilder.ts SUMMARY_TILE_TYPE); dashboard + // and Builderbot tile types are deliberately out of scope here. + type: 4, + title: args.title, + schedule: args.schedule, + instructions: args.instruction, + timeZone, + enableNotifications: args.enable_notifications, + }); + const automationId = response.automationId ?? response.tileId; + if (response.success === false || !automationId) { + throw new CommandError( + "automation_create_failed", + response.errorMsg?.trim() || + "The backend rejected the automation; check the schedule cron " + + "expression and try again.", + ); + } + return { + automation_id: automationId, + title: args.title, + schedule: args.schedule, + }; + }, +}); diff --git a/src/features/berdctl/commands/impl/getAutomation.ts b/src/features/berdctl/commands/impl/getAutomation.ts new file mode 100644 index 000000000..a2c18470c --- /dev/null +++ b/src/features/berdctl/commands/impl/getAutomation.ts @@ -0,0 +1,46 @@ +import { z } from "zod/v4"; + +import { defineCommand } from "../types"; +import type { AutomationDetail } from "../runtime/automations"; + +const getAutomationSchema = z + .object({ + automation_id: z + .string() + .trim() + .min(1) + .max(200) + .describe( + "Automation id, as returned by `berdctl automation list` (1-200 chars).", + ), + }) + .strict(); + +export const getAutomationCommand = defineCommand({ + effect: "read", + visibility: "none", + destructive: false, + summary: "Read one automation's full detail", + description: + "Read one automation's full detail — title, schedule, time zone, status, " + + "pause state, instructions, and latest-run wiring — exactly as the " + + "Automations view shows it; does not change anything on screen.", + helpFooter: `Example: + berdctl automation get --automation-id --json + +Result: + {"automation_id": "...", "title": "...", "schedule": "0 */30 * * * *", + "time_zone": "...", "status": "...", "latest_run_status": "...", + "schedule_paused": false, "instructions": ["..."], + "human_readable_instructions": ["..."], + "latest_chat_session_id": "...", "created": "...", "updated": "..."} + Find ids with \`berdctl automation list\`.`, + schema: getAutomationSchema, + execute: async (args): Promise => { + const { findAutomationOrThrow, detailAutomationTile } = await import( + "../runtime/automations" + ); + const tile = await findAutomationOrThrow(args.automation_id); + return detailAutomationTile(tile); + }, +}); diff --git a/src/features/berdctl/commands/impl/listAutomations.ts b/src/features/berdctl/commands/impl/listAutomations.ts new file mode 100644 index 000000000..9d69b6b5f --- /dev/null +++ b/src/features/berdctl/commands/impl/listAutomations.ts @@ -0,0 +1,58 @@ +import { z } from "zod/v4"; + +import { defineCommand } from "../types"; +import type { AutomationSummary } from "../runtime/automations"; + +const listAutomationsSchema = z + .object({ + query: z + .string() + .trim() + .min(1) + .max(200) + .optional() + .describe( + "Case-insensitive substring filter on the automation title (1-200 chars).", + ), + }) + .strict(); + +interface ListAutomationsResult { + automations: AutomationSummary[]; +} + +export const listAutomationsCommand = defineCommand({ + effect: "read", + visibility: "none", + destructive: false, + summary: "List the user's automations", + description: + "List the user's scheduled automations (id, title, schedule, status, and " + + "last-run info) as the Automations view shows them; does not change " + + 'anything on screen. Read one automation\'s full detail with action "get".', + helpFooter: `Example: + berdctl automation list --json + berdctl automation list --query "review" --json + +Result: + {"automations": [{"automation_id": "...", "title": "...", + "schedule": "0 */30 * * * *", "time_zone": "...", + "status": "...", "latest_run_status": "...", + "schedule_paused": false, "last_success_at": "..."}, ...]} + Read one automation's instructions with \`berdctl automation get\`.`, + schema: listAutomationsSchema, + execute: async (args): Promise => { + const { fetchAutomationTiles, summarizeAutomationTile } = await import( + "../runtime/automations" + ); + const tiles = await fetchAutomationTiles(); + const query = args.query?.toLowerCase(); + const automations = tiles + .map(summarizeAutomationTile) + .filter( + (automation) => + !query || automation.title.toLowerCase().includes(query), + ); + return { automations }; + }, +}); diff --git a/src/features/berdctl/commands/impl/runAutomation.ts b/src/features/berdctl/commands/impl/runAutomation.ts new file mode 100644 index 000000000..56df9528a --- /dev/null +++ b/src/features/berdctl/commands/impl/runAutomation.ts @@ -0,0 +1,64 @@ +import { z } from "zod/v4"; + +import { CommandError, defineCommand } from "../types"; + +const runAutomationSchema = z + .object({ + automation_id: z + .string() + .trim() + .min(1) + .max(200) + .describe( + "Automation id, as returned by `berdctl automation list` (1-200 chars).", + ), + }) + .strict(); + +interface RunAutomationResult { + automation_id: string; + run_session_id?: string; +} + +export const runAutomationCommand = defineCommand({ + effect: "update", + visibility: "discoverable", + destructive: false, + bridgeTimeoutMs: 60_000, + summary: "Run an automation now, off schedule", + description: + "Trigger one immediate run of an automation without changing its " + + "schedule. The run and its output appear in the Automations view like " + + "any scheduled run.", + helpFooter: `Example: + berdctl automation run --automation-id --json + +Result: + {"automation_id": "...", "run_session_id": "..."} — the run's session id, + when the backend reports one. Watch progress in the Automations view or + read the result later with \`berdctl automation get\`.`, + schema: runAutomationSchema, + execute: async (args): Promise => { + const [{ findAutomationOrThrow }, api] = await Promise.all([ + import("../runtime/automations"), + import("@/features/automations/api/kgooseAutomations"), + ]); + // Resolve first so a bad id reads as automation_not_found, not a + // backend refresh failure. + const tile = await findAutomationOrThrow(args.automation_id); + const response = await api.refreshAutomationTile(tile.id ?? ""); + if (response.success === false) { + throw new CommandError( + "automation_run_failed", + response.errorMsg?.trim() || + "The backend refused to start the run; try again from the " + + "Automations view.", + ); + } + const result: RunAutomationResult = { automation_id: tile.id ?? "" }; + if (response.refreshSessionId) { + result.run_session_id = response.refreshSessionId; + } + return result; + }, +}); diff --git a/src/features/berdctl/commands/registry.ts b/src/features/berdctl/commands/registry.ts index 943f27f75..b25e571ee 100644 --- a/src/features/berdctl/commands/registry.ts +++ b/src/features/berdctl/commands/registry.ts @@ -8,16 +8,19 @@ import { listSessionFoldersCommand } from "./impl/listSessionFolders"; import { replaceSessionFolderCommand } from "./impl/replaceSessionFolder"; import { setSessionFolderCwdCommand } from "./impl/setSessionFolderCwd"; import { clearSessionProjectCommand } from "./impl/clearSessionProject"; +import { createAutomationCommand } from "./impl/createAutomation"; import { createAgentCommand } from "./impl/createAgent"; import { createProjectCommand } from "./impl/createProject"; import { createSessionCommand } from "./impl/createSession"; import { createSkillCommand } from "./impl/createSkill"; import { forkSessionCommand } from "./impl/forkSession"; +import { getAutomationCommand } from "./impl/getAutomation"; import { getContextCommand } from "./impl/getContext"; import { getProjectCommand } from "./impl/getProject"; import { getSessionCommand } from "./impl/getSession"; import { getSkillCommand } from "./impl/getSkill"; import { listAgentsCommand } from "./impl/listAgents"; +import { listAutomationsCommand } from "./impl/listAutomations"; import { listHarnessesCommand } from "./impl/listHarnesses"; import { listModelsCommand } from "./impl/listModels"; import { listProjectsCommand } from "./impl/listProjects"; @@ -28,6 +31,7 @@ import { moveSessionToGroupCommand } from "./impl/moveSessionToGroup"; import { openFeedbackCommand } from "./impl/openFeedback"; import { openSessionCommand } from "./impl/openSession"; import { renameSessionCommand } from "./impl/renameSession"; +import { runAutomationCommand } from "./impl/runAutomation"; import { sendSessionCommand } from "./impl/sendSession"; import { setProjectStartupModeCommand } from "./impl/setProjectStartupMode"; import { submitFeedbackCommand } from "./impl/submitFeedback"; @@ -150,6 +154,20 @@ export const ALL_TOOL_GROUPS = { get: getSkillCommand, }, }, + automations: { + description: + "Manage the user's scheduled automations: list, get, create, run now.", + cli: { + noun: "automation", + about: "Manage scheduled automations: list, get, create, run", + }, + actions: { + list: listAutomationsCommand, + get: getAutomationCommand, + create: createAutomationCommand, + run: runAutomationCommand, + }, + }, feedback: { description: "Open an approved report in Berd's feedback form or submit it directly after explicit user approval.", @@ -185,13 +203,19 @@ export const ALL_TOOL_GROUPS = { } as const satisfies Record; /** - * Build-owned command registry. Public builds omit Feedback entirely; an - * enabled distribution opts it back in with the same VITE_FEEDBACK boolean - * that owns the renderer and backend surfaces. + * Build-owned command registry. Public builds omit the Block-only groups + * (Feedback, Automations) entirely; an enabled distribution opts each back + * in with the same VITE_* boolean that owns its renderer and backend + * surfaces. */ +const GROUP_BUILD_GATES: Record = { + feedback: import.meta.env.VITE_FEEDBACK === "1", + automations: import.meta.env.VITE_AUTOMATIONS === "1", +}; + export const TOOL_GROUPS: Record = Object.fromEntries( Object.entries(ALL_TOOL_GROUPS).filter( - ([name]) => name !== "feedback" || import.meta.env.VITE_FEEDBACK === "1", + ([name]) => GROUP_BUILD_GATES[name] ?? true, ), ); diff --git a/src/features/berdctl/commands/runtime/automations.ts b/src/features/berdctl/commands/runtime/automations.ts new file mode 100644 index 000000000..2d8b4b34a --- /dev/null +++ b/src/features/berdctl/commands/runtime/automations.ts @@ -0,0 +1,105 @@ +import { + getAutomationTile, + getAutomationTiles, + type AutomationTile, +} from "@/features/automations/api/kgooseAutomations"; +import { getProfileCapabilitySnapshot } from "@/shared/profile/capabilities"; + +import { CommandError } from "../types"; + +/** Wire summary of one automation, as `automation list` returns it. */ +export interface AutomationSummary { + automation_id: string; + title: string; + schedule: string; + time_zone: string; + status: string; + latest_run_status: string; + schedule_paused: boolean; + paused_reason?: string; + last_success_at?: string; +} + +/** `automation get`'s detail: the summary plus instructions and run wiring. */ +export interface AutomationDetail extends AutomationSummary { + instructions: string[]; + human_readable_instructions: string[]; + allow_human_input?: boolean; + enable_notifications?: boolean; + latest_chat_session_id?: string; + created?: string; + updated?: string; +} + +/** Refuse before touching KGoose when the build/runtime has no Automations + * surface — the same capability that gates the Automations view. */ +export function requireAutomationsCapability(): void { + if (!getProfileCapabilitySnapshot("automations")) { + throw new CommandError( + "automations_disabled", + "Automations are disabled in this build or runtime configuration.", + ); + } +} + +export async function fetchAutomationTiles(): Promise { + requireAutomationsCapability(); + const { tiles } = await getAutomationTiles(); + return tiles; +} + +export async function findAutomationOrThrow( + automationId: string, +): Promise { + requireAutomationsCapability(); + const { tileInfo } = await getAutomationTile(automationId); + if (!tileInfo?.id) { + throw new CommandError( + "automation_not_found", + `No automation "${automationId}"; list automations with ` + + "`berdctl automation list`.", + ); + } + return tileInfo; +} + +function asWireString(value: string | number | undefined): string { + return value === undefined ? "" : String(value); +} + +export function summarizeAutomationTile( + tile: AutomationTile, +): AutomationSummary { + const summary: AutomationSummary = { + automation_id: tile.id ?? "", + title: tile.title ?? "", + schedule: tile.schedule ?? "", + time_zone: tile.timeZone ?? "", + status: asWireString(tile.status), + latest_run_status: asWireString(tile.latestRunStatus), + schedule_paused: tile.schedulePaused === true, + }; + if (tile.pausedReason) summary.paused_reason = tile.pausedReason; + if (tile.lastSuccessAt) summary.last_success_at = tile.lastSuccessAt; + return summary; +} + +export function detailAutomationTile(tile: AutomationTile): AutomationDetail { + const detail: AutomationDetail = { + ...summarizeAutomationTile(tile), + instructions: tile.instructions ?? [], + human_readable_instructions: tile.humanReadableInstructions ?? [], + }; + if (typeof tile.allowHumanInput === "boolean") { + detail.allow_human_input = tile.allowHumanInput; + } + if (typeof tile.enableNotifications === "boolean") { + detail.enable_notifications = tile.enableNotifications; + } + if (tile.latestChatSessionId) { + detail.latest_chat_session_id = tile.latestChatSessionId; + } + if (tile.created) detail.created = tile.created; + if (tile.updated) detail.updated = tile.updated; + return detail; +} diff --git a/src/features/berdctl/commands/types.ts b/src/features/berdctl/commands/types.ts index 978e442a2..15a798870 100644 --- a/src/features/berdctl/commands/types.ts +++ b/src/features/berdctl/commands/types.ts @@ -87,6 +87,10 @@ export const COMMAND_ERROR_CODES = [ "feedback_disabled", "feedback_form_busy", "feedback_submission_failed", + "automations_disabled", + "automation_not_found", + "automation_create_failed", + "automation_run_failed", "timed_out", "internal_error", ] as const;