kimaki 0.4.90 → 0.4.91

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 (114) hide show
  1. package/dist/agent-model.e2e.test.js +80 -2
  2. package/dist/anthropic-auth-plugin.js +246 -195
  3. package/dist/anthropic-auth-plugin.test.js +125 -0
  4. package/dist/anthropic-auth-state.js +231 -0
  5. package/dist/bin.js +6 -3
  6. package/dist/cli-parsing.test.js +23 -0
  7. package/dist/cli-send-thread.e2e.test.js +2 -2
  8. package/dist/cli.js +72 -46
  9. package/dist/commands/merge-worktree.js +6 -3
  10. package/dist/commands/new-worktree.js +18 -7
  11. package/dist/commands/worktrees.js +71 -7
  12. package/dist/context-awareness-plugin.js +52 -50
  13. package/dist/context-awareness-plugin.test.js +68 -1
  14. package/dist/discord-bot.js +126 -54
  15. package/dist/discord-utils.test.js +19 -0
  16. package/dist/errors.js +0 -5
  17. package/dist/exec-async.js +26 -0
  18. package/dist/external-opencode-sync.js +33 -72
  19. package/dist/forum-sync/config.js +2 -2
  20. package/dist/forum-sync/markdown.js +4 -8
  21. package/dist/hrana-server.js +11 -3
  22. package/dist/image-optimizer-plugin.js +153 -0
  23. package/dist/ipc-tools-plugin.js +11 -4
  24. package/dist/kimaki-opencode-plugin.js +1 -0
  25. package/dist/logger.js +0 -1
  26. package/dist/markdown.js +2 -2
  27. package/dist/message-preprocessing.js +100 -16
  28. package/dist/onboarding-tutorial.js +1 -1
  29. package/dist/opencode-command-detection.js +70 -0
  30. package/dist/opencode-command-detection.test.js +210 -0
  31. package/dist/opencode-interrupt-plugin.js +64 -8
  32. package/dist/opencode-interrupt-plugin.test.js +23 -39
  33. package/dist/opencode.js +16 -20
  34. package/dist/pkce.js +23 -0
  35. package/dist/plugin-logger.js +59 -0
  36. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  37. package/dist/queue-advanced-question.e2e.test.js +127 -42
  38. package/dist/sentry.js +7 -114
  39. package/dist/session-handler/event-stream-state.js +1 -1
  40. package/dist/session-handler/thread-runtime-state.js +9 -0
  41. package/dist/session-handler/thread-session-runtime.js +197 -45
  42. package/dist/session-title-rename.test.js +80 -0
  43. package/dist/store.js +1 -2
  44. package/dist/system-message.js +105 -49
  45. package/dist/system-message.test.js +598 -15
  46. package/dist/task-runner.js +7 -4
  47. package/dist/task-schedule.js +2 -0
  48. package/dist/thread-message-queue.e2e.test.js +18 -11
  49. package/dist/unnest-code-blocks.js +11 -1
  50. package/dist/unnest-code-blocks.test.js +32 -0
  51. package/dist/voice-handler.js +15 -5
  52. package/dist/voice.js +53 -23
  53. package/dist/voice.test.js +2 -0
  54. package/dist/worktrees.js +111 -120
  55. package/package.json +15 -19
  56. package/skills/lintcn/SKILL.md +6 -1
  57. package/skills/new-skill/SKILL.md +211 -0
  58. package/skills/npm-package/SKILL.md +3 -2
  59. package/skills/spiceflow/SKILL.md +1 -1
  60. package/skills/usecomputer/SKILL.md +174 -249
  61. package/src/agent-model.e2e.test.ts +95 -2
  62. package/src/anthropic-auth-plugin.test.ts +159 -0
  63. package/src/anthropic-auth-plugin.ts +474 -403
  64. package/src/anthropic-auth-state.ts +282 -0
  65. package/src/bin.ts +6 -3
  66. package/src/cli-parsing.test.ts +32 -0
  67. package/src/cli-send-thread.e2e.test.ts +2 -2
  68. package/src/cli.ts +93 -62
  69. package/src/commands/merge-worktree.ts +8 -3
  70. package/src/commands/new-worktree.ts +22 -10
  71. package/src/commands/worktrees.ts +86 -5
  72. package/src/context-awareness-plugin.test.ts +77 -1
  73. package/src/context-awareness-plugin.ts +85 -64
  74. package/src/discord-bot.ts +135 -56
  75. package/src/discord-utils.test.ts +21 -0
  76. package/src/errors.ts +0 -6
  77. package/src/exec-async.ts +35 -0
  78. package/src/external-opencode-sync.ts +39 -85
  79. package/src/forum-sync/config.ts +2 -2
  80. package/src/forum-sync/markdown.ts +5 -9
  81. package/src/hrana-server.ts +15 -3
  82. package/src/image-optimizer-plugin.ts +194 -0
  83. package/src/ipc-tools-plugin.ts +16 -8
  84. package/src/kimaki-opencode-plugin.ts +1 -0
  85. package/src/logger.ts +0 -1
  86. package/src/markdown.ts +2 -2
  87. package/src/message-preprocessing.ts +117 -16
  88. package/src/onboarding-tutorial.ts +1 -1
  89. package/src/opencode-command-detection.test.ts +268 -0
  90. package/src/opencode-command-detection.ts +79 -0
  91. package/src/opencode-interrupt-plugin.test.ts +93 -50
  92. package/src/opencode-interrupt-plugin.ts +86 -9
  93. package/src/opencode.ts +16 -22
  94. package/src/plugin-logger.ts +68 -0
  95. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  96. package/src/queue-advanced-question.e2e.test.ts +243 -158
  97. package/src/sentry.ts +7 -120
  98. package/src/session-handler/event-stream-state.ts +1 -1
  99. package/src/session-handler/thread-runtime-state.ts +17 -0
  100. package/src/session-handler/thread-session-runtime.ts +232 -46
  101. package/src/session-title-rename.test.ts +112 -0
  102. package/src/store.ts +3 -8
  103. package/src/system-message.test.ts +612 -0
  104. package/src/system-message.ts +136 -63
  105. package/src/task-runner.ts +7 -4
  106. package/src/task-schedule.ts +3 -0
  107. package/src/thread-message-queue.e2e.test.ts +22 -11
  108. package/src/undici.d.ts +12 -0
  109. package/src/unnest-code-blocks.test.ts +34 -0
  110. package/src/unnest-code-blocks.ts +18 -1
  111. package/src/voice-handler.ts +18 -4
  112. package/src/voice.test.ts +2 -0
  113. package/src/voice.ts +68 -23
  114. package/src/worktrees.ts +152 -156
