diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index 4da2a1db..03cf0e98 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { GSDError } from '../errors.js'; +import { GSDError, ErrorClassification, exitCodeFor } from '../errors.js'; // ─── Test setup ───────────────────────────────────────────────────────────── @@ -58,6 +58,41 @@ describe('configGet', () => { await expect(configGet(['nonexistent.key'], tmpDir)).rejects.toThrow(GSDError); }); + it('throws GSDError that maps to exit code 1 for missing key (bug #2544)', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + try { + await configGet(['nonexistent.key'], tmpDir); + throw new Error('expected configGet to throw for missing key'); + } catch (err) { + expect(err).toBeInstanceOf(GSDError); + const gsdErr = err as GSDError; + // UNIX convention: missing config key should exit 1 (like `git config --get`). + // Validation (exit 10) is the previous buggy classification — see issue #2544. + expect(gsdErr.classification).toBe(ErrorClassification.Execution); + expect(exitCodeFor(gsdErr.classification)).toBe(1); + } + }); + + it('throws GSDError that maps to exit code 1 when traversing into non-object (bug #2544)', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + try { + await configGet(['model_profile.subkey'], tmpDir); + throw new Error('expected configGet to throw'); + } catch (err) { + expect(err).toBeInstanceOf(GSDError); + const gsdErr = err as GSDError; + expect(exitCodeFor(gsdErr.classification)).toBe(1); + } + }); + it('reads raw config without merging defaults', async () => { const { configGet } = await import('./config-query.js'); // Write config with only model_profile -- no workflow section diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index f00b9a04..da2866d5 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -104,12 +104,14 @@ export const configGet: QueryHandler = async (args, projectDir, _workstream) => let current: unknown = config; for (const key of keys) { if (current === undefined || current === null || typeof current !== 'object') { - throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation); + // 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. + throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); } current = (current as Record)[key]; } if (current === undefined) { - throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation); + throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); } return { data: current };