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.
- package/dist/agent-model.e2e.test.js +80 -2
- package/dist/anthropic-auth-plugin.js +246 -195
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +6 -3
- package/dist/cli-parsing.test.js +23 -0
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +72 -46
- package/dist/commands/merge-worktree.js +6 -3
- package/dist/commands/new-worktree.js +18 -7
- package/dist/commands/worktrees.js +71 -7
- package/dist/context-awareness-plugin.js +52 -50
- package/dist/context-awareness-plugin.test.js +68 -1
- package/dist/discord-bot.js +126 -54
- package/dist/discord-utils.test.js +19 -0
- package/dist/errors.js +0 -5
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +33 -72
- package/dist/forum-sync/config.js +2 -2
- package/dist/forum-sync/markdown.js +4 -8
- package/dist/hrana-server.js +11 -3
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/ipc-tools-plugin.js +11 -4
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +0 -1
- package/dist/markdown.js +2 -2
- package/dist/message-preprocessing.js +100 -16
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/opencode-command-detection.js +70 -0
- package/dist/opencode-command-detection.test.js +210 -0
- package/dist/opencode-interrupt-plugin.js +64 -8
- package/dist/opencode-interrupt-plugin.test.js +23 -39
- package/dist/opencode.js +16 -20
- package/dist/pkce.js +23 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-question.e2e.test.js +127 -42
- package/dist/sentry.js +7 -114
- package/dist/session-handler/event-stream-state.js +1 -1
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +197 -45
- package/dist/session-title-rename.test.js +80 -0
- package/dist/store.js +1 -2
- package/dist/system-message.js +105 -49
- package/dist/system-message.test.js +598 -15
- package/dist/task-runner.js +7 -4
- package/dist/task-schedule.js +2 -0
- package/dist/thread-message-queue.e2e.test.js +18 -11
- package/dist/unnest-code-blocks.js +11 -1
- package/dist/unnest-code-blocks.test.js +32 -0
- package/dist/voice-handler.js +15 -5
- package/dist/voice.js +53 -23
- package/dist/voice.test.js +2 -0
- package/dist/worktrees.js +111 -120
- package/package.json +15 -19
- package/skills/lintcn/SKILL.md +6 -1
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +3 -2
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +174 -249
- package/src/agent-model.e2e.test.ts +95 -2
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +474 -403
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +6 -3
- package/src/cli-parsing.test.ts +32 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +93 -62
- package/src/commands/merge-worktree.ts +8 -3
- package/src/commands/new-worktree.ts +22 -10
- package/src/commands/worktrees.ts +86 -5
- package/src/context-awareness-plugin.test.ts +77 -1
- package/src/context-awareness-plugin.ts +85 -64
- package/src/discord-bot.ts +135 -56
- package/src/discord-utils.test.ts +21 -0
- package/src/errors.ts +0 -6
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +39 -85
- package/src/forum-sync/config.ts +2 -2
- package/src/forum-sync/markdown.ts +5 -9
- package/src/hrana-server.ts +15 -3
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/ipc-tools-plugin.ts +16 -8
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +0 -1
- package/src/markdown.ts +2 -2
- package/src/message-preprocessing.ts +117 -16
- package/src/onboarding-tutorial.ts +1 -1
- package/src/opencode-command-detection.test.ts +268 -0
- package/src/opencode-command-detection.ts +79 -0
- package/src/opencode-interrupt-plugin.test.ts +93 -50
- package/src/opencode-interrupt-plugin.ts +86 -9
- package/src/opencode.ts +16 -22
- package/src/plugin-logger.ts +68 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-question.e2e.test.ts +243 -158
- package/src/sentry.ts +7 -120
- package/src/session-handler/event-stream-state.ts +1 -1
- package/src/session-handler/thread-runtime-state.ts +17 -0
- package/src/session-handler/thread-session-runtime.ts +232 -46
- package/src/session-title-rename.test.ts +112 -0
- package/src/store.ts +3 -8
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +136 -63
- package/src/task-runner.ts +7 -4
- package/src/task-schedule.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +22 -11
- package/src/undici.d.ts +12 -0
- package/src/unnest-code-blocks.test.ts +34 -0
- package/src/unnest-code-blocks.ts +18 -1
- package/src/voice-handler.ts +18 -4
- package/src/voice.test.ts +2 -0
- package/src/voice.ts +68 -23
- 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
|
|
28
|
-
if (process.env.__KIMAKI_CHILD || isSubcommand ||
|
|
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
|
-
|
|
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
|
});
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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')
|