Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2
Raw
History Blame Contribute Delete
35.7 kB
"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.<cli-name>',
'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 <key.path> <value>', 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 <key.path> <value>', 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.<agent-type>, features.<feature_name>`, 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 <key.path> [--default <value>]');
}
// 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,
};