@@ -0,0 +1,125 @@
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;
32
+ }
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
+ });
47
+ });
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);
64
+ },
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({ refresh: 'refresh-second' });
71
+ expect(store.activeIndex).toBe(1);
72
+ expect(authJson.anthropic?.refresh).toBe('refresh-second');
73
+ expect(authSetCalls).toEqual([
74
+ {
75
+ path: { id: 'anthropic' },
76
+ body: {
77
+ type: 'oauth',
78
+ refresh: 'refresh-second',
79
+ access: 'access-second',
80
+ expires: 2,
81
+ },
82
+ },
83
+ ]);
84
+ });
85
+ });
86
+ describe('removeAccount', () => {
87
+ test('removing the active account promotes the next stored account', async () => {
88
+ await saveAccountStore({
89
+ version: 1,
90
+ activeIndex: 1,
91
+ accounts: [
92
+ { ...firstAccount, addedAt: 1, lastUsed: 1 },
93
+ { ...secondAccount, addedAt: 2, lastUsed: 2 },
94
+ ],
95
+ });
96
+ await removeAccount(1);
97
+ const store = await loadAccountStore();
98
+ const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
99
+ expect(store.activeIndex).toBe(0);
100
+ expect(store.accounts).toHaveLength(1);
101
+ expect(store.accounts[0]?.refresh).toBe('refresh-first');
102
+ expect(authJson.anthropic?.refresh).toBe('refresh-first');
103
+ });
104
+ test('removing the last account clears active Anthropic auth', async () => {
105
+ await saveAccountStore({
106
+ version: 1,
107
+ activeIndex: 0,
108
+ accounts: [{ ...firstAccount, addedAt: 1, lastUsed: 1 }],
109
+ });
110
+ await mkdir(path.dirname(authFilePath()), { recursive: true });
111
+ await writeFile(authFilePath(), JSON.stringify({ anthropic: firstAccount }, null, 2));
112
+ await removeAccount(0);
113
+ const store = await loadAccountStore();
114
+ const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
115
+ expect(store.accounts).toHaveLength(0);
116
+ expect(authJson.anthropic).toBeUndefined();
117
+ });
118
+ });
119
+ describe('shouldRotateAuth', () => {
120
+ test('only rotates on rate limit or auth failures', () => {
121
+ expect(shouldRotateAuth(429, '')).toBe(true);
122
+ expect(shouldRotateAuth(401, 'permission_error')).toBe(true);
123
+ expect(shouldRotateAuth(400, 'bad request')).toBe(false);
124
+ });
125
+ });
@@ -0,0 +1,231 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ const AUTH_LOCK_STALE_MS = 30_000;
5
+ const AUTH_LOCK_RETRY_MS = 100;
6
+ async function readJson(filePath, fallback) {
7
+ try {
8
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
9
+ }
10
+ catch {
11
+ return fallback;
12
+ }
13
+ }
14
+ async function writeJson(filePath, value) {
15
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
16
+ await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
17
+ await fs.chmod(filePath, 0o600);
18
+ }
19
+ function getErrorCode(error) {
20
+ if (!(error instanceof Error))
21
+ return undefined;
22
+ return error.code;
23
+ }
24
+ async function sleep(ms) {
25
+ await new Promise((resolve) => {
26
+ setTimeout(resolve, ms);
27
+ });
28
+ }
29
+ export function authFilePath() {
30
+ if (process.env.XDG_DATA_HOME) {
31
+ return path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json');
32
+ }
33
+ return path.join(homedir(), '.local', 'share', 'opencode', 'auth.json');
34
+ }
35
+ export function accountsFilePath() {
36
+ if (process.env.XDG_DATA_HOME) {
37
+ return path.join(process.env.XDG_DATA_HOME, 'opencode', 'anthropic-oauth-accounts.json');
38
+ }
39
+ return path.join(homedir(), '.local', 'share', 'opencode', 'anthropic-oauth-accounts.json');
40
+ }
41
+ export async function withAuthStateLock(fn) {
42
+ const file = authFilePath();
43
+ const lockDir = `${file}.lock`;
44
+ const deadline = Date.now() + AUTH_LOCK_STALE_MS;
45
+ await fs.mkdir(path.dirname(file), { recursive: true });
46
+ while (true) {
47
+ try {
48
+ await fs.mkdir(lockDir);
49
+ break;
50
+ }
51
+ catch (error) {
52
+ const code = getErrorCode(error);
53
+ if (code !== 'EEXIST') {
54
+ throw error;
55
+ }
56
+ const stats = await fs.stat(lockDir).catch(() => {
57
+ return null;
58
+ });
59
+ if (stats && Date.now() - stats.mtimeMs > AUTH_LOCK_STALE_MS) {
60
+ await fs.rm(lockDir, { force: true, recursive: true }).catch(() => { });
61
+ continue;
62
+ }
63
+ if (Date.now() >= deadline) {
64
+ throw new Error(`Timed out waiting for auth lock: ${lockDir}`);
65
+ }
66
+ await sleep(AUTH_LOCK_RETRY_MS);
67
+ }
68
+ }
69
+ try {
70
+ return await fn();
71
+ }
72
+ finally {
73
+ await fs.rm(lockDir, { force: true, recursive: true }).catch(() => { });
74
+ }
75
+ }
76
+ export function normalizeAccountStore(input) {
77
+ const accounts = Array.isArray(input?.accounts)
78
+ ? input.accounts.filter((account) => !!account &&
79
+ account.type === 'oauth' &&
80
+ typeof account.refresh === 'string' &&
81
+ typeof account.access === 'string' &&
82
+ typeof account.expires === 'number' &&
83
+ typeof account.addedAt === 'number' &&
84
+ typeof account.lastUsed === 'number')
85
+ : [];
86
+ const rawIndex = typeof input?.activeIndex === 'number' ? Math.floor(input.activeIndex) : 0;
87
+ const activeIndex = accounts.length === 0 ? 0 : ((rawIndex % accounts.length) + accounts.length) % accounts.length;
88
+ return { version: 1, activeIndex, accounts };
89
+ }
90
+ export async function loadAccountStore() {
91
+ const raw = await readJson(accountsFilePath(), null);
92
+ return normalizeAccountStore(raw);
93
+ }
94
+ export async function saveAccountStore(store) {
95
+ await writeJson(accountsFilePath(), normalizeAccountStore(store));
96
+ }
97
+ function findCurrentAccountIndex(store, auth) {
98
+ if (!store.accounts.length)
99
+ return 0;
100
+ const byRefresh = store.accounts.findIndex((account) => {
101
+ return account.refresh === auth.refresh;
102
+ });
103
+ if (byRefresh >= 0)
104
+ return byRefresh;
105
+ const byAccess = store.accounts.findIndex((account) => {
106
+ return account.access === auth.access;
107
+ });
108
+ if (byAccess >= 0)
109
+ return byAccess;
110
+ return store.activeIndex;
111
+ }
112
+ export function upsertAccount(store, auth, now = Date.now()) {
113
+ const index = store.accounts.findIndex((account) => {
114
+ return account.refresh === auth.refresh || account.access === auth.access;
115
+ });
116
+ const nextAccount = {
117
+ type: 'oauth',
118
+ refresh: auth.refresh,
119
+ access: auth.access,
120
+ expires: auth.expires,
121
+ addedAt: now,
122
+ lastUsed: now,
123
+ };
124
+ if (index < 0) {
125
+ store.accounts.push(nextAccount);
126
+ store.activeIndex = store.accounts.length - 1;
127
+ return store.activeIndex;
128
+ }
129
+ const existing = store.accounts[index];
130
+ if (!existing)
131
+ return index;
132
+ store.accounts[index] = {
133
+ ...existing,
134
+ ...nextAccount,
135
+ addedAt: existing.addedAt,
136
+ };
137
+ store.activeIndex = index;
138
+ return index;
139
+ }
140
+ export async function rememberAnthropicOAuth(auth) {
141
+ await withAuthStateLock(async () => {
142
+ const store = await loadAccountStore();
143
+ upsertAccount(store, auth);
144
+ await saveAccountStore(store);
145
+ });
146
+ }
147
+ async function writeAnthropicAuthFile(auth) {
148
+ const file = authFilePath();
149
+ const data = await readJson(file, {});
150
+ if (auth) {
151
+ data.anthropic = auth;
152
+ }
153
+ else {
154
+ delete data.anthropic;
155
+ }
156
+ await writeJson(file, data);
157
+ }
158
+ export async function setAnthropicAuth(auth, client) {
159
+ await writeAnthropicAuthFile(auth);
160
+ await client.auth.set({ path: { id: 'anthropic' }, body: auth });
161
+ }
162
+ export async function rotateAnthropicAccount(auth, client) {
163
+ return withAuthStateLock(async () => {
164
+ const store = await loadAccountStore();
165
+ if (store.accounts.length < 2)
166
+ return undefined;
167
+ const currentIndex = findCurrentAccountIndex(store, auth);
168
+ const nextIndex = (currentIndex + 1) % store.accounts.length;
169
+ const nextAccount = store.accounts[nextIndex];
170
+ if (!nextAccount)
171
+ return undefined;
172
+ nextAccount.lastUsed = Date.now();
173
+ store.activeIndex = nextIndex;
174
+ await saveAccountStore(store);
175
+ const nextAuth = {
176
+ type: 'oauth',
177
+ refresh: nextAccount.refresh,
178
+ access: nextAccount.access,
179
+ expires: nextAccount.expires,
180
+ };
181
+ await setAnthropicAuth(nextAuth, client);
182
+ return nextAuth;
183
+ });
184
+ }
185
+ export async function removeAccount(index) {
186
+ return withAuthStateLock(async () => {
187
+ const store = await loadAccountStore();
188
+ if (!Number.isInteger(index) || index < 0 || index >= store.accounts.length) {
189
+ throw new Error(`Account ${index + 1} does not exist`);
190
+ }
191
+ store.accounts.splice(index, 1);
192
+ if (store.accounts.length === 0) {
193
+ store.activeIndex = 0;
194
+ await saveAccountStore(store);
195
+ await writeAnthropicAuthFile(undefined);
196
+ return { store, active: undefined };
197
+ }
198
+ if (store.activeIndex > index) {
199
+ store.activeIndex -= 1;
200
+ }
201
+ else if (store.activeIndex >= store.accounts.length) {
202
+ store.activeIndex = 0;
203
+ }
204
+ const active = store.accounts[store.activeIndex];
205
+ if (!active)
206
+ throw new Error('Active Anthropic account disappeared during removal');
207
+ active.lastUsed = Date.now();
208
+ await saveAccountStore(store);
209
+ const nextAuth = {
210
+ type: 'oauth',
211
+ refresh: active.refresh,
212
+ access: active.access,
213
+ expires: active.expires,
214
+ };
215
+ await writeAnthropicAuthFile(nextAuth);
216
+ return { store, active: nextAuth };
217
+ });
218
+ }
219
+ export function shouldRotateAuth(status, bodyText) {
220
+ const haystack = bodyText.toLowerCase();
221
+ if (status === 429)
222
+ return true;
223
+ if (status === 401 || status === 403)
224
+ return true;
225
+ return (haystack.includes('rate_limit') ||
226
+ haystack.includes('rate limit') ||
227
+ haystack.includes('invalid api key') ||
228
+ haystack.includes('authentication_error') ||
229
+ haystack.includes('permission_error') ||
230
+ haystack.includes('oauth'));
231
+ }
package/dist/bin.js CHANGED
@@ -24,11 +24,13 @@ const HEAP_SNAPSHOT_DIR = path.join(os.homedir(), '.kimaki', 'heap-snapshots');
24
24
  // If it doesn't start with '-', it's a subcommand (e.g. "send", "tunnel", "project").
