"use strict"; /** * Config — Planning config CRUD operations * * ADR-457 build-at-publish: the hand-written bin/lib/config.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only strict types are added. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const node_os_1 = __importDefault(require("node:os")); // eslint-disable-next-line @typescript-eslint/no-require-imports const io = require("./io.cjs"); const { output, error, ERROR_REASON } = io; // eslint-disable-next-line @typescript-eslint/no-require-imports const configLoader = require("./config-loader.cjs"); const { CONFIG_DEFAULTS } = configLoader; const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs"); // eslint-disable-next-line @typescript-eslint/no-require-imports const planningWorkspace = require("./planning-workspace.cjs"); const { planningDir, withPlanningLock } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports const modelProfiles = require("./model-profiles.cjs"); const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles; // eslint-disable-next-line @typescript-eslint/no-require-imports const configSchema = require("./config-schema.cjs"); const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema; const secrets_cjs_1 = require("./secrets.cjs"); const review_reviewer_selection_cjs_1 = require("./review-reviewer-selection.cjs"); const configuration_cjs_1 = require("./configuration.cjs"); // ─── Constants ──────────────────────────────────────────────────────────────── const CONFIG_KEY_SUGGESTIONS = { 'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation', 'agents.nyquist_validation_enabled': 'workflow.nyquist_validation', 'nyquist.validation_enabled': 'workflow.nyquist_validation', 'hooks.research_questions': 'workflow.research_before_questions', 'workflow.research_questions': 'workflow.research_before_questions', 'workflow.codereview': 'workflow.code_review', 'workflow.review_command': 'workflow.code_review_command', 'workflow.review': 'workflow.code_review', 'workflow.code_review_level': 'workflow.code_review_depth', 'workflow.review_depth': 'workflow.code_review_depth', 'review.model': 'review.models.', 'sub_repos': 'planning.sub_repos', 'plan_checker': 'workflow.plan_check', }; const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']); const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([ 'phase_number', 'phase_name', 'phase_dir', 'base_branch', 'padded_phase', ]); const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/; /** * Schema-level defaults for well-known config keys. * When a key is absent from config.json and no --default flag was supplied, * cmdConfigGet checks here before emitting "Key not found". */ const SCHEMA_DEFAULTS = { 'context_window': 200000, 'executor.stall_detect_interval_minutes': 5, 'executor.stall_threshold_minutes': 10, 'git.create_tag': true, }; // ─── Validation helpers ─────────────────────────────────────────────────────── function validateKnownConfigKeyPath(keyPath) { const suggested = CONFIG_KEY_SUGGESTIONS[keyPath]; if (suggested) { error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY); } } function validateShipPrBodySections(value) { if (!Array.isArray(value)) { error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.'); } value.forEach((section, index) => { const prefix = `Invalid ship.pr_body_sections[${index}]`; if (!section || typeof section !== 'object' || Array.isArray(section)) { error(`${prefix}. Expected an object.`); } const sectionObj = section; const unknownKeys = Object.keys(sectionObj).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key)); if (unknownKeys.length > 0) { error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`); } if (typeof sectionObj['heading'] !== 'string' || sectionObj['heading'].trim() === '') { error(`${prefix}. heading must be a non-empty string.`); } if (/[\r\n]/.test(sectionObj['heading'])) { error(`${prefix}. heading must be a single line.`); } if ('enabled' in sectionObj && typeof sectionObj['enabled'] !== 'boolean') { error(`${prefix}. enabled must be true or false.`); } for (const field of ['source', 'fallback', 'template']) { if (field in sectionObj && typeof sectionObj[field] !== 'string') { error(`${prefix}. ${field} must be a string.`); } } const hasContent = ['source', 'fallback', 'template'].some((field) => { const v = sectionObj[field]; return typeof v === 'string' && v.trim() !== ''; }); if (!hasContent) { error(`${prefix}. Provide at least one of source, fallback, or template.`); } if (typeof sectionObj['source'] === 'string' && sectionObj['source'].trim() !== '') { const selectors = sectionObj['source'].split('||').map((selector) => selector.trim()).filter(Boolean); if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) { error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`); } } if (typeof sectionObj['template'] === 'string') { const tokens = sectionObj['template'].matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g); for (const match of tokens) { if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) { error(`${prefix}. Unsupported template token: {${match[1]}}.`); } } } }); } // ─── Core config operations ─────────────────────────────────────────────────── /** * Build a fully-materialized config object for a new project. * * Merges (increasing priority): * 1. Hardcoded defaults — every key that loadConfig() resolves, plus mode/granularity * 2. User-level defaults from ~/.gsd/defaults.json (if present) * 3. userChoices — the settings the user explicitly selected during /gsd:new-project * * Uses the canonical `git` namespace for branching keys (consistent with VALID_CONFIG_KEYS * and the settings workflow). loadConfig() handles both flat and nested formats, so this * is backward-compatible with existing projects that have flat keys. * * Returns a plain object — does NOT write any files. */ function buildNewProjectConfig(userChoices) { const choices = userChoices || {}; const homedir = node_os_1.default.homedir(); // Detect API key availability const braveKeyFile = node_path_1.default.join(homedir, '.gsd', 'brave_api_key'); const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || node_fs_1.default.existsSync(braveKeyFile)); const firecrawlKeyFile = node_path_1.default.join(homedir, '.gsd', 'firecrawl_api_key'); const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || node_fs_1.default.existsSync(firecrawlKeyFile)); const exaKeyFile = node_path_1.default.join(homedir, '.gsd', 'exa_api_key'); const hasExaSearch = !!(process.env['EXA_API_KEY'] || node_fs_1.default.existsSync(exaKeyFile)); const tavilyKeyFile = node_path_1.default.join(homedir, '.gsd', 'tavily_api_key'); const hasTavilySearch = !!(process.env['TAVILY_API_KEY'] || node_fs_1.default.existsSync(tavilyKeyFile)); const refKeyFile = node_path_1.default.join(homedir, '.gsd', 'ref_api_key'); const hasRefSearch = !!(process.env['REF_API_KEY'] || node_fs_1.default.existsSync(refKeyFile)); const perplexityKeyFile = node_path_1.default.join(homedir, '.gsd', 'perplexity_api_key'); const hasPerplexity = !!(process.env['PERPLEXITY_API_KEY'] || node_fs_1.default.existsSync(perplexityKeyFile)); const jinaKeyFile = node_path_1.default.join(homedir, '.gsd', 'jina_api_key'); const hasJina = !!(process.env['JINA_API_KEY'] || node_fs_1.default.existsSync(jinaKeyFile)); // Load user-level defaults from ~/.gsd/defaults.json if available const globalDefaultsPath = node_path_1.default.join(homedir, '.gsd', 'defaults.json'); let userDefaults = {}; try { if (node_fs_1.default.existsSync(globalDefaultsPath)) { userDefaults = JSON.parse(node_fs_1.default.readFileSync(globalDefaultsPath, 'utf-8')); // Migrate deprecated "depth" key to "granularity" if ('depth' in userDefaults && !('granularity' in userDefaults)) { const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; userDefaults['granularity'] = depthToGranularity[userDefaults['depth']] || userDefaults['depth']; delete userDefaults['depth']; try { (0, shell_command_projection_cjs_1.platformWriteSync)(globalDefaultsPath, JSON.stringify(userDefaults, null, 2)); } catch { /* intentionally empty */ } } } } catch { // Ignore malformed global defaults } const hardcoded = { model_profile: CONFIG_DEFAULTS.model_profile, commit_docs: CONFIG_DEFAULTS.commit_docs, parallelization: CONFIG_DEFAULTS.parallelization, search_gitignored: CONFIG_DEFAULTS.search_gitignored, brave_search: hasBraveSearch, firecrawl: hasFirecrawl, exa_search: hasExaSearch, tavily_search: hasTavilySearch, ref_search: hasRefSearch, perplexity: hasPerplexity, jina: hasJina, git: { branching_strategy: CONFIG_DEFAULTS.branching_strategy, create_tag: true, phase_branch_template: CONFIG_DEFAULTS.phase_branch_template, milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template, quick_branch_template: CONFIG_DEFAULTS.quick_branch_template, }, workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true, auto_advance: false, node_repair: true, node_repair_budget: 2, ui_phase: true, ui_safety_gate: true, ai_integration_phase: true, human_verify_mode: 'end-of-phase', text_mode: false, research_before_questions: false, discuss_mode: 'discuss', skip_discuss: false, code_review: true, code_review_depth: 'standard', code_review_command: null, pattern_mapper: true, plan_bounce: false, plan_bounce_script: null, plan_bounce_passes: 2, auto_prune_state: false, post_planning_gaps: CONFIG_DEFAULTS.post_planning_gaps, security_enforcement: CONFIG_DEFAULTS.security_enforcement, security_asvs_level: CONFIG_DEFAULTS.security_asvs_level, security_block_on: CONFIG_DEFAULTS.security_block_on, }, ship: { pr_body_sections: [], }, hooks: { context_warnings: true, }, project_code: null, phase_naming: 'sequential', agent_skills: {}, claude_md_path: './.claude/CLAUDE.md', plan_review: { source_grounding: true, source_grounding_authority: 'grep', }, }; const ud = userDefaults; const ch = choices; const hd = hardcoded; // Three-level deep merge: hardcoded <- userDefaults <- choices const config = { ...hardcoded, ...userDefaults, ...choices, git: { ...hd['git'], ...(ud['git'] || {}), ...(ch['git'] || {}), }, workflow: { ...hd['workflow'], ...(ud['workflow'] || {}), ...(ch['workflow'] || {}), }, ship: { ...hd['ship'], ...(ud['ship'] || {}), ...(ch['ship'] || {}), }, hooks: { ...hd['hooks'], ...(ud['hooks'] || {}), ...(ch['hooks'] || {}), }, agent_skills: { ...hd['agent_skills'], ...(ud['agent_skills'] || {}), ...(ch['agent_skills'] || {}), }, plan_review: { ...hd['plan_review'], ...(ud['plan_review'] || {}), ...(ch['plan_review'] || {}), }, }; validateShipPrBodySections(config['ship']['pr_body_sections']); return config; } /** * Command: create a fully-materialized .planning/config.json for a new project. * * Accepts user-chosen settings as a JSON string (the keys the user explicitly * configured during /gsd:new-project). All remaining keys are filled from * hardcoded defaults and optional ~/.gsd/defaults.json. * * Idempotent: if config.json already exists, returns { created: false }. */ function cmdConfigNewProject(cwd, choicesJson, raw) { const planningBase = planningDir(cwd); const configPath = node_path_1.default.join(planningBase, 'config.json'); // Idempotent: don't overwrite existing config if (node_fs_1.default.existsSync(configPath)) { output({ created: false, reason: 'already_exists' }, raw, 'exists'); return; } // Parse user choices let userChoices = {}; if (choicesJson && choicesJson.trim() !== '') { try { userChoices = JSON.parse(choicesJson); } catch (err) { error('Invalid JSON for config-new-project: ' + err.message); } } // Ensure .planning directory exists try { (0, shell_command_projection_cjs_1.platformEnsureDir)(planningBase); } catch (err) { error('Failed to create .planning directory: ' + err.message); } const config = buildNewProjectConfig(userChoices); try { (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2)); output({ created: true, path: '.planning/config.json' }, raw, 'created'); } catch (err) { error('Failed to write config.json: ' + err.message); } } /** * Ensures the config file exists (creates it if needed). * * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in * the happy path. But note that `error()` will still `exit(1)` out of the process. */ function ensureConfigFile(cwd) { const planningBase = planningDir(cwd); const configPath = node_path_1.default.join(planningBase, 'config.json'); // Ensure .planning directory exists try { (0, shell_command_projection_cjs_1.platformEnsureDir)(planningBase); } catch (err) { error('Failed to create .planning directory: ' + err.message); } // Check if config already exists if (node_fs_1.default.existsSync(configPath)) { return { created: false, reason: 'already_exists' }; } const config = buildNewProjectConfig({}); try { (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2)); return { created: true, path: '.planning/config.json' }; } catch (err) { error('Failed to create config.json: ' + err.message); } } /** * Command to ensure the config file exists (creates it if needed). * * Note that this exits the process (via `output()`) even in the happy path; use * `ensureConfigFile()` directly if you need to avoid this. */ function cmdConfigEnsureSection(cwd, raw) { const ensureConfigFileResult = ensureConfigFile(cwd); if (ensureConfigFileResult && ensureConfigFileResult.created) { output(ensureConfigFileResult, raw, 'created'); } else { output(ensureConfigFileResult, raw, 'exists'); } } /** * Shared helper: write a single key-path into an in-memory config object. * * Prototype-pollution guard: reject dangerous segments via inline literal * comparisons on the exact key used to index `current`, immediately before * each write. The inline comparison is the barrier CodeQL's * js/prototype-pollution-utility query recognises — the previous Set-based * pre-loop check was functionally correct but not traced through, so * code-scanning alert #26 kept firing. Behaviour is unchanged from #663. * * Returns the previous value at the leaf key (undefined if absent). * Never writes to disk — callers handle persistence. * Calls error() (process.exit(1)) on prototype-pollution attempts. */ function _setNestedValue(config, keyPath, parsedValue) { const keys = keyPath.split('.'); let current = config; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (key === '__proto__' || key === 'prototype' || key === 'constructor') { error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); } const existingChild = current[key]; if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) { current[key] = {}; } current = current[key]; } const lastKey = keys[keys.length - 1]; if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') { error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); } const previousValue = current[lastKey]; current[lastKey] = parsedValue; return previousValue; } /** * Sets a value in the config file, allowing nested values via dot notation (e.g., * "workflow.research"). * * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in * the happy path. But note that `error()` will still `exit(1)` out of the process. */ function setConfigValue(cwd, keyPath, parsedValue) { const configPath = node_path_1.default.join(planningDir(cwd), 'config.json'); return withPlanningLock(cwd, () => { // Load existing config or start with empty object let config = {}; try { if (node_fs_1.default.existsSync(configPath)) { config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8')); } } catch (err) { error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); } const previousValue = _setNestedValue(config, keyPath, parsedValue); // Write back try { (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2)); return { updated: true, key: keyPath, value: parsedValue, previousValue }; } catch (err) { error('Failed to write config.json: ' + err.message); } }); } /** * Batched sibling of setConfigValue: apply multiple key-path writes in a * single load → set-all → write cycle inside ONE withPlanningLock call. * * Returns { updated: true, results: SetConfigValueResult[] } on success. * An empty entries array is a no-op and returns { updated: false, results: [] }. * * Prototype-pollution guards are enforced per entry (identical inline-literal * guards as setConfigValue — CodeQL barrier requirement). */ function setConfigValues(cwd, entries) { if (entries.length === 0) { return { updated: false, results: [] }; } const configPath = node_path_1.default.join(planningDir(cwd), 'config.json'); return withPlanningLock(cwd, () => { // Load existing config or start with empty object let config = {}; try { if (node_fs_1.default.existsSync(configPath)) { config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8')); } } catch (err) { error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); } const results = []; for (const entry of entries) { const previousValue = _setNestedValue(config, entry.keyPath, entry.value); results.push({ updated: true, key: entry.keyPath, value: entry.value, previousValue }); } // Write back once for all entries try { (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2)); return { updated: true, results }; } catch (err) { error('Failed to write config.json: ' + err.message); } }); } /** * Command to set a value in the config file, allowing nested values via dot notation (e.g., * "workflow.research"). * * Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()` * directly if you need to avoid this. */ function cmdConfigSet(cwd, keyPath, value, raw) { if (!keyPath) { error('Usage: config-set ', ERROR_REASON.USAGE); } // #3593: reject the "key without value" form (e.g. `config-set // model_profile` with args[2] === undefined). Without this guard the // value passes through as undefined, the number/boolean/json branches // all fall through, and the write either silently strips the key // (JSON.stringify drops undefined values) or writes a corrupt entry. // Typed reason so the negative-matrix test can assert on it instead // of greppinng prose. if (value === undefined) { error('Usage: config-set ', ERROR_REASON.USAGE); } // After the two error() guards above, keyPath and value are narrowed to string. // TypeScript doesn't always infer never-return narrowing through error(), so we assert. const kp = keyPath; const val = value; validateKnownConfigKeyPath(kp); if (!isValidConfigKey(kp)) { error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills., features.`, ERROR_REASON.CONFIG_INVALID_KEY); } // Parse value (handle booleans, numbers, and JSON arrays/objects) let parsedValue = val; if (val === 'true') parsedValue = true; else if (val === 'false') parsedValue = false; else if (!isNaN(Number(val)) && val !== '') parsedValue = Number(val); else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) { try { parsedValue = JSON.parse(val); } catch { /* keep as string */ } } const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) { error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`); } // Codebase drift detector (#2003) const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) { error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`); } if (kp === 'workflow.drift_threshold') { if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) { error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`); } } // Post-planning gap checker (#2493) if (kp === 'workflow.post_planning_gaps') { if (typeof parsedValue !== 'boolean') { error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`); } } // #3086 — git.create_tag: boolean only if (kp === 'git.create_tag') { if (typeof parsedValue !== 'boolean') { error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`); } } if (kp === 'ship.pr_body_sections') { validateShipPrBodySections(parsedValue); } // Human verification checkpoint mode (#3309) const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase']; if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) { error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`); } // Context position enum validation (#2937) const VALID_CONTEXT_POSITIONS = ['front', 'end']; if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`); } // Fallow scope + profile enum validation (#3424) const VALID_FALLOW_SCOPES = ['phase', 'repo']; if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`); } const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict']; if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) { error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`); } // plan_review.source_grounding (#22) — boolean only if (kp === 'plan_review.source_grounding') { if (typeof parsedValue !== 'boolean') { error(`Invalid plan_review.source_grounding '${val}'. Must be a boolean (true or false).`); } } // plan_review.source_grounding_authority (#22) — enum const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip']; if (kp === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) { error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`); } if (kp === 'review.default_reviewers') { const normalized = (0, review_reviewer_selection_cjs_1.normalizeConfiguredDefaultReviewers)(parsedValue); if (normalized.errors.length > 0) { error(normalized.errors[0]); } parsedValue = normalized.values; } const setConfigValueResult = setConfigValue(cwd, kp, parsedValue); // Mask secrets in both JSON and text output. The plaintext is written // to config.json (that's where secrets live on disk); the CLI output // must never echo it. See lib/secrets.cjs. if ((0, secrets_cjs_1.isSecretKey)(kp)) { // parsedValue is unknown at this point; maskSecret accepts MaskableValue const masked = (0, secrets_cjs_1.maskSecret)(parsedValue); const maskedPrev = setConfigValueResult.previousValue === undefined ? undefined : (0, secrets_cjs_1.maskSecret)(setConfigValueResult.previousValue); const maskedResult = { ...setConfigValueResult, value: masked, previousValue: maskedPrev, masked: true, }; output(maskedResult, raw, `${kp}=${masked}`); return; } output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`); } function cmdConfigGet(cwd, keyPath, raw, defaultValue) { const configPath = node_path_1.default.join(planningDir(cwd), 'config.json'); const hasDefault = defaultValue !== undefined; if (!keyPath) { error('Usage: config-get [--default ]'); } // After the error() guard, keyPath is narrowed to string. const kp = keyPath; let config = {}; try { if (node_fs_1.default.existsSync(configPath)) { config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8')); } else if (hasDefault) { // eslint-disable-next-line @typescript-eslint/no-base-to-string output(defaultValue, raw, String(defaultValue)); return; } else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) { const def = SCHEMA_DEFAULTS[kp]; output(def, raw, String(def)); return; } else { error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE); } } catch (err) { if (err.message.startsWith('No config.json')) throw err; error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); } // Traverse dot-notation path (e.g., "workflow.auto_advance") const keys = kp.split('.'); let current = config; for (const key of keys) { if (current === undefined || current === null || typeof current !== 'object') { // eslint-disable-next-line @typescript-eslint/no-base-to-string if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) { const def = SCHEMA_DEFAULTS[kp]; output(def, raw, String(def)); return; } error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND); } current = current[key]; } if (current === undefined) { // eslint-disable-next-line @typescript-eslint/no-base-to-string if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) { const def = SCHEMA_DEFAULTS[kp]; output(def, raw, String(def)); return; } error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND); } // Never echo plaintext for sensitive keys via config-get. Plaintext lives // in config.json on disk; the CLI surface always shows the masked form. if ((0, secrets_cjs_1.isSecretKey)(kp)) { const masked = (0, secrets_cjs_1.maskSecret)(current); output(masked, raw, masked); return; } output(current, raw, String(current)); } /** * Command to set the model profile in the config file. * * Note that this exits the process (via `output()`) even in the happy path. */ function cmdConfigSetModelProfile(cwd, profile, raw) { if (!profile) { error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`); } const normalizedProfile = profile.toLowerCase().trim(); if (!VALID_PROFILES.includes(normalizedProfile)) { error(`Invalid profile '${String(profile)}'. Valid profiles: ${VALID_PROFILES.join(', ')}`); } // Ensure config exists (create if needed) ensureConfigFile(cwd); // Set the model profile in the config const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile); const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced'; // Build result value / message and return const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile); const result = { updated: true, profile: normalizedProfile, previousProfile, agentToModelMap, }; const rawValue = getCmdConfigSetModelProfileResultMessage(normalizedProfile, previousProfile, agentToModelMap); output(result, raw, rawValue); } /** * Returns the message to display for the result of the `config-set-model-profile` command when * displaying raw output. */ function getCmdConfigSetModelProfileResultMessage(normalizedProfile, previousProfile, agentToModelMap) { const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap); const didChange = previousProfile !== normalizedProfile; const paragraphs = didChange ? [ `✓ Model profile set to: ${normalizedProfile} (was: ${previousProfile})`, 'Agents will now use:', agentToModelTable, 'Next spawned agents will use the new profile.', ] : [ `✓ Model profile is already set to: ${normalizedProfile}`, 'Agents are using:', agentToModelTable, ]; return paragraphs.join('\n\n'); } /** * Print the resolved config.json path (workstream-aware). Used by settings.md * so the workflow writes/reads the correct file when a workstream is active (#2282). */ function cmdConfigPath(cwd, _raw, workstreamContext = null) { // Always emit as plain text — a file path is used via shell substitution, // never consumed as JSON. Passing raw=true forces plain-text output. const configPath = workstreamContext && workstreamContext.configPath ? workstreamContext.configPath : node_path_1.default.join(planningDir(cwd), 'config.json'); output(configPath, true, configPath); } /** * Explicit on-disk migration of legacy config keys to canonical nested shape. * * Wraps the Configuration Module's migrateOnDisk() for the CLI surface. This * is the Phase 2 acceptance-criteria deliverable for opt-in migration (#3536): * users can run `gsd-tools migrate-config` to apply all four legacy-key * migrations to their .planning/config.json without having to load any config * implicitly via another command. * * Output: JSON object with { migrated, normalizations, wrote } or a human-readable * summary when --raw is set. Exits 0 in all cases (including no-op). * * Note: migrateOnDisk() is synchronous; the original CJS used async for * forward-compatibility but no await is needed. Dropped async per ADR-457 policy * (caller uses `await` which is safe on a sync return value). */ function cmdMigrateConfig(cwd, raw) { const ws = process.env['GSD_WORKSTREAM'] || null; const report = (0, configuration_cjs_1.migrateOnDisk)(cwd, ws || undefined); if (raw) { if (!report.migrated) { const msg = 'No legacy keys found — config is already canonical.'; output(msg, true, msg); } else { const lines = [ `Migrated: ${String(report.wrote)}`, ...report.normalizations.map(n => ` ${n.from} → ${n.to}`), ].join('\n'); output(lines, true, lines); } } else { // output() JSON.stringify's its first arg when raw=false; pass the report object. output(report, false, report); } } module.exports = { VALID_CONFIG_KEYS, cmdConfigEnsureSection, cmdConfigSet, cmdConfigGet, cmdConfigSetModelProfile, cmdConfigNewProject, cmdConfigPath, cmdMigrateConfig, // Exported for programmatic use by capability-writer and tests setConfigValue, setConfigValues, };