Skip to content

Commit 3b5b99c

Browse files
committed
feat(cli): add agent setup command
1 parent 0b1cc33 commit 3b5b99c

9 files changed

Lines changed: 783 additions & 5 deletions

File tree

‎e2e/cli.e2e.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,21 @@ describe('CLI basics', () => {
1919
expect(result.stdout).toContain('memory');
2020
expect(result.stdout).toContain('skill');
2121
expect(result.stdout).toContain('phase');
22-
expect(result.stdout).not.toContain('setup');
22+
expect(result.stdout).toContain('setup');
2323
});
2424

25-
it('should not expose removed workflow command setup', () => {
26-
const result = run('setup');
27-
expect(result.exitCode).not.toBe(0);
25+
it('should run setup against an isolated home directory', () => {
26+
const homeDir = mkdtempSync(join(tmpdir(), 'ai-devkit-setup-e2e-home-'));
27+
28+
try {
29+
const result = run('setup', { env: { HOME: homeDir } });
30+
expect(result.exitCode).toBe(0);
31+
expect(result.stdout).toContain('Setup Summary');
32+
expect(result.stdout).toContain('codex-session-hook');
33+
expect(result.stdout).toContain('pi-session-tracker');
34+
} finally {
35+
rmSync(homeDir, { recursive: true, force: true });
36+
}
2837
});
2938

3039
it('should exit with error for unknown command', () => {
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
const fs = require('node:fs');
2+
const os = require('node:os');
3+
const path = require('node:path');
4+
5+
const SESSION_FILE_ENV_KEYS = [
6+
'CODEX_SESSION_FILE',
7+
'CODEX_SESSION_FILE_PATH',
8+
'CODEX_TRANSCRIPT_PATH',
9+
'OPENAI_CODEX_SESSION_FILE',
10+
'OPENAI_CODEX_SESSION_FILE_PATH',
11+
];
12+
13+
const SESSION_FILE_INPUT_KEYS = [
14+
'session_file',
15+
'sessionFile',
16+
'session_file_path',
17+
'sessionFilePath',
18+
'transcript_path',
19+
'transcriptPath',
20+
];
21+
22+
const SESSION_ID_ENV_KEYS = [
23+
'CODEX_THREAD_ID',
24+
'CODEX_SESSION_ID',
25+
'OPENAI_CODEX_THREAD_ID',
26+
'OPENAI_CODEX_SESSION_ID',
27+
];
28+
29+
const SESSION_ID_INPUT_KEYS = [
30+
'session_id',
31+
'sessionId',
32+
'thread_id',
33+
'threadId',
34+
'conversation_id',
35+
'conversationId',
36+
];
37+
38+
const CODEX_PID_ENV_KEYS = [
39+
'CODEX_PID',
40+
'CODEX_PROCESS_PID',
41+
'OPENAI_CODEX_PID',
42+
'OPENAI_CODEX_PROCESS_PID',
43+
'AI_DEVKIT_CODEX_PID',
44+
];
45+
46+
const CODEX_PID_INPUT_KEYS = [
47+
'codex_pid',
48+
'codexPid',
49+
'codex_process_pid',
50+
'codexProcessPid',
51+
'pid',
52+
];
53+
54+
function readHookInput() {
55+
if (process.stdin.isTTY) return {};
56+
57+
try {
58+
const raw = fs.readFileSync(0, 'utf8').trim();
59+
return raw ? JSON.parse(raw) : {};
60+
} catch {
61+
return {};
62+
}
63+
}
64+
65+
function normalizeValue(value) {
66+
if (typeof value === 'string' && value.trim()) return value.trim();
67+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value);
68+
return undefined;
69+
}
70+
71+
function inputSources(input) {
72+
return [input, input?.payload, input?.session, input?.thread].filter(
73+
(source) => source && typeof source === 'object',
74+
);
75+
}
76+
77+
function inputValue(input, keys) {
78+
for (const source of inputSources(input)) {
79+
const value = firstValue(keys, (key) => source[key]);
80+
if (value) return value;
81+
}
82+
83+
return undefined;
84+
}
85+
86+
function envValue(keys) {
87+
return firstValue(keys, (key) => process.env[key]);
88+
}
89+
90+
function firstValue(keys, getValue) {
91+
for (const key of keys) {
92+
const value = normalizeValue(getValue(key));
93+
if (value) return value;
94+
}
95+
96+
return undefined;
97+
}
98+
99+
function readRegistry(registryFile) {
100+
let existing;
101+
102+
try {
103+
existing = JSON.parse(fs.readFileSync(registryFile, 'utf8'));
104+
} catch {
105+
return {};
106+
}
107+
108+
return existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
109+
}
110+
111+
function writeRegistry(registryFile, registry) {
112+
fs.mkdirSync(path.dirname(registryFile), { recursive: true });
113+
fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2), 'utf8');
114+
}
115+
116+
function findSessionFileById(homeDir, sessionId) {
117+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
118+
const pendingDirs = [codexSessionsDir];
119+
120+
while (pendingDirs.length > 0) {
121+
const dir = pendingDirs.pop();
122+
let entries;
123+
124+
try {
125+
entries = fs.readdirSync(dir, { withFileTypes: true });
126+
} catch {
127+
continue;
128+
}
129+
130+
for (const entry of entries) {
131+
const entryPath = path.join(dir, entry.name);
132+
if (entry.isDirectory()) {
133+
pendingDirs.push(entryPath);
134+
} else if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.includes(sessionId)) {
135+
return entryPath;
136+
}
137+
}
138+
}
139+
140+
return undefined;
141+
}
142+
143+
function resolveSessionFile(homeDir, input) {
144+
const explicitPath = envValue(SESSION_FILE_ENV_KEYS) || inputValue(input, SESSION_FILE_INPUT_KEYS);
145+
146+
if (explicitPath) return explicitPath;
147+
148+
const sessionId = envValue(SESSION_ID_ENV_KEYS) || inputValue(input, SESSION_ID_INPUT_KEYS);
149+
150+
return sessionId ? findSessionFileById(homeDir, sessionId) : undefined;
151+
}
152+
153+
function resolveCodexPid(input) {
154+
const pid = envValue(CODEX_PID_ENV_KEYS) || inputValue(input, CODEX_PID_INPUT_KEYS);
155+
156+
return pid || String(process.ppid || process.pid);
157+
}
158+
159+
const input = readHookInput();
160+
const homeDir = os.homedir();
161+
const registryFile = path.join(homeDir, '.codex', 'ai-devkit', 'sessions.json');
162+
const registry = readRegistry(registryFile);
163+
164+
registry[resolveCodexPid(input)] = resolveSessionFile(homeDir, input) || 'ephemeral';
165+
writeRegistry(registryFile, registry);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"hooks": {
3+
"SessionStart": [
4+
{
5+
"matcher": "startup|resume|clear|compact",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "node ~/.codex/hooks/codex-session-mapping.cjs",
10+
"timeout": 5
11+
}
12+
]
13+
}
14+
]
15+
}
16+
}