25
25
  const firstArg = process.argv[2];
26
26
  const isSubcommand = firstArg && !firstArg.startsWith('-');
27
- const hasAutoRestart = process.argv.includes('--auto-restart');
28
- if (process.env.__KIMAKI_CHILD || isSubcommand || !hasAutoRestart) {
27
+ const isHelpFlag = process.argv.includes('--help');
28
+ if (process.env.__KIMAKI_CHILD || isSubcommand || isHelpFlag) {
29
29
  await import('./cli.js');
30
30
  }
31
31
  else {
32
+ console.error('no subcommand detected. kimaki will automatically restart on crash');
33
+ console.error();
32
34
  const EXIT_NO_RESTART = 64;
33
35
  const MAX_RAPID_RESTARTS = 5;
34
36
  const RAPID_RESTART_WINDOW_MS = 60_000;
@@ -45,7 +47,8 @@ else {
45
47
  `--heapsnapshot-near-heap-limit=3`,
46
48
  `--diagnostic-dir=${HEAP_SNAPSHOT_DIR}`,
47
49
  ];
48
- child = spawn(process.argv[0], [...heapArgs, ...process.execArgv, ...process.argv.slice(1)], {
50
+ const args = [...heapArgs, ...process.execArgv, ...process.argv.slice(1)];
51
+ child = spawn(process.argv[0], args, {
49
52
  stdio: 'inherit',
50
53
  env: { ...process.env, __KIMAKI_CHILD: '1' },
51
54
  });
@@ -22,6 +22,8 @@ function createCliForIdParsing() {
22
22
  .command('add-project', 'Add a project')
23
23
  .option('-g, --guild <guildId>', 'Discord guild/server ID');
24
24
  cli.command('task delete <id>', 'Delete task');
25
+ cli.command('anthropic-accounts list', 'List stored Anthropic accounts').hidden();
26
+ cli.command('anthropic-accounts remove <index>', 'Remove stored Anthropic account').hidden();
25
27
  return cli;
26
28
  }
27
29
  describe('goke CLI ID parsing', () => {
@@ -111,4 +113,25 @@ describe('goke CLI ID parsing', () => {
111
113
  expect(result.args[0]).toBe(taskId);
112
114
  expect(typeof result.args[0]).toBe('string');
113
115
  });
116
+ test('hidden anthropic account commands still parse', () => {
117
+ const cli = createCliForIdParsing();
118
+ const result = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', '2'], { run: false });
119
+ expect(result.args[0]).toBe('2');
120
+ expect(typeof result.args[0]).toBe('string');
121
+ });
122
+ test('hidden anthropic account commands are excluded from help output', () => {
123
+ const stdout = {
124
+ text: '',
125
+ write(data) {
126
+ this.text += String(data);
127
+ },
128
+ };
129
+ const cli = goke('kimaki', { stdout: stdout });
130
+ cli.command('send', 'Send a message');
131
+ cli.command('anthropic-accounts list', 'List stored Anthropic accounts').hidden();
132
+ cli.help();
133
+ cli.parse(['node', 'kimaki', '--help'], { run: false });
134
+ expect(stdout.text).toContain('send');
135
+ expect(stdout.text).not.toContain('anthropic-accounts');
136
+ });
114
137
  });
@@ -24,7 +24,7 @@ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChann
24
24
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
25
25
  import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
26
26
  import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
27
- import yaml from 'js-yaml';
27
+ import YAML from 'yaml';
28
28
  const TEST_USER_ID = '200000000000000830';
29
29
  const TEXT_CHANNEL_ID = '200000000000000831';
30
30
  const BOT_USER_ID = '200000000000000832';
@@ -212,7 +212,7 @@ describe('kimaki send --channel thread creation', () => {
212
212
  body: {
213
213
  content: prompt,
214
214
  embeds: [
215
- { color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } },
215
+ { color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } },
216
216
  ],
217
217
  },
218
218
  }));
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
15
15
  import { sendWelcomeMessage } from './onboarding-welcome.js';
16
16
  import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
17
17
  import { selectResolvedCommand } from './opencode-command.js';
18
- import yaml from 'js-yaml';
18
+ import YAML from 'yaml';
19
19
  import { Events, ChannelType, ActivityType, Routes, AttachmentBuilder, } from 'discord.js';
20
20
  import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js';
21
21
  import crypto from 'node:crypto';
@@ -27,11 +27,12 @@ import { initSentry, notifyError } from './sentry.js';
27
27
  import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
28
28
  import { spawn, execSync } from 'node:child_process';
29
29
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir, } from './config.js';
30
- import { execAsync } from './worktrees.js';
30
+ import { execAsync, validateWorktreeDirectory } from './worktrees.js';
31
31
  import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.js';
