Files
msd-core/gsd-core/workflows/settings-integrations.md
Tom Boucher 63abcface9 feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools (#3831)
* feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools

The predecessor package get-shit-done-cc publishes a colliding gsd-tools bin whose phases.clear DELETES where this package's ARCHIVES, and both print success-shaped output against a gitignored .planning/ -- which is how #3129 cost a user 43 phase directories with no error and nothing recoverable from git.

The launcher's PATH branch now resolves gsd_run, published only by this package and self-locating via its own symlink chain to the sibling shim, instead of the colliding gsd-tools. A foreign handler becomes unreachable from PATH, and when no gsd_run is reachable the resolver fails closed rather than falling back -- that fallback was the vulnerability. This is smaller than the branch it replaces, which matters: the preamble is inlined into 113 shipped files and agents/gsd-verifier.md sits 2 bytes under a red-line size cap.

unset -f gsd_run leads the preamble so a re-source is idempotent. Without it, command -v finds the shell function, returns a bare name, and the resolver falls through to an exit 1 that kills a sourced caller's shell.

Adds gsd-tools runtime-identity, a manual diagnostic reporting this runtime's package coordinates over the baked package-identity (#498) and readHostVersion, with a strict total classifier: only a JSON object with an exact packageName verifies, since JSON.parse admits 0/"str"/[]/null/true.

An inlined identity assertion was built and reviewed first, then withdrawn -- it breaks five frozen size ceilings and no assertion fits in 2 bytes.

Closes #3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3146): stop sync:launcher relocating a deliberate preamble placement

Pre-existing defect, surfaced by this PR because sync is a no-op unless the snippet content actually changes. transformFile inserts the preamble into the first block that CALLS gsd_run, but gsd-core/workflows/explore.md deliberately places it in a bootstrap-only block that DEFINES gsd_run without calling it -- its own comment explains why: declining the research offer must not leave Step 5's commit call unbootstrapped. Stripping empties that block of calls, so the preamble migrated forward and broke the define-before-use invariant tests/explore-command.test.cjs pins.

Reproduced on a pristine origin/next checkout with the base snippet and base file, so this was not introduced here. The insertion target now honours a block that already carried the preamble, falling back to the first calling block for files that have none yet. Adds a behavioral regression test over a two-block fixture.

Also updates three runtime-launcher-parity tests that pinned the removed PATH fallback to gsd-tools. Their intent is preserved -- the PATH stub is renamed gsd_run so it is reachable by the new resolver, and the RUNTIME_DIR-wins test still asserts the stub is never invoked. Fixture shebangs move to an absolute /bin/sh, because the fixture PATH is deliberately restricted and #!/usr/bin/env sh could not resolve.

Refs #3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3146): backfill changeset PR number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#3146): document the FEATURES.md section-numbering practice

The monotonically increasing section number in docs/FEATURES.md is the most frequent merge-conflict source in this repo, and it has TWO conflict cells, not one: the ### N. heading and the hand-maintained table of contents. Two PRs adding differently numbered features still collide on the TOC, so renumbering alone does not make a branch safe. This branch alone was renumbered 165 -> 166 -> 167 -> 168 across successive rebases.

Adds a CONTRIBUTING section stating the practice: allocate the number last, never pre-emptively renumber, take max+1 after a rebase and update the TOC in the same commit, and never renumber someone else's section. Fork contributors are told explicitly they may leave the number to a maintainer at merge rather than chasing the counter. Agents are told to lease the allocation and to include the file in their published touched set.

Records the durable fix as planned rather than pretending it exists: FEATURES.md should be generated from per-feature fragments the way CHANGELOG.md is generated from .changeset/, and the way tests/emitted-drift-acks/ works (#2914).

Also renumbers this branch's own section to 168, leaving 167 to the PR already in flight.

Refs #3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:57:16 -04:00

18 KiB

Interactive configuration of third-party integrations for GSD — search API keys (Brave / Firecrawl / Exa), code-review CLI routing (`review.models.`), and agent-skill injection (`agent_skills.`). Writes to `.planning/config.json` via `gsd-tools` so unrelated keys are preserved, never clobbered.

This command is deliberately separate from /gsd:settings (workflow toggles) and any /gsd-settings-advanced tuning surface. It exists because API keys and cross-tool routing are connectivity concerns, not workflow or tuning knobs.

**API keys are secrets.** They are written as plaintext to `.planning/config.json` — that is where secrets live on disk, and file permissions are the security boundary. The UI must never display, echo, or log the plaintext value. The workflow follows these rules:
  • Masking convention: ****<last-4> (e.g. sk-abc123def456 → ****f456). Strings shorter than 8 characters render as **** with no tail so a short secret does not leak a meaningful fraction of its bytes. Unset values render as (unset).
  • Plaintext is never echoed by AskUserQuestion descriptions, confirmation tables, or any log line. It is not written to any file under .planning/ other than config.json itself.
  • config-set output is masked for keys in the secret set (brave_search, firecrawl, exa_search) — see gsd-core/bin/lib/secrets.cjs.
  • Agent-type and CLI slug validation. agent_skills.<agent-type> slug inputs are checked against ^[a-zA-Z0-9_-]+$ before any write; inputs containing path separators (/, \, ..), whitespace, or shell metacharacters are rejected. This closes off skill-injection attacks on that open namespace (dynamic key pattern). For review.models.<cli> no slug-shape check is needed or performed: the gate is membership in the closed, registry-derived settable set (see the review-models section below), which subsumes slug shape — slug shape alone never makes a review.models.* key writable.

<required_reading> Read all files referenced by the invoking prompt's execution_context before starting. </required_reading>

Ensure config exists and resolve the active config path (flat vs workstream, #2282):
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd_run is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --default "" 2>/dev/null || echo "")
gsd_run query config-ensure-section
if [[ -z "${GSD_CONFIG_PATH:-}" ]]; then
  if [[ -f .planning/active-workstream ]]; then
    WS=$(tr -d '\n\r' < .planning/active-workstream)
    GSD_CONFIG_PATH=".planning/workstreams/${WS}/config.json"
  else
    GSD_CONFIG_PATH=".planning/config.json"
  fi
fi

If response_language is set: All user-facing questions, prompts, and explanations in this workflow MUST be presented in {response_language}. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.

Store $GSD_CONFIG_PATH. Every subsequent read/write uses it.

Read the current config and compute a masked view for display. For each integration field, compute one of:
  • (unset) — field is null / missing
  • ****<last-4> — secret field that is populated (plaintext never shown)
  • <value> — non-secret routing/skill string, shown as-is
BRAVE=$(gsd_run query config-get brave_search --default null)
FIRECRAWL=$(gsd_run query config-get firecrawl --default null)
EXA=$(gsd_run query config-get exa_search --default null)
SEARCH_GITIGNORED=$(gsd_run query config-get search_gitignored --default false)

For each secret key (brave_search, firecrawl, exa_search) the displayed value is ****<last-4> when set, never the raw string. Never echo the plaintext to stdout, stderr, or any log.

Text mode (workflow.text_mode: true or --text flag): Set TEXT_MODE=true and replace every AskUserQuestion call with a plain-text numbered list. Required for non-Claude runtimes.

Ask the user what they want to do for each search API key. For keys that are already set, show **** already set and offer Leave / Replace / Clear. For unset keys, offer Skip / Set.

AskUserQuestion([
  {
    question: "Brave Search API key — used for web research during plan/discuss phases",
    header: "Brave",
    multiSelect: false,
    options: [
      // When already set:
      { label: "Leave (**** already set)", description: "Keep current value" },
      { label: "Replace", description: "Enter a new API key" },
      { label: "Clear", description: "Remove the stored key" }
      // When unset, use the two-option shape: Skip / Set.
    ]
  },
  {
    question: "Firecrawl API key — used for deep-crawl scraping",
    header: "Firecrawl",
    multiSelect: false,
    options: [ /* same Leave/Replace/Clear or Skip/Set */ ]
  },
  {
    question: "Exa Search API key — used for semantic search",
    header: "Exa",
    multiSelect: false,
    options: [ /* same Leave/Replace/Clear or Skip/Set */ ]
  },
  {
    question: "Include gitignored files in local code searches?",
    header: "Gitignored",
    multiSelect: false,
    options: [
      { label: "No (Recommended)", description: "Respect .gitignore. Safer — excludes secrets, node_modules, build artifacts." },
      { label: "Yes", description: "Include gitignored files. Useful when secrets/artifacts genuinely contain searchable intent." }
    ]
  }
])

For each "Set" or "Replace", follow with a text-input prompt that asks for the key value. The answer must not be echoed back in subsequent question descriptions or confirmation text. Write the value via:

gsd_run query config-set brave_search "<value>"     # masked in output
gsd_run query config-set firecrawl "<value>"        # masked in output
gsd_run query config-set exa_search "<value>"       # masked in output
gsd_run query config-set search_gitignored true|false

For "Clear", write null:

gsd_run query config-set brave_search null

review.models.<cli> is a closed, registry-derived map that tells the review workflow which model id a reviewer lane uses. It is not an open namespace: a review.models.<cli> key is settable only when that lane's capability declares a modelConfigKey, and config-set accepts exactly those keys (federated from the capability registry). No dynamic-key regex governs this namespace — any other slug fails with Unknown config key.

Settable keys (the shipped registry's model-bearing lanes):

review.models.agy (Antigravity), review.models.claude, review.models.codex, review.models.gemini, review.models.kimi-code, review.models.llama_cpp, review.models.lm_studio, review.models.ollama, review.models.opencode.

Reviewer lanes cursor, qwen, and coderabbit declare no modelConfigKey — there is nothing to configure for them here (whether they should have a per-lane model key is a separate question, out of scope for this workflow). If the user asks for one of those, say exactly that and skip.

AskUserQuestion([
  {
    question: "Review model CLI mapping — what next?",
    header: "Review",
    multiSelect: false,
    options: [
      { label: "Configure CLI", description: "Pick a reviewer lane and set/clear its model id" },
      { label: "Done", description: "Finish this section" }
    ]
  }
])

If "Configure CLI" is selected, ask:

AskUserQuestion([
  {
    question: "Which reviewer lane do you want to configure? (Common lanes below; any settable lane from the list above works — or type its slug)",
    header: "CLI",
    multiSelect: false,
    options: [
      { label: "Claude", description: "review.models.claude — defaults to session model when unset" },
      { label: "Codex", description: "review.models.codex — bare model id injected into --model, e.g. 'gpt-5'" },
      { label: "Gemini", description: "review.models.gemini — bare model id injected into -m, e.g. 'gemini-2.5-pro'" },
      { label: "OpenCode", description: "review.models.opencode — bare model id injected into --model, e.g. 'claude-sonnet-4'" }
    ]
  }
])

For a slug received as free text, check it against the settable set above. If it is not one of the settable keys, print:

Rejected: review.models.<slug> is not settable — only the reviewer lanes whose
keys are enumerated above can be configured here. (cursor, qwen, and
coderabbit have no per-lane model key.)

and re-prompt.

For the selected lane, show the current value (or (unset)) and offer Leave / Replace / Clear, followed by a text-input prompt for the model id string. Write via:

gsd_run query config-set review.models.<cli> "<model id>"

After each update, return to the "Review model CLI mapping — what next?" question. Loop until the user selects "Done".

agent_skills.<agent-type> injects extra skill names into an agent's spawn frontmatter. The slug is user-extensible, so input is free-text validated against ^[a-zA-Z0-9_-]+$. Inputs with path separators, spaces, or shell metacharacters are rejected.

AskUserQuestion([
  {
    question: "Agent skills mapping — what next?",
    header: "Agent Skills",
    multiSelect: false,
    options: [
      { label: "Configure agent", description: "Pick an agent type and set/clear skills" },
      { label: "Done", description: "Finish this section" }
    ]
  }
])

If "Configure agent" is selected, ask:

AskUserQuestion([
  {
    question: "Configure agent_skills for which agent type?",
    header: "Agent Type",
    multiSelect: false,
    options: [
      { label: "gsd-executor", description: "Skills injected when spawning executor agents" },
      { label: "gsd-planner", description: "Skills injected when spawning planner agents" },
      { label: "gsd-verifier", description: "Skills injected when spawning verifier agents" },
      { label: "Custom…", description: "Enter a custom agent-type slug" }
    ]
  }
])

For "Custom…", prompt for a slug and validate it matches ^[a-zA-Z0-9_-]+$. If it fails validation, print:

Rejected: agent-type '<slug>' must match [a-zA-Z0-9_-]+ (no path separators,
spaces, or shell metacharacters).

and re-prompt.

For a selected slug, prompt for the skill list (text input; a comma-separated reply is fine). Show the current value if any, offer Leave / Replace / Clear.

Split the reply before writing: the resolver never splits strings, so a comma-joined string would be stored as ONE skill path that silently fails resolution at spawn time (#3651 — gsd-core/references/planning-config.md: "Paths cannot be comma-joined into one string; each path must be its own array element"). Split on commas, trim each entry, drop empty entries, reject any entry containing a quote character (' or " — it cannot be written safely through the single-quoted form), and write the JSON array form:

gsd_run query config-set agent_skills.<slug> '["skills/alpha","skills/beta"]'

A single skill may be written as one bare string or a one-element array — both resolve identically.

After each update, return to the "Agent skills mapping — what next?" question. Loop until "Done".

Display the masked confirmation table. **No plaintext API keys appear in this output under any circumstance.**
### GSD ► INTEGRATIONS UPDATED

Search Integrations
| Field              | Value             |
|--------------------|-------------------|
| brave_search       | ****<last-4>      |  (or "(unset)")
| firecrawl          | ****<last-4>      |
| exa_search         | ****<last-4>      |
| search_gitignored  | true | false      |

Code Review CLI Routing
| Lane        | Model id                             |
|-------------|--------------------------------------|
| <lane>      | <value or (unset)>                   |
| ...         | ... one row per lane the user set    |

Agent Skills Injection
| Agent Type       | Skills                    |
|------------------|---------------------------|
| <slug>           | <skill-a, skill-b>        |
| ...              | ...                       |

Notes:
- API keys are stored plaintext in .planning/config.json. The confirmation
  table above never displays plaintext — keys appear as ****<last-4>.
- Plaintext is not echoed back by this workflow, not written to any log,
  and not displayed in error messages.

Quick commands:
- /gsd:settings — workflow toggles and model profile
- /gsd-set-profile <profile> — switch model profile

<success_criteria>

  • Current config read from $GSD_CONFIG_PATH
  • User presented with three sections: Search Integrations, Review CLI Routing, Agent Skills Injection
  • API keys written plaintext only to config.json; never echoed, never logged, never displayed
  • Masked confirmation table uses ****<last-4> for set keys and (unset) for null
  • agent_skills.<agent-type> slugs validated against [a-zA-Z0-9_-]+ before write; review.models.<cli> slugs accepted only from the registry-derived settable set; skill lists written as JSON arrays (never comma-joined strings)
  • Config merge preserves all keys outside the three sections this workflow owns </success_criteria>