feat(config): add --default flag to config-get for graceful absent-key handling (#1893) (#1920)

When --default <value> is passed, config-get returns the default value
(exit 0) instead of erroring (exit 1) when the key is absent or
config.json doesn't exist. When the key IS present, --default is
ignored and the real value returned.

This lets workflows express optional config reads without defensive
`2>/dev/null || true` boilerplate that obscures intent and is fragile
under `set -e`.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tibsfox
2026-04-07 14:25:11 -07:00
committed by GitHub
parent 4334e49419
commit 602b34afb7
3 changed files with 133 additions and 6 deletions

View File

@@ -294,6 +294,17 @@ async function main() {
args.splice(pickIdx, 2);
}
// --default <value>: 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;
}

View File

@@ -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 <key.path>');
error('Usage: config-get <key.path> [--default <value>]');
}
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}`);
}

View File

@@ -0,0 +1,110 @@
/**
* Tests for config-get --default flag (#1893)
*
* When --default <value> 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');
});
});