32
32
  import { startHranaServer } from './hrana-server.js';
33
33
  import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
34
34
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
35
+ import { accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
35
36
  const cliLogger = createLogger(LogPrefix.CLI);
36
37
  // Gateway bot mode constants.
37
38
  // KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
@@ -438,33 +439,8 @@ const cli = goke('kimaki');
438
439
  process.title = 'kimaki';
439
440
  import { store } from './store.js';
440
441
  import { registerCommands, SKIP_USER_COMMANDS } from './discord-command-registration.js';
441
- async function reconcileKimakiRole({ guild }) {
442
- try {
443
- const roles = await guild.roles.fetch();
444
- const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki');
445
- if (existingRole) {
446
- if (existingRole.position > 1) {
447
- await existingRole.setPosition(1);
448
- cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`);
449
- }
450
- return;
451
- }
452
- await guild.roles.create({
453
- name: 'Kimaki',
454
- position: 1,
455
- reason: 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
456
- });
457
- cliLogger.info(`Created "Kimaki" role in ${guild.name}`);
458
- }
459
- catch (error) {
460
- cliLogger.warn(`Could not reconcile Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
461
- }
462
- }
463
- async function collectKimakiChannels({ guilds, reconcileRoles, }) {
442
+ async function collectKimakiChannels({ guilds, }) {
464
443
  const guildResults = await Promise.all(guilds.map(async (guild) => {
465
- if (reconcileRoles) {
466
- void reconcileKimakiRole({ guild });
467
- }
468
444
  const channels = await getChannelsWithDescriptions(guild);
469
445
  const kimakiChans = channels.filter((ch) => ch.kimakiDirectory);
470
446
  return { guild, channels: kimakiChans };
@@ -1023,10 +999,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1023
999
  return;
1024
1000
  }
1025
1001
  // Process guild metadata when setup flow needs channel prompts.
1026
- const guildResults = await collectKimakiChannels({
1027
- guilds,
1028
- reconcileRoles: true,
1029
- });
1002
+ const guildResults = await collectKimakiChannels({ guilds });
1030
1003
  // Collect results
1031
1004
  for (const result of guildResults) {
1032
1005
  kimakiChannels.push(result);
@@ -1087,10 +1060,7 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
1087
1060
  // Never blocks ready state.
1088
1061
  void (async () => {
1089
1062
  try {
1090
- const backgroundChannels = await collectKimakiChannels({
1091
- guilds,
1092
- reconcileRoles: true,
1093
- });
1063
+ const backgroundChannels = await collectKimakiChannels({ guilds });
1094
1064
  await storeChannelDirectories({ kimakiChannels: backgroundChannels });
1095
1065
  cliLogger.log(`Background channel sync completed for ${backgroundChannels.length} guild(s)`);
1096
1066
  }
@@ -1313,7 +1283,6 @@ cli
1313
1283
  .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
1314
1284
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
1315
1285
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
1316
- .option('--verbose-opencode-server', 'Forward OpenCode server stdout/stderr to kimaki.log')
1317
1286
  .option('--no-sentry', 'Disable Sentry error reporting')
1318
1287
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
1319
1288
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
@@ -1357,7 +1326,6 @@ cli
1357
1326
  }),
1358
1327
  ...(options.mentionMode && { defaultMentionMode: true }),
1359
1328
  ...(options.noCritique && { critiqueEnabled: false }),
1360
- ...(options.verboseOpencodeServer && { verboseOpencodeServer: true }),
1361
1329
  });
1362
1330
  if (options.verbosity) {
1363
1331
  cliLogger.log(`Default verbosity: ${options.verbosity}`);
@@ -1368,9 +1336,6 @@ cli
1368
1336
  if (options.noCritique) {
1369
1337
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
1370
1338
  }
1371
- if (options.verboseOpencodeServer) {
1372
- cliLogger.log('Verbose OpenCode server: stdout/stderr will be forwarded to kimaki.log');
1373
- }
1374
1339
  if (options.noSentry) {
1375
1340
  process.env.KIMAKI_SENTRY_DISABLED = '1';
1376
1341
  cliLogger.log('Sentry error reporting disabled (--no-sentry)');
@@ -1657,6 +1622,7 @@ cli
1657
1622
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
1658
1623
  .option('--notify-only', 'Create notification thread without starting AI session')
1659
1624
  .option('--worktree [name]', 'Create git worktree for session (name optional, derives from thread name)')
1625
+ .option('--cwd <path>', 'Start session in an existing git worktree directory instead of the main project directory')
1660
1626
  .option('-u, --user <username>', 'Discord username to add to thread')
1661
1627
  .option('--agent <agent>', 'Agent to use for the session')
1662
1628
  .option('--model <model>', 'Model to use (format: provider/model)')
@@ -1723,6 +1689,14 @@ cli
1723
1689
  cliLogger.error('Cannot use --worktree with --notify-only');
1724
1690
  process.exit(EXIT_NO_RESTART);
1725
1691
  }
1692
+ if (options.cwd && options.worktree) {
1693
+ cliLogger.error('Cannot use --cwd with --worktree');
1694
+ process.exit(EXIT_NO_RESTART);
1695
+ }
1696
+ if (options.cwd && notifyOnly) {
1697
+ cliLogger.error('Cannot use --cwd with --notify-only');
1698
+ process.exit(EXIT_NO_RESTART);
1699
+ }
1726
1700
  if (options.wait && notifyOnly) {
1727
1701
  cliLogger.error('Cannot use --wait with --notify-only');
1728
1702
  process.exit(EXIT_NO_RESTART);
@@ -1735,6 +1709,9 @@ cli
1735
1709
  if (options.worktree) {
1736
1710
  incompatibleFlags.push('--worktree');
1737
1711
  }
1712
+ if (options.cwd) {
1713
+ incompatibleFlags.push('--cwd');
1714
+ }
1738
1715
  if (name) {
1739
1716
  incompatibleFlags.push('--name');
1740
1717
  }
@@ -1922,11 +1899,13 @@ cli
1922
1899
  const promptEmbed = [
1923
1900
  {
1924
1901
  color: 0x2b2d31,
1925
- footer: { text: yaml.dump(threadPromptMarker) },
1902
+ footer: { text: YAML.stringify(threadPromptMarker) },
1926
1903
  },
1927
1904
  ];
1928
- // Prefix the prompt so it's clear who sent it (matches /queue format)
1929
- const prefixedPrompt = **kimaki-cli:** ${prompt}`;
1905
+ // Prefix the prompt so it's clear who sent it (matches /queue format).
1906
+ // Use a newline between prefix and prompt so leading /command
1907
+ // detection can find the command on its own line.
1908
+ const prefixedPrompt = `» **kimaki-cli:**\n${prompt}`;
1930
1909
  await sendDiscordMessageWithOptionalAttachment({
1931
1910
  channelId: targetThreadId,
1932
1911
  prompt: prefixedPrompt,
@@ -1958,6 +1937,19 @@ cli
1958
1937
  throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
1959
1938
  }
1960
1939
  const projectDirectory = channelConfig.directory;
1940
+ // Validate --cwd is an existing git worktree of the project
1941
+ let resolvedCwd;
1942
+ if (options.cwd) {
1943
+ const cwdResult = await validateWorktreeDirectory({
1944
+ projectDirectory,
1945
+ candidatePath: options.cwd,
1946
+ });
1947
+ if (cwdResult instanceof Error) {
1948
+ cliLogger.error(cwdResult.message);
1949
+ process.exit(EXIT_NO_RESTART);
1950
+ }
1951
+ resolvedCwd = cwdResult;
1952
+ }
1961
1953
  // Resolve username to user ID if provided
1962
1954
  const resolvedUser = await (async () => {
1963
1955
  if (!options.user) {
@@ -2004,6 +1996,7 @@ cli
2004
1996
  name: name || null,
2005
1997
  notifyOnly: Boolean(notifyOnly),
2006
1998
  worktreeName: worktreeName || null,
1999
+ cwd: resolvedCwd || null,
2007
2000
  agent: options.agent || null,
2008
2001
  model: options.model || null,
2009
2002
  username: resolvedUser?.username || null,
@@ -2034,6 +2027,7 @@ cli
2034
2027
  : {
2035
2028
  start: true,
2036
2029
  ...(worktreeName && { worktree: worktreeName }),
2030
+ ...(resolvedCwd && { cwd: resolvedCwd }),
2037
2031
  ...(resolvedUser && {
2038
2032
  username: resolvedUser.username,
2039
2033
  userId: resolvedUser.id,
@@ -2044,7 +2038,7 @@ cli
2044
2038
  ...(options.injectionGuard?.length && { injectionGuardPatterns: options.injectionGuard }),
2045
2039
  };
2046
2040
  const autoStartEmbed = embedMarker
2047
- ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
2041
+ ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
2048
2042
  : undefined;
2049
2043
  const starterMessage = await sendDiscordMessageWithOptionalAttachment({
2050
2044
  channelId,
@@ -2069,7 +2063,9 @@ cli
2069
2063
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
2070
2064
  const worktreeNote = worktreeName
2071
2065
  ? `\nWorktree: ${worktreeName} (will be created by bot)`
2072
- : '';
2066
+ : resolvedCwd
2067
+ ? `\nWorking directory: ${resolvedCwd}`
2068
+ : '';
2073
2069
  const successMessage = notifyOnly
2074
2070
  ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
2075
2071
  : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}${worktreeNote}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
@@ -2226,6 +2222,36 @@ cli
2226
2222
  process.exit(EXIT_NO_RESTART);
2227
2223
  }
2228
2224
  });
2225
+ cli
2226
+ .command('anthropic-accounts list', 'List stored Anthropic OAuth accounts used for automatic rotation')
2227
+ .hidden()
2228
+ .action(async () => {
2229
+ const store = await loadAccountStore();
2230
+ console.log(`Store: ${accountsFilePath()}`);
2231
+ if (store.accounts.length === 0) {
2232
+ console.log('No Anthropic OAuth accounts configured.');
2233
+ process.exit(0);
2234
+ }
2235
+ store.accounts.forEach((account, index) => {
2236
+ const active = index === store.activeIndex ? '*' : ' ';
2237
+ const label = `${account.refresh.slice(0, 8)}...${account.refresh.slice(-4)}`;
2238
+ console.log(`${active} ${index + 1}. ${label}`);
2239
+ });
2240
+ process.exit(0);
2241
+ });
2242
+ cli
2243
+ .command('anthropic-accounts remove <index>', 'Remove a stored Anthropic OAuth account from the rotation pool')
2244
+ .hidden()
2245
+ .action(async (index) => {
2246
+ const value = Number(index);
2247
+ if (!Number.isInteger(value) || value < 1) {
2248
+ cliLogger.error('Usage: kimaki anthropic-accounts remove <index>');
2249
+ process.exit(EXIT_NO_RESTART);
2250
+ }
2251
+ await removeAccount(value - 1);
2252
+ cliLogger.log(`Removed Anthropic account ${value}`);
2253
+ process.exit(0);
2254
+ });
2229
2255
  cli
2230
2256
  .command('project add [directory]', 'Create Discord channels for a project directory (replaces legacy add-project)')
2231
2257
  .alias('add-project')