tsunami-code 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ import readline from 'readline';
3
+ import chalk from 'chalk';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import os from 'os';
7
+ import { agentLoop } from './lib/loop.js';
8
+ import { buildSystemPrompt } from './lib/prompt.js';
9
+ import { runPreflight } from './lib/preflight.js';
10
+
11
+ const VERSION = '2.5.0';
12
+ const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
13
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
14
+ const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
15
+
16
+ // ── Config ────────────────────────────────────────────────────────────────────
17
+ function loadConfig() {
18
+ if (existsSync(CONFIG_FILE)) {
19
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch {}
20
+ }
21
+ return {};
22
+ }
23
+
24
+ function saveConfig(cfg) {
25
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
26
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
27
+ }
28
+
29
+ function getServerUrl(argv) {
30
+ // Priority: --server flag > KEYSTONE_SERVER env var > saved config > default
31
+ const flagIdx = argv.indexOf('--server');
32
+ if (flagIdx !== -1 && argv[flagIdx + 1]) return argv[flagIdx + 1];
33
+ if (process.env.TSUNAMI_SERVER) return process.env.TSUNAMI_SERVER;
34
+ const cfg = loadConfig();
35
+ if (cfg.server) return cfg.server;
36
+ return DEFAULT_SERVER;
37
+ }
38
+
39
+ // ── Formatting ────────────────────────────────────────────────────────────────
40
+ const dim = (s) => chalk.dim(s);
41
+ const bold = (s) => chalk.bold(s);
42
+ const cyan = (s) => chalk.cyan(s);
43
+ const red = (s) => chalk.red(s);
44
+ const green = (s) => chalk.green(s);
45
+ const yellow = (s) => chalk.yellow(s);
46
+
47
+ function printBanner(serverUrl) {
48
+ console.log(cyan(bold('\n 🌊 Tsunami Code CLI')) + dim(` v${VERSION}`));
49
+ console.log(dim(' by Keystone World Management · Navy Seal Unit XI3'));
50
+ console.log(dim(` Model server: ${serverUrl}\n`));
51
+ }
52
+
53
+ function printToolCall(name, args) {
54
+ let parsed = {};
55
+ try { parsed = JSON.parse(args); } catch {}
56
+ const preview = Object.entries(parsed)
57
+ .map(([k, v]) => `${k}=${JSON.stringify(String(v).slice(0, 60))}`)
58
+ .join(', ');
59
+ process.stdout.write('\n' + dim(` ⚙ ${name}(${preview})\n`));
60
+ }
61
+
62
+ // ── CLI Parsing ───────────────────────────────────────────────────────────────
63
+ const argv = process.argv.slice(2);
64
+
65
+ if (argv.includes('--help') || argv.includes('-h')) {
66
+ console.log(`
67
+ ${cyan(bold('tsunami'))} — Tsunami Code CLI · AI coding agent
68
+
69
+ ${bold('Usage:')}
70
+ keystonecli [options]
71
+
72
+ ${bold('Options:')}
73
+ --server <url> Model server URL (default: env KEYSTONE_SERVER or saved config)
74
+ --set-server <url> Save server URL to config permanently
75
+ --version Show version
76
+ --help Show this help
77
+
78
+ ${bold('Environment:')}
79
+ TSUNAMI_SERVER Model server URL
80
+
81
+ ${bold('Config:')}
82
+ ${CONFIG_FILE}
83
+ `);
84
+ process.exit(0);
85
+ }
86
+
87
+ if (argv.includes('--version') || argv.includes('-v')) {
88
+ console.log(`keystonecli v${VERSION}`);
89
+ process.exit(0);
90
+ }
91
+
92
+ const setServerIdx = argv.indexOf('--set-server');
93
+ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
94
+ const url = argv[setServerIdx + 1];
95
+ const cfg = loadConfig();
96
+ cfg.server = url;
97
+ saveConfig(cfg);
98
+ console.log(green(` Server URL saved: ${url}`));
99
+ process.exit(0);
100
+ }
101
+
102
+ // ── Main ──────────────────────────────────────────────────────────────────────
103
+ async function run() {
104
+ const serverUrl = getServerUrl(argv);
105
+
106
+ printBanner(serverUrl);
107
+
108
+ // Preflight checks
109
+ process.stdout.write(dim(' Checking server connection...'));
110
+ const { errors, warnings } = await runPreflight(serverUrl);
111
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
112
+
113
+ if (warnings.length) {
114
+ for (const w of warnings) console.log(yellow(` ⚠ ${w}`));
115
+ console.log();
116
+ }
117
+
118
+ if (errors.length) {
119
+ for (const e of errors) console.log(red(` ✗ ${e}`));
120
+ console.log();
121
+ process.exit(1);
122
+ }
123
+
124
+ console.log(green(' ✓ Connected') + dim(' · Type your task. /help for commands. Ctrl+C to exit.\n'));
125
+
126
+ let currentServerUrl = serverUrl;
127
+ let messages = [];
128
+ let systemPrompt = buildSystemPrompt();
129
+
130
+ function resetSession() {
131
+ messages = [];
132
+ systemPrompt = buildSystemPrompt();
133
+ }
134
+
135
+ const rl = readline.createInterface({
136
+ input: process.stdin,
137
+ output: process.stdout,
138
+ prompt: cyan('❯ '),
139
+ terminal: process.stdin.isTTY
140
+ });
141
+
142
+ rl.prompt();
143
+
144
+ let isProcessing = false;
145
+ let pendingClose = false;
146
+
147
+ rl.on('close', () => {
148
+ if (isProcessing) { pendingClose = true; return; }
149
+ console.log(dim('\n Goodbye.\n'));
150
+ process.exit(0);
151
+ });
152
+
153
+ rl.on('line', async (input) => {
154
+ const line = input.trim();
155
+ if (!line) { rl.prompt(); return; }
156
+
157
+ if (line.startsWith('/')) {
158
+ const [cmd, ...rest] = line.slice(1).split(' ');
159
+ switch (cmd.toLowerCase()) {
160
+ case 'help':
161
+ console.log(dim(`
162
+ Commands:
163
+ /clear Start new conversation
164
+ /status Show context size
165
+ /server <url> Change server URL for this session
166
+ /exit Exit
167
+ `));
168
+ break;
169
+ case 'clear':
170
+ resetSession();
171
+ console.log(green(' Session cleared.\n'));
172
+ break;
173
+ case 'status':
174
+ console.log(dim(` Messages in context: ${messages.length} | Server: ${currentServerUrl}\n`));
175
+ break;
176
+ case 'server':
177
+ if (rest[0]) {
178
+ currentServerUrl = rest[0];
179
+ console.log(green(` Server changed to: ${rest[0]}\n`));
180
+ } else {
181
+ console.log(dim(` Current server: ${currentServerUrl}\n`));
182
+ }
183
+ break;
184
+ case 'exit': case 'quit':
185
+ process.exit(0);
186
+ default:
187
+ console.log(red(` Unknown command: /${cmd}\n`));
188
+ }
189
+ rl.prompt();
190
+ return;
191
+ }
192
+
193
+ const fullMessages = [
194
+ { role: 'system', content: systemPrompt },
195
+ ...messages,
196
+ { role: 'user', content: line }
197
+ ];
198
+ messages.push({ role: 'user', content: line });
199
+
200
+ rl.pause();
201
+ isProcessing = true;
202
+ process.stdout.write('\n');
203
+
204
+ let firstToken = true;
205
+ try {
206
+ await agentLoop(
207
+ currentServerUrl,
208
+ fullMessages,
209
+ (token) => {
210
+ if (firstToken) { process.stdout.write(' '); firstToken = false; }
211
+ process.stdout.write(token);
212
+ },
213
+ (toolName, toolArgs) => {
214
+ printToolCall(toolName, toolArgs);
215
+ firstToken = true;
216
+ }
217
+ );
218
+
219
+ messages = fullMessages.slice(1);
220
+ process.stdout.write('\n\n');
221
+ } catch (e) {
222
+ process.stdout.write('\n');
223
+ console.error(red(` Error: ${e.message}\n`));
224
+ }
225
+
226
+ isProcessing = false;
227
+ if (pendingClose) {
228
+ console.log(dim(' Goodbye.\n'));
229
+ process.exit(0);
230
+ }
231
+ rl.resume();
232
+ rl.prompt();
233
+ });
234
+
235
+ process.on('SIGINT', () => {
236
+ if (!isProcessing) {
237
+ console.log(dim('\n Goodbye.\n'));
238
+ process.exit(0);
239
+ }
240
+ });
241
+ }
242
+
243
+ run().catch(e => {
244
+ console.error(chalk.red(`Fatal: ${e.message}`));
245
+ process.exit(1);
246
+ });
package/lib/loop.js ADDED
@@ -0,0 +1,172 @@
1
+ import fetch from 'node-fetch';
2
+ import { ALL_TOOLS } from './tools.js';
3
+
4
+ // Parse tool calls from any format the model might produce
5
+ function parseToolCalls(content) {
6
+ const calls = [];
7
+ const seen = new Set();
8
+
9
+ function add(name, args) {
10
+ const tool = ALL_TOOLS.find(t => t.name === name);
11
+ if (!tool) return;
12
+ const key = name + JSON.stringify(args);
13
+ if (seen.has(key)) return;
14
+ seen.add(key);
15
+ calls.push({ name, arguments: typeof args === 'string' ? args : args });
16
+ }
17
+
18
+ // Format 1: <tool_call>{"name":..., "arguments":...}</tool_call>
19
+ const tc1 = /<tool_call>([\s\S]*?)<\/tool_call>/g;
20
+ let m;
21
+ while ((m = tc1.exec(content)) !== null) {
22
+ try {
23
+ const p = JSON.parse(m[1].trim());
24
+ if (p.name) add(p.name, p.arguments || p.params || {});
25
+ } catch {}
26
+ }
27
+
28
+ // Format 2: ```json or ```bash code block with {"name":..., "arguments":...}
29
+ const tc2 = /```(?:\w+)?\s*(\{[\s\S]*?"name"[\s\S]*?\})\s*```/g;
30
+ while ((m = tc2.exec(content)) !== null) {
31
+ try {
32
+ const p = JSON.parse(m[1].trim());
33
+ if (p.name) add(p.name, p.arguments || p.params || {});
34
+ } catch {}
35
+ }
36
+
37
+ // Format 3: <function_call><name>X</name><arguments>JSON</arguments></function_call>
38
+ const tc3 = /<function_call[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<arguments>([\s\S]*?)<\/arguments>[\s\S]*?<\/function_call>/g;
39
+ while ((m = tc3.exec(content)) !== null) {
40
+ const name = m[1].trim();
41
+ const argsRaw = m[2].trim();
42
+ try {
43
+ add(name, JSON.parse(argsRaw));
44
+ } catch {
45
+ const args = {};
46
+ const kv = /<(\w+)>([\s\S]*?)<\/\1>/g;
47
+ let a;
48
+ while ((a = kv.exec(argsRaw)) !== null) args[a[1]] = a[2];
49
+ if (Object.keys(args).length) add(name, args);
50
+ }
51
+ }
52
+
53
+ // Format 4: bare JSON with "name" field (last resort)
54
+ if (calls.length === 0) {
55
+ const tc4 = /\{[\s\S]*?"name"\s*:\s*"(\w+)"[\s\S]*?"arguments"\s*:\s*(\{[\s\S]*?\})[\s\S]*?\}/g;
56
+ while ((m = tc4.exec(content)) !== null) {
57
+ try { add(m[1], JSON.parse(m[2])); } catch {}
58
+ }
59
+ }
60
+
61
+ return calls;
62
+ }
63
+
64
+ // Normalize argument keys — model sometimes uses aliases
65
+ function normalizeArgs(args) {
66
+ if (typeof args !== 'object' || !args) return args;
67
+ const out = { ...args };
68
+ const aliases = {
69
+ path: 'file_path', file: 'file_path', filepath: 'file_path', filename: 'file_path',
70
+ cmd: 'command', shell_command: 'command',
71
+ regex: 'pattern', search: 'pattern', query: 'pattern',
72
+ text: 'content', body: 'content',
73
+ old: 'old_string', new: 'new_string', replacement: 'new_string'
74
+ };
75
+ for (const [alias, canonical] of Object.entries(aliases)) {
76
+ if (out[alias] !== undefined && out[canonical] === undefined) {
77
+ out[canonical] = out[alias];
78
+ delete out[alias];
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ async function runTool(name, args) {
85
+ const tool = ALL_TOOLS.find(t => t.name === name);
86
+ if (!tool) return `Error: Unknown tool "${name}"`;
87
+ try {
88
+ const parsed = typeof args === 'string' ? JSON.parse(args) : args;
89
+ return await tool.run(normalizeArgs(parsed));
90
+ } catch (e) {
91
+ return `Error executing ${name}: ${e.message}`;
92
+ }
93
+ }
94
+
95
+ async function waitForServer(serverUrl, retries = 10, delay = 2000) {
96
+ for (let i = 0; i < retries; i++) {
97
+ try {
98
+ const r = await fetch(`${serverUrl}/health`);
99
+ const j = await r.json();
100
+ if (j.status === 'ok') return;
101
+ } catch {}
102
+ await new Promise(r => setTimeout(r, delay));
103
+ }
104
+ throw new Error(`Model server not responding at ${serverUrl}`);
105
+ }
106
+
107
+ async function streamCompletion(serverUrl, messages, onToken) {
108
+ await waitForServer(serverUrl, 5, 1000);
109
+ const res = await fetch(`${serverUrl}/v1/chat/completions`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({
113
+ model: 'local',
114
+ messages,
115
+ stream: true,
116
+ temperature: 0.1,
117
+ max_tokens: 4096,
118
+ stop: ['</tool_call>\n\nContinue']
119
+ })
120
+ });
121
+
122
+ if (!res.ok) throw new Error(`Model server: ${res.status} ${await res.text()}`);
123
+
124
+ let fullContent = '';
125
+ let buffer = '';
126
+
127
+ for await (const chunk of res.body) {
128
+ buffer += chunk.toString();
129
+ const lines = buffer.split('\n');
130
+ buffer = lines.pop();
131
+
132
+ for (const line of lines) {
133
+ if (!line.startsWith('data: ')) continue;
134
+ const data = line.slice(6).trim();
135
+ if (data === '[DONE]') continue;
136
+ let parsed;
137
+ try { parsed = JSON.parse(data); } catch { continue; }
138
+ const token = parsed.choices?.[0]?.delta?.content;
139
+ if (token) {
140
+ fullContent += token;
141
+ onToken(token);
142
+ }
143
+ }
144
+ }
145
+
146
+ return fullContent;
147
+ }
148
+
149
+ export async function agentLoop(serverUrl, messages, onToken, onToolCall, maxIterations = 15) {
150
+ for (let i = 0; i < maxIterations; i++) {
151
+ const content = await streamCompletion(serverUrl, messages, onToken);
152
+ const toolCalls = parseToolCalls(content);
153
+
154
+ messages.push({ role: 'assistant', content });
155
+
156
+ if (toolCalls.length === 0) break;
157
+
158
+ const results = [];
159
+ for (const tc of toolCalls) {
160
+ onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
161
+ const result = await runTool(tc.name, tc.arguments);
162
+ results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
163
+ }
164
+
165
+ messages.push({
166
+ role: 'user',
167
+ content: results.join('\n\n---\n\n') + '\n\nContinue with the task.'
168
+ });
169
+
170
+ onToken('\n');
171
+ }
172
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { execSync } from 'child_process';
5
+ import fetch from 'node-fetch';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const VENDOR = join(__dirname, '..', 'vendor');
9
+
10
+ export function getRgPath() {
11
+ const vendored = join(VENDOR, process.platform === 'win32' ? 'rg.exe' : 'rg');
12
+ if (existsSync(vendored)) return vendored;
13
+ // Fall back to system rg
14
+ try {
15
+ const which = execSync(process.platform === 'win32' ? 'where rg' : 'which rg', { stdio: 'pipe' }).toString().trim().split('\n')[0];
16
+ if (which) return which;
17
+ } catch {}
18
+ return null;
19
+ }
20
+
21
+ export async function checkServer(serverUrl, timeoutMs = 5000) {
22
+ try {
23
+ const controller = new AbortController();
24
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
25
+ const res = await fetch(`${serverUrl}/health`, { signal: controller.signal });
26
+ clearTimeout(timer);
27
+ const json = await res.json();
28
+ return json.status === 'ok';
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function runPreflight(serverUrl) {
35
+ const errors = [];
36
+ const warnings = [];
37
+
38
+ // Node version
39
+ const [major] = process.versions.node.split('.').map(Number);
40
+ if (major < 18) errors.push(`Node.js 18+ required (found ${process.versions.node})`);
41
+
42
+ // ripgrep
43
+ const rgPath = getRgPath();
44
+ if (!rgPath) warnings.push('ripgrep not found — Grep tool disabled (run: npm install -g ripgrep or brew install ripgrep)');
45
+
46
+ // Server connectivity
47
+ const serverOk = await checkServer(serverUrl);
48
+ if (!serverOk) errors.push(`Cannot reach model server at ${serverUrl}\n → Check your KEYSTONE_SERVER env var or set it with: keystonecli --server <url>`);
49
+
50
+ return { errors, warnings, rgPath };
51
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import os from 'os';
4
+
5
+ function loadContextFile() {
6
+ const locations = [
7
+ join(process.cwd(), 'CLAUDE.md'),
8
+ join(process.cwd(), 'AGENTS.md'),
9
+ join(os.homedir(), '.claude', 'CLAUDE.md'),
10
+ join(os.homedir(), 'AGENTS.md'),
11
+ ];
12
+ for (const loc of locations) {
13
+ if (existsSync(loc)) {
14
+ return `\n\n<project-context source="${loc}">\n${readFileSync(loc, 'utf8')}\n</project-context>`;
15
+ }
16
+ }
17
+ return '';
18
+ }
19
+
20
+ export function buildSystemPrompt() {
21
+ const cwd = process.cwd();
22
+ const context = loadContextFile();
23
+
24
+ return `You are an expert software engineer and technical assistant operating as a CLI agent. You think deeply before acting, trace data flow before changing code, and verify your work.
25
+
26
+ To use a tool, output ONLY this format — nothing else before or after the tool call block:
27
+ <tool_call>
28
+ {"name": "ToolName", "arguments": {"param": "value"}}
29
+ </tool_call>
30
+
31
+ After receiving a tool result, continue your response naturally.
32
+ Available tools: Bash, Read, Write, Edit, Glob, Grep. Use them autonomously to complete tasks without asking permission.
33
+
34
+ <environment>
35
+ - Working directory: ${cwd}
36
+ - Platform: ${process.platform}
37
+ - Shell: ${process.platform === 'win32' ? 'cmd/powershell' : 'bash'}
38
+ - Date: ${new Date().toISOString().split('T')[0]}
39
+ </environment>
40
+
41
+ <tools>
42
+ - **Bash**: Run shell commands. Never use for grep/find/cat — use dedicated tools.
43
+ - **Read**: Read file contents with line numbers. Always read before editing.
44
+ - **Write**: Create new files or fully overwrite existing ones.
45
+ - **Edit**: Precise string replacements. Preferred for modifying files.
46
+ - **Glob**: Find files by pattern.
47
+ - **Grep**: Search file contents by regex. Always use instead of grep in Bash.
48
+ </tools>
49
+
50
+ <reasoning_protocol>
51
+ Before touching any file:
52
+ 1. Read it first — never edit code you haven't seen
53
+ 2. Trace the data flow — understand all layers before changing one
54
+ 3. Ask: what else uses this?
55
+
56
+ When something breaks:
57
+ 1. Read the error literally
58
+ 2. Narrow the blast radius — which layer?
59
+ 3. Add a log to confirm assumptions before fixing
60
+ 4. Change one thing at a time
61
+ </reasoning_protocol>
62
+
63
+ <behavior>
64
+ - Complete tasks fully without stopping to ask unless genuinely blocked
65
+ - Short user messages = full autonomy, proceed immediately
66
+ - Never summarize what you just did
67
+ - Never add preamble or filler
68
+ - Pick the most reasonable interpretation and execute
69
+ - Prefer editing existing files over creating new ones
70
+ - Don't add features beyond what was asked
71
+ </behavior>
72
+
73
+ <code_quality>
74
+ - Read before edit, always
75
+ - Each function does one thing
76
+ - Error paths as clear as success paths
77
+ - Parameterized queries only — never concatenate user input into SQL
78
+ - Every protected route: check auth at the top, first line
79
+ </code_quality>${context}`;
80
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,206 @@
1
+ import { execSync, exec } from 'child_process';
2
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
3
+ import { glob } from 'glob';
4
+ import { promisify } from 'util';
5
+ import { getRgPath } from './preflight.js';
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ // ── BASH ──────────────────────────────────────────────────────────────────────
10
+ export const BashTool = {
11
+ name: 'Bash',
12
+ description: `Executes a bash command and returns its output. The working directory persists between commands.
13
+
14
+ IMPORTANT: Never use for grep/find/cat/head/tail/sed/awk — use Read, Grep, Glob tools instead.
15
+
16
+ - Always quote file paths with spaces
17
+ - Chain dependent commands with && not newlines
18
+ - Max timeout: 120000ms`,
19
+ input_schema: {
20
+ type: 'object',
21
+ properties: {
22
+ command: { type: 'string', description: 'The bash command to execute' },
23
+ timeout: { type: 'number', description: 'Timeout ms (max 120000)', default: 120000 },
24
+ description: { type: 'string', description: 'Short description of what this command does' }
25
+ },
26
+ required: ['command']
27
+ },
28
+ async run({ command, timeout = 120000 }) {
29
+ try {
30
+ const shell = process.platform === 'win32' ? undefined : '/bin/bash';
31
+ const { stdout, stderr } = await execAsync(command, {
32
+ timeout: Math.min(timeout, 120000),
33
+ maxBuffer: 10 * 1024 * 1024,
34
+ shell
35
+ });
36
+ const out = stdout || '';
37
+ const err = stderr ? `\nSTDERR: ${stderr}` : '';
38
+ return (out + err).trim() || '(no output)';
39
+ } catch (e) {
40
+ if (e.killed) return `Error: Command timed out after ${timeout}ms`;
41
+ return `Error (exit ${e.code}): ${e.message}\n${e.stderr || ''}`.trim();
42
+ }
43
+ }
44
+ };
45
+
46
+ // ── READ ──────────────────────────────────────────────────────────────────────
47
+ export const ReadTool = {
48
+ name: 'Read',
49
+ description: `Reads a file from the local filesystem. Returns content with line numbers (cat -n format).
50
+
51
+ - file_path must be absolute
52
+ - Reads up to 2000 lines by default
53
+ - Use offset and limit for large files`,
54
+ input_schema: {
55
+ type: 'object',
56
+ properties: {
57
+ file_path: { type: 'string', description: 'Absolute path to the file' },
58
+ offset: { type: 'number', description: 'Line to start from (0-indexed)' },
59
+ limit: { type: 'number', description: 'Lines to read (default 2000)' }
60
+ },
61
+ required: ['file_path']
62
+ },
63
+ async run({ file_path, offset = 0, limit = 2000 }) {
64
+ if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
65
+ try {
66
+ const content = readFileSync(file_path, 'utf8');
67
+ const lines = content.split('\n');
68
+ const slice = lines.slice(offset, offset + limit);
69
+ return slice.map((l, i) => `${offset + i + 1}\t${l}`).join('\n');
70
+ } catch (e) {
71
+ return `Error reading file: ${e.message}`;
72
+ }
73
+ }
74
+ };
75
+
76
+ // ── WRITE ─────────────────────────────────────────────────────────────────────
77
+ export const WriteTool = {
78
+ name: 'Write',
79
+ description: `Writes content to a file (creates or overwrites). Prefer Edit for modifying existing files.`,
80
+ input_schema: {
81
+ type: 'object',
82
+ properties: {
83
+ file_path: { type: 'string', description: 'Absolute path to write to' },
84
+ content: { type: 'string', description: 'Content to write' }
85
+ },
86
+ required: ['file_path', 'content']
87
+ },
88
+ async run({ file_path, content }) {
89
+ try {
90
+ writeFileSync(file_path, content, 'utf8');
91
+ return `Written ${content.split('\n').length} lines to ${file_path}`;
92
+ } catch (e) {
93
+ return `Error writing file: ${e.message}`;
94
+ }
95
+ }
96
+ };
97
+
98
+ // ── EDIT ──────────────────────────────────────────────────────────────────────
99
+ export const EditTool = {
100
+ name: 'Edit',
101
+ description: `Exact string replacement in a file. Fails if old_string not found or not unique.
102
+
103
+ - Read the file first before editing
104
+ - old_string must match exactly including whitespace
105
+ - Use replace_all to replace every occurrence`,
106
+ input_schema: {
107
+ type: 'object',
108
+ properties: {
109
+ file_path: { type: 'string', description: 'Absolute path to the file' },
110
+ old_string: { type: 'string', description: 'Exact text to replace' },
111
+ new_string: { type: 'string', description: 'Replacement text' },
112
+ replace_all: { type: 'boolean', description: 'Replace all occurrences', default: false }
113
+ },
114
+ required: ['file_path', 'old_string', 'new_string']
115
+ },
116
+ async run({ file_path, old_string, new_string, replace_all = false }) {
117
+ if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
118
+ try {
119
+ let content = readFileSync(file_path, 'utf8');
120
+ if (!content.includes(old_string)) return `Error: old_string not found in ${file_path}`;
121
+ if (!replace_all) {
122
+ const count = content.split(old_string).length - 1;
123
+ if (count > 1) return `Error: old_string appears ${count} times — use replace_all or add more context to make it unique`;
124
+ }
125
+ const updated = replace_all
126
+ ? content.split(old_string).join(new_string)
127
+ : content.replace(old_string, new_string);
128
+ writeFileSync(file_path, updated, 'utf8');
129
+ return `Edited ${file_path}`;
130
+ } catch (e) {
131
+ return `Error editing file: ${e.message}`;
132
+ }
133
+ }
134
+ };
135
+
136
+ // ── GLOB ──────────────────────────────────────────────────────────────────────
137
+ export const GlobTool = {
138
+ name: 'Glob',
139
+ description: `File pattern matching. Returns matching paths sorted by modification time.
140
+
141
+ - Supports patterns like "**/*.js" or "src/**/*.ts"
142
+ - Use for file discovery; use Grep for content search`,
143
+ input_schema: {
144
+ type: 'object',
145
+ properties: {
146
+ pattern: { type: 'string', description: 'Glob pattern' },
147
+ path: { type: 'string', description: 'Directory to search in (defaults to cwd)' }
148
+ },
149
+ required: ['pattern']
150
+ },
151
+ async run({ pattern, path: searchPath }) {
152
+ try {
153
+ const cwd = searchPath || process.cwd();
154
+ const matches = await glob(pattern, { cwd, absolute: true });
155
+ if (matches.length === 0) return 'No files matched';
156
+ return matches.join('\n');
157
+ } catch (e) {
158
+ return `Error: ${e.message}`;
159
+ }
160
+ }
161
+ };
162
+
163
+ // ── GREP ──────────────────────────────────────────────────────────────────────
164
+ export const GrepTool = {
165
+ name: 'Grep',
166
+ description: `Search file contents using regex (ripgrep). Always use this instead of running grep in Bash.
167
+
168
+ - output_mode: "content" shows lines, "files_with_matches" shows paths, "count" shows counts
169
+ - Supports glob filter, type filter, -i for case insensitive, -C for context lines`,
170
+ input_schema: {
171
+ type: 'object',
172
+ properties: {
173
+ pattern: { type: 'string', description: 'Regex pattern' },
174
+ path: { type: 'string', description: 'File or directory to search' },
175
+ glob: { type: 'string', description: 'Glob filter (e.g. "*.js")' },
176
+ output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'], default: 'files_with_matches' },
177
+ '-i': { type: 'boolean', description: 'Case insensitive' },
178
+ '-C': { type: 'number', description: 'Context lines around each match' },
179
+ head_limit: { type: 'number', description: 'Limit output to first N results', default: 250 }
180
+ },
181
+ required: ['pattern']
182
+ },
183
+ async run({ pattern, path: searchPath = '.', glob: globFilter, output_mode = 'files_with_matches', '-i': caseInsensitive, '-C': context, head_limit = 250 }) {
184
+ const rgPath = getRgPath();
185
+ if (!rgPath) return 'Error: ripgrep not available — install with: brew install ripgrep or apt install ripgrep';
186
+ try {
187
+ const rgBin = JSON.stringify(rgPath);
188
+ let cmd = rgBin;
189
+ if (caseInsensitive) cmd += ' -i';
190
+ if (context) cmd += ` -C ${context}`;
191
+ if (globFilter) cmd += ` --glob ${JSON.stringify(globFilter)}`;
192
+ if (output_mode === 'files_with_matches') cmd += ' -l';
193
+ else if (output_mode === 'count') cmd += ' --count';
194
+ else cmd += ' -n';
195
+ cmd += ` ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)}`;
196
+ const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 });
197
+ const lines = stdout.trim().split('\n').filter(Boolean);
198
+ return lines.slice(0, head_limit).join('\n') || 'No matches found';
199
+ } catch (e) {
200
+ if (e.code === 1) return 'No matches found';
201
+ return `Error: ${e.message}`;
202
+ }
203
+ }
204
+ };
205
+
206
+ export const ALL_TOOLS = [BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool];
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "tsunami-code",
3
+ "version": "2.5.0",
4
+ "description": "Tsunami Code CLI — AI coding agent powered by open-source models",
5
+ "type": "module",
6
+ "bin": {
7
+ "tsunami": "index.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node scripts/setup.js"
11
+ },
12
+ "dependencies": {
13
+ "chalk": "^5.3.0",
14
+ "glob": "^13.0.6",
15
+ "node-fetch": "^3.3.2"
16
+ },
17
+ "engines": {
18
+ "node": ">=18.0.0"
19
+ },
20
+ "keywords": [
21
+ "ai",
22
+ "cli",
23
+ "agent",
24
+ "coding",
25
+ "llm",
26
+ "tsunami"
27
+ ],
28
+ "author": "Keystone World Management Navy Seal Unit XI3",
29
+ "license": "MIT"
30
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // Runs at npm install time — downloads the ripgrep binary for this platform
3
+ import { createWriteStream, existsSync, mkdirSync, chmodSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { pipeline } from 'stream/promises';
7
+ import https from 'https';
8
+ import { createGunzip } from 'zlib';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+
12
+ const execAsync = promisify(exec);
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const ROOT = join(__dirname, '..');
15
+ const VENDOR = join(ROOT, 'vendor');
16
+
17
+ const RG_VERSION = '14.1.1';
18
+ const PLATFORMS = {
19
+ 'linux-x64': { url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz`, bin: 'rg' },
20
+ 'linux-arm64': { url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-aarch64-unknown-linux-gnu.tar.gz`, bin: 'rg' },
21
+ 'darwin-x64': { url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-x86_64-apple-darwin.tar.gz`, bin: 'rg' },
22
+ 'darwin-arm64':{ url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-aarch64-apple-darwin.tar.gz`, bin: 'rg' },
23
+ 'win32-x64': { url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-x86_64-pc-windows-msvc.zip`, bin: 'rg.exe' },
24
+ };
25
+
26
+ const key = `${process.platform}-${process.arch}`;
27
+ const platform = PLATFORMS[key];
28
+
29
+ if (!platform) {
30
+ console.warn(`[tsunami-code] No prebuilt ripgrep for ${key} — Grep tool will fall back to system rg`);
31
+ process.exit(0);
32
+ }
33
+
34
+ const binPath = join(VENDOR, platform.bin);
35
+ if (existsSync(binPath)) {
36
+ process.exit(0); // already installed
37
+ }
38
+
39
+ if (!existsSync(VENDOR)) mkdirSync(VENDOR, { recursive: true });
40
+
41
+ console.log(`[tsunami-code] Downloading ripgrep for ${key}...`);
42
+
43
+ function httpsGet(url) {
44
+ return new Promise((resolve, reject) => {
45
+ https.get(url, { headers: { 'User-Agent': 'keystonewm-setup' } }, (res) => {
46
+ if (res.statusCode === 301 || res.statusCode === 302) {
47
+ httpsGet(res.headers.location).then(resolve).catch(reject);
48
+ return;
49
+ }
50
+ if (res.statusCode !== 200) {
51
+ reject(new Error(`HTTP ${res.statusCode}`));
52
+ return;
53
+ }
54
+ resolve(res);
55
+ }).on('error', reject);
56
+ });
57
+ }
58
+
59
+ async function extractTarGz(stream, destDir, binName) {
60
+ // Extract using system tar (available on all platforms with Node 18+)
61
+ const tarPath = join(destDir, '_rg.tar.gz');
62
+ await pipeline(stream, createWriteStream(tarPath));
63
+ await execAsync(`tar -xzf "${tarPath}" -C "${destDir}" --strip-components=1 --wildcards "*/${binName}" 2>/dev/null || tar -xzf "${tarPath}" -C "${destDir}" --strip-components=1`);
64
+ try { await execAsync(`rm "${tarPath}"`); } catch {}
65
+ }
66
+
67
+ async function extractZip(stream, destDir, binName) {
68
+ const zipPath = join(destDir, '_rg.zip');
69
+ await pipeline(stream, createWriteStream(zipPath));
70
+ // Use PowerShell on Windows
71
+ await execAsync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`);
72
+ // Find and move the exe to vendor root
73
+ try {
74
+ await execAsync(`powershell -Command "Get-ChildItem -Path '${destDir}' -Recurse -Filter '${binName}' | Select-Object -First 1 | Copy-Item -Destination '${join(destDir, binName)}'"`);
75
+ await execAsync(`powershell -Command "Remove-Item '${zipPath}' -Force"`);
76
+ } catch {}
77
+ }
78
+
79
+ try {
80
+ const res = await httpsGet(platform.url);
81
+ if (platform.url.endsWith('.zip')) {
82
+ await extractZip(res, VENDOR, platform.bin);
83
+ } else {
84
+ await extractTarGz(res, VENDOR, platform.bin);
85
+ }
86
+
87
+ if (existsSync(binPath)) {
88
+ if (process.platform !== 'win32') chmodSync(binPath, 0o755);
89
+ console.log(`[tsunami-code] ripgrep installed at ${binPath}`);
90
+ } else {
91
+ console.warn(`[tsunami-code] ripgrep extraction completed but binary not found at expected path — Grep tool will fall back to system rg`);
92
+ }
93
+ } catch (e) {
94
+ console.warn(`[tsunami-code] Could not install ripgrep: ${e.message} — Grep tool will fall back to system rg`);
95
+ }