diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 92f816084..2bd34c619 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -6,7 +6,7 @@ * Replaces repetitive inline bash patterns across ~50 GSD command/workflow/agent files. * Centralizes: config parsing, model resolution, phase lookup, git commits, summary verification. * - * Usage: node gsd-tools.cjs [args] [--raw] + * Usage: node gsd-tools.cjs [args] [--raw] [--pick ] * * Atomic Commands: * state load Load project config + state @@ -189,10 +189,21 @@ async function main() { const raw = rawIndex !== -1; if (rawIndex !== -1) args.splice(rawIndex, 1); + // --pick : extract a single field from JSON output (replaces jq dependency). + // Supports dot-notation (e.g., --pick workflow.research) and bracket notation + // for arrays (e.g., --pick directories[-1]). + const pickIdx = args.indexOf('--pick'); + let pickField = null; + if (pickIdx !== -1) { + pickField = args[pickIdx + 1]; + if (!pickField || pickField.startsWith('--')) error('Missing value for --pick'); + args.splice(pickIdx, 2); + } + const command = args[0]; if (!command) { - error('Usage: gsd-tools [args] [--raw] [--cwd ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-new-project, init'); + error('Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-new-project, init'); } // Multi-repo guard: resolve project root for commands that read/write .planning/. @@ -206,6 +217,67 @@ async function main() { cwd = findProjectRoot(cwd); } + // When --pick is active, intercept stdout to extract the requested field. + if (pickField) { + const origWriteSync = fs.writeSync; + const chunks = []; + fs.writeSync = function (fd, data, ...rest) { + if (fd === 1) { chunks.push(String(data)); return; } + return origWriteSync.call(fs, fd, data, ...rest); + }; + const cleanup = () => { + fs.writeSync = origWriteSync; + const captured = chunks.join(''); + let jsonStr = captured; + if (jsonStr.startsWith('@file:')) { + jsonStr = fs.readFileSync(jsonStr.slice(6), 'utf-8'); + } + try { + const obj = JSON.parse(jsonStr); + const value = extractField(obj, pickField); + const result = value === null || value === undefined ? '' : String(value); + origWriteSync.call(fs, 1, result); + } catch { + origWriteSync.call(fs, 1, captured); + } + }; + try { + await runCommand(command, args, cwd, raw); + cleanup(); + } catch (e) { + fs.writeSync = origWriteSync; + throw e; + } + return; + } + + await runCommand(command, args, cwd, raw); +} + +/** + * Extract a field from an object using dot-notation and bracket syntax. + * Supports: 'field', 'parent.child', 'arr[-1]', 'arr[0]' + */ +function extractField(obj, fieldPath) { + const parts = fieldPath.split('.'); + let current = obj; + for (const part of parts) { + if (current === null || current === undefined) return undefined; + const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/); + if (bracketMatch) { + const key = bracketMatch[1]; + const index = parseInt(bracketMatch[2], 10); + current = current[key]; + if (!Array.isArray(current)) return undefined; + current = index < 0 ? current[current.length + index] : current[index]; + } else { + current = current[part]; + } + } + return current; +} + +async function runCommand(command, args, cwd, raw) { switch (command) { case 'state': { const subcommand = args[1]; diff --git a/get-shit-done/references/decimal-phase-calculation.md b/get-shit-done/references/decimal-phase-calculation.md index dd1b57230..ba5c5cbdc 100644 --- a/get-shit-done/references/decimal-phase-calculation.md +++ b/get-shit-done/references/decimal-phase-calculation.md @@ -32,9 +32,8 @@ With existing decimals: ## Extract Values ```bash -DECIMAL_INFO=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" phase next-decimal "${AFTER_PHASE}") -DECIMAL_PHASE=$(printf '%s\n' "$DECIMAL_INFO" | jq -r '.next') -BASE_PHASE=$(printf '%s\n' "$DECIMAL_INFO" | jq -r '.base_phase') +DECIMAL_PHASE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" phase next-decimal "${AFTER_PHASE}" --pick next) +BASE_PHASE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" phase next-decimal "${AFTER_PHASE}" --pick base_phase) ``` Or with --raw flag: diff --git a/get-shit-done/references/phase-argument-parsing.md b/get-shit-done/references/phase-argument-parsing.md index ee483fcf3..193ab14c4 100644 --- a/get-shit-done/references/phase-argument-parsing.md +++ b/get-shit-done/references/phase-argument-parsing.md @@ -45,8 +45,8 @@ fi Use `roadmap get-phase` to validate phase exists: ```bash -PHASE_CHECK=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}") -if [ "$(printf '%s\n' "$PHASE_CHECK" | jq -r '.found')" = "false" ]; then +PHASE_CHECK=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}" --pick found) +if [ "$PHASE_CHECK" = "false" ]; then echo "ERROR: Phase ${PHASE} not found in roadmap" exit 1 fi diff --git a/get-shit-done/workflows/audit-milestone.md b/get-shit-done/workflows/audit-milestone.md index 7aff0145c..5d9787d97 100644 --- a/get-shit-done/workflows/audit-milestone.md +++ b/get-shit-done/workflows/audit-milestone.md @@ -105,7 +105,7 @@ For each phase's VERIFICATION.md, extract the expanded requirements table: For each phase's SUMMARY.md, extract `requirements-completed` from YAML frontmatter: ```bash for summary in .planning/phases/*-*/*-SUMMARY.md; do - node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" summary-extract "$summary" --fields requirements_completed | jq -r '.requirements_completed' + node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" summary-extract "$summary" --fields requirements_completed --pick requirements_completed done ``` diff --git a/get-shit-done/workflows/complete-milestone.md b/get-shit-done/workflows/complete-milestone.md index a3b7f77db..67824029c 100644 --- a/get-shit-done/workflows/complete-milestone.md +++ b/get-shit-done/workflows/complete-milestone.md @@ -156,7 +156,7 @@ Extract one-liners from SUMMARY.md files using summary-extract: ```bash # For each phase in milestone, extract one-liner for summary in .planning/phases/*-*/*-SUMMARY.md; do - node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" summary-extract "$summary" --fields one_liner | jq -r '.one_liner' + node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" summary-extract "$summary" --fields one_liner --pick one_liner done ``` diff --git a/get-shit-done/workflows/plan-milestone-gaps.md b/get-shit-done/workflows/plan-milestone-gaps.md index 7fa760866..5913cb927 100644 --- a/get-shit-done/workflows/plan-milestone-gaps.md +++ b/get-shit-done/workflows/plan-milestone-gaps.md @@ -65,8 +65,7 @@ Gap: Flow "View dashboard" broken at data fetch Find highest existing phase: ```bash # Get sorted phase list, extract last one -PHASES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" phases list) -HIGHEST=$(printf '%s\n' "$PHASES" | jq -r '.directories[-1]') +HIGHEST=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" phases list --pick directories[-1]) ``` New phases continue from there: diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index f30140e5a..9569a2a27 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -255,7 +255,7 @@ Display banner: ### Spawn gsd-phase-researcher ```bash -PHASE_DESC=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}" | jq -r '.section') +PHASE_DESC=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}" --pick section) ``` Research prompt: @@ -384,14 +384,15 @@ ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null Extract from INIT JSON: ```bash -STATE_PATH=$(printf '%s\n' "$INIT" | jq -r '.state_path // empty') -ROADMAP_PATH=$(printf '%s\n' "$INIT" | jq -r '.roadmap_path // empty') -REQUIREMENTS_PATH=$(printf '%s\n' "$INIT" | jq -r '.requirements_path // empty') -RESEARCH_PATH=$(printf '%s\n' "$INIT" | jq -r '.research_path // empty') -VERIFICATION_PATH=$(printf '%s\n' "$INIT" | jq -r '.verification_path // empty') -UAT_PATH=$(printf '%s\n' "$INIT" | jq -r '.uat_path // empty') -CONTEXT_PATH=$(printf '%s\n' "$INIT" | jq -r '.context_path // empty') -REVIEWS_PATH=$(printf '%s\n' "$INIT" | jq -r '.reviews_path // empty') +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +STATE_PATH=$(_gsd_field "$INIT" state_path) +ROADMAP_PATH=$(_gsd_field "$INIT" roadmap_path) +REQUIREMENTS_PATH=$(_gsd_field "$INIT" requirements_path) +RESEARCH_PATH=$(_gsd_field "$INIT" research_path) +VERIFICATION_PATH=$(_gsd_field "$INIT" verification_path) +UAT_PATH=$(_gsd_field "$INIT" uat_path) +CONTEXT_PATH=$(_gsd_field "$INIT" context_path) +REVIEWS_PATH=$(_gsd_field "$INIT" reviews_path) ``` ## 7.5. Verify Nyquist Artifacts diff --git a/tests/pick-flag.test.cjs b/tests/pick-flag.test.cjs new file mode 100644 index 000000000..c2ca57be4 --- /dev/null +++ b/tests/pick-flag.test.cjs @@ -0,0 +1,58 @@ +/** + * GSD Tools Tests - --pick flag + * + * Regression tests for the --pick CLI flag that extracts a single field + * from JSON output, replacing the need for jq as an external dependency. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); +const { runGsdTools } = require('./helpers.cjs'); + +// ─── --pick flag ───────────────────────────────────────────────────────────── + +describe('--pick flag', () => { + test('extracts a top-level field from JSON output', () => { + const result = runGsdTools('generate-slug "hello world" --pick slug'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.output, 'hello-world'); + }); + + test('extracts a top-level field using array args', () => { + const result = runGsdTools(['generate-slug', 'hello world', '--pick', 'slug']); + assert.strictEqual(result.success, true); + assert.strictEqual(result.output, 'hello-world'); + }); + + test('returns empty string for missing field', () => { + const result = runGsdTools('generate-slug "test" --pick nonexistent'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.output, ''); + }); + + test('errors when --pick has no value', () => { + const result = runGsdTools('generate-slug "test" --pick'); + assert.strictEqual(result.success, false); + assert.match(result.error, /Missing value for --pick/); + }); + + test('errors when --pick value starts with --', () => { + const result = runGsdTools(['generate-slug', 'test', '--pick', '--raw']); + assert.strictEqual(result.success, false); + assert.match(result.error, /Missing value for --pick/); + }); + + test('does not collide with frontmatter --field flag', () => { + // frontmatter subcommand uses --field internally; --pick should not interfere + const result = runGsdTools('generate-slug "test-value" --pick slug'); + assert.strictEqual(result.success, true); + assert.strictEqual(result.output, 'test-value'); + }); + + test('works with current-timestamp command', () => { + const result = runGsdTools('current-timestamp --pick timestamp'); + assert.strictEqual(result.success, true); + assert.ok(result.output.length > 0, 'timestamp should not be empty'); + assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/); + }); +});