Deepen SDK package seam and converge runtime skills policy (#3238)

* Deepen SDK package seam and converge runtime skills policy

* fix(sdk): unified install-root resolution for workflows and agents (CR finding 1)

Use the already-resolved gsdInstallDir constant instead of calling
resolveLegacyInstallDir() again when computing agentsDir, ensuring
workflowsDir and agentsDir share the same install root.

* fix(sdk): tilde shortening requires path-boundary match (CR finding 2)

Both renderGlobalSkillsBaseDisplayPath and renderGlobalSkillDisplayPath
used startsWith(home) which could incorrectly shorten unrelated paths
sharing the same prefix. Now checks for home === base or
base.startsWith(home + sep) to ensure a real directory boundary.

* fix(sdk): validate loadConfig export before invocation (CR finding 3)

After requiring core.cjs, check typeof mod.loadConfig === 'function'
before calling it. Throws a classified GSDError with the module path
if the export is missing, rather than a generic TypeError.

* fix(test): guard root lookup before .path dereference (CR finding 4)

Added assert.ok() guards for claudeRoot and codexRoot after the .find()
calls so that a missing root produces an explicit assertion failure
rather than a TypeError on .path dereference.

* fix(ci): fail-safe on transient API errors in approval dismissal (CR finding 6)

resolveRole() returns 'unknown' for non-404 errors (rate limits, 5xx,
network blips). shouldDismissReviewer() now treats 'unknown' as
unresolvable and skips dismissal, preventing legitimate approvals from
being dismissed due to a transient API failure. Only 'none' (true 404)
is treated as a confirmed non-collaborator.

* changeset: pr=3238 SDK package seam and runtime skills convergence

* fix(sdk): harden resolveGlobalSkillDir against path traversal (CR finding 1)

Use resolve+relative to validate that skillName cannot escape the global
skills base directory. Values like "../../foo" or absolute paths now
return null instead of joining directly. All imports (resolve, relative,
isAbsolute) were already present in helpers.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): split skill-dir-resolution and skill-not-found warnings (CR finding 2)

After resolveGlobalSkillDir's hardening can return null for traversal
attempts, the old single-branch warning "Global skill not found at ..."
was misleading. Split into two distinct cases:
- skillDir === null → "Could not resolve global skill directory for ..."
- skillMd missing → "Global skill not found at ..."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: lock skill path-traversal rejection in resolveGlobalSkillDir

Regression test verifying that traversal segments (../../foo, ../escape),
empty string, and absolute paths are all rejected (return null), while
a legitimate skill name resolves correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): align display-path contract + traversal coverage for resolveGlobalSkillMarkdownPath (CR nitpicks)

- renderGlobalSkillsBaseDisplayPath now returns a non-null string for
  unsupported runtimes (e.g. cline → "(cline does not use a skills directory)")
  matching the existing renderGlobalSkillDisplayPath contract; callers
  of both helpers no longer need null-checks for unsupported runtimes.
- Remove now-redundant ! non-null assertion on renderGlobalSkillsBaseDisplayPath
  calls in skill-manifest.ts (return type is string, not string | null).
- Extend the path-traversal test block to assert resolveGlobalSkillMarkdownPath
  also propagates null for ../../foo, ../escape, empty, and /abs/path inputs,
  locking the null-propagation contract against future refactors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-08 09:06:43 -04:00
committed by GitHub
parent 924c697097
commit 397c34142a
27 changed files with 658 additions and 99 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3238
---
SDK package seam deepened and runtime skills policy converged on a single home-directory resolution path — install root is now consistent across workflows and agents directories

View File

