Files
msd-core/sdk/src/query/config-query.ts
2026-05-13 19:43:28 -04:00

238 lines
9.4 KiB
TypeScript

/**
* Config-get and resolve-model query handlers.
*
* Ported from get-shit-done/bin/lib/config.cjs and commands.cjs.
* Provides raw config.json traversal and model profile resolution.
*
* @example
* ```typescript
* import { configGet, resolveModel } from './config-query.js';
*
* const result = await configGet(['workflow.auto_advance'], '/project');
* // { data: true }
*
* const model = await resolveModel(['gsd-planner'], '/project');
* // { data: { model: 'opus', profile: 'balanced' } }
* ```
*/
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { GSDError, ErrorClassification } from '../errors.js';
import { loadConfig } from '../config.js';
import { planningPaths } from './helpers.js';
import { maskIfSecret } from './secrets.js';
import type { QueryHandler } from './utils.js';
export { MODEL_PROFILES, VALID_PROFILES, getAgentToModelMapForProfile } from '../model-catalog.js';
import {
AGENT_TO_PHASE_TYPE,
MODEL_PROFILES,
VALID_PROFILES,
getAgentToModelMapForProfile,
resolveRuntimeTierDefault,
} from '../model-catalog.js';
// ─── configGet ──────────────────────────────────────────────────────────────
/**
* Query handler for config-get command.
*
* Reads raw .planning/config.json and traverses dot-notation key paths.
* Does NOT merge with defaults (matches gsd-tools.cjs behavior).
*
* @param args - args[0] is the dot-notation key path (e.g., 'workflow.auto_advance')
* @param projectDir - Project root directory
* @returns QueryResult with the config value at the given path
* @throws GSDError with Validation classification if key missing or not found
*/
export const configGet: QueryHandler = async (args, projectDir, workstream) => {
// Support --default <value> flag (#2803): return this value (exit 0) when the
// key is absent, mirroring gsd-tools.cjs config-get behavior from #1893.
const defaultIdx = args.indexOf('--default');
let defaultValue: string | undefined;
let filteredArgs = args;
if (defaultIdx !== -1) {
if (defaultIdx + 1 >= args.length) {
throw new GSDError('Usage: config-get <key.path> [--default <value>]', ErrorClassification.Validation);
}
defaultValue = String(args[defaultIdx + 1]);
filteredArgs = [...args.slice(0, defaultIdx), ...args.slice(defaultIdx + 2)];
}
const keyPath = filteredArgs[0];
if (!keyPath) {
throw new GSDError('Usage: config-get <key.path> [--default <value>]', ErrorClassification.Validation);
}
const paths = planningPaths(projectDir, workstream);
let raw: string;
try {
raw = await readFile(paths.config, 'utf-8');
} catch {
throw new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation);
}
let config: Record<string, unknown>;
try {
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new GSDError(`Malformed config.json at ${paths.config}`, ErrorClassification.Validation);
}
const keys = keyPath.split('.');
let current: unknown = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
// UNIX convention (cf. `git config --get`): missing key exits 1, not 10.
// See issue #2544 — callers use `if ! gsd-sdk query config-get k; then` patterns.
if (defaultValue !== undefined) return { data: defaultValue };
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
}
current = (current as Record<string, unknown>)[key];
}
if (current === undefined) {
if (defaultValue !== undefined) return { data: defaultValue };
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
}
// Mask plaintext for keys in SECRET_CONFIG_KEYS to match CJS behavior at
// config.cjs:440-441 — without this, `gsd-sdk query config-get brave_search`
// would echo the plaintext credential into machine-readable output. (#2997)
return { data: maskIfSecret(keyPath, current) };
};
// ─── configPath ─────────────────────────────────────────────────────────────
/**
* Query handler for config-path — resolved `.planning/config.json` path (workstream-aware via cwd).
*
* Port of `cmdConfigPath` from `config.cjs`. The JSON query API returns `{ path }`; the CJS CLI
* emits the path as plain text for shell substitution.
*
* @param _args - Unused
* @param projectDir - Project root directory
* @returns QueryResult with `{ path: string }` absolute or project-relative resolution via planningPaths
*/
export const configPath: QueryHandler = async (_args, projectDir, workstream) => {
const paths = planningPaths(projectDir, workstream);
return { data: { path: paths.config } };
};
// ─── resolveModel ───────────────────────────────────────────────────────────
type RuntimeTierName = 'opus' | 'sonnet' | 'haiku';
interface RuntimeTierEntry {
model?: string;
reasoning_effort?: string;
}
function isRuntimeTierName(value: string): value is RuntimeTierName {
return value === 'opus' || value === 'sonnet' || value === 'haiku';
}
function normalizeRuntimeTierEntry(entry: unknown): RuntimeTierEntry | null {
if (typeof entry === 'string') return { model: entry };
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
return entry as RuntimeTierEntry;
}
return null;
}
function resolveRuntimeTier(config: Record<string, unknown>, tier: string): RuntimeTierEntry | null {
if (!isRuntimeTierName(tier)) return null;
const runtime = typeof config.runtime === 'string' ? config.runtime : '';
if (!runtime || runtime === 'claude') return null;
const builtin = resolveRuntimeTierDefault(runtime, tier);
const profileOverrides = config.model_profile_overrides as Record<string, unknown> | undefined;
const runtimeOverrides = profileOverrides?.[runtime] as Record<string, unknown> | undefined;
const userEntry = normalizeRuntimeTierEntry(runtimeOverrides?.[tier]);
if (!builtin && !userEntry) return null;
return { ...(builtin ?? {}), ...(userEntry ?? {}) };
}
/**
* Query handler for resolve-model command.
*
* Resolves the model alias for a given agent type based on the current profile.
* Uses loadConfig (with defaults) and MODEL_PROFILES for lookup.
*
* @param args - args[0] is the agent type (e.g., 'gsd-planner')
* @param projectDir - Project root directory
* @param workstream - Optional workstream name; forwarded to loadConfig so per-workstream
* model_profile settings are respected (mirrors configGet/configPath behavior)
* @returns QueryResult with { model, profile } or { model, profile, unknown_agent: true }
* @throws GSDError with Validation classification if agent type not provided
*/
export const resolveModel: QueryHandler = async (args, projectDir, workstream) => {
const agentType = args[0];
if (!agentType) {
throw new GSDError('agent-type required', ErrorClassification.Validation);
}
const configFilePath = planningPaths(projectDir, workstream).config;
const configExists = existsSync(configFilePath);
const config = await loadConfig(projectDir, workstream);
const profile = String(config.model_profile || 'balanced').toLowerCase();
// Check per-agent override first
const overrides = (config as Record<string, unknown>).model_overrides as Record<string, string> | undefined;
const override = overrides?.[agentType];
if (override) {
const agentModels = MODEL_PROFILES[agentType];
const result = agentModels
? { model: override, profile }
: { model: override, profile, unknown_agent: true };
return { data: result };
}
const agentModels = MODEL_PROFILES[agentType];
// No project config -> return empty model id (CJS parity)
const resolveModelIds = (config as Record<string, unknown>).resolve_model_ids;
if (!configExists) {
const result = agentModels
? { model: '', profile }
: { model: '', profile, unknown_agent: true };
return { data: result };
}
// Fall back to profile lookup
if (!agentModels) {
const semanticFallback =
profile === 'quality' ? 'opus'
: profile === 'budget' ? 'haiku'
: profile === 'inherit' ? 'inherit'
: 'sonnet';
return { data: { model: semanticFallback, profile, unknown_agent: true } };
}
if (profile === 'inherit') {
return { data: { model: 'inherit', profile } };
}
const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet';
const phaseType = AGENT_TO_PHASE_TYPE[agentType];
const phaseTier = phaseType && typeof (config as Record<string, unknown>).models === 'object'
? ((config as Record<string, unknown>).models as Record<string, unknown>)[phaseType]
: undefined;
const tier = typeof phaseTier === 'string' ? phaseTier : alias;
const runtimeTier = resolveRuntimeTier(config as Record<string, unknown>, tier);
if (runtimeTier?.model) {
const result: Record<string, unknown> = { model: runtimeTier.model, profile };
if (runtimeTier.reasoning_effort) {
result.reasoning_effort = runtimeTier.reasoning_effort;
}
return { data: result };
}
if (resolveModelIds === 'omit') {
return { data: { model: '', profile } };
}
return { data: { model: alias, profile } };
};