kimaki 0.4.102 → 0.4.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/agent-model.e2e.test.js +1 -0
  2. package/dist/anthropic-auth-plugin.js +22 -1
  3. package/dist/anthropic-auth-plugin.test.js +100 -122
  4. package/dist/anthropic-auth-state.js +31 -0
  5. package/dist/bash-tool.js +194 -0
  6. package/dist/bash-tool.test.js +82 -0
  7. package/dist/btw-prefix-detection.js +17 -0
  8. package/dist/btw-prefix-detection.test.js +63 -0
  9. package/dist/cli-parsing.test.js +62 -81
  10. package/dist/cli.js +101 -15
  11. package/dist/commands/agent.js +21 -2
  12. package/dist/commands/ask-question.js +50 -4
  13. package/dist/commands/ask-question.test.js +92 -0
  14. package/dist/commands/btw.js +71 -66
  15. package/dist/commands/new-worktree.js +92 -35
  16. package/dist/commands/queue.js +17 -0
  17. package/dist/commands/worktrees.js +196 -139
  18. package/dist/context-awareness-plugin.js +16 -8
  19. package/dist/context-awareness-plugin.test.js +4 -2
  20. package/dist/discord-bot.js +35 -2
  21. package/dist/discord-command-registration.js +9 -2
  22. package/dist/memory-overview-plugin.js +3 -1
  23. package/dist/opencode.js +9 -0
  24. package/dist/queue-question-select-drain.e2e.test.js +159 -23
  25. package/dist/session-handler/event-stream-state.js +28 -1
  26. package/dist/session-handler/event-stream-state.test.js +3 -3
  27. package/dist/session-handler/thread-runtime-state.js +27 -0
  28. package/dist/session-handler/thread-session-runtime.js +124 -51
  29. package/dist/session-title-rename.test.js +12 -0
  30. package/dist/skill-filter.js +31 -0
  31. package/dist/skill-filter.test.js +65 -0
  32. package/dist/store.js +2 -0
  33. package/dist/system-message.js +12 -3
  34. package/dist/system-message.test.js +10 -6
  35. package/dist/thread-message-queue.e2e.test.js +109 -0
  36. package/dist/worktree-lifecycle.e2e.test.js +4 -1
  37. package/dist/worktrees.js +106 -12
  38. package/dist/worktrees.test.js +232 -6
  39. package/package.json +5 -5
  40. package/skills/goke/SKILL.md +13 -619
  41. package/skills/new-skill/SKILL.md +34 -10
  42. package/skills/npm-package/SKILL.md +336 -2
  43. package/skills/profano/SKILL.md +24 -0
  44. package/skills/zele/SKILL.md +50 -21
  45. package/src/agent-model.e2e.test.ts +1 -0
  46. package/src/anthropic-auth-plugin.ts +24 -4
  47. package/src/anthropic-auth-state.ts +45 -0
  48. package/src/btw-prefix-detection.test.ts +73 -0
  49. package/src/btw-prefix-detection.ts +23 -0
  50. package/src/cli-parsing.test.ts +69 -98
  51. package/src/cli.ts +138 -46
  52. package/src/commands/agent.ts +24 -2
  53. package/src/commands/ask-question.test.ts +111 -0
  54. package/src/commands/ask-question.ts +69 -4
  55. package/src/commands/btw.ts +105 -85
  56. package/src/commands/new-worktree.ts +107 -40
  57. package/src/commands/queue.ts +22 -0
  58. package/src/commands/worktrees.ts +246 -154
  59. package/src/context-awareness-plugin.test.ts +4 -2
  60. package/src/context-awareness-plugin.ts +16 -8
  61. package/src/discord-bot.ts +40 -2
  62. package/src/discord-command-registration.ts +12 -2
  63. package/src/memory-overview-plugin.ts +3 -1
  64. package/src/opencode.ts +9 -0
  65. package/src/queue-question-select-drain.e2e.test.ts +199 -24
  66. package/src/session-handler/event-stream-state.test.ts +3 -5
  67. package/src/session-handler/event-stream-state.ts +50 -4
  68. package/src/session-handler/thread-runtime-state.ts +36 -1
  69. package/src/session-handler/thread-session-runtime.ts +162 -68
  70. package/src/session-title-rename.test.ts +18 -0
  71. package/src/skill-filter.test.ts +83 -0
  72. package/src/skill-filter.ts +42 -0
  73. package/src/store.ts +17 -0
  74. package/src/system-message.test.ts +10 -6
  75. package/src/system-message.ts +12 -3
  76. package/src/thread-message-queue.e2e.test.ts +126 -0
  77. package/src/worktree-lifecycle.e2e.test.ts +6 -1
  78. package/src/worktrees.test.ts +274 -9
  79. 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