@@ -54,18 +54,21 @@ jobs:
function shouldDismissReviewer(login, roleName) {
const reviewer = norm(login);
const isBlocked = blockedReviewers.has(reviewer);
// 'unknown' means a transient API error — fail-safe: keep the approval.
// Only 'none' (true 404) is a confirmed non-collaborator.
const isKnownRole = roleName !== 'unknown';
const isAllowedRole = allowedRoles.has(roleName);
const reasons = [];
if (isBlocked) {
reasons.push(`reviewer is blocked (${reviewer})`);
}
if (!isAllowedRole) {
if (isKnownRole && !isAllowedRole) {
reasons.push(`reviewer is not a collaborator or higher (detected role: ${roleName})`);
}
return {
dismiss: isBlocked || !isAllowedRole,
dismiss: isBlocked || (isKnownRole && !isAllowedRole),
message: `Auto-dismissed approval: ${reasons.join('; ')}.`,
};
}

View File

@@ -40,6 +40,12 @@ Module owning command resolution, policy projection (`mutation`, `output_mode`),
### Query Pre-Project Config Policy Module
Module policy that defines query-time behavior when `.planning/config.json` is absent: use built-in defaults for parity-sensitive query Interfaces, and emit parity-aligned empty model ids for pre-project model resolution surfaces.
### SDK Package Seam Module
Module owning SDK-to-`get-shit-done-cc` compatibility policy: legacy asset discovery, install-layout probing, transition-only error messaging, and thin Adapter access for CJS-era assets that native SDK Modules have not replaced yet.
### Runtime-Global Skills Policy Module
Module owning runtime-aware global skills directory policy for SDK query surfaces. Resolves runtime-global skills bases/skill paths from runtime + env precedence, renders display paths for warnings/manifests, and reports unsupported runtimes with no skills directory.
### MVP Mode
Phase-level planning mode that frames work as a vertical slice (UI → API → DB) of one user-visible capability instead of horizontal layers. Resolved at workflow init via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → `workflow.mvp_mode` config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as `MVP_MODE=true|false` to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: `roadmap.cjs` `**Mode:**` field; canonical resolution chain documented in `workflows/plan-phase.md`. Concept index: `references/mvp-concepts.md`.

View File

@@ -0,0 +1,8 @@
# SDK Package Seam Module owns SDK-to-get-shit-done-cc compatibility
- **Status:** Accepted
- **Date:** 2026-05-07
We decided to define one explicit SDK Package Seam Module for the `@gsd-build/sdk` → `get-shit-done-cc` transition. During this transition, install-layout probing, legacy `gsd-tools.cjs` discovery, legacy `core.cjs` discovery, and compatibility-only missing-asset diagnostics must live behind one seam instead of leaking across SDK Modules. This keeps callers thin, raises leverage for standalone-SDK testing, and improves locality by making package-readiness bugs land in one place. First tracer-bullet slice: add one compatibility Adapter Module at this seam and migrate current legacy asset callers onto it before broader native replacement work.
Runtime-global skills directory resolution is explicitly out of scope for this seam. That policy varies by runtime (`claude`, `codex`, `cline`, etc.) rather than by legacy package/install layout, so it now lives in a separate Runtime-Global Skills Policy Module consumed by `agent-skills` and `skill-manifest`.

View File

@@ -1770,6 +1770,7 @@ function cmdAgentSkills(cwd, agentType, raw) {
*/
function buildSkillManifest(cwd, skillsDir = null) {
const { extractFrontmatter } = require('./frontmatter.cjs');
const { getGlobalSkillsBase } = require('./runtime-homes.cjs');
const os = require('os');
const canonicalRoots = skillsDir ? [{
@@ -1811,13 +1812,13 @@ function buildSkillManifest(cwd, skillsDir = null) {
},
{
root: '~/.claude/skills',
path: path.join(os.homedir(), '.claude', 'skills'),
path: getGlobalSkillsBase('claude'),
scope: 'global',
kind: 'skills',
},
{
root: '~/.codex/skills',
path: path.join(os.homedir(), '.codex', 'skills'),
path: getGlobalSkillsBase('codex'),
scope: 'global',
kind: 'skills',
},

View File

@@ -13,6 +13,7 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
const { getGlobalSkillDir } = require('./runtime-homes.cjs');
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -786,7 +787,18 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
// string) so the cline-install leaked-path lint does not flag it.
let outputPath = options.output;
if (!outputPath) {
outputPath = path.join(os.homedir(), '.claude', 'skills', 'gsd-dev-preferences', 'SKILL.md');
let effectiveRuntime = 'claude';
try {
const config = loadConfig(cwd);
effectiveRuntime = process.env.GSD_RUNTIME || config.runtime || 'claude';
} catch {
effectiveRuntime = process.env.GSD_RUNTIME || 'claude';
}
const skillDir = getGlobalSkillDir(effectiveRuntime, 'gsd-dev-preferences');
if (!skillDir) {
error(`Runtime "${effectiveRuntime}" does not use a skills directory; pass --output to choose a path explicitly.`);
}
outputPath = path.join(skillDir, 'SKILL.md');
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
}

View File

@@ -11,7 +11,6 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { execFile } from 'node:child_process';
import type {
@@ -34,10 +33,11 @@ import { loadConfig } from './config.js';
import { runPhaseStepSession } from './session-runner.js';
import { sanitizePrompt } from './prompt-sanitizer.js';
import { resolveAgentsDir } from './query/helpers.js';
import { resolveLegacyTemplatesDir } from './sdk-package-compatibility.js';
// ─── Constants ───────────────────────────────────────────────────────────────
const GSD_TEMPLATES_DIR = join(homedir(), '.claude', 'get-shit-done', 'templates');
const GSD_TEMPLATES_DIR = resolveLegacyTemplatesDir();
const GSD_AGENTS_DIR = resolveAgentsDir();
const RESEARCH_TYPES = ['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS'] as const;

View File

@@ -9,13 +9,13 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ContextFiles, ParsedPlan } from './types.js';
import { PhaseType } from './types.js';
import { buildExecutorPrompt } from './prompt-builder.js';
import { PHASE_AGENT_MAP } from './tool-scoping.js';
import { sanitizePrompt } from './prompt-sanitizer.js';
import { resolveLegacyInstallDir } from './sdk-package-compatibility.js';
// ─── Workflow file mapping ───────────────────────────────────────────────────
@@ -89,9 +89,9 @@ export class PromptFactory {
sdkPromptsDir?: string;
projectDir?: string;
}) {
const gsdInstallDir = options?.gsdInstallDir ?? join(homedir(), '.claude', 'get-shit-done');
const gsdInstallDir = options?.gsdInstallDir ?? resolveLegacyInstallDir();
this.workflowsDir = join(gsdInstallDir, 'workflows');
this.agentsDir = options?.agentsDir ?? join(homedir(), '.claude', 'agents');
this.agentsDir = options?.agentsDir ?? join(gsdInstallDir, '..', 'agents');
this.projectAgentsDir = options?.projectAgentsDir;
this.projectDir = options?.projectDir;
// SDK prompts dir: explicit override → package-relative default via import.meta.url

View File

@@ -1,24 +1 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const BUNDLED_GSD_TOOLS_PATH = fileURLToPath(
new URL('../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
);
/**
* Resolve gsd-tools.cjs path.
* Probe order: SDK-bundled repo copy → project/.claude/get-shit-done → ~/.claude/get-shit-done
*/
export function resolveGsdToolsPath(projectDir: string): string {
const candidates = [
BUNDLED_GSD_TOOLS_PATH,
join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
];
return candidates.find(candidate => existsSync(candidate)) ?? candidates[candidates.length - 1]!;
}
export { BUNDLED_GSD_TOOLS_PATH };
export { resolveGsdToolsPath, BUNDLED_GSD_TOOLS_PATH } from './sdk-package-compatibility.js';

View File

@@ -107,7 +107,7 @@ Handlers for `**state.signal-waiting`**, `**state.signal-resume**`, `**state.val
**`state.json` vs `state.load` (different CJS commands):**
- **`state.json`** / `state json` — port of **`cmdStateJson`** (`state.ts` `stateJson`): rebuilt STATE.md frontmatter JSON. Read-only golden: `read-only-parity.integration.test.ts` compares to CJS `state json` with **`last_updated`** stripped.
- **`state.load`** / `state load` — port of **`cmdStateLoad`** (`state-project-load.ts` `stateProjectLoad`): `{ config, state_raw, state_exists, roadmap_exists, config_exists }`; **`config`** comes from **`get-shit-done/bin/lib/core.cjs`** `loadConfig` (resolved via the same candidate paths as a normal GSD install). Read-only golden: full `toEqual` vs `state load`. If `core.cjs` cannot be resolved, dispatch throws **`GSDError`** (document for minimal `@gsd-build/sdk`-only installs).
- **`state.load`** / `state load` — port of **`cmdStateLoad`** (`state-project-load.ts` `stateProjectLoad`): `{ config, state_raw, state_exists, roadmap_exists, config_exists }`; **`config`** comes from **`get-shit-done/bin/lib/core.cjs`** `loadConfig`, but discovery now routes through the **SDK Package Seam Module** (`sdk-package-compatibility.ts`) so install-layout probing stays behind one compatibility Adapter. Read-only golden: full `toEqual` vs `state load`. If `core.cjs` cannot be resolved, dispatch throws **`GSDError`** with the checked probe list (document for minimal `@gsd-build/sdk`-only installs).
`stateExtractField` in `helpers.ts` uses **horizontal whitespace only** after `Field:` so YAML keys such as lowercase `progress:` in frontmatter are not mistaken for the body `Progress:` line (see `get-shit-done/bin/lib/state.cjs` — same rule).
@@ -176,8 +176,9 @@ From `read-only-parity.integration.test.ts` (full `toEqual` on this repo):
| `summary.extract` | Fixture `sdk/src/golden/fixtures/summary-extract-sample.md`; uses `extractFrontmatterLeading` (first `---` block) for parity with `frontmatter.cjs`. |
| `history.digest` | No args; aggregate over `.planning/phases` + archived milestone phase dirs (`commands.cjs` `cmdHistoryDigest`). |
| `audit-uat` | No args; full JSON parity with `uat.cjs` `cmdAuditUat` (`results`, `summary` with `by_category` / `by_phase`). |
| `skill-manifest` | No args; full manifest parity with `init.cjs` `buildSkillManifest` / `cmdSkillManifest`. Handler uses `extractFrontmatterLeading` (first `---` block) like CJS `frontmatter.cjs` `extractFrontmatter` — not TS `extractFrontmatter` (last block), so skills with multiple `---` sections match CJS. |
| `skill-manifest` | No args; full manifest parity with `init.cjs` `buildSkillManifest` / `cmdSkillManifest`. Handler uses `extractFrontmatterLeading` (first `---` block) like CJS `frontmatter.cjs` `extractFrontmatter` — not TS `extractFrontmatter` (last block), so skills with multiple `---` sections match CJS. Runtime-global skill roots now route through the **Runtime-Global Skills Policy Module**; legacy import-only skill root discovery (`~/.claude/get-shit-done/skills`) routes through the **SDK Package Seam Module**. |
| `validate.agents` | No args; `agents_dir` matches `core.cjs` `getAgentsDir` (`GSD_AGENTS_DIR` or `sdk/dist/query/../../../agents` in this monorepo — same absolute path as CLI). `MODEL_PROFILES` / `expected` list stays aligned with `get-shit-done/bin/lib/model-profiles.cjs`. |
| `agent-skills` | Reads `config.agent_skills[agentType]` and emits raw `<agent_skills>` XML. Project-relative entries stay project-root validated; `global:<name>` resolves through the **Runtime-Global Skills Policy Module** instead of a Claude-only path. |
| `state.get` | Dedicated tests: no args → full `{ content }` vs `state get`; one field (`milestone`) → `{ milestone: "…" }` vs `state get milestone` (frontmatter line match). |
| `state.json` | `state json` vs SDK; **`last_updated`** stripped before `toEqual` (volatile). |
| `state.load` | `state load` vs SDK; full **`cmdStateLoad`** object graph (`config`, `state_raw`, existence flags). |

View File

@@ -21,6 +21,11 @@ import {
resolveAgentsDir,
getRuntimeConfigDir,
detectRuntime,
resolveGlobalSkillsBase,
resolveGlobalSkillDir,
resolveGlobalSkillMarkdownPath,
renderGlobalSkillsBaseDisplayPath,
renderGlobalSkillDisplayPath,
findProjectRoot,
SUPPORTED_RUNTIMES,
type Runtime,
@@ -428,6 +433,55 @@ describe('resolveAgentsDir (runtime-aware)', () => {
// ─── findProjectRoot (issue #2623) ─────────────────────────────────────────
describe('runtime-global skills directory helpers', () => {
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('appends /skills for runtimes with a global skills directory', () => {
process.env.CODEX_HOME = '/codex';
expect(resolveGlobalSkillsBase('codex')).toBe(join('/codex', 'skills'));
expect(resolveGlobalSkillDir('codex', 'demo')).toBe(join('/codex', 'skills', 'demo'));
expect(resolveGlobalSkillMarkdownPath('codex', 'demo')).toBe(join('/codex', 'skills', 'demo', 'SKILL.md'));
});
it('returns null for cline and renders unsupported display path', () => {
expect(resolveGlobalSkillsBase('cline')).toBeNull();
expect(renderGlobalSkillsBaseDisplayPath('cline')).toBe('(cline does not use a skills directory)');
expect(resolveGlobalSkillDir('cline', 'demo')).toBeNull();
expect(resolveGlobalSkillMarkdownPath('cline', 'demo')).toBeNull();
expect(renderGlobalSkillDisplayPath('cline', 'demo')).toBe('(cline does not use a skills directory)');
});
it('renders home-relative display paths with ~ for warnings', () => {
expect(renderGlobalSkillsBaseDisplayPath('claude')).toBe('~/.claude/skills');
expect(renderGlobalSkillDisplayPath('claude', 'demo')).toBe(join('~/.claude/skills', 'demo'));
});
it('rejects path-traversal segments — resolveGlobalSkillDir returns null for ../../foo', () => {
process.env.CODEX_HOME = '/codex';
expect(resolveGlobalSkillDir('codex', '../../foo')).toBeNull();
expect(resolveGlobalSkillDir('codex', '../escape')).toBeNull();
expect(resolveGlobalSkillDir('codex', '')).toBeNull();
// Absolute path as skillName is also rejected
expect(resolveGlobalSkillDir('codex', '/abs/path')).toBeNull();
// Legitimate name still works
expect(resolveGlobalSkillDir('codex', 'demo')).toBe(join('/codex', 'skills', 'demo'));
// resolveGlobalSkillMarkdownPath must also propagate the null for unsafe inputs
expect(resolveGlobalSkillMarkdownPath('codex', '../../foo')).toBeNull();
expect(resolveGlobalSkillMarkdownPath('codex', '../escape')).toBeNull();
expect(resolveGlobalSkillMarkdownPath('codex', '')).toBeNull();
expect(resolveGlobalSkillMarkdownPath('codex', '/abs/path')).toBeNull();
});
});
describe('findProjectRoot (multi-repo .planning resolution)', () => {
let workspace: string;

View File

@@ -121,6 +121,59 @@ export function resolveAgentsDir(runtime: Runtime = 'claude'): string {
return join(getRuntimeConfigDir(runtime), 'agents');
}
/**
* Resolve the runtime-global skills base directory.
*
* Most runtimes store global skills under `<configDir>/skills`.
* `cline` is rules-based and has no global skills directory.
*/
export function resolveGlobalSkillsBase(runtime: Runtime): string | null {
if (runtime === 'cline') return null;
return join(getRuntimeConfigDir(runtime), 'skills');
}
/**
* Render a human-readable runtime-global skills base path.
* Uses `~` when the path lives under the current home dir.
* Returns a displayable string for unsupported runtimes (never null).
*/
export function renderGlobalSkillsBaseDisplayPath(runtime: Runtime): string {
const base = resolveGlobalSkillsBase(runtime);
if (!base) return `(${runtime} does not use a skills directory)`;
const home = homedir();
const homeWithSep = home.endsWith(pathSep) ? home : `${home}${pathSep}`;
return (base === home || base.startsWith(homeWithSep)) ? `~${base.slice(home.length)}` : base;
}
/** Resolve one runtime-global skill directory, or `null` when unsupported. */
export function resolveGlobalSkillDir(runtime: Runtime, skillName: string): string | null {
const base = resolveGlobalSkillsBase(runtime);
if (base === null) return null;
const candidate = resolve(base, skillName);
const rel = relative(base, candidate);
if (!skillName || rel.startsWith('..') || isAbsolute(rel)) return null;
return candidate;
}
/** Resolve the canonical SKILL.md path for one runtime-global skill. */
export function resolveGlobalSkillMarkdownPath(runtime: Runtime, skillName: string): string | null {
const dir = resolveGlobalSkillDir(runtime, skillName);
if (dir === null) return null;
return join(dir, 'SKILL.md');
}
/**
* Render a human-readable global skill path for warnings.
* Uses `~` when the path lives under the current home dir.
*/
export function renderGlobalSkillDisplayPath(runtime: Runtime, skillName: string): string {
const dir = resolveGlobalSkillDir(runtime, skillName);
if (!dir) return `(${runtime} does not use a skills directory)`;
const home = homedir();
const homeWithSep = home.endsWith(pathSep) ? home : `${home}${pathSep}`;
return (dir === home || dir.startsWith(homeWithSep)) ? `~${dir.slice(home.length)}` : dir;
}
// ─── Types ──────────────────────────────────────────────────────────────────
/** Paths to common .planning files. */

View File

@@ -13,15 +13,15 @@ import {
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from '../config.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { detectRuntime } from './helpers.js';
import { detectRuntime, resolveGlobalSkillMarkdownPath } from './helpers.js';
import { CLAUDE_INSTRUCTIONS } from './profile-questionnaire-data.js';
import type { QueryHandler } from './utils.js';
import { resolveBundledTemplatesDir } from '../sdk-package-compatibility.js';
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../../get-shit-done/templates');
const TEMPLATE_DIR = resolveBundledTemplatesDir();
const DIMENSION_KEYS = [
'communication_style',
@@ -601,7 +601,21 @@ export const generateDevPreferences: QueryHandler = async (args, projectDir) =>
let outPath = outputPathOpt;
if (!outPath) {
outPath = join(homedir(), '.claude', 'commands', 'gsd', 'dev-preferences.md');
let runtime = detectRuntime();
try {
const config = await loadConfig(projectDir);
runtime = detectRuntime(config as { runtime?: unknown });
} catch {
/* default runtime */
}
const defaultSkillPath = resolveGlobalSkillMarkdownPath(runtime, 'gsd-dev-preferences');
if (!defaultSkillPath) {
throw new GSDError(
`Runtime "${runtime}" does not use a skills directory; pass --output to choose a path explicitly.`,
ErrorClassification.Validation,
);
}
outPath = defaultSkillPath;
} else if (!isAbsolute(outPath)) {
outPath = join(projectDir, outPath);
}

View File

@@ -7,7 +7,7 @@ import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { writeProfile } from './profile-output.js';
import { generateDevPreferences, writeProfile } from './profile-output.js';
import { learningsCopy } from './profile.js';
describe('writeProfile', () => {
@@ -52,6 +52,68 @@ describe('writeProfile', () => {
});
});
describe('generateDevPreferences', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-dev-preferences-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('writes to runtime-global skills dir by default', async () => {
const analysisPath = join(tmpDir, 'analysis.json');
const codexHome = join(tmpDir, 'codex-home');
await writeFile(
analysisPath,
JSON.stringify({
data_source: 'test',
dimensions: {
communication_style: { rating: 'terse', confidence: 'HIGH', claude_instruction: 'Keep it short.' },
},
}),
'utf-8',
);
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' }), 'utf-8');
const prevCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
try {
const result = await generateDevPreferences(['--analysis', analysisPath], tmpDir);
const data = result.data as Record<string, unknown>;
const expectedPath = join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md');
expect(data.command_path).toBe(expectedPath);
const md = await readFile(expectedPath, 'utf-8');
expect(md).toContain('Behavioral Directives');
} finally {
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
}
});
it('requires --output when runtime has no skills dir', async () => {
const analysisPath = join(tmpDir, 'analysis.json');
await writeFile(
analysisPath,
JSON.stringify({
data_source: 'test',
dimensions: {
communication_style: { rating: 'terse', confidence: 'HIGH', claude_instruction: 'Keep it short.' },
},
}),
'utf-8',
);
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'cline' }), 'utf-8');
await expect(generateDevPreferences(['--analysis', analysisPath], tmpDir)).rejects.toThrow(
'Runtime "cline" does not use a skills directory; pass --output to choose a path explicitly.',
);
});
});
describe('learningsCopy', () => {
let tmpDir: string;

View File

@@ -21,6 +21,7 @@ import { existsSync } from 'node:fs';
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { resolveGsdToolsPath } from '../sdk-package-compatibility.js';
import {
escapeRegex,
normalizePhaseName,
@@ -737,11 +738,7 @@ export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir
}
const { spawnSync } = await import('node:child_process');
const { fileURLToPath } = await import('node:url');
const toolsPath = fileURLToPath(
new URL('../../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
);
const toolsPath = resolveGsdToolsPath(projectDir);
const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], {
cwd: projectDir,

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { buildSkillManifest } from './skill-manifest.js';
import { resolveGlobalSkillsBase, renderGlobalSkillsBaseDisplayPath } from './helpers.js';
import { resolveLegacySkillsDir } from '../sdk-package-compatibility.js';
describe('skill-manifest', () => {
it('resolves runtime-global skills roots through shared runtime policy', () => {
const manifest = buildSkillManifest('/tmp/project-that-does-not-exist');
const claudeRoot = manifest.roots.find(root => root.root === renderGlobalSkillsBaseDisplayPath('claude'));
const codexRoot = manifest.roots.find(root => root.root === renderGlobalSkillsBaseDisplayPath('codex'));
expect(claudeRoot?.path).toBe(resolveGlobalSkillsBase('claude'));
expect(claudeRoot?.scope).toBe('global');
expect(codexRoot?.path).toBe(resolveGlobalSkillsBase('codex'));
expect(codexRoot?.scope).toBe('global');
});
it('resolves legacy import-only skills root through SDK Package Seam Module', () => {
const manifest = buildSkillManifest('/tmp/project-that-does-not-exist');
const legacyRoot = manifest.roots.find(root => root.root === '.claude/get-shit-done/skills');
expect(legacyRoot).toBeDefined();
expect(legacyRoot?.path).toBe(resolveLegacySkillsDir());
expect(legacyRoot?.scope).toBe('import-only');
expect(legacyRoot?.deprecated).toBe(true);
});
it('discovers skills from explicit --skills-dir without scanning global roots', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skill-manifest-'));
try {
const skillsDir = join(tmpDir, 'custom-skills');
const alphaDir = join(skillsDir, 'alpha-skill');
await mkdir(alphaDir, { recursive: true });
await writeFile(
join(alphaDir, 'SKILL.md'),
'---\nname: alpha-skill\ndescription: alpha desc\n---\n\nTRIGGER when: need alpha\n',
);
const manifest = buildSkillManifest(tmpDir, skillsDir);
expect(manifest.counts.roots).toBe(1);
expect(manifest.roots[0]?.path).toBe(skillsDir);
expect(manifest.skills).toHaveLength(1);
expect(manifest.skills[0]).toMatchObject({
name: 'alpha-skill',
description: 'alpha desc',
triggers: ['need alpha'],
root: skillsDir,
scope: 'custom',
});
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it('legacy skills dir helper points at ~/.claude/get-shit-done/skills', () => {
expect(resolveLegacySkillsDir()).toBe(join(homedir(), '.claude', 'get-shit-done', 'skills'));
});
});

View File

@@ -12,7 +12,9 @@ import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { extractFrontmatterLeading } from './frontmatter.js';
import { resolveGlobalSkillsBase, renderGlobalSkillsBaseDisplayPath } from './helpers.js';
import type { QueryHandler } from './utils.js';
import { resolveLegacySkillsDir } from '../sdk-package-compatibility.js';
export interface SkillManifestSkill {
name: string;
@@ -64,11 +66,11 @@ export function buildSkillManifest(cwd: string, skillsDir: string | null = null)
{ root: '.cursor/skills', path: join(cwd, '.cursor', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.github/skills', path: join(cwd, '.github', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.codex/skills', path: join(cwd, '.codex', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '~/.claude/skills', path: join(homedir(), '.claude', 'skills'), scope: 'global', kind: 'skills' as const },
{ root: '~/.codex/skills', path: join(homedir(), '.codex', 'skills'), scope: 'global', kind: 'skills' as const },
{ root: renderGlobalSkillsBaseDisplayPath('claude'), path: resolveGlobalSkillsBase('claude')!, scope: 'global', kind: 'skills' as const },
{ root: renderGlobalSkillsBaseDisplayPath('codex'), path: resolveGlobalSkillsBase('codex')!, scope: 'global', kind: 'skills' as const },
{
root: '.claude/get-shit-done/skills',
path: join(homedir(), '.claude', 'get-shit-done', 'skills'),
path: resolveLegacySkillsDir(),
scope: 'import-only',
kind: 'skills' as const,
deprecated: true,

View File

@@ -135,6 +135,42 @@ describe('agentSkills', () => {
expect(r.format).toBe('text');
});
it('resolves global: skills through runtime-aware global skills dir policy', async () => {
const codexHome = join(tmpDir, 'codex-home');
await writeSkill(join(codexHome, 'skills'), 'global-skill');
await writeConfig(tmpDir, {
runtime: 'codex',
agent_skills: { 'gsd-planner': ['global:global-skill'] },
});
const prevCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
try {
const r = await agentSkills(['gsd-planner'], tmpDir);
expect(r.data).toBe(
'<agent_skills>\n' +
'Read these user-configured skills:\n' +
`- @${join(codexHome, 'skills', 'global-skill', 'SKILL.md')}\n` +
'</agent_skills>',
);
expect(r.format).toBe('text');
} finally {
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
}
});
it('returns empty string for global: skills on runtimes without a skills dir', async () => {
await writeConfig(tmpDir, {
runtime: 'cline',
agent_skills: { 'gsd-planner': ['global:nope'] },
});
const r = await agentSkills(['gsd-planner'], tmpDir);
expect(r.data).toBe('');
expect(r.format).toBeUndefined();
});
it('does not signal format:"text" for empty result', async () => {
const r = await agentSkills(['gsd-planner'], tmpDir);
expect(r.format).toBeUndefined();

View File

@@ -5,7 +5,9 @@
* Ports `buildAgentSkillsBlock` semantics from
* `get-shit-done/bin/lib/init.cjs` so the SDK path honors
* `config.agent_skills[agentType]` the same way the legacy
* `gsd-tools.cjs agent-skills <type>` path does. Fixes #2555.
* `gsd-tools.cjs agent-skills <type>` path does. Project-relative skills stay
* project-root validated; `global:<name>` now resolves through runtime-aware
* global skills dir policy rather than a Claude-only hardcoded path. Fixes #2555.
*
* @example
* ```typescript
@@ -23,9 +25,9 @@
import { existsSync, realpathSync } from 'node:fs';
import { join, resolve, sep } from 'node:path';
import { homedir } from 'node:os';
import type { QueryHandler } from './utils.js';
import { detectRuntime, renderGlobalSkillDisplayPath, resolveGlobalSkillDir, resolveGlobalSkillsBase } from './helpers.js';
import { loadConfig } from '../config.js';
const GLOBAL_SKILL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
@@ -76,13 +78,14 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
}
if (skillPaths.length === 0) return { data: '' };
const globalSkillsBase = join(homedir(), '.claude', 'skills');
const runtime = detectRuntime(config as { runtime?: unknown });
const globalSkillsBase = resolveGlobalSkillsBase(runtime);
const validEntries: Array<{ ref: string }> = [];
for (const entry of skillPaths) {
if (typeof entry !== 'string') continue;
// `global:<name>` — skill installed under ~/.claude/skills/<name>/ (#1992).
// `global:<name>` — skill installed under the runtime-global skills dir (#1992, #3126).
if (entry.startsWith('global:')) {
const skillName = entry.slice(7);
if (!skillName) {
@@ -93,17 +96,26 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
process.stderr.write(`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`);
continue;
}
const skillDir = join(globalSkillsBase, skillName);
if (globalSkillsBase === null) {
process.stderr.write(`[agent-skills] WARNING: Runtime "${runtime}" does not use a skills directory — "global:${skillName}" is not supported on this runtime\n`);
continue;
}
const skillDir = resolveGlobalSkillDir(runtime, skillName);
const displayPath = renderGlobalSkillDisplayPath(runtime, skillName);
if (!skillDir) {
process.stderr.write(`[agent-skills] WARNING: Could not resolve global skill directory for "${skillName}" on runtime "${runtime}" — skipping\n`);
continue;
}
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) {
process.stderr.write(`[agent-skills] WARNING: Global skill not found at "~/.claude/skills/${skillName}/SKILL.md" — skipping\n`);
process.stderr.write(`[agent-skills] WARNING: Global skill not found at "${displayPath}/SKILL.md" — skipping\n`);
continue;
}
if (resolveWithinBase(skillMd, globalSkillsBase) === null) {
process.stderr.write(`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`);
continue;
}
validEntries.push({ ref: `~/.claude/skills/${skillName}/SKILL.md` });
validEntries.push({ ref: skillMd });
continue;
}

View File

@@ -13,38 +13,9 @@
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);
}
import { loadLegacyCoreConfig } from '../sdk-package-compatibility.js';
/**
* Query handler for `state load` / bare `state` (normalize → `state.load`).
@@ -52,7 +23,7 @@ function loadConfigCjs(projectDir: string): Record<string, unknown> {
* Port of `cmdStateLoad` from `get-shit-done/bin/lib/state.cjs` lines 44–86.
*/
export const stateProjectLoad: QueryHandler = async (_args, projectDir, workstream) => {
const config = loadConfigCjs(projectDir);
const config = loadLegacyCoreConfig(projectDir);
const planDir = planningPaths(projectDir, workstream).planning;
let stateRaw = '';

View File

@@ -17,7 +17,6 @@
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { MODEL_PROFILES } from './config-query.js';
@@ -25,6 +24,7 @@ import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js';
import { escapeRegex, normalizePhaseName, planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
import { resolveBundledAgentsDir } from '../sdk-package-compatibility.js';
/** Max length for key_links regex patterns (ReDoS mitigation). */
const MAX_KEY_LINK_PATTERN_LEN = 512;
@@ -790,8 +790,7 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream)
*/
function getAgentsDirForValidateAgents(): string {
if (process.env.GSD_AGENTS_DIR) return process.env.GSD_AGENTS_DIR;
const here = dirname(fileURLToPath(import.meta.url));
return resolve(here, '..', '..', '..', 'agents');
return resolveBundledAgentsDir();
}
/**

View File

@@ -26,6 +26,7 @@ import {
planningPaths,
} from './helpers.js';
import type { QueryHandler } from './utils.js';
import { resolveGsdToolsPath } from '../sdk-package-compatibility.js';
// ─── verifyPlanStructure ───────────────────────────────────────────────────
@@ -658,14 +659,7 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir, workstre
export const verifyCodebaseDrift: QueryHandler = async (_args, projectDir) => {
try {
const { execFileSync } = await import('node:child_process');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const here = typeof __dirname === 'string'
? __dirname
: dirname(fileURLToPath(import.meta.url));
// sdk/src/query -> ../../../get-shit-done/bin/gsd-tools.cjs
// sdk/dist/query -> ../../../get-shit-done/bin/gsd-tools.cjs
const toolsPath = resolve(here, '..', '..', '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
const toolsPath = resolveGsdToolsPath(projectDir);
const out = execFileSync(process.execPath, [toolsPath, 'verify', 'codebase-drift'], {
cwd: projectDir,
encoding: 'utf-8',

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import {
BUNDLED_CORE_CJS_PATH,
BUNDLED_GSD_AGENTS_DIR,
BUNDLED_GSD_TEMPLATES_DIR,
BUNDLED_GSD_TOOLS_PATH,
loadLegacyCoreConfig,
probeLegacySdkAsset,
resolveBundledAgentsDir,
resolveBundledTemplatesDir,
resolveGsdToolsPath,
resolveLegacyInstallDir,
resolveLegacyTemplatesDir,
resolveLegacyWorkflowsDir,
} from './sdk-package-compatibility.js';
import { GSDError } from './errors.js';
describe('SDK Package Seam Module', () => {
const projectDir = '/work/project';
const homeDir = '/users/tester';
it('resolves legacy install-relative directories through one seam', () => {
expect(resolveLegacyInstallDir(homeDir)).toBe(join(homeDir, '.claude', 'get-shit-done'));
expect(resolveLegacyTemplatesDir(homeDir)).toBe(join(homeDir, '.claude', 'get-shit-done', 'templates'));
expect(resolveLegacyWorkflowsDir(homeDir)).toBe(join(homeDir, '.claude', 'get-shit-done', 'workflows'));
expect(resolveBundledTemplatesDir()).toBe(BUNDLED_GSD_TEMPLATES_DIR);
expect(resolveBundledAgentsDir()).toBe(BUNDLED_GSD_AGENTS_DIR);
});
it('probes legacy gsd-tools locations in bundled -> project -> home order', () => {
const resolution = probeLegacySdkAsset('gsd-tools', projectDir, {
homeDir,
existsSync: path => path === join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
});
expect(resolution.probes).toEqual([
BUNDLED_GSD_TOOLS_PATH,
join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
join(homeDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
]);
expect(resolution.path).toBe(join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'));
expect(resolution.fallbackPath).toBe(join(homeDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'));
});
it('returns concrete fallback gsd-tools path when no legacy probe exists', () => {
const path = resolveGsdToolsPath(projectDir, {
homeDir,
existsSync: () => false,
});
expect(path).toBe(join(homeDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'));
});
it('loads legacy core.cjs through one compatibility adapter', () => {
const loadConfig = vi.fn((cwd: string) => ({ cwd, source: 'legacy-core' }));
const requireFn = vi.fn(() => ({ loadConfig }));
const createRequire = vi.fn(() => requireFn as unknown as NodeJS.Require);
const result = loadLegacyCoreConfig(projectDir, {
homeDir,
existsSync: path => path === BUNDLED_CORE_CJS_PATH,
createRequire,
});
expect(createRequire).toHaveBeenCalledOnce();
expect(requireFn).toHaveBeenCalledWith(BUNDLED_CORE_CJS_PATH);
expect(loadConfig).toHaveBeenCalledWith(projectDir);
expect(result).toEqual({ cwd: projectDir, source: 'legacy-core' });
});
it('reports checked core.cjs probes when legacy asset missing', () => {
expect(() => loadLegacyCoreConfig(projectDir, {
homeDir,
existsSync: () => false,
})).toThrow(GSDError);
try {
loadLegacyCoreConfig(projectDir, {
homeDir,
existsSync: () => false,
});
expect.fail('expected GSDError');
} catch (error) {
expect(error).toBeInstanceOf(GSDError);
const message = (error as Error).message;
expect(message).toContain('state load: get-shit-done/bin/lib/core.cjs not found.');
expect(message).toContain('Checked:');
expect(message).toContain(BUNDLED_CORE_CJS_PATH);
expect(message).toContain(join(projectDir, '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'));
expect(message).toContain(join(homeDir, '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'));
}
});
});

View File

@@ -0,0 +1,137 @@
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { GSDError, ErrorClassification } from './errors.js';
export type LegacySdkAsset = 'gsd-tools' | 'core-cjs';
export interface LegacySdkAssetResolution {
asset: LegacySdkAsset;
path: string | null;
fallbackPath: string;
probes: string[];
}
interface LegacySdkCompatibilityDeps {
existsSync?: (path: string) => boolean;
homeDir?: string;
createRequire?: typeof createRequire;
}
export const BUNDLED_GSD_TOOLS_PATH = fileURLToPath(
new URL('../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
);
export const BUNDLED_CORE_CJS_PATH = fileURLToPath(
new URL('../../get-shit-done/bin/lib/core.cjs', import.meta.url),
);
export const BUNDLED_GSD_TEMPLATES_DIR = fileURLToPath(
new URL('../../get-shit-done/templates', import.meta.url),
);
export const BUNDLED_GSD_AGENTS_DIR = fileURLToPath(
new URL('../../agents', import.meta.url),
);
const LEGACY_ASSET_SUBPATH: Record<LegacySdkAsset, string> = {
'gsd-tools': 'gsd-tools.cjs',
'core-cjs': join('lib', 'core.cjs'),
};
const BUNDLED_LEGACY_ASSET_PATH: Record<LegacySdkAsset, string> = {
'gsd-tools': BUNDLED_GSD_TOOLS_PATH,
'core-cjs': BUNDLED_CORE_CJS_PATH,
};
export function resolveLegacyInstallDir(homeDir: string = homedir()): string {
return join(homeDir, '.claude', 'get-shit-done');
}
export function resolveLegacyTemplatesDir(homeDir: string = homedir()): string {
return join(resolveLegacyInstallDir(homeDir), 'templates');
}
export function resolveLegacyWorkflowsDir(homeDir: string = homedir()): string {
return join(resolveLegacyInstallDir(homeDir), 'workflows');
}
export function resolveLegacySkillsDir(homeDir: string = homedir()): string {
return join(resolveLegacyInstallDir(homeDir), 'skills');
}
export function resolveBundledTemplatesDir(): string {
return BUNDLED_GSD_TEMPLATES_DIR;
}
export function resolveBundledAgentsDir(): string {
return BUNDLED_GSD_AGENTS_DIR;
}
function legacyAssetProbes(asset: LegacySdkAsset, projectDir: string, homeDir: string): string[] {
const suffix = LEGACY_ASSET_SUBPATH[asset];
return [
BUNDLED_LEGACY_ASSET_PATH[asset],
join(projectDir, '.claude', 'get-shit-done', 'bin', suffix),
join(homeDir, '.claude', 'get-shit-done', 'bin', suffix),
];
}
export function probeLegacySdkAsset(
asset: LegacySdkAsset,
projectDir: string,
deps: LegacySdkCompatibilityDeps = {},
): LegacySdkAssetResolution {
const pathExists = deps.existsSync ?? existsSync;
const probes = legacyAssetProbes(asset, projectDir, deps.homeDir ?? homedir());
return {
asset,
path: probes.find(candidate => pathExists(candidate)) ?? null,
fallbackPath: probes[probes.length - 1]!,
probes,
};
}
/**
* Resolve the legacy `gsd-tools.cjs` executable path through the SDK Package Seam Module.
*
* Preserves historical behavior: if no probe exists, return the final fallback path so
* downstream subprocess errors still show a concrete location.
*/
export function resolveGsdToolsPath(projectDir: string, deps: LegacySdkCompatibilityDeps = {}): string {
const resolution = probeLegacySdkAsset('gsd-tools', projectDir, deps);
return resolution.path ?? resolution.fallbackPath;
}
function missingLegacyCoreMessage(resolution: LegacySdkAssetResolution): string {
return [
'state load: get-shit-done/bin/lib/core.cjs not found.',
`Checked: ${resolution.probes.join(', ')}`,
'Install GSD (e.g. npm i -g get-shit-done-cc) or clone with get-shit-done next to the SDK.',
].join(' ');
}
/**
* Load `loadConfig(cwd)` from the legacy CJS install through one compatibility seam.
*/
export function loadLegacyCoreConfig(projectDir: string, deps: LegacySdkCompatibilityDeps = {}): Record<string, unknown> {
const resolution = probeLegacySdkAsset('core-cjs', projectDir, deps);
if (!resolution.path) {
throw new GSDError(
missingLegacyCoreMessage(resolution),
ErrorClassification.Blocked,
);
}
const req = (deps.createRequire ?? createRequire)(import.meta.url);
const mod = req(resolution.path) as Partial<{ loadConfig: (cwd: string) => Record<string, unknown> }>;
if (typeof mod.loadConfig !== 'function') {
throw new GSDError(
`state load: invalid core.cjs at ${resolution.path} (missing loadConfig(cwd)).`,
ErrorClassification.Blocked,
);
}
return mod.loadConfig(projectDir);
}

View File

@@ -197,7 +197,7 @@ export interface PlanResult {
export interface GSDOptions {
/** Root directory of the project. */
projectDir: string;
/** Path to gsd-tools.cjs. Falls back to <projectDir>/.claude/, then the bundled repo path, then ~/.claude/. */
/** Path to gsd-tools.cjs. Falls back to the bundled repo path, then <projectDir>/.claude/, then ~/.claude/. */
gsdToolsPath?: string;
/**
* Optional session correlation id for query mutation events when using {@link GSD.createTools}.

View File

@@ -211,4 +211,45 @@ describe('generate-dev-preferences command', () => {
const out = JSON.parse(result.output);
assert.ok(out.command_path || out.command_name, 'should return command output');
});
test('uses runtime-aware skills dir for codex by default', () => {
const analysis = {
profile_version: '1.0',
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
},
};
const analysisPath = path.join(tmpDir, 'analysis.json');
const codexHome = path.join(tmpDir, 'codex-home');
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
const result = runGsdTools(
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
tmpDir,
{ CODEX_HOME: codexHome, GSD_RUNTIME: 'codex' }
);
assert.ok(result.success, `Failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
assert.ok(fs.existsSync(out.command_path), 'runtime-aware output should be written');
});
test('errors for cline unless --output is supplied', () => {
const analysis = {
profile_version: '1.0',
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
},
};
const analysisPath = path.join(tmpDir, 'analysis.json');
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
const result = runGsdTools(
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
tmpDir,
{ GSD_RUNTIME: 'cline' }
);
assert.ok(!result.success, 'cline should require explicit --output');
assert.ok(result.error.includes('does not use a skills directory'), 'should explain unsupported runtime');
});
});

View File

@@ -127,4 +127,21 @@ describe('skill-manifest', () => {
assert.ok(Array.isArray(manifest.skills));
assert.ok(manifest.installation);
});
test('global roots honor runtime-home env overrides instead of hardcoded home paths', () => {
const result = runGsdTools(['skill-manifest'], tmpDir, {
HOME: homeDir,
CLAUDE_CONFIG_DIR: path.join(homeDir, 'claude-custom'),
CODEX_HOME: path.join(homeDir, 'codex-custom'),
});
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
const claudeRoot = manifest.roots.find((root) => root.root === '~/.claude/skills');
const codexRoot = manifest.roots.find((root) => root.root === '~/.codex/skills');
assert.ok(claudeRoot, 'Expected ~/.claude/skills root to be present');
assert.ok(codexRoot, 'Expected ~/.codex/skills root to be present');
assert.strictEqual(claudeRoot.path, path.join(homeDir, 'claude-custom', 'skills'));
assert.strictEqual(codexRoot.path, path.join(homeDir, 'codex-custom', 'skills'));
});
});