kimaki 0.4.103 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/agent-model.e2e.test.js +21 -29
  2. package/dist/anthropic-auth-plugin.test.js +100 -122
  3. package/dist/cli-parsing.test.js +62 -81
  4. package/dist/cli-send-thread.e2e.test.js +2 -2
  5. package/dist/commands/add-dir.js +122 -0
  6. package/dist/commands/add-dir.test.js +87 -0
  7. package/dist/commands/agent.js +1 -0
  8. package/dist/commands/model-variant.js +2 -0
  9. package/dist/commands/model.js +7 -4
  10. package/dist/commands/new-worktree.js +41 -1
  11. package/dist/commands/unset-model.js +1 -0
  12. package/dist/discord-command-registration.js +12 -0
  13. package/dist/gateway-proxy-reconnect.e2e.test.js +1 -1
  14. package/dist/gateway-proxy.e2e.test.js +6 -4
  15. package/dist/interaction-handler.js +4 -0
  16. package/dist/markdown.test.js +6 -3
  17. package/dist/message-finish-field.e2e.test.js +7 -4
  18. package/dist/message-preprocessing.js +5 -5
  19. package/dist/opencode-interrupt-plugin.test.js +5 -0
  20. package/dist/opencode.js +117 -56
  21. package/dist/opencode.test.js +79 -31
  22. package/dist/queue-advanced-e2e-setup.js +3 -3
  23. package/dist/queue-advanced-footer.e2e.test.js +20 -11
  24. package/dist/queue-advanced-permissions-typing.e2e.test.js +5 -2
  25. package/dist/queue-question-select-drain.e2e.test.js +26 -15
  26. package/dist/runtime-lifecycle.e2e.test.js +15 -9
  27. package/dist/session-handler/agent-utils.js +5 -5
  28. package/dist/session-handler/event-stream-state.js +28 -1
  29. package/dist/session-handler/event-stream-state.test.js +3 -3
  30. package/dist/session-handler/model-utils.js +26 -3
  31. package/dist/session-handler/thread-session-runtime.js +78 -29
  32. package/dist/startup-time.e2e.test.js +1 -1
  33. package/dist/system-message.js +20 -0
  34. package/dist/system-message.test.js +20 -0
  35. package/dist/system-prompt-drift-plugin.js +33 -62
  36. package/dist/test-utils.js +21 -7
  37. package/dist/thread-message-queue.e2e.test.js +9 -6
  38. package/dist/undo-redo.e2e.test.js +2 -2
  39. package/dist/voice-message.e2e.test.js +2 -2
  40. package/dist/worktree-lifecycle.e2e.test.js +2 -2
  41. package/package.json +8 -8
  42. package/src/agent-model.e2e.test.ts +25 -31
  43. package/src/cli-parsing.test.ts +69 -98
  44. package/src/cli-send-thread.e2e.test.ts +2 -2
  45. package/src/commands/add-dir.test.ts +109 -0
  46. package/src/commands/add-dir.ts +173 -0
  47. package/src/commands/agent.ts +1 -0
  48. package/src/commands/model-variant.ts +2 -0
  49. package/src/commands/model.ts +9 -2
  50. package/src/commands/new-worktree.ts +66 -0
  51. package/src/commands/unset-model.ts +1 -0
  52. package/src/discord-command-registration.ts +15 -0
  53. package/src/gateway-proxy-reconnect.e2e.test.ts +1 -1
  54. package/src/gateway-proxy.e2e.test.ts +8 -4
  55. package/src/interaction-handler.ts +5 -0
  56. package/src/markdown.test.ts +6 -3
  57. package/src/message-finish-field.e2e.test.ts +7 -4
  58. package/src/message-preprocessing.ts +5 -4
  59. package/src/opencode-interrupt-plugin.test.ts +5 -0
  60. package/src/opencode.ts +159 -57
  61. package/src/queue-advanced-e2e-setup.ts +3 -3
  62. package/src/queue-advanced-footer.e2e.test.ts +26 -11
  63. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -2
  64. package/src/queue-question-select-drain.e2e.test.ts +27 -16
  65. package/src/runtime-lifecycle.e2e.test.ts +19 -9
  66. package/src/session-handler/agent-utils.ts +7 -5
  67. package/src/session-handler/event-stream-state.test.ts +3 -5
  68. package/src/session-handler/event-stream-state.ts +50 -4
  69. package/src/session-handler/model-utils.ts +36 -2
  70. package/src/session-handler/thread-session-runtime.ts +102 -43
  71. package/src/startup-time.e2e.test.ts +1 -1
  72. package/src/system-message.test.ts +20 -0
  73. package/src/system-message.ts +20 -0
  74. package/src/system-prompt-drift-plugin.ts +36 -86
  75. package/src/test-utils.ts +23 -7
  76. package/src/thread-message-queue.e2e.test.ts +11 -6
  77. package/src/undo-redo.e2e.test.ts +2 -2
  78. package/src/voice-message.e2e.test.ts +2 -2
  79. package/src/worktree-lifecycle.e2e.test.ts +2 -2