- return (text.slice(0, startIdx) + text.slice(endIdx)).replaceAll("opencode", "openc0de");
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") {
@@ -1,131 +1,109 @@
1
- // Tests for Anthropic OAuth multi-account persistence and rotation.
2
- import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import path from 'node:path';
5
- import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
- import { authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
7
- const firstAccount = {
8
- type: 'oauth',
9
- refresh: 'refresh-first',
10
- access: 'access-first',
11
- expires: 1,
12
- };
13
- const secondAccount = {
14
- type: 'oauth',
15
- refresh: 'refresh-second',
16
- access: 'access-second',
17
- expires: 2,
18
- };
19
- let originalXdgDataHome;
20
- let tempDir = '';
21
- beforeEach(async () => {
22
- originalXdgDataHome = process.env.XDG_DATA_HOME;
23
- tempDir = await mkdtemp(path.join(tmpdir(), 'anthropic-auth-plugin-'));
24
- process.env.XDG_DATA_HOME = tempDir;
25
- });
26
- afterEach(async () => {
27
- if (originalXdgDataHome === undefined) {
28
- delete process.env.XDG_DATA_HOME;
29
- }
30
- else {
31
- process.env.XDG_DATA_HOME = originalXdgDataHome;
1
+ // Tests Anthropic request-time prompt rewriting and transform fallback behavior.
2
+ import { describe, expect, test } from 'vitest';
3
+ import { replacer, rewriteAnthropicRequestPayload, } from './anthropic-auth-plugin.js';
4
+ function parseRewrittenBody(body) {
5
+ if (!body) {
6
+ throw new Error('Expected rewritten body');
32
7
  }
33
- await rm(tempDir, { force: true, recursive: true });
34
- });
35
- describe('rememberAnthropicOAuth', () => {
36
- test('stores accounts and updates existing entries by refresh token', async () => {
37
- await rememberAnthropicOAuth(firstAccount);
38
- await rememberAnthropicOAuth({ ...firstAccount, access: 'access-first-new', expires: 3 });
39
- const store = await loadAccountStore();
40
- expect(store.activeIndex).toBe(0);
41
- expect(store.accounts).toHaveLength(1);
42
- expect(store.accounts[0]).toMatchObject({
43
- refresh: 'refresh-first',
44
- access: 'access-first-new',
45
- expires: 3,
46
- });
8
+ return JSON.parse(body);
9
+ }
10
+ describe('rewriteAnthropicRequestPayload', () => {
11
+ test('sanitizes raw opencode system text at request time', () => {
12
+ const rewritten = rewriteAnthropicRequestPayload(JSON.stringify({
13
+ model: 'claude-sonnet-4-5',
14
+ system: "You are OpenCode, the best coding agent on the planet.\nOS: macOS\nCWD: /repo\nSkills provide specialized instructions\nUse opencode tools carefully.",
15
+ tool_choice: { type: 'tool', name: 'read' },
16
+ tools: [{ name: 'read' }],
17
+ }));
18
+ const payload = parseRewrittenBody(rewritten.body);
19
+ expect(payload).toMatchInlineSnapshot(`
20
+ {
21
+ "model": "claude-sonnet-4-5",
22
+ "system": [
23
+ {
24
+ "text": "You are Claude Code, Anthropic's official CLI for Claude.",
25
+ "type": "text",
26
+ },
27
+ {
28
+ "text": "
29
+ <environment>
30
+ <cwd>/Users/morse/Documents/GitHub/kimakivoice/cli</cwd>
31
+ </environment>
32
+ Skills provide specialized instructions
33
+ Use openc0de tools carefully.",
34
+ "type": "text",
35
+ },
36
+ ],
37
+ "tool_choice": {
38
+ "name": "Read",
39
+ "type": "tool",
40
+ },
41
+ "tools": [
42
+ {
43
+ "name": "Read",
44
+ },
45
+ ],
46
+ }
47
+ `);
47
48
  });
48
- });
49
- describe('rotateAnthropicAccount', () => {
50
- test('rotates to the next stored account and syncs auth state', async () => {
51
- await saveAccountStore({
52
- version: 1,
53
- activeIndex: 0,
54
- accounts: [
55
- { ...firstAccount, addedAt: 1, lastUsed: 1 },
56
- { ...secondAccount, addedAt: 2, lastUsed: 2 },
57
- ],
58
- });
59
- const authSetCalls = [];
60
- const client = {
61
- auth: {
62
- set: async (input) => {
63
- authSetCalls.push(input);
49
+ test('does not duplicate claude code identity when request was already sanitized', () => {
50
+ const rewritten = rewriteAnthropicRequestPayload(JSON.stringify({
51
+ model: 'claude-sonnet-4-5',
52
+ system: [
53
+ {
54
+ type: 'text',
55
+ text: "You are Claude Code, Anthropic's official CLI for Claude.",
64
56
  },
65
- },
66
- };
67
- const rotated = await rotateAnthropicAccount(firstAccount, client);
68
- const store = await loadAccountStore();
69
- const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
70
- expect(rotated).toMatchObject({
71
- auth: { refresh: 'refresh-second' },
72
- fromLabel: '#1 (refresh-...irst)',
73
- toLabel: '#2 (refresh-...cond)',
74
- fromIndex: 0,
75
- toIndex: 1,
76
- });
77
- expect(store.activeIndex).toBe(1);
78
- expect(authJson.anthropic?.refresh).toBe('refresh-second');
79
- expect(authSetCalls).toEqual([
80
- {
81
- path: { id: 'anthropic' },
82
- body: {
83
- type: 'oauth',
84
- refresh: 'refresh-second',
85
- access: 'access-second',
86
- expires: 2,
57
+ {
58
+ type: 'text',
59
+ text: '<environment>\n<cwd>/repo</cwd>\n</environment>\nSkills provide specialized instructions',
87
60
  },
88
- },
89
- ]);
90
- });
91
- });
92
- describe('removeAccount', () => {
93
- test('removing the active account promotes the next stored account', async () => {
94
- await saveAccountStore({
95
- version: 1,
96
- activeIndex: 1,
97
- accounts: [
98
- { ...firstAccount, addedAt: 1, lastUsed: 1 },
99
- { ...secondAccount, addedAt: 2, lastUsed: 2 },
100
61
  ],
101
- });
102
- await removeAccount(1);
103
- const store = await loadAccountStore();
104
- const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
105
- expect(store.activeIndex).toBe(0);
106
- expect(store.accounts).toHaveLength(1);
107
- expect(store.accounts[0]?.refresh).toBe('refresh-first');
108
- expect(authJson.anthropic?.refresh).toBe('refresh-first');
109
- });
110
- test('removing the last account clears active Anthropic auth', async () => {
111
- await saveAccountStore({
112
- version: 1,
113
- activeIndex: 0,
114
- accounts: [{ ...firstAccount, addedAt: 1, lastUsed: 1 }],
115
- });
116
- await mkdir(path.dirname(authFilePath()), { recursive: true });
117
- await writeFile(authFilePath(), JSON.stringify({ anthropic: firstAccount }, null, 2));
118
- await removeAccount(0);
119
- const store = await loadAccountStore();
120
- const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
121
- expect(store.accounts).toHaveLength(0);
122
- expect(authJson.anthropic).toBeUndefined();
62
+ }));
63
+ const payload = parseRewrittenBody(rewritten.body);
64
+ expect(payload.system).toMatchInlineSnapshot(`
65
+ [
66
+ {
67
+ "text": "You are Claude Code, Anthropic's official CLI for Claude.",
68
+ "type": "text",
69
+ },
70
+ {
71
+ "text": "<environment>
72
+ <cwd>/repo</cwd>
73
+ </environment>
74
+ Skills provide specialized instructions",
75
+ "type": "text",
76
+ },
77
+ ]
78
+ `);
123
79
  });
124
80
  });
125
- describe('shouldRotateAuth', () => {
126
- test('only rotates on rate limit or auth failures', () => {
127
- expect(shouldRotateAuth(429, '')).toBe(true);
128
- expect(shouldRotateAuth(401, 'permission_error')).toBe(true);
129
- expect(shouldRotateAuth(400, 'bad request')).toBe(false);
81
+ describe('replacer', () => {
82
+ test('sanitizes system text only for anthropic provider metadata', async () => {
83
+ const plugin = await replacer({});
84
+ const transform = plugin['experimental.chat.system.transform'];
85
+ if (!transform) {
86
+ throw new Error('Expected experimental.chat.system.transform hook');
87
+ }
88
+ const output = {
89
+ system: [
90
+ "You are OpenCode, the best coding agent on the planet.\nOS: macOS\nSkills provide specialized instructions\nUse opencode tools carefully.",
91
+ ],
92
+ };
93
+ await transform({
94
+ model: {
95
+ providerID: 'anthropic',
96
+ },
97
+ }, output);
98
+ expect(output.system).toMatchInlineSnapshot(`
99
+ [
100
+ "
101
+ <environment>
102
+ <cwd>/Users/morse/Documents/GitHub/kimakivoice/cli</cwd>
103
+ </environment>
104
+ Skills provide specialized instructions
105
+ Use openc0de tools carefully.",
106
+ ]
107
+ `);
130
108
  });
131
109
  });
@@ -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
+ });