feat(sdk): GSDTools native dispatch and CJS fallback routing (#2302 Track C) (#2342)

This commit is contained in:
Rezolv
2026-04-18 12:35:23 -04:00
committed by GitHub
parent 381c138534
commit 0171f70553
6 changed files with 455 additions and 38 deletions

View File

@@ -0,0 +1,56 @@
/**
* Normalize `gsd-sdk query <argv...>` command tokens to match `createRegistry()` keys.
*
* `gsd-tools` takes a top-level command plus a subcommand (`state json`, `init execute-phase 9`).
* The SDK CLI originally passed only argv[0] as the registry key, so `query state json` dispatched
* `state` (unknown) instead of `state.json`. This module merges the same prefixes gsd-tools nests
* under `runCommand()` so two-token (and longer) invocations resolve to dotted registry names.
*/
const MERGE_FIRST_WITH_SUBCOMMAND = new Set<string>([
'state',
'template',
'frontmatter',
'verify',
'phase',
'phases',
'roadmap',
'requirements',
'validate',
'init',
'workstream',
'intel',
'learnings',
'uat',
'todo',
'milestone',
'check',
'detect',
'route',
]);
/**
* @param command - First token after `query` (e.g. `state`, `init`, `config-get`)
* @param args - Remaining tokens (flags like `--pick` should already be stripped)
* @returns Registry command string and handler args
*/
export function normalizeQueryCommand(command: string, args: string[]): [string, string[]] {
if (command === 'scaffold') {
return ['phase.scaffold', args];
}
if (command === 'state' && args.length === 0) {
return ['state.load', []];
}
if (MERGE_FIRST_WITH_SUBCOMMAND.has(command) && args.length > 0) {
const sub = args[0];
return [`${command}.${sub}`, args.slice(1)];
}
if ((command === 'progress' || command === 'stats') && args.length > 0) {
return [`${command}.${args[0]}`, args.slice(1)];
}
return [command, args];
}

View File

@@ -0,0 +1,109 @@
/**
* `state load` — full project config + STATE.md raw text (CJS `cmdStateLoad`).
*
* Uses the same `loadConfig(cwd)` as `get-shit-done/bin/lib/state.cjs` by resolving
* `core.cjs` next to a shipped/bundled/user `get-shit-done` install (same probe order
* as `resolveGsdToolsPath`). This keeps JSON output **byte-compatible** with
* `node gsd-tools.cjs state load` for monorepo and standard installs.
*
* Distinct from {@link stateJson} (`state json` / `state.json`) which mirrors
* `cmdStateJson` (rebuilt frontmatter only).
*/
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { planningPaths } from './helpers.js';
import type { QueryHandler } from './utils.js';
import { GSDError, ErrorClassification } from '../errors.js';
const BUNDLED_CORE_CJS = fileURLToPath(
new URL('../../../get-shit-done/bin/lib/core.cjs', import.meta.url),
);
function resolveCoreCjsPath(projectDir: string): string | null {
const candidates = [
BUNDLED_CORE_CJS,
join(projectDir, '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'),
join(homedir(), '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'),
];
return candidates.find(p => existsSync(p)) ?? null;
}
function loadConfigCjs(projectDir: string): Record<string, unknown> {
const corePath = resolveCoreCjsPath(projectDir);
if (!corePath) {
throw new GSDError(
'state load: get-shit-done/bin/lib/core.cjs not found. Install GSD (e.g. npm i -g get-shit-done-cc) or clone with get-shit-done next to the SDK.',
ErrorClassification.Blocked,
);
}
const req = createRequire(import.meta.url);
const { loadConfig } = req(corePath) as { loadConfig: (cwd: string) => Record<string, unknown> };
return loadConfig(projectDir);
}
/**
* Query handler for `state load` / bare `state` (normalize → `state.load`).
*
* Port of `cmdStateLoad` from `get-shit-done/bin/lib/state.cjs` lines 44–86.
*/
export const stateProjectLoad: QueryHandler = async (_args, projectDir) => {
const config = loadConfigCjs(projectDir);
const planDir = planningPaths(projectDir).planning;
let stateRaw = '';
try {
stateRaw = await readFile(join(planDir, 'STATE.md'), 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
const configExists = existsSync(join(planDir, 'config.json'));
const roadmapExists = existsSync(join(planDir, 'ROADMAP.md'));
const stateExists = stateRaw.length > 0;
return {
data: {
config,
state_raw: stateRaw,
state_exists: stateExists,
roadmap_exists: roadmapExists,
config_exists: configExists,
},
};
};
/**
* `--raw` stdout for `state load` (matches CJS `cmdStateLoad` lines 65–83).
*/
export function formatStateLoadRawStdout(data: unknown): string {
const d = data as Record<string, unknown>;
const c = d.config as Record<string, unknown> | undefined;
if (!c) {
return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}
const configExists = d.config_exists;
const roadmapExists = d.roadmap_exists;
const stateExists = d.state_exists;
const lines = [
`model_profile=${c.model_profile}`,
`commit_docs=${c.commit_docs}`,
`branching_strategy=${c.branching_strategy}`,
`phase_branch_template=${c.phase_branch_template}`,
`milestone_branch_template=${c.milestone_branch_template}`,
`parallelization=${c.parallelization}`,
`research=${c.research}`,
`plan_checker=${c.plan_checker}`,
`verifier=${c.verifier}`,
`config_exists=${configExists}`,
`roadmap_exists=${roadmapExists}`,
`state_exists=${stateExists}`,
];
return lines.join('\n');
}