mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Compare commits
4 Commits
fix/2399-c
...
feat/2402-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2060561148 | ||
|
|
819e164ff1 | ||
|
|
b4608ce130 | ||
|
|
0f3d84ea60 |
@@ -10,6 +10,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- **`/gsd-ingest-docs` command** — Scan a repo containing mixed ADRs, PRDs, SPECs, and DOCs and bootstrap or merge the full `.planning/` setup from them in a single pass. Parallel classification (`gsd-doc-classifier`), synthesis with precedence rules and cycle detection (`gsd-doc-synthesizer`), three-bucket conflicts report (`INGEST-CONFLICTS.md`: auto-resolved, competing-variants, unresolved-blockers), and hard-block on LOCKED-vs-LOCKED ADR contradictions in both new and merge modes. Supports directory-convention discovery and `--manifest <file>` YAML override with per-doc precedence. v1 caps at 50 docs per invocation; `--resolve interactive` is reserved. Extracts shared conflict-detection contract into `references/doc-conflict-engine.md` which `/gsd-import` now also consumes (#2387)
|
||||
|
||||
### Fixed
|
||||
- **SDK `checkAgentsInstalled` is now runtime-aware** — `sdk/src/query/init.ts::checkAgentsInstalled` only knew where Claude Code put agents (`~/.claude/agents`). Users running GSD on Codex, OpenCode, Gemini, Kilo, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen, CodeBuddy, or Cline got `agents_installed: false` even with a complete install, which hard-blocked any workflow that gates subagent spawning on that flag. `sdk/src/query/helpers.ts` now resolves the right directory via three-tier detection (`GSD_RUNTIME` env → `config.runtime` → `claude` fallback) and mirrors `bin/install.js::getGlobalDir()` for all 14 runtimes. `GSD_AGENTS_DIR` still short-circuits the chain. `init-runner.ts` stays Claude-only by design (#2402)
|
||||
- **`init` query agents-installed check looks at the correct directory** — `checkAgentsInstalled` in `sdk/src/query/init.ts` defaulted to `~/.claude/get-shit-done/agents/`, but the installer writes GSD agents to `~/.claude/agents/`. Every init query therefore reported `agents_installed: false` on clean installs, which made workflows refuse to spawn `gsd-executor` and other parallel subagents. The default now matches `sdk/src/init-runner.ts` and the installer (#2400)
|
||||
- **Installer now installs `@gsd-build/sdk` automatically** so `gsd-sdk` lands on PATH. Resolves `command not found: gsd-sdk` errors that affected every `/gsd-*` command after a fresh install or `/gsd-update` to 1.36+. Adds `--no-sdk` to opt out and `--sdk` to force reinstall. Implements the `--sdk` flag that was previously documented in README but never wired up (#2385)
|
||||
|
||||
## [1.37.1] - 2026-04-17
|
||||
|
||||
@@ -9,7 +9,7 @@ Read all files referenced by the invoking prompt's execution_context before star
|
||||
|
||||
Key references:
|
||||
- @$HOME/.claude/get-shit-done/references/ui-brand.md (display patterns)
|
||||
- @$HOME/.claude/get-shit-done/agents/gsd-user-profiler.md (profiler agent definition)
|
||||
- @$HOME/.claude/agents/gsd-user-profiler.md (profiler agent definition)
|
||||
- @$HOME/.claude/get-shit-done/references/user-profiling.md (profiling reference doc)
|
||||
</required_reading>
|
||||
|
||||
|
||||
@@ -33,11 +33,12 @@ import type { GSDEventStream } from './event-stream.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { runPhaseStepSession } from './session-runner.js';
|
||||
import { sanitizePrompt } from './prompt-sanitizer.js';
|
||||
import { resolveAgentsDir } from './query/helpers.js';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const GSD_TEMPLATES_DIR = join(homedir(), '.claude', 'get-shit-done', 'templates');
|
||||
const GSD_AGENTS_DIR = join(homedir(), '.claude', 'agents');
|
||||
const GSD_AGENTS_DIR = resolveAgentsDir();
|
||||
|
||||
const RESEARCH_TYPES = ['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS'] as const;
|
||||
type ResearchType = (typeof RESEARCH_TYPES)[number];
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
planningPaths,
|
||||
normalizeMd,
|
||||
resolvePathUnderProject,
|
||||
resolveAgentsDir,
|
||||
getRuntimeConfigDir,
|
||||
detectRuntime,
|
||||
SUPPORTED_RUNTIMES,
|
||||
type Runtime,
|
||||
} from './helpers.js';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// ─── escapeRegex ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -252,3 +258,156 @@ describe('resolvePathUnderProject', () => {
|
||||
await expect(resolvePathUnderProject(tmpDir, '../../etc/passwd')).rejects.toThrow(GSDError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Runtime-aware agents dir resolution (#2402) ───────────────────────────
|
||||
|
||||
const RUNTIME_ENV_VARS = [
|
||||
'GSD_AGENTS_DIR', 'GSD_RUNTIME', 'CLAUDE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR',
|
||||
'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME',
|
||||
'GEMINI_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
|
||||
'CURSOR_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', 'TRAE_CONFIG_DIR',
|
||||
'QWEN_CONFIG_DIR', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR',
|
||||
] as const;
|
||||
|
||||
describe('getRuntimeConfigDir', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
beforeEach(() => {
|
||||
for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const k of RUNTIME_ENV_VARS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
const defaults: Record<Runtime, string> = {
|
||||
claude: join(homedir(), '.claude'),
|
||||
opencode: join(homedir(), '.config', 'opencode'),
|
||||
kilo: join(homedir(), '.config', 'kilo'),
|
||||
gemini: join(homedir(), '.gemini'),
|
||||
codex: join(homedir(), '.codex'),
|
||||
copilot: join(homedir(), '.copilot'),
|
||||
antigravity: join(homedir(), '.gemini', 'antigravity'),
|
||||
cursor: join(homedir(), '.cursor'),
|
||||
windsurf: join(homedir(), '.codeium', 'windsurf'),
|
||||
augment: join(homedir(), '.augment'),
|
||||
trae: join(homedir(), '.trae'),
|
||||
qwen: join(homedir(), '.qwen'),
|
||||
codebuddy: join(homedir(), '.codebuddy'),
|
||||
cline: join(homedir(), '.cline'),
|
||||
};
|
||||
|
||||
for (const runtime of SUPPORTED_RUNTIMES) {
|
||||
it(`resolves default path for ${runtime}`, () => {
|
||||
expect(getRuntimeConfigDir(runtime)).toBe(defaults[runtime]);
|
||||
});
|
||||
}
|
||||
|
||||
const envOverrides: Array<[Runtime, string, string]> = [
|
||||
['claude', 'CLAUDE_CONFIG_DIR', '/x/claude'],
|
||||
['gemini', 'GEMINI_CONFIG_DIR', '/x/gemini'],
|
||||
['codex', 'CODEX_HOME', '/x/codex'],
|
||||
['copilot', 'COPILOT_CONFIG_DIR', '/x/copilot'],
|
||||
['antigravity', 'ANTIGRAVITY_CONFIG_DIR', '/x/antigravity'],
|
||||
['cursor', 'CURSOR_CONFIG_DIR', '/x/cursor'],
|
||||
['windsurf', 'WINDSURF_CONFIG_DIR', '/x/windsurf'],
|
||||
['augment', 'AUGMENT_CONFIG_DIR', '/x/augment'],
|
||||
['trae', 'TRAE_CONFIG_DIR', '/x/trae'],
|
||||
['qwen', 'QWEN_CONFIG_DIR', '/x/qwen'],
|
||||
['codebuddy', 'CODEBUDDY_CONFIG_DIR', '/x/codebuddy'],
|
||||
['cline', 'CLINE_CONFIG_DIR', '/x/cline'],
|
||||
['opencode', 'OPENCODE_CONFIG_DIR', '/x/opencode'],
|
||||
['kilo', 'KILO_CONFIG_DIR', '/x/kilo'],
|
||||
];
|
||||
for (const [runtime, envVar, value] of envOverrides) {
|
||||
it(`${runtime} honors ${envVar}`, () => {
|
||||
process.env[envVar] = value;
|
||||
expect(getRuntimeConfigDir(runtime)).toBe(value);
|
||||
});
|
||||
}
|
||||
|
||||
it('opencode uses XDG_CONFIG_HOME when direct vars unset', () => {
|
||||
process.env.XDG_CONFIG_HOME = '/xdg';
|
||||
expect(getRuntimeConfigDir('opencode')).toBe(join('/xdg', 'opencode'));
|
||||
});
|
||||
|
||||
it('opencode OPENCODE_CONFIG uses dirname', () => {
|
||||
process.env.OPENCODE_CONFIG = '/cfg/opencode.json';
|
||||
expect(getRuntimeConfigDir('opencode')).toBe('/cfg');
|
||||
});
|
||||
|
||||
it('kilo uses XDG_CONFIG_HOME when direct vars unset', () => {
|
||||
process.env.XDG_CONFIG_HOME = '/xdg';
|
||||
expect(getRuntimeConfigDir('kilo')).toBe(join('/xdg', 'kilo'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectRuntime', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
beforeEach(() => {
|
||||
for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const k of RUNTIME_ENV_VARS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to claude with no signals', () => {
|
||||
expect(detectRuntime()).toBe('claude');
|
||||
});
|
||||
|
||||
it('uses GSD_RUNTIME when set to a known runtime', () => {
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
expect(detectRuntime()).toBe('codex');
|
||||
});
|
||||
|
||||
it('falls back to config.runtime when GSD_RUNTIME unset', () => {
|
||||
expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini');
|
||||
});
|
||||
|
||||
it('GSD_RUNTIME wins over config.runtime', () => {
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
expect(detectRuntime({ runtime: 'gemini' })).toBe('codex');
|
||||
});
|
||||
|
||||
it('unknown GSD_RUNTIME falls through to config then claude', () => {
|
||||
process.env.GSD_RUNTIME = 'bogus';
|
||||
expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini');
|
||||
expect(detectRuntime()).toBe('claude');
|
||||
});
|
||||
|
||||
it('unknown config.runtime falls through to claude', () => {
|
||||
expect(detectRuntime({ runtime: 'bogus' })).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentsDir (runtime-aware)', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
beforeEach(() => {
|
||||
for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const k of RUNTIME_ENV_VARS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to Claude agents dir with no args', () => {
|
||||
expect(resolveAgentsDir()).toBe(join(homedir(), '.claude', 'agents'));
|
||||
});
|
||||
|
||||
it('GSD_AGENTS_DIR short-circuits regardless of runtime', () => {
|
||||
process.env.GSD_AGENTS_DIR = '/explicit/agents';
|
||||
expect(resolveAgentsDir('codex')).toBe('/explicit/agents');
|
||||
expect(resolveAgentsDir('claude')).toBe('/explicit/agents');
|
||||
});
|
||||
|
||||
it('appends /agents to the per-runtime config dir', () => {
|
||||
process.env.CODEX_HOME = '/codex';
|
||||
expect(resolveAgentsDir('codex')).toBe(join('/codex', 'agents'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,10 +17,108 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { join, relative, resolve, isAbsolute, normalize } from 'node:path';
|
||||
import { join, dirname, relative, resolve, isAbsolute, normalize } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
|
||||
// ─── Runtime-aware agents directory resolution ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Supported GSD runtimes. Kept in sync with `bin/install.js:getGlobalDir()`.
|
||||
*/
|
||||
export const SUPPORTED_RUNTIMES = [
|
||||
'claude', 'opencode', 'kilo', 'gemini', 'codex', 'copilot', 'antigravity',
|
||||
'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy', 'cline',
|
||||
] as const;
|
||||
|
||||
export type Runtime = (typeof SUPPORTED_RUNTIMES)[number];
|
||||
|
||||
function expandTilde(p: string): string {
|
||||
return p.startsWith('~/') || p === '~' ? join(homedir(), p.slice(1)) : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-runtime config directory, mirroring
|
||||
* `bin/install.js:getGlobalDir()`. Agents live at `<configDir>/agents`.
|
||||
*/
|
||||
export function getRuntimeConfigDir(runtime: Runtime): string {
|
||||
switch (runtime) {
|
||||
case 'claude':
|
||||
return process.env.CLAUDE_CONFIG_DIR
|
||||
? expandTilde(process.env.CLAUDE_CONFIG_DIR)
|
||||
: join(homedir(), '.claude');
|
||||
case 'opencode':
|
||||
if (process.env.OPENCODE_CONFIG_DIR) return expandTilde(process.env.OPENCODE_CONFIG_DIR);
|
||||
if (process.env.OPENCODE_CONFIG) return dirname(expandTilde(process.env.OPENCODE_CONFIG));
|
||||
if (process.env.XDG_CONFIG_HOME) return join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
|
||||
return join(homedir(), '.config', 'opencode');
|
||||
case 'kilo':
|
||||
if (process.env.KILO_CONFIG_DIR) return expandTilde(process.env.KILO_CONFIG_DIR);
|
||||
if (process.env.KILO_CONFIG) return dirname(expandTilde(process.env.KILO_CONFIG));
|
||||
if (process.env.XDG_CONFIG_HOME) return join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
|
||||
return join(homedir(), '.config', 'kilo');
|
||||
case 'gemini':
|
||||
return process.env.GEMINI_CONFIG_DIR ? expandTilde(process.env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini');
|
||||
case 'codex':
|
||||
return process.env.CODEX_HOME ? expandTilde(process.env.CODEX_HOME) : join(homedir(), '.codex');
|
||||
case 'copilot':
|
||||
return process.env.COPILOT_CONFIG_DIR ? expandTilde(process.env.COPILOT_CONFIG_DIR) : join(homedir(), '.copilot');
|
||||
case 'antigravity':
|
||||
return process.env.ANTIGRAVITY_CONFIG_DIR ? expandTilde(process.env.ANTIGRAVITY_CONFIG_DIR) : join(homedir(), '.gemini', 'antigravity');
|
||||
case 'cursor':
|
||||
return process.env.CURSOR_CONFIG_DIR ? expandTilde(process.env.CURSOR_CONFIG_DIR) : join(homedir(), '.cursor');
|
||||
case 'windsurf':
|
||||
return process.env.WINDSURF_CONFIG_DIR ? expandTilde(process.env.WINDSURF_CONFIG_DIR) : join(homedir(), '.codeium', 'windsurf');
|
||||
case 'augment':
|
||||
return process.env.AUGMENT_CONFIG_DIR ? expandTilde(process.env.AUGMENT_CONFIG_DIR) : join(homedir(), '.augment');
|
||||
case 'trae':
|
||||
return process.env.TRAE_CONFIG_DIR ? expandTilde(process.env.TRAE_CONFIG_DIR) : join(homedir(), '.trae');
|
||||
case 'qwen':
|
||||
return process.env.QWEN_CONFIG_DIR ? expandTilde(process.env.QWEN_CONFIG_DIR) : join(homedir(), '.qwen');
|
||||
case 'codebuddy':
|
||||
return process.env.CODEBUDDY_CONFIG_DIR ? expandTilde(process.env.CODEBUDDY_CONFIG_DIR) : join(homedir(), '.codebuddy');
|
||||
case 'cline':
|
||||
return process.env.CLINE_CONFIG_DIR ? expandTilde(process.env.CLINE_CONFIG_DIR) : join(homedir(), '.cline');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the invoking runtime using issue #2402 precedence:
|
||||
* 1. `GSD_RUNTIME` env var
|
||||
* 2. `config.runtime` field (from `.planning/config.json` when loaded)
|
||||
* 3. Fallback to `'claude'`
|
||||
*
|
||||
* Unknown values fall through to the next tier rather than throwing, so
|
||||
* stale env values don't hard-block workflows.
|
||||
*/
|
||||
export function detectRuntime(config?: { runtime?: unknown }): Runtime {
|
||||
const envValue = process.env.GSD_RUNTIME;
|
||||
if (envValue && (SUPPORTED_RUNTIMES as readonly string[]).includes(envValue)) {
|
||||
return envValue as Runtime;
|
||||
}
|
||||
const configValue = config?.runtime;
|
||||
if (typeof configValue === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(configValue)) {
|
||||
return configValue as Runtime;
|
||||
}
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the GSD agents directory for a given runtime.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. `GSD_AGENTS_DIR` — explicit SDK override (wins over runtime selection)
|
||||
* 2. `<getRuntimeConfigDir(runtime)>/agents` — installer-parity default
|
||||
*
|
||||
* Defaults to Claude when no runtime is passed, matching prior behavior
|
||||
* (see `init-runner.ts`, which is Claude-only by design).
|
||||
*/
|
||||
export function resolveAgentsDir(runtime: Runtime = 'claude'): string {
|
||||
if (process.env.GSD_AGENTS_DIR) return process.env.GSD_AGENTS_DIR;
|
||||
return join(getRuntimeConfigDir(runtime), 'agents');
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Paths to common .planning files. */
|
||||
|
||||
@@ -162,7 +162,7 @@ export const initNewProject: QueryHandler = async (_args, projectDir) => {
|
||||
project_path: '.planning/PROJECT.md',
|
||||
};
|
||||
|
||||
return { data: withProjectRoot(projectDir, result) };
|
||||
return { data: withProjectRoot(projectDir, result, config as Record<string, unknown>) };
|
||||
};
|
||||
|
||||
// ─── initProgress ─────────────────────────────────────────────────────────
|
||||
@@ -309,7 +309,7 @@ export const initProgress: QueryHandler = async (_args, projectDir) => {
|
||||
config_path: toPosixPath(relative(projectDir, paths.config)),
|
||||
};
|
||||
|
||||
return { data: withProjectRoot(projectDir, result) };
|
||||
return { data: withProjectRoot(projectDir, result, config as Record<string, unknown>) };
|
||||
};
|
||||
|
||||
// ─── initManager ─────────────────────────────────────────────────────────
|
||||
@@ -574,5 +574,5 @@ export const initManager: QueryHandler = async (_args, projectDir) => {
|
||||
manager_flags: managerFlags,
|
||||
};
|
||||
|
||||
return { data: withProjectRoot(projectDir, result) };
|
||||
return { data: withProjectRoot(projectDir, result, config as Record<string, unknown>) };
|
||||
};
|
||||
|
||||
@@ -116,6 +116,198 @@ describe('withProjectRoot', () => {
|
||||
const enriched = withProjectRoot(tmpDir, result, {});
|
||||
expect(enriched.response_language).toBeUndefined();
|
||||
});
|
||||
|
||||
// Regression: #2400 — checkAgentsInstalled was looking at the wrong default
|
||||
// directory (~/.claude/get-shit-done/agents) while the installer writes to
|
||||
// ~/.claude/agents, causing agents_installed: false even on clean installs.
|
||||
it('reports agents_installed: true when all expected agents exist in GSD_AGENTS_DIR', async () => {
|
||||
const { MODEL_PROFILES } = await import('./config-query.js');
|
||||
const agentsDir = join(tmpDir, 'fake-agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
for (const name of Object.keys(MODEL_PROFILES)) {
|
||||
await writeFile(join(agentsDir, `${name}.md`), '# stub');
|
||||
}
|
||||
const prev = process.env.GSD_AGENTS_DIR;
|
||||
process.env.GSD_AGENTS_DIR = agentsDir;
|
||||
try {
|
||||
const enriched = withProjectRoot(tmpDir, {});
|
||||
expect(enriched.agents_installed).toBe(true);
|
||||
expect(enriched.missing_agents).toEqual([]);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports missing agents when GSD_AGENTS_DIR is empty', async () => {
|
||||
const agentsDir = join(tmpDir, 'empty-agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
const prev = process.env.GSD_AGENTS_DIR;
|
||||
process.env.GSD_AGENTS_DIR = agentsDir;
|
||||
try {
|
||||
const enriched = withProjectRoot(tmpDir, {}) as Record<string, unknown>;
|
||||
expect(enriched.agents_installed).toBe(false);
|
||||
expect((enriched.missing_agents as string[]).length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: #2400 follow-up — installer honors CLAUDE_CONFIG_DIR for custom
|
||||
// Claude install roots. The SDK check must follow the same precedence or it
|
||||
// false-negatives agent presence on non-default installs.
|
||||
it('honors CLAUDE_CONFIG_DIR when GSD_AGENTS_DIR is unset', async () => {
|
||||
const { MODEL_PROFILES } = await import('./config-query.js');
|
||||
const configDir = join(tmpDir, 'custom-claude');
|
||||
const agentsDir = join(configDir, 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
for (const name of Object.keys(MODEL_PROFILES)) {
|
||||
await writeFile(join(agentsDir, `${name}.md`), '# stub');
|
||||
}
|
||||
const prevAgents = process.env.GSD_AGENTS_DIR;
|
||||
const prevClaude = process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.GSD_AGENTS_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
try {
|
||||
const enriched = withProjectRoot(tmpDir, {}) as Record<string, unknown>;
|
||||
expect(enriched.agents_installed).toBe(true);
|
||||
expect(enriched.missing_agents).toEqual([]);
|
||||
} finally {
|
||||
if (prevAgents === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prevAgents;
|
||||
if (prevClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = prevClaude;
|
||||
}
|
||||
});
|
||||
|
||||
// #2402 — runtime-aware resolution: GSD_RUNTIME selects which runtime's
|
||||
// config-dir env chain to consult, so non-Claude installs stop
|
||||
// false-negating.
|
||||
it('GSD_RUNTIME=codex resolves agents under CODEX_HOME/agents', async () => {
|
||||
const { MODEL_PROFILES } = await import('./config-query.js');
|
||||
const codexHome = join(tmpDir, 'codex-home');
|
||||
const agentsDir = join(codexHome, 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
for (const name of Object.keys(MODEL_PROFILES)) {
|
||||
await writeFile(join(agentsDir, `${name}.md`), '# stub');
|
||||
}
|
||||
const prevAgents = process.env.GSD_AGENTS_DIR;
|
||||
const prevRuntime = process.env.GSD_RUNTIME;
|
||||
const prevCodex = process.env.CODEX_HOME;
|
||||
delete process.env.GSD_AGENTS_DIR;
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
const enriched = withProjectRoot(tmpDir, {}) as Record<string, unknown>;
|
||||
expect(enriched.agents_installed).toBe(true);
|
||||
expect(enriched.missing_agents).toEqual([]);
|
||||
} finally {
|
||||
if (prevAgents === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prevAgents;
|
||||
if (prevRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = prevRuntime;
|
||||
if (prevCodex === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodex;
|
||||
}
|
||||
});
|
||||
|
||||
it('config.runtime drives detection when GSD_RUNTIME is unset', async () => {
|
||||
const { MODEL_PROFILES } = await import('./config-query.js');
|
||||
const geminiHome = join(tmpDir, 'gemini-home');
|
||||
const agentsDir = join(geminiHome, 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
for (const name of Object.keys(MODEL_PROFILES)) {
|
||||
await writeFile(join(agentsDir, `${name}.md`), '# stub');
|
||||
}
|
||||
const prevAgents = process.env.GSD_AGENTS_DIR;
|
||||
const prevRuntime = process.env.GSD_RUNTIME;
|
||||
const prevGemini = process.env.GEMINI_CONFIG_DIR;
|
||||
delete process.env.GSD_AGENTS_DIR;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.GEMINI_CONFIG_DIR = geminiHome;
|
||||
try {
|
||||
const enriched = withProjectRoot(tmpDir, {}, { runtime: 'gemini' }) as Record<string, unknown>;
|
||||
expect(enriched.agents_installed).toBe(true);
|
||||
} finally {
|
||||
if (prevAgents === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prevAgents;
|
||||
if (prevRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = prevRuntime;
|
||||
if (prevGemini === undefined) delete process.env.GEMINI_CONFIG_DIR;
|
||||
else process.env.GEMINI_CONFIG_DIR = prevGemini;
|
||||
}
|
||||
});
|
||||
|
||||
it('GSD_RUNTIME wins over config.runtime', async () => {
|
||||
const { MODEL_PROFILES } = await import('./config-query.js');
|
||||
const codexHome = join(tmpDir, 'codex-win');
|
||||
const agentsDir = join(codexHome, 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
for (const name of Object.keys(MODEL_PROFILES)) {
|
||||
await writeFile(join(agentsDir, `${name}.md`), '# stub');
|
||||
}
|
||||
const prevAgents = process.env.GSD_AGENTS_DIR;
|
||||
const prevRuntime = process.env.GSD_RUNTIME;
|
||||
const prevCodex = process.env.CODEX_HOME;
|
||||
delete process.env.GSD_AGENTS_DIR;
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
// config says gemini, env says codex — codex should win and find agents.
|
||||
const enriched = withProjectRoot(tmpDir, {}, { runtime: 'gemini' }) as Record<string, unknown>;
|
||||
expect(enriched.agents_installed).toBe(true);
|
||||
} finally {
|
||||
if (prevAgents === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prevAgents;
|
||||
if (prevRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = prevRuntime;
|
||||
if (prevCodex === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodex;
|
||||
}
|
||||
});
|
||||
|
||||
it('unknown GSD_RUNTIME falls through to config/Claude default', () => {
|
||||
const prevAgents = process.env.GSD_AGENTS_DIR;
|
||||
const prevRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_AGENTS_DIR;
|
||||
process.env.GSD_RUNTIME = 'not-a-runtime';
|
||||
try {
|
||||
// Should not throw; falls back to Claude — missing_agents on a blank tmpDir.
|
||||
const enriched = withProjectRoot(tmpDir, {}) as Record<string, unknown>;
|
||||
expect(typeof enriched.agents_installed).toBe('boolean');
|
||||
} finally {
|
||||
if (prevAgents === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prevAgents;
|
||||
if (prevRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = prevRuntime;
|
||||
}
|
||||
});
|
||||
|
||||
it('GSD_AGENTS_DIR takes precedence over CLAUDE_CONFIG_DIR', async () => {
|
||||
const { MODEL_PROFILES } = await import('./config-query.js');
|
||||
const winningDir = join(tmpDir, 'winning-agents');
|
||||
const losingDir = join(tmpDir, 'losing-config', 'agents');
|
||||
await mkdir(winningDir, { recursive: true });
|
||||
await mkdir(losingDir, { recursive: true });
|
||||
// Only populate the winning dir.
|
||||
for (const name of Object.keys(MODEL_PROFILES)) {
|
||||
await writeFile(join(winningDir, `${name}.md`), '# stub');
|
||||
}
|
||||
const prevAgents = process.env.GSD_AGENTS_DIR;
|
||||
const prevClaude = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.GSD_AGENTS_DIR = winningDir;
|
||||
process.env.CLAUDE_CONFIG_DIR = join(tmpDir, 'losing-config');
|
||||
try {
|
||||
const enriched = withProjectRoot(tmpDir, {}) as Record<string, unknown>;
|
||||
expect(enriched.agents_installed).toBe(true);
|
||||
} finally {
|
||||
if (prevAgents === undefined) delete process.env.GSD_AGENTS_DIR;
|
||||
else process.env.GSD_AGENTS_DIR = prevAgents;
|
||||
if (prevClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = prevClaude;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('initExecutePhase', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { loadConfig } from '../config.js';
|
||||
import { resolveModel, MODEL_PROFILES } from './config-query.js';
|
||||
import { findPhase } from './phase.js';
|
||||
import { roadmapGetPhase, getMilestoneInfo } from './roadmap.js';
|
||||
import { planningPaths, normalizePhaseName, toPosixPath } from './helpers.js';
|
||||
import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────
|
||||
@@ -79,11 +79,16 @@ function getLatestCompletedMilestone(projectDir: string): { version: string; nam
|
||||
|
||||
/**
|
||||
* Check which GSD agents are installed on disk.
|
||||
*
|
||||
* Runtime-aware per issue #2402: detects the invoking runtime
|
||||
* (`GSD_RUNTIME` → `config.runtime` → 'claude') and probes that runtime's
|
||||
* canonical `agents/` directory. `GSD_AGENTS_DIR` still short-circuits.
|
||||
*
|
||||
* Port of checkAgentsInstalled from core.cjs lines 1274-1306.
|
||||
*/
|
||||
function checkAgentsInstalled(): { agents_installed: boolean; missing_agents: string[] } {
|
||||
const agentsDir = process.env.GSD_AGENTS_DIR
|
||||
|| join(homedir(), '.claude', 'get-shit-done', 'agents');
|
||||
function checkAgentsInstalled(config?: { runtime?: unknown }): { agents_installed: boolean; missing_agents: string[] } {
|
||||
const runtime = detectRuntime(config);
|
||||
const agentsDir = resolveAgentsDir(runtime);
|
||||
const expectedAgents = Object.keys(MODEL_PROFILES);
|
||||
|
||||
if (!existsSync(agentsDir)) {
|
||||
@@ -172,7 +177,7 @@ export function withProjectRoot(
|
||||
): Record<string, unknown> {
|
||||
result.project_root = projectDir;
|
||||
|
||||
const agentStatus = checkAgentsInstalled();
|
||||
const agentStatus = checkAgentsInstalled(config);
|
||||
result.agents_installed = agentStatus.agents_installed;
|
||||
result.missing_agents = agentStatus.missing_agents;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user