kimaki 0.4.102 → 0.4.103
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 +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/bash-tool.js +194 -0
- package/dist/bash-tool.test.js +82 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +9 -0
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +5 -5
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +9 -0
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
|
@@ -742,6 +742,7 @@ describe('agent model resolution', () => {
|
|
|
742
742
|
⬥ ok
|
|
743
743
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
744
744
|
Switched to **plan** agent for this session (was **test-agent**)
|
|
745
|
+
Model: *deterministic-provider/plan-model-v2*
|
|
745
746
|
The agent will change on the next message.
|
|
746
747
|
--- from: user (agent-model-tester)
|
|
747
748
|
Reply with exactly: after-switch-msg
|
|
@@ -460,6 +460,20 @@ function buildAuthorizeHandler(mode) {
|
|
|
460
460
|
function toClaudeCodeToolName(name) {
|
|
461
461
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
|
|
462
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Strips the OpenCode identity block (from "You are OpenCode…" up to the
|
|
465
|
+
* Anthropic prompt marker "Skills provide specialized instructions") and
|
|
466
|
+
* re-injects essential environment context as a small XML tag.
|
|
467
|
+
*
|
|
468
|
+
* The original OpenCode prompt between those markers contains the current
|
|
469
|
+
* working directory and other runtime context. Stripping it wholesale loses
|
|
470
|
+
* that info, so we add back what the model needs (cwd) in a compact form.
|
|
471
|
+
*
|
|
472
|
+
* Original OpenCode Anthropic prompt structure (for reference):
|
|
473
|
+
* "You are OpenCode, the best coding agent on the planet."
|
|
474
|
+
* + environment block (cwd, OS, shell, date, etc.)
|
|
475
|
+
* + "Skills provide specialized instructions …"
|
|
476
|
+
*/
|
|
463
477
|
function sanitizeAnthropicSystemText(text, onError) {
|
|
464
478
|
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
465
479
|
if (startIdx === -1)
|
|
@@ -470,7 +484,14 @@ function sanitizeAnthropicSystemText(text, onError) {
|
|
|
470
484
|
onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
|
|
471
485
|
return text;
|
|
472
486
|
}
|
|
473
|
-
|
|
487
|
+
// Re-inject the process working directory that was inside the stripped block.
|
|
488
|
+
const envContext = `\n<environment>\n<cwd>${process.cwd()}</cwd>\n</environment>\n`;
|
|
489
|
+
// Replace all case-insensitive whole-word occurrences of "opencode" with "openc0de"
|
|
490
|
+
const result = text.slice(0, startIdx) +
|
|
491
|
+
envContext +
|
|
492
|
+
text.slice(endIdx);
|
|
493
|
+
// Use a regex with global, case-insensitive, and word boundary flags
|
|
494
|
+
return result.replace(/\bopencode\b/gi, "openc0de");
|
|
474
495
|
}
|
|
475
496
|
function mapSystemTextPart(part, onError) {
|
|
476
497
|
if (typeof part === "string") {
|
|
@@ -186,6 +186,37 @@ async function writeAnthropicAuthFile(auth) {
|
|
|
186
186
|
}
|
|
187
187
|
await writeJson(file, data);
|
|
188
188
|
}
|
|
189
|
+
function isOAuthStored(value) {
|
|
190
|
+
if (!value || typeof value !== 'object') {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const record = value;
|
|
194
|
+
return (record.type === 'oauth' &&
|
|
195
|
+
typeof record.refresh === 'string' &&
|
|
196
|
+
typeof record.access === 'string' &&
|
|
197
|
+
typeof record.expires === 'number');
|
|
198
|
+
}
|
|
199
|
+
export async function getCurrentAnthropicAccount() {
|
|
200
|
+
const authJson = await readJson(authFilePath(), {});
|
|
201
|
+
const auth = authJson.anthropic;
|
|
202
|
+
if (!isOAuthStored(auth)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const store = await loadAccountStore();
|
|
206
|
+
const index = findCurrentAccountIndex(store, auth);
|
|
207
|
+
const account = store.accounts[index];
|
|
208
|
+
if (!account) {
|
|
209
|
+
return { auth };
|
|
210
|
+
}
|
|
211
|
+
if (account.refresh !== auth.refresh && account.access !== auth.access) {
|
|
212
|
+
return { auth };
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
auth,
|
|
216
|
+
account,
|
|
217
|
+
index,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
189
220
|
export async function setAnthropicAuth(auth, client) {
|
|
190
221
|
await writeAnthropicAuthFile(auth);
|
|
191
222
|
await client.auth.set({ path: { id: 'anthropic' }, body: auth });
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Bash tool for the GenAI worker.
|
|
2
|
+
// Executes shell commands in the project directory and can preload remote
|
|
3
|
+
// SKILL.md files into a local cache so their metadata can be exposed to the model.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { tool } from './ai-tool.js';
|
|
9
|
+
import { getDataDir } from './config.js';
|
|
10
|
+
import { execAsync } from './exec-async.js';
|
|
11
|
+
import { parseFrontmatter } from './forum-sync/markdown.js';
|
|
12
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
13
|
+
const bashToolLogger = createLogger(LogPrefix.TOOLS);
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
const DEFAULT_SHELL = process.env['SHELL'] || '/bin/zsh';
|
|
16
|
+
const bashToolGlobalState = (() => {
|
|
17
|
+
const key = '__kimakiBashToolState';
|
|
18
|
+
const state = globalThis;
|
|
19
|
+
if (!state[key]) {
|
|
20
|
+
state[key] = {
|
|
21
|
+
skills: new Map(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return state[key];
|
|
25
|
+
})();
|
|
26
|
+
function getExecErrorFields({ error, }) {
|
|
27
|
+
if (!(error instanceof Error)) {
|
|
28
|
+
return {
|
|
29
|
+
exitCode: 1,
|
|
30
|
+
stderr: String(error),
|
|
31
|
+
stdout: '',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const execError = error;
|
|
35
|
+
return {
|
|
36
|
+
exitCode: typeof execError.code === 'number' ? execError.code : 1,
|
|
37
|
+
stderr: typeof execError.stderr === 'string' ? execError.stderr : error.message,
|
|
38
|
+
stdout: typeof execError.stdout === 'string' ? execError.stdout : '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function sanitizeSkillName({ name }) {
|
|
42
|
+
return (name
|
|
43
|
+
.trim()
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
46
|
+
.replace(/^-+|-+$/g, '') || 'skill');
|
|
47
|
+
}
|
|
48
|
+
export function normalizeSkillMarkdownUrl({ url }) {
|
|
49
|
+
const parsed = new URL(url);
|
|
50
|
+
if (parsed.hostname !== 'github.com') {
|
|
51
|
+
return parsed.toString();
|
|
52
|
+
}
|
|
53
|
+
const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
54
|
+
if (parts.length < 5) {
|
|
55
|
+
return parsed.toString();
|
|
56
|
+
}
|
|
57
|
+
const [owner, repo, kind, ref, ...rest] = parts;
|
|
58
|
+
if (kind !== 'blob' || rest.length === 0) {
|
|
59
|
+
return parsed.toString();
|
|
60
|
+
}
|
|
61
|
+
return new URL(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${rest.join('/')}`).toString();
|
|
62
|
+
}
|
|
63
|
+
async function fetchSkillMarkdown({ url }) {
|
|
64
|
+
const normalizedUrl = normalizeSkillMarkdownUrl({ url });
|
|
65
|
+
const candidates = normalizedUrl === url ? [url] : [normalizedUrl, url];
|
|
66
|
+
let lastStatus = null;
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const response = await fetch(candidate);
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
lastStatus = response.status;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
return response.text();
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Failed to fetch skill markdown from ${url}${lastStatus ? ` (last status ${lastStatus})` : ''}`);
|
|
76
|
+
}
|
|
77
|
+
async function cacheSkillMarkdown({ url, }) {
|
|
78
|
+
const markdown = await fetchSkillMarkdown({ url });
|
|
79
|
+
const parsed = parseFrontmatter({ markdown });
|
|
80
|
+
const name = parsed.frontmatter['name'];
|
|
81
|
+
const description = parsed.frontmatter['description'];
|
|
82
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
83
|
+
throw new Error(`Skill at ${url} is missing a valid frontmatter name`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof description !== 'string' || description.trim().length === 0) {
|
|
86
|
+
throw new Error(`Skill at ${url} is missing a valid frontmatter description`);
|
|
87
|
+
}
|
|
88
|
+
const root = path.join(getDataDir(), 'remote-skills', sanitizeSkillName({ name }));
|
|
89
|
+
await fs.promises.mkdir(root, { recursive: true });
|
|
90
|
+
const location = path.join(root, 'SKILL.md');
|
|
91
|
+
await fs.promises.writeFile(location, markdown, 'utf-8');
|
|
92
|
+
return {
|
|
93
|
+
content: parsed.body,
|
|
94
|
+
description,
|
|
95
|
+
location,
|
|
96
|
+
name,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export async function loadRemoteSkills({ skillUrls, }) {
|
|
100
|
+
const uniqueUrls = Array.from(new Set(skillUrls));
|
|
101
|
+
const loaded = await Promise.all(uniqueUrls.map(async (url) => {
|
|
102
|
+
const cached = bashToolGlobalState.skills.get(url);
|
|
103
|
+
if (cached) {
|
|
104
|
+
return cached;
|
|
105
|
+
}
|
|
106
|
+
const promise = cacheSkillMarkdown({ url }).catch((error) => {
|
|
107
|
+
bashToolGlobalState.skills.delete(url);
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
bashToolGlobalState.skills.set(url, promise);
|
|
111
|
+
return promise;
|
|
112
|
+
}));
|
|
113
|
+
return loaded.sort((a, b) => a.name.localeCompare(b.name));
|
|
114
|
+
}
|
|
115
|
+
export function formatAvailableSkillsXml({ skills, }) {
|
|
116
|
+
if (skills.length === 0) {
|
|
117
|
+
return 'No skills are currently available.';
|
|
118
|
+
}
|
|
119
|
+
return [
|
|
120
|
+
'<available_skills>',
|
|
121
|
+
...skills.flatMap((skill) => {
|
|
122
|
+
return [
|
|
123
|
+
' <skill>',
|
|
124
|
+
` <name>${skill.name}</name>`,
|
|
125
|
+
` <description>${skill.description}</description>`,
|
|
126
|
+
` <location>${pathToFileURL(skill.location).href}</location>`,
|
|
127
|
+
' </skill>',
|
|
128
|
+
];
|
|
129
|
+
}),
|
|
130
|
+
'</available_skills>',
|
|
131
|
+
].join('\n');
|
|
132
|
+
}
|
|
133
|
+
function buildDescription({ directory, skills, }) {
|
|
134
|
+
const lines = [
|
|
135
|
+
`Execute a shell command in ${directory}.`,
|
|
136
|
+
'Use `workdir` instead of `cd` commands when you need a different directory.',
|
|
137
|
+
'Return fields include `stdout`, `stderr`, and `exitCode`.',
|
|
138
|
+
];
|
|
139
|
+
if (skills.length === 0) {
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
return [
|
|
143
|
+
...lines,
|
|
144
|
+
'',
|
|
145
|
+
'Skills provide specialized instructions and workflows for specific tasks.',
|
|
146
|
+
'If a task matches a skill, read that SKILL.md file with bash before continuing.',
|
|
147
|
+
formatAvailableSkillsXml({ skills }),
|
|
148
|
+
].join('\n');
|
|
149
|
+
}
|
|
150
|
+
export async function createBashTool({ directory, skillUrls = [], }) {
|
|
151
|
+
const skills = await loadRemoteSkills({ skillUrls });
|
|
152
|
+
const description = buildDescription({ directory, skills });
|
|
153
|
+
if (skills.length > 0) {
|
|
154
|
+
bashToolLogger.info('Loaded cached remote skills for bash tool', skills.map((skill) => {
|
|
155
|
+
return skill.name;
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
return tool({
|
|
159
|
+
description,
|
|
160
|
+
inputSchema: z.object({
|
|
161
|
+
command: z.string().describe('The shell command to execute'),
|
|
162
|
+
timeout: z.number().optional().describe('Optional timeout in milliseconds'),
|
|
163
|
+
workdir: z
|
|
164
|
+
.string()
|
|
165
|
+
.optional()
|
|
166
|
+
.describe('Optional working directory. Use this instead of cd commands.'),
|
|
167
|
+
description: z.string().describe('Short explanation of what the command does'),
|
|
168
|
+
hasSideEffect: z
|
|
169
|
+
.boolean()
|
|
170
|
+
.optional()
|
|
171
|
+
.describe('Whether this command writes files or changes external state'),
|
|
172
|
+
}),
|
|
173
|
+
execute: async ({ command, timeout, workdir }) => {
|
|
174
|
+
const cwd = workdir || directory;
|
|
175
|
+
const result = await execAsync(command, {
|
|
176
|
+
cwd,
|
|
177
|
+
shell: DEFAULT_SHELL,
|
|
178
|
+
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
|
|
179
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
180
|
+
})
|
|
181
|
+
.then(({ stdout, stderr }) => {
|
|
182
|
+
return {
|
|
183
|
+
exitCode: 0,
|
|
184
|
+
stderr,
|
|
185
|
+
stdout,
|
|
186
|
+
};
|
|
187
|
+
})
|
|
188
|
+
.catch((error) => {
|
|
189
|
+
return getExecErrorFields({ error });
|
|
190
|
+
});
|
|
191
|
+
return result;
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Tests for the GenAI bash tool helper and remote skill loading cache.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
6
|
+
import { setDataDir } from './config.js';
|
|
7
|
+
import { formatAvailableSkillsXml, loadRemoteSkills, normalizeSkillMarkdownUrl, } from './bash-tool.js';
|
|
8
|
+
const tempRoot = path.join(process.cwd(), 'tmp', 'bash-tool-tests');
|
|
9
|
+
let server;
|
|
10
|
+
let serverUrl = '';
|
|
11
|
+
let requestCount = 0;
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
await fs.promises.mkdir(tempRoot, { recursive: true });
|
|
14
|
+
setDataDir(tempRoot);
|
|
15
|
+
server = http.createServer((request, response) => {
|
|
16
|
+
if (request.url !== '/skill.md') {
|
|
17
|
+
response.statusCode = 404;
|
|
18
|
+
response.end('missing');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
requestCount += 1;
|
|
22
|
+
response.setHeader('content-type', 'text/markdown; charset=utf-8');
|
|
23
|
+
response.end(`---
|
|
24
|
+
name: remote-skill
|
|
25
|
+
description: Remote cached skill for bash tool tests
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Remote skill
|
|
29
|
+
|
|
30
|
+
Use this skill in tests.
|
|
31
|
+
`);
|
|
32
|
+
});
|
|
33
|
+
await new Promise((resolve) => {
|
|
34
|
+
server.listen(0, '127.0.0.1', () => {
|
|
35
|
+
const address = server.address();
|
|
36
|
+
if (typeof address === 'object' && address) {
|
|
37
|
+
serverUrl = `http://127.0.0.1:${address.port}/skill.md`;
|
|
38
|
+
}
|
|
39
|
+
resolve();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
if (!server) {
|
|
46
|
+
resolve();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
server.close((error) => {
|
|
50
|
+
if (error) {
|
|
51
|
+
reject(error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('normalizeSkillMarkdownUrl', () => {
|
|
59
|
+
test('converts GitHub blob URLs to raw content URLs', () => {
|
|
60
|
+
expect(normalizeSkillMarkdownUrl({
|
|
61
|
+
url: 'https://github.com/remorses/kimaki/blob/main/cli/skills/errore/SKILL.md',
|
|
62
|
+
})).toBe('https://raw.githubusercontent.com/remorses/kimaki/main/cli/skills/errore/SKILL.md');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('loadRemoteSkills', () => {
|
|
66
|
+
test('fetches once, caches, writes SKILL.md, and formats skill XML', async () => {
|
|
67
|
+
const first = await loadRemoteSkills({ skillUrls: [serverUrl] });
|
|
68
|
+
const second = await loadRemoteSkills({ skillUrls: [serverUrl] });
|
|
69
|
+
expect(requestCount).toBe(1);
|
|
70
|
+
expect(second[0]?.location).toBe(first[0]?.location);
|
|
71
|
+
expect(await fs.promises.readFile(first[0].location, 'utf-8')).toContain('name: remote-skill');
|
|
72
|
+
expect(formatAvailableSkillsXml({ skills: first })).toMatchInlineSnapshot(`
|
|
73
|
+
"<available_skills>
|
|
74
|
+
<skill>
|
|
75
|
+
<name>remote-skill</name>
|
|
76
|
+
<description>Remote cached skill for bash tool tests</description>
|
|
77
|
+
<location>file:///Users/morse/Documents/GitHub/kimakivoice/cli/tmp/bash-tool-tests/remote-skills/remote-skill/SKILL.md</location>
|
|
78
|
+
</skill>
|
|
79
|
+
</available_skills>"
|
|
80
|
+
`);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Detects the raw `btw ` Discord message shortcut used to fork a side-question
|
|
2
|
+
// thread without invoking the /btw slash command UI.
|
|
3
|
+
export function extractBtwPrefix(content) {
|
|
4
|
+
if (!content) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
// Match "btw" followed by whitespace or punctuation (. , : ; ! ?) then the prompt
|
|
8
|
+
const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i);
|
|
9
|
+
if (!match) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const prompt = match[1]?.trim();
|
|
13
|
+
if (!prompt) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return { prompt };
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { extractBtwPrefix } from './btw-prefix-detection.js';
|
|
3
|
+
describe('extractBtwPrefix', () => {
|
|
4
|
+
test('matches lowercase prefix', () => {
|
|
5
|
+
expect(extractBtwPrefix('btw fix this')).toMatchInlineSnapshot(`
|
|
6
|
+
{
|
|
7
|
+
"prompt": "fix this",
|
|
8
|
+
}
|
|
9
|
+
`);
|
|
10
|
+
});
|
|
11
|
+
test('matches uppercase prefix', () => {
|
|
12
|
+
expect(extractBtwPrefix('BTW check this')).toMatchInlineSnapshot(`
|
|
13
|
+
{
|
|
14
|
+
"prompt": "check this",
|
|
15
|
+
}
|
|
16
|
+
`);
|
|
17
|
+
});
|
|
18
|
+
test('keeps multiline content', () => {
|
|
19
|
+
expect(extractBtwPrefix(' btw first line\nsecond line ')).toMatchInlineSnapshot(`
|
|
20
|
+
{
|
|
21
|
+
"prompt": "first line
|
|
22
|
+
second line",
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
});
|
|
26
|
+
test('matches dot separator', () => {
|
|
27
|
+
expect(extractBtwPrefix('btw. fix this')).toMatchInlineSnapshot(`
|
|
28
|
+
{
|
|
29
|
+
"prompt": "fix this",
|
|
30
|
+
}
|
|
31
|
+
`);
|
|
32
|
+
});
|
|
33
|
+
test('matches comma separator', () => {
|
|
34
|
+
expect(extractBtwPrefix('btw, fix this')).toMatchInlineSnapshot(`
|
|
35
|
+
{
|
|
36
|
+
"prompt": "fix this",
|
|
37
|
+
}
|
|
38
|
+
`);
|
|
39
|
+
});
|
|
40
|
+
test('matches colon separator', () => {
|
|
41
|
+
expect(extractBtwPrefix('btw: fix this')).toMatchInlineSnapshot(`
|
|
42
|
+
{
|
|
43
|
+
"prompt": "fix this",
|
|
44
|
+
}
|
|
45
|
+
`);
|
|
46
|
+
});
|
|
47
|
+
test('matches punctuation without trailing space', () => {
|
|
48
|
+
expect(extractBtwPrefix('btw.fix this')).toMatchInlineSnapshot(`
|
|
49
|
+
{
|
|
50
|
+
"prompt": "fix this",
|
|
51
|
+
}
|
|
52
|
+
`);
|
|
53
|
+
});
|
|
54
|
+
test('does not match without separating whitespace', () => {
|
|
55
|
+
expect(extractBtwPrefix('btwfix this')).toMatchInlineSnapshot(`null`);
|
|
56
|
+
});
|
|
57
|
+
test('does not match mid-message', () => {
|
|
58
|
+
expect(extractBtwPrefix('hello btw fix this')).toMatchInlineSnapshot(`null`);
|
|
59
|
+
});
|
|
60
|
+
test('does not match empty payload', () => {
|
|
61
|
+
expect(extractBtwPrefix('btw ')).toMatchInlineSnapshot(`null`);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChan
|
|
|
10
10
|
import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, } from './database.js';
|
|
11
11
|
import { ShareMarkdown } from './markdown.js';
|
|
12
12
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts, } from './session-search.js';
|
|
13
|
-
import { formatWorktreeName } from './commands/new-worktree.js';
|
|
13
|
+
import { formatWorktreeName, formatAutoWorktreeName } from './commands/new-worktree.js';
|
|
14
14
|
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';
|
|
@@ -21,6 +21,7 @@ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxy
|
|
|
21
21
|
import crypto from 'node:crypto';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import fs from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
24
25
|
import * as errore from 'errore';
|
|
25
26
|
import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js';
|
|
26
27
|
import { initSentry, notifyError } from './sentry.js';
|
|
@@ -32,7 +33,7 @@ import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.
|
|
|
32
33
|
import { startHranaServer } from './hrana-server.js';
|
|
33
34
|
import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
|
|
34
35
|
import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
|
|
35
|
-
import { accountLabel, accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
|
|
36
|
+
import { accountLabel, accountsFilePath, authFilePath, getCurrentAnthropicAccount, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
|
|
36
37
|
const cliLogger = createLogger(LogPrefix.CLI);
|
|
37
38
|
// Gateway bot mode constants.
|
|
38
39
|
// KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
|
|
@@ -1287,6 +1288,14 @@ cli
|
|
|
1287
1288
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
1288
1289
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
1289
1290
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
1291
|
+
.option('--enable-skill <name>', z
|
|
1292
|
+
.array(z.string())
|
|
1293
|
+
.optional()
|
|
1294
|
+
.describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/remorses/kimaki/tree/main/cli/skills for available skills.'))
|
|
1295
|
+
.option('--disable-skill <name>', z
|
|
1296
|
+
.array(z.string())
|
|
1297
|
+
.optional()
|
|
1298
|
+
.describe('Blacklist a built-in skill by name. Listed skills are hidden from the model. Repeatable: pass --disable-skill multiple times. Mutually exclusive with --enable-skill. See https://github.com/remorses/kimaki/tree/main/cli/skills for available skills.'))
|
|
1290
1299
|
.action(async (options) => {
|
|
1291
1300
|
// Guard: only one kimaki bot process can run at a time (they share a lock
|
|
1292
1301
|
// port). Running `kimaki` here would kill the already-running bot process
|
|
@@ -1321,13 +1330,54 @@ cli
|
|
|
1321
1330
|
process.exit(EXIT_NO_RESTART);
|
|
1322
1331
|
}
|
|
1323
1332
|
}
|
|
1333
|
+
// --enable-skill and --disable-skill are mutually exclusive: the user
|
|
1334
|
+
// either whitelists a small allowlist or blacklists a few unwanted
|
|
1335
|
+
// skills, never both. Applied later in opencode.ts as permission.skill
|
|
1336
|
+
// rules via computeSkillPermission().
|
|
1337
|
+
const enabledSkills = options.enableSkill ?? [];
|
|
1338
|
+
const disabledSkills = options.disableSkill ?? [];
|
|
1339
|
+
if (enabledSkills.length > 0 && disabledSkills.length > 0) {
|
|
1340
|
+
cliLogger.error('Cannot use --enable-skill and --disable-skill at the same time. Use one or the other.');
|
|
1341
|
+
process.exit(EXIT_NO_RESTART);
|
|
1342
|
+
}
|
|
1343
|
+
// Soft-validate skill names against the bundled skills/ folder. Users
|
|
1344
|
+
// may rely on skills loaded from their own .opencode / .claude / .agents
|
|
1345
|
+
// dirs, so unknown names only emit a warning rather than hard-failing.
|
|
1346
|
+
if (enabledSkills.length > 0 || disabledSkills.length > 0) {
|
|
1347
|
+
const bundledSkillsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
1348
|
+
const availableBundledSkills = (() => {
|
|
1349
|
+
try {
|
|
1350
|
+
return fs
|
|
1351
|
+
.readdirSync(bundledSkillsDir, { withFileTypes: true })
|
|
1352
|
+
.filter((entry) => entry.isDirectory())
|
|
1353
|
+
.map((entry) => entry.name);
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return [];
|
|
1357
|
+
}
|
|
1358
|
+
})();
|
|
1359
|
+
const availableSet = new Set(availableBundledSkills);
|
|
1360
|
+
for (const name of [...enabledSkills, ...disabledSkills]) {
|
|
1361
|
+
if (!availableSet.has(name)) {
|
|
1362
|
+
cliLogger.warn(`Skill "${name}" is not a bundled kimaki skill. Rule will still apply (user-provided skills from .opencode/.claude/.agents dirs may match). Available bundled skills: ${availableBundledSkills.join(', ')}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1324
1366
|
store.setState({
|
|
1325
1367
|
...(options.verbosity && {
|
|
1326
1368
|
defaultVerbosity: options.verbosity,
|
|
1327
1369
|
}),
|
|
1328
1370
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
1329
1371
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
1372
|
+
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
1373
|
+
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
1330
1374
|
});
|
|
1375
|
+
if (enabledSkills.length > 0) {
|
|
1376
|
+
cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
|
|
1377
|
+
}
|
|
1378
|
+
if (disabledSkills.length > 0) {
|
|
1379
|
+
cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
|
|
1380
|
+
}
|
|
1331
1381
|
if (options.verbosity) {
|
|
1332
1382
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
1333
1383
|
}
|
|
@@ -1637,7 +1687,12 @@ cli
|
|
|
1637
1687
|
.option('--wait', 'Wait for session to complete, then print session text to stdout')
|
|
1638
1688
|
.action(async (options) => {
|
|
1639
1689
|
try {
|
|
1640
|
-
|
|
1690
|
+
// `--name` / `--app-id` are optional-value flags: `undefined` when
|
|
1691
|
+
// omitted, `''` when passed bare, a real string when given a value.
|
|
1692
|
+
// `||` collapses `''` to `undefined` for downstream consumers.
|
|
1693
|
+
const optionAppId = options.appId || undefined;
|
|
1694
|
+
let { channel: channelId, prompt, notifyOnly, thread: threadId, session: sessionId, } = options;
|
|
1695
|
+
let name = options.name || undefined;
|
|
1641
1696
|
const { project: projectPath } = options;
|
|
1642
1697
|
const sendAt = options.sendAt;
|
|
1643
1698
|
const existingThreadMode = Boolean(threadId || sessionId);
|
|
@@ -1981,10 +2036,12 @@ cli
|
|
|
1981
2036
|
(cleanPrompt.length > 80
|
|
1982
2037
|
? cleanPrompt.slice(0, 77) + '...'
|
|
1983
2038
|
: cleanPrompt);
|
|
2039
|
+
// Explicit string => use as-is via formatWorktreeName (no vowel strip).
|
|
2040
|
+
// Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
|
|
1984
2041
|
const worktreeName = options.worktree
|
|
1985
|
-
?
|
|
1986
|
-
? options.worktree
|
|
1987
|
-
: baseThreadName)
|
|
2042
|
+
? typeof options.worktree === 'string'
|
|
2043
|
+
? formatWorktreeName(options.worktree)
|
|
2044
|
+
: formatAutoWorktreeName(baseThreadName)
|
|
1988
2045
|
: undefined;
|
|
1989
2046
|
const threadName = worktreeName
|
|
1990
2047
|
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
@@ -2238,6 +2295,33 @@ cli
|
|
|
2238
2295
|
});
|
|
2239
2296
|
process.exit(0);
|
|
2240
2297
|
});
|
|
2298
|
+
cli
|
|
2299
|
+
.command('anthropic-accounts current', 'Show the current Anthropic OAuth account being used, if any')
|
|
2300
|
+
.action(async () => {
|
|
2301
|
+
const current = await getCurrentAnthropicAccount();
|
|
2302
|
+
console.log(`Store: ${accountsFilePath()}`);
|
|
2303
|
+
console.log(`Auth: ${authFilePath()}`);
|
|
2304
|
+
if (!current) {
|
|
2305
|
+
console.log('No active Anthropic OAuth account configured.');
|
|
2306
|
+
process.exit(0);
|
|
2307
|
+
}
|
|
2308
|
+
const lines = [];
|
|
2309
|
+
lines.push(`Current: ${accountLabel(current.account || current.auth, current.index)}`);
|
|
2310
|
+
if (current.account?.email) {
|
|
2311
|
+
lines.push(`Email: ${current.account.email}`);
|
|
2312
|
+
}
|
|
2313
|
+
else {
|
|
2314
|
+
lines.push('Email: unavailable');
|
|
2315
|
+
}
|
|
2316
|
+
if (current.account?.accountId) {
|
|
2317
|
+
lines.push(`Account ID: ${current.account.accountId}`);
|
|
2318
|
+
}
|
|
2319
|
+
if (!current.account) {
|
|
2320
|
+
lines.push('Rotation pool entry: not found');
|
|
2321
|
+
}
|
|
2322
|
+
console.log(lines.join('\n'));
|
|
2323
|
+
process.exit(0);
|
|
2324
|
+
});
|
|
2241
2325
|
cli
|
|
2242
2326
|
.command('anthropic-accounts remove <indexOrEmail>', 'Remove a stored Anthropic OAuth account from the rotation pool by index or email')
|
|
2243
2327
|
.action(async (indexOrEmail) => {
|
|
@@ -2633,13 +2717,15 @@ cli
|
|
|
2633
2717
|
process.exit(EXIT_NO_RESTART);
|
|
2634
2718
|
}
|
|
2635
2719
|
const guildId = String(options.guild);
|
|
2720
|
+
// Bare `--query` comes through as `''`; collapse it to undefined
|
|
2721
|
+
const query = options.query || undefined;
|
|
2636
2722
|
await initDatabase();
|
|
2637
2723
|
const { token: botToken } = await resolveBotCredentials();
|
|
2638
2724
|
const rest = createDiscordRest(botToken);
|
|
2639
2725
|
const members = await (async () => {
|
|
2640
|
-
if (
|
|
2726
|
+
if (query) {
|
|
2641
2727
|
return (await rest.get(Routes.guildMembersSearch(guildId), {
|
|
2642
|
-
query: new URLSearchParams({ query
|
|
2728
|
+
query: new URLSearchParams({ query, limit: '20' }),
|
|
2643
2729
|
}));
|
|
2644
2730
|
}
|
|
2645
2731
|
return (await rest.get(Routes.guildMembers(guildId), {
|
|
@@ -2647,8 +2733,8 @@ cli
|
|
|
2647
2733
|
}));
|
|
2648
2734
|
})();
|
|
2649
2735
|
if (members.length === 0) {
|
|
2650
|
-
const msg =
|
|
2651
|
-
? `No users found matching "${
|
|
2736
|
+
const msg = query
|
|
2737
|
+
? `No users found matching "${query}"`
|
|
2652
2738
|
: 'No users found in guild';
|
|
2653
2739
|
cliLogger.log(msg);
|
|
2654
2740
|
process.exit(0);
|
|
@@ -2659,8 +2745,8 @@ cli
|
|
|
2659
2745
|
return `- ${displayName} (ID: ${m.user.id}) - mention: <@${m.user.id}>`;
|
|
2660
2746
|
})
|
|
2661
2747
|
.join('\n');
|
|
2662
|
-
const header =
|
|
2663
|
-
? `Found ${members.length} users matching "${
|
|
2748
|
+
const header = query
|
|
2749
|
+
? `Found ${members.length} users matching "${query}":`
|
|
2664
2750
|
: `Found ${members.length} users:`;
|
|
2665
2751
|
console.log(`${header}\n${userList}`);
|
|
2666
2752
|
process.exit(0);
|
|
@@ -2693,10 +2779,10 @@ cli
|
|
|
2693
2779
|
const { command } = parseCommandFromArgv(process.argv);
|
|
2694
2780
|
await runTunnel({
|
|
2695
2781
|
port,
|
|
2696
|
-
tunnelId: options.tunnelId,
|
|
2697
|
-
localHost: options.host,
|
|
2782
|
+
tunnelId: options.tunnelId || undefined,
|
|
2783
|
+
localHost: options.host || undefined,
|
|
2698
2784
|
baseDomain: 'kimaki.dev',
|
|
2699
|
-
serverUrl: options.server,
|
|
2785
|
+
serverUrl: options.server || undefined,
|
|
2700
2786
|
command: command.length > 0 ? command : undefined,
|
|
2701
2787
|
kill: options.kill,
|
|
2702
2788
|
});
|