fix(#167): support query meta-command in gsd-tools (#202)

* fix(#167): support query meta-command in gsd-tools

* chore(#167): add changeset for query meta-command fix

* fix(#167): pin claude runtime in local-agent regression tests

* test(#3751): stabilize local-agent CI assertions
This commit is contained in:
Tom Boucher
2026-05-24 14:37:10 -04:00
committed by GitHub
parent 3d82761f9a
commit 4bc3653578
4 changed files with 82 additions and 18 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 167
---
**`gsd-tools` query meta-command parity** — direct invocations like `node gsd-tools.cjs query init.progress` now behave the same as `node gsd-tools.cjs init.progress` instead of failing with `Unknown command: query`. This unblocks workflow preflight paths that call the CJS entrypoint directly with the `query` prefix.

View File

@@ -419,6 +419,13 @@ async function main() {
let command = args[0];
// Accept `query` meta-prefix parity with `gsd-sdk query ...`.
// Workflows may call `node gsd-tools.cjs query <command>` directly.
if (command === 'query') {
args.shift();
command = args[0];
}
// #3243: accept dotted canonical form (e.g. `state.update`) as well as the
// spaced form (`state update`). Workflow files and stale SDK binaries pass
// the dotted canonical form directly; any caller that bypasses the SDK

View File

@@ -0,0 +1,20 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools } = require('./helpers.cjs');
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
const direct = runGsdTools(['init.progress']);
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
const meta = runGsdTools(['query', 'init.progress']);
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
assert.deepEqual(
JSON.parse(meta.output),
JSON.parse(direct.output),
'query-prefixed and direct invocations should return identical init.progress payloads'
);
});

View File

@@ -30,6 +30,30 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const MODEL_PROFILES = require('../get-shit-done/bin/lib/model-profiles.cjs').MODEL_PROFILES;
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
function writeRequiredAgents(agentsDir) {
for (const agentName of EXPECTED_AGENTS) {
fs.writeFileSync(
path.join(agentsDir, `${agentName}.md`),
`---\nname: ${agentName}\ndescription: test\ntools: Read\n---\nAgent content.\n`,
);
}
}
function normalizePathForAssert(targetPath) {
if (typeof targetPath !== 'string') return targetPath;
const resolved = path.resolve(targetPath);
try {
if (typeof fs.realpathSync.native === 'function') {
return fs.realpathSync.native(resolved);
}
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
// ─── Source-file structural assertions (no build required) ───────────────────
@@ -152,11 +176,14 @@ describe('#3751: resolveAgentsDir() repo-local fallback — runtime behaviour',
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3751-'));
savedEnv = {
GSD_AGENTS_DIR: process.env.GSD_AGENTS_DIR,
GSD_RUNTIME: process.env.GSD_RUNTIME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
HOME: process.env.HOME,
};
// Clear explicit overrides so we exercise the fallback path
delete process.env.GSD_AGENTS_DIR;
// Make runtime deterministic: this suite validates Claude local-agent semantics.
process.env.GSD_RUNTIME = 'claude';
});
afterEach(() => {
@@ -167,6 +194,11 @@ describe('#3751: resolveAgentsDir() repo-local fallback — runtime behaviour',
} else {
delete process.env.GSD_AGENTS_DIR;
}
if (savedEnv.GSD_RUNTIME !== undefined) {
process.env.GSD_RUNTIME = savedEnv.GSD_RUNTIME;
} else {
delete process.env.GSD_RUNTIME;
}
if (savedEnv.CLAUDE_CONFIG_DIR !== undefined) {
process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
} else {
@@ -187,14 +219,12 @@ describe('#3751: resolveAgentsDir() repo-local fallback — runtime behaviour',
// DO NOT create fakeGlobalConfig/agents/ — simulates absent global agents
process.env.CLAUDE_CONFIG_DIR = fakeGlobalConfig;
// Set up repo-local .claude/agents with a GSD agent definition
// Set up repo-local .claude/agents with all required GSD agent files.
// `agents_installed` is only true when every MODEL_PROFILES key exists.
const repoRoot = path.join(tmpDir, 'repo');
const repoLocalAgentsDir = path.join(repoRoot, '.claude', 'agents');
fs.mkdirSync(repoLocalAgentsDir, { recursive: true });
fs.writeFileSync(
path.join(repoLocalAgentsDir, 'gsd-project-researcher.md'),
'---\nname: gsd-project-researcher\ndescription: test\ntools: Read\n---\nAgent content.\n',
);
writeRequiredAgents(repoLocalAgentsDir);
// Dynamically require helpers so CLAUDE_CONFIG_DIR is picked up
// (Node caches modules, so we clear the cache first)
@@ -314,15 +344,20 @@ describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3799-'));
savedEnv = {
GSD_AGENTS_DIR: process.env.GSD_AGENTS_DIR,
GSD_RUNTIME: process.env.GSD_RUNTIME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
};
delete process.env.GSD_AGENTS_DIR;
// Keep runtime fixed across suite-order changes and leaked test env.
process.env.GSD_RUNTIME = 'claude';
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
if (savedEnv.GSD_AGENTS_DIR !== undefined) process.env.GSD_AGENTS_DIR = savedEnv.GSD_AGENTS_DIR;
else delete process.env.GSD_AGENTS_DIR;
if (savedEnv.GSD_RUNTIME !== undefined) process.env.GSD_RUNTIME = savedEnv.GSD_RUNTIME;
else delete process.env.GSD_RUNTIME;
if (savedEnv.CLAUDE_CONFIG_DIR !== undefined) process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
else delete process.env.CLAUDE_CONFIG_DIR;
});
@@ -346,14 +381,11 @@ describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour
// global agents/ exists but is EMPTY — simulates Claude auto-creating the dir
process.env.CLAUDE_CONFIG_DIR = fakeGlobalConfig;
// Set up project-local .claude/agents with a GSD agent definition
// Set up project-local .claude/agents with all required GSD agent files
const repoRoot = path.join(tmpDir, 'repo');
const repoLocalAgentsDir = path.join(repoRoot, '.claude', 'agents');
fs.mkdirSync(repoLocalAgentsDir, { recursive: true });
fs.writeFileSync(
path.join(repoLocalAgentsDir, 'gsd-project-researcher.md'),
'---\nname: gsd-project-researcher\ndescription: test\ntools: Read\n---\nAgent content.\n',
);
writeRequiredAgents(repoLocalAgentsDir);
const result = runGsdTools(
['query', 'init.new-project', '--raw'],
@@ -369,8 +401,8 @@ describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour
if (parsed && typeof parsed.agents_dir !== 'undefined') {
// agents_dir must point to the local dir, not the empty global dir
assert.strictEqual(
parsed.agents_dir,
repoLocalAgentsDir,
normalizePathForAssert(parsed.agents_dir),
normalizePathForAssert(repoLocalAgentsDir),
`agents_dir must resolve to project-local path, got: ${parsed.agents_dir} (#3799)`,
);
}
@@ -410,8 +442,8 @@ describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour
try { parsed = JSON.parse(result.output); } catch { return; }
if (parsed && typeof parsed.agents_dir !== 'undefined') {
assert.strictEqual(
parsed.agents_dir,
fakeGlobalAgents,
normalizePathForAssert(parsed.agents_dir),
normalizePathForAssert(fakeGlobalAgents),
`agents_dir must resolve to global path when no local dir exists, got: ${parsed.agents_dir} (#3799)`,
);
}
@@ -426,13 +458,13 @@ describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour
const fakeGlobalConfig = path.join(tmpDir, 'fake-global-both');
const fakeGlobalAgents = path.join(fakeGlobalConfig, 'agents');
fs.mkdirSync(fakeGlobalAgents, { recursive: true });
fs.writeFileSync(path.join(fakeGlobalAgents, 'gsd-project-researcher.md'), '# global agent\n');
writeRequiredAgents(fakeGlobalAgents);
process.env.CLAUDE_CONFIG_DIR = fakeGlobalConfig;
const repoRoot = path.join(tmpDir, 'repo-both');
const repoLocalAgentsDir = path.join(repoRoot, '.claude', 'agents');
fs.mkdirSync(repoLocalAgentsDir, { recursive: true });
fs.writeFileSync(path.join(repoLocalAgentsDir, 'gsd-project-researcher.md'), '# local agent\n');
writeRequiredAgents(repoLocalAgentsDir);
const result = runGsdTools(
['query', 'init.new-project', '--raw'],
@@ -447,8 +479,8 @@ describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour
try { parsed = JSON.parse(result.output); } catch { return; }
if (parsed && typeof parsed.agents_dir !== 'undefined') {
assert.strictEqual(
parsed.agents_dir,
repoLocalAgentsDir,
normalizePathForAssert(parsed.agents_dir),
normalizePathForAssert(repoLocalAgentsDir),
`agents_dir must resolve to local path when both exist, got: ${parsed.agents_dir} (#3799)`,
);
}