Merge pull request #1306 from gsd-build/fix/jq-dependency-1300
fix: remove jq as undocumented hard dependency
This commit is contained in:
@@ -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 <command> [args] [--raw]
|
||||
* Usage: node gsd-tools.cjs <command> [args] [--raw] [--pick <field>]
|
||||
*
|
||||
* 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 <name>: 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 <command> [args] [--raw] [--cwd <path>]\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 <command> [args] [--raw] [--pick <field>] [--cwd <path>]\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];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
58
tests/pick-flag.test.cjs
Normal file
58
tests/pick-flag.test.cjs
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user