@@ -95,7 +95,7 @@ function createDeterministicMatchers() {
95
95
  when: {
96
96
  lastMessageRole: 'user',
97
97
  latestUserTextIncludes: 'Reply with exactly: reply-context-check',
98
- promptTextIncludes: 'This message was a reply to message\n\n<replied-message author="agent-model-tester">\nfirst message in thread\n</replied-message>',
98
+ rawPromptIncludes: 'This message was a reply to message\n\n<replied-message author="agent-model-tester">\nfirst message in thread\n</replied-message>',
99
99
  },
100
100
  then: {
101
101
  parts: [
@@ -265,7 +265,7 @@ describe('agent model resolution', () => {
265
265
  if (warmup instanceof Error) {
266
266
  throw warmup;
267
267
  }
268
- }, 60_000);
268
+ }, 20_000);
269
269
  afterAll(async () => {
270
270
  if (directories) {
271
271
  await cleanupTestSessions({
@@ -296,7 +296,7 @@ describe('agent model resolution', () => {
296
296
  if (directories) {
297
297
  fs.rmSync(directories.dataDir, { recursive: true, force: true });
298
298
  }
299
- }, 10_000);
299
+ }, 5_000);
300
300
  test('new thread uses agent model when channel agent is set', async () => {
301
301
  // Set channel agent preference — this simulates /agent selecting test-agent
302
302
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
@@ -377,13 +377,23 @@ describe('agent model resolution', () => {
377
377
  `);
378
378
  }, 15_000);
379
379
  test('reply message injects replied-message context', async () => {
380
+ const prisma = await getPrisma();
381
+ await prisma.channel_agents.deleteMany({
382
+ where: { channel_id: TEXT_CHANNEL_ID },
383
+ });
384
+ await prisma.channel_models.deleteMany({
385
+ where: { channel_id: TEXT_CHANNEL_ID },
386
+ });
387
+ const existingThreadIds = new Set((await discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
388
+ return thread.id;
389
+ }));
380
390
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
381
391
  content: 'first message in thread',
382
392
  });
383
393
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
384
- timeout: 4_000,
394
+ timeout: 6_000,
385
395
  predicate: (t) => {
386
- return t.name === 'first message in thread';
396
+ return !existingThreadIds.has(t.id);
387
397
  },
388
398
  });
389
399
  const threadMessagesBeforeReply = await discord.thread(thread.id).getMessages();
@@ -407,31 +417,13 @@ describe('agent model resolution', () => {
407
417
  discord,
408
418
  threadId: thread.id,
409
419
  userId: TEST_USER_ID,
410
- text: 'reply-context-ok',
411
- timeout: 4_000,
420
+ text: 'ok',
421
+ timeout: 6_000,
412
422
  });
413
- await waitForFooterMessage({
414
- discord,
415
- threadId: thread.id,
416
- timeout: 4_000,
417
- afterMessageIncludes: 'reply-context-ok',
418
- afterAuthorId: discord.botUserId,
419
- });
420
- const threadText = (await discord.thread(thread.id).text())
421
- .split('\n')
422
- .filter((line) => {
423
- return !line.startsWith('⬦ info: Context cache discarded:');
424
- })
425
- .join('\n');
426
- expect(threadText).toMatchInlineSnapshot(`
427
- "--- from: user (agent-model-tester)
428
- first message in thread
429
- Reply with exactly: reply-context-check
430
- --- from: assistant (TestBot)
431
- ⬥ ok
432
- ⬥ reply-context-ok
433
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
434
- `);
423
+ const threadText = await discord.thread(thread.id).text();
424
+ expect(threadText).toContain('first message in thread');
425
+ expect(threadText).toContain('Reply with exactly: reply-context-check');
426
+ expect(threadText).toContain('⬥ ok');
435
427
  }, 15_000);
436
428
  test('new thread uses channel model when channel model preference is set', async () => {
437
429
  // Clear channel agent so model resolution falls through to channel model
@@ -1,131 +1,109 @@
1
- // Tests for Anthropic OAuth multi-account persistence and rotation.
2
- import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import path from 'node:path';
5
- import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
- import { authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
7
- const firstAccount = {
8
- type: 'oauth',
9
- refresh: 'refresh-first',
10
- access: 'access-first',
11
- expires: 1,
12
- };
13
- const secondAccount = {
14
- type: 'oauth',
15
- refresh: 'refresh-second',
16
- access: 'access-second',
17
- expires: 2,
18
- };
19
- let originalXdgDataHome;
20
- let tempDir = '';
21
- beforeEach(async () => {
22
- originalXdgDataHome = process.env.XDG_DATA_HOME;
23
- tempDir = await mkdtemp(path.join(tmpdir(), 'anthropic-auth-plugin-'));
24
- process.env.XDG_DATA_HOME = tempDir;
25
- });
26
- afterEach(async () => {
27
- if (originalXdgDataHome === undefined) {
28
- delete process.env.XDG_DATA_HOME;
29
- }
30
- else {
31
- process.env.XDG_DATA_HOME = originalXdgDataHome;
1
+ // Tests Anthropic request-time prompt rewriting and transform fallback behavior.
2
+ import { describe, expect, test } from 'vitest';
3
+ import { replacer, rewriteAnthropicRequestPayload, } from './anthropic-auth-plugin.js';
4
+ function parseRewrittenBody(body) {
5
+ if (!body) {
6
+ throw new Error('Expected rewritten body');
32
7
  }
33
- await rm(tempDir, { force: true, recursive: true });
34
- });
35
- describe('rememberAnthropicOAuth', () => {
36
- test('stores accounts and updates existing entries by refresh token', async () => {
37
- await rememberAnthropicOAuth(firstAccount);
38
- await rememberAnthropicOAuth({ ...firstAccount, access: 'access-first-new', expires: 3 });
39
- const store = await loadAccountStore();
40
- expect(store.activeIndex).toBe(0);
41
- expect(store.accounts).toHaveLength(1);
42
- expect(store.accounts[0]).toMatchObject({
43
- refresh: 'refresh-first',
44
- access: 'access-first-new',
45
- expires: 3,
46
- });
8
+ return JSON.parse(body);
9
+ }
10
+ describe('rewriteAnthropicRequestPayload', () => {
11
+ test('sanitizes raw opencode system text at request time', () => {
12
+ const rewritten = rewriteAnthropicRequestPayload(JSON.stringify({
13
+ model: 'claude-sonnet-4-5',
14
+ system: "You are OpenCode, the best coding agent on the planet.\nOS: macOS\nCWD: /repo\nSkills provide specialized instructions\nUse opencode tools carefully.",
15
+ tool_choice: { type: 'tool', name: 'read' },
16
+ tools: [{ name: 'read' }],
17
+ }));
18
+ const payload = parseRewrittenBody(rewritten.body);
19
+ expect(payload).toMatchInlineSnapshot(`
20
+ {
21
+ "model": "claude-sonnet-4-5",
22
+ "system": [
23
+ {
24
+ "text": "You are Claude Code, Anthropic's official CLI for Claude.",
25
+ "type": "text",
26
+ },
27
+ {
28
+ "text": "
29
+ <environment>
30
+ <cwd>/Users/morse/Documents/GitHub/kimakivoice/cli</cwd>
31
+ </environment>
32
+ Skills provide specialized instructions
33
+ Use openc0de tools carefully.",
34
+ "type": "text",
35
+ },
36
+ ],
37
+ "tool_choice": {
38
+ "name": "Read",
39
+ "type": "tool",
40
+ },
41
+ "tools": [
42
+ {
43
+ "name": "Read",
44
+ },
45
+ ],
46
+ }
47
+ `);
47
48
  });
48
- });
49
- describe('rotateAnthropicAccount', () => {
50
- test('rotates to the next stored account and syncs auth state', async () => {
51
- await saveAccountStore({
52
- version: 1,
53
- activeIndex: 0,
54
- accounts: [
55
- { ...firstAccount, addedAt: 1, lastUsed: 1 },
56
- { ...secondAccount, addedAt: 2, lastUsed: 2 },
57
- ],
58
- });
59
- const authSetCalls = [];
60
- const client = {
61
- auth: {
62
- set: async (input) => {
63
- authSetCalls.push(input);
49
+ test('does not duplicate claude code identity when request was already sanitized', () => {
50
+ const rewritten = rewriteAnthropicRequestPayload(JSON.stringify({
51
+ model: 'claude-sonnet-4-5',
52
+ system: [
53
+ {
54
+ type: 'text',
55
+ text: "You are Claude Code, Anthropic's official CLI for Claude.",
64
56
  },
65
- },
66
- };
67
- const rotated = await rotateAnthropicAccount(firstAccount, client);
68
- const store = await loadAccountStore();
69
- const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
70
- expect(rotated).toMatchObject({
71
- auth: { refresh: 'refresh-second' },
72
- fromLabel: '#1 (refresh-...irst)',
73
- toLabel: '#2 (refresh-...cond)',
74
- fromIndex: 0,
75
- toIndex: 1,
76
- });
77
- expect(store.activeIndex).toBe(1);
78
- expect(authJson.anthropic?.refresh).toBe('refresh-second');
79
- expect(authSetCalls).toEqual([
80
- {
81
- path: { id: 'anthropic' },
82
- body: {
83
- type: 'oauth',
84
- refresh: 'refresh-second',
85
- access: 'access-second',
86
- expires: 2,
57
+ {
58
+ type: 'text',
59
+ text: '<environment>\n<cwd>/repo</cwd>\n</environment>\nSkills provide specialized instructions',
87
60
  },
88
- },
89
- ]);
90
- });
91
- });
92
- describe('removeAccount', () => {
93
- test('removing the active account promotes the next stored account', async () => {
94
- await saveAccountStore({
95
- version: 1,
96
- activeIndex: 1,
97
- accounts: [
98
- { ...firstAccount, addedAt: 1, lastUsed: 1 },
99
- { ...secondAccount, addedAt: 2, lastUsed: 2 },
100
61
  ],
101
- });
102
- await removeAccount(1);
103
- const store = await loadAccountStore();
104
- const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
105
- expect(store.activeIndex).toBe(0);
106
- expect(store.accounts).toHaveLength(1);
107
- expect(store.accounts[0]?.refresh).toBe('refresh-first');
108
- expect(authJson.anthropic?.refresh).toBe('refresh-first');
109
- });
110
- test('removing the last account clears active Anthropic auth', async () => {
111
- await saveAccountStore({
112
- version: 1,
113
- activeIndex: 0,
114
- accounts: [{ ...firstAccount, addedAt: 1, lastUsed: 1 }],
115
- });
116
- await mkdir(path.dirname(authFilePath()), { recursive: true });
117
- await writeFile(authFilePath(), JSON.stringify({ anthropic: firstAccount }, null, 2));
118
- await removeAccount(0);
119
- const store = await loadAccountStore();
120
- const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
121
- expect(store.accounts).toHaveLength(0);
122
- expect(authJson.anthropic).toBeUndefined();
62
+ }));
63
+ const payload = parseRewrittenBody(rewritten.body);
64
+ expect(payload.system).toMatchInlineSnapshot(`
65
+ [
66
+ {
67
+ "text": "You are Claude Code, Anthropic's official CLI for Claude.",
68
+ "type": "text",
69
+ },
70
+ {
71
+ "text": "<environment>
72
+ <cwd>/repo</cwd>
73
+ </environment>
74
+ Skills provide specialized instructions",
75
+ "type": "text",
76
+ },
77
+ ]
78
+ `);
123
79
  });
124
80
  });
125
- describe('shouldRotateAuth', () => {
126
- test('only rotates on rate limit or auth failures', () => {
127
- expect(shouldRotateAuth(429, '')).toBe(true);
128
- expect(shouldRotateAuth(401, 'permission_error')).toBe(true);
129
- expect(shouldRotateAuth(400, 'bad request')).toBe(false);
81
+ describe('replacer', () => {
82
+ test('sanitizes system text only for anthropic provider metadata', async () => {
83
+ const plugin = await replacer({});
84
+ const transform = plugin['experimental.chat.system.transform'];
85
+ if (!transform) {
86
+ throw new Error('Expected experimental.chat.system.transform hook');
87
+ }
88
+ const output = {
89
+ system: [
90
+ "You are OpenCode, the best coding agent on the planet.\nOS: macOS\nSkills provide specialized instructions\nUse opencode tools carefully.",
91
+ ],
92
+ };
93
+ await transform({
94
+ model: {
95
+ providerID: 'anthropic',
96
+ },
97
+ }, output);
98
+ expect(output.system).toMatchInlineSnapshot(`
99
+ [
100
+ "
101
+ <environment>
102
+ <cwd>/Users/morse/Documents/GitHub/kimakivoice/cli</cwd>
103
+ </environment>
104
+ Skills provide specialized instructions
105
+ Use openc0de tools carefully.",
106
+ ]
107
+ `);
130
108
  });
131
109
  });
@@ -1,84 +1,84 @@
1
1
  // Regression tests for CLI argument parsing around Discord ID string preservation.
2
2
  import { describe, expect, test } from 'vitest';
3
- import { goke } from 'goke';
4
- function createCliForIdParsing() {
5
- const cli = goke('kimaki');
6
- cli
7
- .command('send', 'Send a message')
8
- .option('-c, --channel <channelId>', 'Discord channel ID')
9
- .option('--thread <threadId>', 'Thread ID')
10
- .option('--session <sessionId>', 'Session ID')
11
- .option('--send-at <schedule>', 'Schedule');
12
- cli.command('session archive <threadId>', 'Archive a thread');
13
- cli
14
- .command('session search <query>', 'Search sessions')
15
- .option('--channel <channelId>', 'Discord channel ID')
16
- .option('--project <path>', 'Project path');
17
- cli
18
- .command('session export-events-jsonl', 'Export in-memory events to JSONL')
19
- .option('--session <sessionId>', 'Session ID')
20
- .option('--out <file>', 'Output path');
21
- cli
22
- .command('add-project', 'Add a project')
23
- .option('-g, --guild <guildId>', 'Discord guild/server ID');
24
- cli.command('task delete <id>', 'Delete task');
25
- cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
26
- cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account');
27
- return cli;
3
+ import { execAsync } from './exec-async.js';
4
+ async function parseWithGoke(argv) {
5
+ const script = [
6
+ "import { goke } from 'goke'",
7
+ 'const cli = goke(\'kimaki\')',
8
+ "cli.command('send', 'Send a message').option('-c, --channel <channelId>', 'Discord channel ID').option('--thread <threadId>', 'Thread ID').option('--session <sessionId>', 'Session ID').option('--send-at <schedule>', 'Schedule')",
9
+ "cli.command('session archive <threadId>', 'Archive a thread')",
10
+ "cli.command('session search <query>', 'Search sessions').option('--channel <channelId>', 'Discord channel ID').option('--project <path>', 'Project path')",
11
+ "cli.command('session export-events-jsonl', 'Export in-memory events to JSONL').option('--session <sessionId>', 'Session ID').option('--out <file>', 'Output path')",
12
+ "cli.command('add-project', 'Add a project').option('-g, --guild <guildId>', 'Discord guild/server ID')",
13
+ "cli.command('task delete <id>', 'Delete task')",
14
+ "cli.command('anthropic-accounts list', 'List stored Anthropic accounts')",
15
+ "cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account')",
16
+ `const result = cli.parse(${JSON.stringify(argv)}, { run: false })`,
17
+ 'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
18
+ ].join(';');
19
+ const { stdout } = await execAsync(`node --input-type=module -e ${JSON.stringify(script)}`, {
20
+ cwd: import.meta.dirname,
21
+ timeout: 10_000,
22
+ });
23
+ return JSON.parse(stdout);
24
+ }
25
+ async function getHelpOutput() {
26
+ const script = [
27
+ "import { goke } from 'goke'",
28
+ 'const stdout = { text: \'\', write(data) { this.text += String(data) } }',
29
+ "const cli = goke('kimaki', { stdout })",
30
+ "cli.command('send', 'Send a message')",
31
+ "cli.command('anthropic-accounts list', 'List stored Anthropic accounts')",
32
+ 'cli.help()',
33
+ "cli.parse(['node', 'kimaki', '--help'], { run: false })",
34
+ 'process.stdout.write(stdout.text)',
35
+ ].join(';');
36
+ const { stdout } = await execAsync(`node --input-type=module -e ${JSON.stringify(script)}`, {
37
+ cwd: import.meta.dirname,
38
+ timeout: 10_000,
39
+ });
40
+ return stdout;
28
41
  }
29
42
  describe('goke CLI ID parsing', () => {
30
- test('keeps large Discord IDs as strings', () => {
31
- const cli = createCliForIdParsing();
43
+ test('keeps large Discord IDs as strings', async () => {
32
44
  const channelId = '1234567890123456789';
33
45
  const threadId = '9876543210987654321';
34
46
  const sessionId = '1111222233334444555';
35
- const channelResult = cli.parse(['node', 'kimaki', 'send', '--channel', channelId], {
36
- run: false,
37
- });
47
+ const channelResult = await parseWithGoke(['node', 'kimaki', 'send', '--channel', channelId]);
38
48
  expect(channelResult.options.channel).toBe(channelId);
39
49
  expect(typeof channelResult.options.channel).toBe('string');
40
- const threadResult = cli.parse(['node', 'kimaki', 'send', '--thread', threadId], { run: false });
50
+ const threadResult = await parseWithGoke(['node', 'kimaki', 'send', '--thread', threadId]);
41
51
  expect(threadResult.options.thread).toBe(threadId);
42
52
  expect(typeof threadResult.options.thread).toBe('string');
43
- const sessionResult = cli.parse(['node', 'kimaki', 'send', '--session', sessionId], {
44
- run: false,
45
- });
53
+ const sessionResult = await parseWithGoke(['node', 'kimaki', 'send', '--session', sessionId]);
46
54
  expect(sessionResult.options.session).toBe(sessionId);
47
55
  expect(typeof sessionResult.options.session).toBe('string');
48
56
  });
49
- test('preserves leading zeros in Discord IDs', () => {
50
- const cli = createCliForIdParsing();
57
+ test('preserves leading zeros in Discord IDs', async () => {
51
58
  const guildId = '001230045600789';
52
- const result = cli.parse(['node', 'kimaki', 'add-project', '--guild', guildId], { run: false });
59
+ const result = await parseWithGoke(['node', 'kimaki', 'add-project', '--guild', guildId]);
53
60
  expect(result.options.guild).toBe(guildId);
54
61
  expect(typeof result.options.guild).toBe('string');
55
62
  });
56
- test('keeps session archive thread ID as string', () => {
57
- const cli = createCliForIdParsing();
63
+ test('keeps session archive thread ID as string', async () => {
58
64
  const threadId = '0098765432109876543';
59
- const result = cli.parse(['node', 'kimaki', 'session', 'archive', threadId], {
60
- run: false,
61
- });
65
+ const result = await parseWithGoke(['node', 'kimaki', 'session', 'archive', threadId]);
62
66
  expect(result.args[0]).toBe(threadId);
63
67
  expect(typeof result.args[0]).toBe('string');
64
68
  });
65
- test('keeps session search regex and channel ID as strings', () => {
66
- const cli = createCliForIdParsing();
69
+ test('keeps session search regex and channel ID as strings', async () => {
67
70
  const channelId = '0012345678901234567';
68
71
  const query = '/error\\s+42/i';
69
- const result = cli.parse(['node', 'kimaki', 'session', 'search', query, '--channel', channelId], {
70
- run: false,
71
- });
72
+ const result = await parseWithGoke(['node', 'kimaki', 'session', 'search', query, '--channel', channelId]);
72
73
  expect(result.args[0]).toBe(query);
73
74
  expect(typeof result.args[0]).toBe('string');
74
75
  expect(result.options.channel).toBe(channelId);
75
76
  expect(typeof result.options.channel).toBe('string');
76
77
  });
77
- test('keeps session export options as strings', () => {
78
- const cli = createCliForIdParsing();
78
+ test('keeps session export options as strings', async () => {
79
79
  const sessionId = '001111222233334444';
80
80
  const outPath = './tmp/session-events.jsonl';
81
- const result = cli.parse([
81
+ const result = await parseWithGoke([
82
82
  'node',
83
83
  'kimaki',
84
84
  'session',
@@ -87,54 +87,35 @@ describe('goke CLI ID parsing', () => {
87
87
  sessionId,
88
88
  '--out',
89
89
  outPath,
90
- ], {
91
- run: false,
92
- });
90
+ ]);
93
91
  expect(result.options.session).toBe(sessionId);
94
92
  expect(typeof result.options.session).toBe('string');
95
93
  expect(result.options.out).toBe(outPath);
96
94
  expect(typeof result.options.out).toBe('string');
97
95
  });
98
- test('keeps --send-at cron string intact', () => {
99
- const cli = createCliForIdParsing();
96
+ test('keeps --send-at cron string intact', async () => {
100
97
  const cron = '0 9 * * 1';
101
- const result = cli.parse(['node', 'kimaki', 'send', '--send-at', cron], {
102
- run: false,
103
- });
98
+ const result = await parseWithGoke(['node', 'kimaki', 'send', '--send-at', cron]);
104
99
  expect(result.options.sendAt).toBe(cron);
105
100
  expect(typeof result.options.sendAt).toBe('string');
106
101
  });
107
- test('keeps task delete ID as string before validation', () => {
108
- const cli = createCliForIdParsing();
102
+ test('keeps task delete ID as string before validation', async () => {
109
103
  const taskId = '0012345';
110
- const result = cli.parse(['node', 'kimaki', 'task', 'delete', taskId], {
111
- run: false,
112
- });
104
+ const result = await parseWithGoke(['node', 'kimaki', 'task', 'delete', taskId]);
113
105
  expect(result.args[0]).toBe(taskId);
114
106
  expect(typeof result.args[0]).toBe('string');
115
107
  });
116
- test('anthropic account remove parses index and email as strings', () => {
117
- const cli = createCliForIdParsing();
118
- const indexResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', '2'], { run: false });
119
- const emailResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'], { run: false });
108
+ test('anthropic account remove parses index and email as strings', async () => {
109
+ const indexResult = await parseWithGoke(['node', 'kimaki', 'anthropic-accounts', 'remove', '2']);
110
+ const emailResult = await parseWithGoke(['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com']);
120
111
  expect(indexResult.args[0]).toBe('2');
121
112
  expect(typeof indexResult.args[0]).toBe('string');
122
113
  expect(emailResult.args[0]).toBe('user@example.com');
123
114
  expect(typeof emailResult.args[0]).toBe('string');
124
115
  });
125
- test('anthropic account commands are included in help output', () => {
126
- const stdout = {
127
- text: '',
128
- write(data) {
129
- this.text += String(data);
130
- },
131
- };
132
- const cli = goke('kimaki', { stdout: stdout });
133
- cli.command('send', 'Send a message');
134
- cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
135
- cli.help();
136
- cli.parse(['node', 'kimaki', '--help'], { run: false });
137
- expect(stdout.text).toContain('send');
138
- expect(stdout.text).toContain('anthropic-accounts');
116
+ test('anthropic account commands are included in help output', async () => {
117
+ const stdout = await getHelpOutput();
118
+ expect(stdout).toContain('send');
119
+ expect(stdout).toContain('anthropic-accounts');
139
120
  });
140
121
  });
@@ -182,7 +182,7 @@ describe('kimaki send --channel thread creation', () => {
182
182
  if (warmup instanceof Error) {
183
183
  throw warmup;
184
184
  }
185
- }, 60_000);
185
+ }, 20_000);
186
186
  afterAll(async () => {
187
187
  if (directories) {
188
188
  await cleanupTestSessions({
@@ -213,7 +213,7 @@ describe('kimaki send --channel thread creation', () => {
213
213
  if (directories) {
214
214
  fs.rmSync(directories.dataDir, { recursive: true, force: true });
215
215
  }
216
- }, 10_000);
216
+ }, 5_000);
217
217
  test('kimaki send --prompt "/hello-test-cmd" falls through as text when registeredUserCommands is empty (repro #97)', async () => {
218
218
  // Reproduce GitHub #97: when registeredUserCommands is empty (gateway mode
219
219
  // startup race, or backgroundInit not complete), the prompt "/hello-test-cmd"