diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index e68d436d..fd699446 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -294,6 +294,17 @@ async function main() { args.splice(pickIdx, 2); } + // --default : for config-get, return this value instead of erroring + // when the key is absent. Allows workflows to express optional config reads + // without defensive `2>/dev/null || true` boilerplate (#1893). + const defaultIdx = args.indexOf('--default'); + let defaultValue = undefined; + if (defaultIdx !== -1) { + defaultValue = args[defaultIdx + 1]; + if (defaultValue === undefined) defaultValue = ''; + args.splice(defaultIdx, 2); + } + const command = args[0]; if (!command) { @@ -346,7 +357,7 @@ async function main() { } }; try { - await runCommand(command, args, cwd, raw); + await runCommand(command, args, cwd, raw, defaultValue); cleanup(); } catch (e) { fs.writeSync = origWriteSync; @@ -355,7 +366,7 @@ async function main() { return; } - await runCommand(command, args, cwd, raw); + await runCommand(command, args, cwd, raw, defaultValue); } /** @@ -381,7 +392,7 @@ function extractField(obj, fieldPath) { return current; } -async function runCommand(command, args, cwd, raw) { +async function runCommand(command, args, cwd, raw, defaultValue) { switch (command) { case 'state': { const subcommand = args[1]; @@ -589,7 +600,7 @@ async function runCommand(command, args, cwd, raw) { } case 'config-get': { - config.cmdConfigGet(cwd, args[1], raw); + config.cmdConfigGet(cwd, args[1], raw, defaultValue); break; } diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index fe8929fb..0cd64ab5 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -361,17 +361,21 @@ function cmdConfigSet(cwd, keyPath, value, raw) { output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`); } -function cmdConfigGet(cwd, keyPath, raw) { +function cmdConfigGet(cwd, keyPath, raw, defaultValue) { const configPath = path.join(planningRoot(cwd), 'config.json'); + const hasDefault = defaultValue !== undefined; if (!keyPath) { - error('Usage: config-get '); + error('Usage: config-get [--default ]'); } let config = {}; try { if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } else if (hasDefault) { + output(defaultValue, raw, String(defaultValue)); + return; } else { error('No config.json found at ' + configPath); } @@ -385,12 +389,14 @@ function cmdConfigGet(cwd, keyPath, raw) { let current = config; for (const key of keys) { if (current === undefined || current === null || typeof current !== 'object') { + if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } error(`Key not found: ${keyPath}`); } current = current[key]; } if (current === undefined) { + if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } error(`Key not found: ${keyPath}`); } diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs new file mode 100644 index 00000000..3bc3279c --- /dev/null +++ b/tests/config-get-default.test.cjs @@ -0,0 +1,110 @@ +/** + * Tests for config-get --default flag (#1893) + * + * When --default is passed, config-get should return the default + * value (exit 0) instead of erroring (exit 1) when the key is absent. + * When the key IS present, --default should be ignored and the real value + * returned. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const os = require('os'); + +const GSD_TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + +describe('config-get --default flag (#1893)', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-default-')); + planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function run(...args) { + return execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], { + encoding: 'utf-8', + timeout: 5000, + }).trim(); + } + + function runRaw(...args) { + return run(...args, '--raw'); + } + + function runExpectError(...args) { + try { + execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.fail('Expected command to exit non-zero'); + } catch (err) { + assert.ok(err.status !== 0, 'Expected non-zero exit code'); + return err; + } + } + + test('absent key without --default errors', () => { + fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); + runExpectError('config-get', 'nonexistent.key', '--raw'); + }); + + test('absent key with --default returns default value', () => { + fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); + const result = runRaw('config-get', 'nonexistent.key', '--default', 'fallback'); + assert.equal(result, 'fallback'); + }); + + test('absent key with --default "" returns empty string', () => { + fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); + const result = runRaw('config-get', 'nonexistent.key', '--default', ''); + assert.equal(result, ''); + }); + + test('present key with --default returns real value (ignores default)', () => { + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ + workflow: { discuss_mode: 'adaptive' } + })); + const result = runRaw('config-get', 'workflow.discuss_mode', '--default', 'ignored'); + assert.equal(result, 'adaptive'); + }); + + test('nested absent key with --default returns default', () => { + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ + workflow: {} + })); + const result = runRaw('config-get', 'workflow.deep.missing.key', '--default', 'safe'); + assert.equal(result, 'safe'); + }); + + test('missing config.json with --default returns default', () => { + // No config.json written + const result = runRaw('config-get', 'any.key', '--default', 'no-config'); + assert.equal(result, 'no-config'); + }); + + test('missing config.json without --default errors', () => { + // No config.json written + runExpectError('config-get', 'any.key', '--raw'); + }); + + test('--default works with JSON output (no --raw)', () => { + fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); + const result = run('config-get', 'missing.key', '--default', 'json-test'); + const parsed = JSON.parse(result); + assert.equal(parsed, 'json-test'); + }); +});