‎packages/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"ai-devkit": "dist/cli.js"
1010
},
1111
"scripts": {
12-
"build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -R templates dist/templates",
12+
"build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -R templates dist/templates && cp -R assets dist/assets",
1313
"dev:build": "swc src -d dist --strip-leading-paths --watch",
1414
"dev": "node --no-warnings --loader ts-node/esm src/cli.ts",
1515
"lint": "eslint src --ext .ts",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Command } from 'commander';
2+
3+
const {
4+
mockSetupService,
5+
mockUi,
6+
} = vi.hoisted(() => ({
7+
mockSetupService: {
8+
run: vi.fn(),
9+
},
10+
mockUi: {
11+
error: vi.fn(),
12+
summary: vi.fn(),
13+
table: vi.fn(),
14+
},
15+
}));
16+
17+
vi.mock('../../services/setup/setup.service.js', () => ({
18+
createSetupService: () => mockSetupService,
19+
SUPPORTED_SETUP_AGENTS: ['codex', 'pi'],
20+
}));
21+
22+
vi.mock('../../util/terminal-ui.js', () => ({
23+
ui: mockUi,
24+
}));
25+
26+
import { registerSetupCommand } from '../../commands/setup.js';
27+
28+
describe('setup command', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
process.exitCode = undefined;
32+
mockSetupService.run.mockResolvedValue({
33+
results: [
34+
{
35+
agent: 'codex',
36+
step: 'codex-session-hook',
37+
status: 'installed',
38+
message: 'Installed Codex SessionStart hook.',
39+
},
40+
{
41+
agent: 'pi',
42+
step: 'pi-session-tracker',
43+
status: 'skipped',
44+
message: '~/.pi does not exist.',
45+
},
46+
],
47+
});
48+
});
49+
50+
afterEach(() => {
51+
process.exitCode = undefined;
52+
});
53+
54+
it('registers setup and runs all configured agent setup steps by default', async () => {
55+
const program = new Command();
56+
registerSetupCommand(program);
57+
58+
await program.parseAsync(['node', 'test', 'setup']);
59+
60+
expect(mockSetupService.run).toHaveBeenCalledWith({ agents: undefined });
61+
expect(mockUi.summary).toHaveBeenCalledWith({
62+
title: 'Setup Summary',
63+
items: [
64+
{ type: 'success', count: 1, label: 'step(s) installed' },
65+
{ type: 'warning', count: 1, label: 'step(s) skipped' },
66+
{ type: 'error', count: 0, label: 'step(s) failed' },
67+
],
68+
});
69+
expect(mockUi.table).toHaveBeenCalledWith({
70+
headers: ['agent', 'step', 'status', 'message'],
71+
rows: [
72+
['codex', 'codex-session-hook', 'installed', 'Installed Codex SessionStart hook.'],
73+
['pi', 'pi-session-tracker', 'skipped', '~/.pi does not exist.'],
74+
],
75+
});
76+
expect(process.exitCode).toBe(0);
77+
});
78+
79+
it('passes selected agents to the setup service', async () => {
80+
const program = new Command();
81+
registerSetupCommand(program);
82+
83+
await program.parseAsync(['node', 'test', 'setup', '--agent', 'codex,pi']);
84+
85+
expect(mockSetupService.run).toHaveBeenCalledWith({ agents: ['codex', 'pi'] });
86+
});
87+
88+
it('fails for unsupported agents before running setup', async () => {
89+
const program = new Command();
90+
registerSetupCommand(program);
91+
92+
await program.parseAsync(['node', 'test', 'setup', '--agent', 'cursor']);
93+
94+
expect(mockSetupService.run).not.toHaveBeenCalled();
95+
expect(mockUi.error).toHaveBeenCalledWith('Unsupported setup agent: cursor. Supported agents: codex, pi.');
96+
expect(process.exitCode).toBe(1);
97+
});
98+
});

0 commit comments

Comments
 (0)