@calamity-inc/slave 0.1.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +19 -10
  2. package/index.js +432 -179
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -2,17 +2,16 @@
2
2
 
3
3
  Vibe coding without the kaboom factor.
4
4
 
5
- ## Installation
5
+ ## Setup
6
6
 
7
- ```
8
- npm i -g @calamity-inc/slave
9
- ```
7
+ 1. Install slave: `npm i -g @calamity-inc/slave`
8
+ 2. Create a default profile, e.g. `slave --create-profile default openai gpt-6-luna` or `slave --create-profile default anthropic claude-haiku-5-5`
10
9
 
11
10
  ## Usage
12
11
 
13
12
  In any directory:
14
13
  ```
15
- slave [flags] [--] How do I use the 'slave' command?
14
+ slave [options] [--] How do I use the 'slave' command?
16
15
  ```
17
16
 
18
17
  You can also pipe text in:
@@ -20,19 +19,29 @@ You can also pipe text in:
20
19
  cat yap.txt | slave Summarize this text
21
20
  ```
22
21
 
23
- Flags:
22
+ Options:
24
23
  - `-c`, `--cont`, `--continue` — add your previous invocation to the context of this one
24
+ - `-a`, `--amend` — retry the previous invocation, using the context from before it
25
25
  - `-w`, `--web` — allow the model to search the web
26
- - `-n`, `--no-fs`, `--nofs` — disables filesystem access so you can just have a non-binding chat with the model
26
+ - `-n`, `--no-fs`, `--nofs` — disable filesystem access
27
+ - `-s`, `--sandbox` — allow the model to use a docker container
28
+ - `-p`, `--profile` `<name>` — use the specified profile
27
29
 
28
30
  Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
29
31
 
30
- ## Sandboxing / Security
32
+ Subcommands:
33
+ - `--create-profile <name> <provider> <model>` — creates a profile
34
+ - `provider` can be `openai`, `anthropic`, or an OpenAI-compatible base URL like `http://127.0.0.1:1234/v1` for [LM Studio](https://lmstudio.ai/developers).
35
+ - `--delete-profile <name>` — deletes a profile
36
+ - `--update-api-key <provider>` — updates an API Key
37
+
38
+ ## Security
31
39
 
32
- The model gets no command prompt access and limited access to your filesystem:
33
- - Only within the working directory and subdirectories.
40
+ The model can't run commands on your system; it only gets limited access to your filesystem:
41
+ - Within the working directory and subdirectories.
34
42
  - `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
35
43
  - Files that are `.gitignore`d can't be written to.
44
+ - You can provide a `.slaveignore` for files that the model should not be allowed to read or write. It will still be able to know of their existence.
36
45
 
37
46
  However, these restrictions are only as good as your practices; you should:
38
47
  - Only run `slave` in Git-tracked folders and be sure that anything you don't want to lose is committed or at least staged so you can always revert any changes made by the model.
package/index.js CHANGED
@@ -1,48 +1,48 @@
1
1
  #!/usr/bin/env node
2
+
3
+ import { execFileSync, spawnSync } from "node:child_process";
2
4
  import fs from "node:fs";
3
5
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { parseEnv } from "node:util";
6
6
  import { createInterface } from "node:readline/promises";
7
+
8
+ import Anthropic from "@anthropic-ai/sdk";
9
+ import { Command } from "commander";
10
+ import envPaths from "env-paths";
7
11
  import ignore from "ignore";
8
12
  import { marked } from "marked";
9
13
  import { markedTerminal } from "marked-terminal";
10
14
  import OpenAI from "openai";
11
15
 
12
- const configDir = path.dirname(fileURLToPath(import.meta.url));
13
- const env = fs.existsSync(path.join(configDir, ".env")) ? parseEnv(fs.readFileSync(path.join(configDir, ".env"), "utf8")) : {};
14
- if (!env.OPENAI_API_KEY) {
15
- if (!process.stdin.isTTY) {
16
- console.error(`No OPENAI_API_KEY found. Run this command in a terminal to enter your key, or add it to ${path.join(configDir, ".env")}.`);
17
- process.exit(1);
18
- }
19
16
 
17
+ const configDir = envPaths("slave").data;
18
+ const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
19
+ state.profiles ??= {};
20
+ state.apiKeys ??= {};
21
+ state.providerStates ??= {};
22
+ const promptForApiKey = async (provider) => {
20
23
  const readline = createInterface({ input: process.stdin, output: process.stdout });
21
24
  try {
22
- env.OPENAI_API_KEY = (await readline.question("Enter your OpenAI API key: ")).trim();
25
+ state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
23
26
  }
24
27
  finally {
25
28
  readline.close();
26
29
  }
27
- if (!env.OPENAI_API_KEY) {
28
- console.error("An API key is required.");
29
- process.exit(1);
30
- }
31
- console.log();
30
+ };
31
+ const saveState = () => {
32
+ fs.mkdirSync(configDir, { recursive: true });
33
+ fs.writeFileSync(path.join(configDir, "state.json"), JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
34
+ fs.chmodSync(path.join(configDir, "state.json"), 0o600);
35
+ };
32
36
 
33
- fs.writeFileSync(path.join(configDir, ".env"), `OPENAI_API_KEY=${env.OPENAI_API_KEY}`, { encoding: "utf8", mode: 0o600 });
34
- fs.chmodSync(path.join(configDir, ".env"), 0o600);
37
+ const slaveignore = ignore();
38
+ if (fs.existsSync(".slaveignore")) {
39
+ slaveignore.add(fs.readFileSync(".slaveignore", "utf8").replaceAll("/\n", "\n"));
35
40
  }
36
- const client = new OpenAI({
37
- apiKey: env.OPENAI_API_KEY,
38
- });
39
-
40
41
  const gitignore = ignore();
41
42
  if (fs.existsSync(".gitignore")) {
42
43
  gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
43
44
  }
44
-
45
- const isOutOfReach = (p, type) => {
45
+ const isOutOfReach = (p, accessType) => {
46
46
  const rel = path.relative(".", p);
47
47
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
48
48
  return true;
@@ -51,25 +51,53 @@ const isOutOfReach = (p, type) => {
51
51
  if (segs.includes(".env") || segs.includes(".git")) {
52
52
  return true;
53
53
  }
54
- if (type == "write" && rel && gitignore.ignores(rel)) {
54
+ if (rel && (slaveignore.ignores(rel) || (accessType == "write" && gitignore.ignores(rel)))) {
55
55
  return true;
56
56
  }
57
57
  return false;
58
58
  };
59
59
 
60
+ const fixPath = (path, accessType) => {
61
+ if (path.startsWith("/")) {
62
+ path = path.substring(1);
63
+ path ||= ".";
64
+ }
65
+ return isOutOfReach(path, accessType) ? "" : path;
66
+ };
67
+
60
68
  const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
61
69
  const writeFileFromModel = (filePath, data, usesCRLF) => {
62
- if (usesCRLF === undefined) {
63
- usesCRLF = fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8").includes("\r\n");
70
+ if (fs.existsSync(filePath)) {
71
+ if (usesCRLF === undefined) {
72
+ usesCRLF = fs.readFileSync(filePath, "utf8").includes("\r\n");
73
+ }
74
+ }
75
+ else {
76
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
77
  }
65
78
  const normalized = data.replaceAll("\r\n", "\n");
66
79
  fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
67
80
  };
68
81
 
82
+ let used_docker;
83
+ const ensureSandbox = () => {
84
+ if (!used_docker) {
85
+ try {
86
+ // `docker pull ghcr.io/calamity-inc/slave-sandbox` is not needed to get the image, but would be needed to update it.
87
+ execFileSync("docker", ["run", "-d", "--name", "slave-sandbox", "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
88
+ }
89
+ catch (e) {
90
+ // container may already exist if it was not cleaned up previously. this is okay-ish.
91
+ }
92
+ used_docker = true;
93
+ }
94
+ };
95
+
69
96
  const functions = {
70
97
  ls: ({ path }) => {
71
98
  path ??= ".";
72
- if (isOutOfReach(path)) {
99
+ path = fixPath(path);
100
+ if (!path) {
73
101
  return "Out of your reach";
74
102
  }
75
103
  let files;
@@ -83,7 +111,8 @@ const functions = {
83
111
  },
84
112
  findFiles: ({ path, substring, recursive }) => {
85
113
  path ??= ".";
86
- if (isOutOfReach(path)) {
114
+ path = fixPath(path);
115
+ if (!path) {
87
116
  return "Out of your reach";
88
117
  }
89
118
  let files;
@@ -111,21 +140,27 @@ const functions = {
111
140
  return res.join("\n");
112
141
  },
113
142
  readFile: ({ path }) => {
114
- if (isOutOfReach(path)) {
115
- return "Out of your reach";
143
+ path = fixPath(path);
144
+ if (!path) {
145
+ return "You don't have read access to this file";
146
+ }
147
+ if (!fs.existsSync(path) || !fs.statSync(path).isFile()) {
148
+ return "No such file";
116
149
  }
117
150
  return readFileForModel(path);
118
151
  },
119
152
  writeFile: ({ path, data }) => {
120
- if (isOutOfReach(path, "write")) {
121
- return "Out of your reach";
153
+ path = fixPath(path, "write");
154
+ if (!path) {
155
+ return "You don't have write access to this file";
122
156
  }
123
157
  writeFileFromModel(path, data);
124
158
  return "OK";
125
159
  },
126
160
  fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
127
- if (isOutOfReach(path, "write")) {
128
- return "Out of your reach";
161
+ path = fixPath(path, "write");
162
+ if (!path) {
163
+ return "You don't have write access to this file";
129
164
  }
130
165
  const fileContent = fs.readFileSync(path, "utf8");
131
166
  const usesCRLF = fileContent.includes("\r\n");
@@ -137,129 +172,235 @@ const functions = {
137
172
  writeFileFromModel(path, arr.join(replacement), usesCRLF);
138
173
  return "OK";
139
174
  },
140
- system: ({ command }) => {
175
+ sandboxExec: ({ command, args }) => {
176
+ ensureSandbox();
177
+ const result = spawnSync("docker", [ "exec", "slave-sandbox", command, ...args ], { encoding: "utf8" });
178
+ const output = (result.stdout ?? "") + (result.stderr ?? "");
179
+ if (result.error) {
180
+ return (output || result.error.message).trimEnd();
181
+ }
182
+ if (result.status !== 0 && !output) {
183
+ return `Command exited with status ${result.status}`;
184
+ }
185
+ return output;
186
+ },
187
+ sandboxCopyIn: ({ hostPath, sbxPath }) => {
188
+ hostPath = fixPath(hostPath);
189
+ if (!hostPath) {
190
+ return "You don't have read access to this file";
191
+ }
192
+ if (!fs.existsSync(hostPath) || !fs.statSync(hostPath).isFile()) {
193
+ return "Host path must point to a file";
194
+ }
195
+ ensureSandbox();
196
+ try {
197
+ execFileSync("docker", ["cp", "-L", hostPath, `slave-sandbox:${sbxPath}`], { encoding: "utf8" });
198
+ return "OK";
199
+ }
200
+ catch (e) {
201
+ return (e.stdout ?? e.message).trimEnd();
202
+ }
203
+ },
204
+ sandboxCopyOut: ({ sbxPath, hostPath }) => {
205
+ hostPath = fixPath(hostPath, "write");
206
+ if (!hostPath) {
207
+ return "You don't have write access to this file";
208
+ }
209
+ if (fs.existsSync(hostPath) && !fs.statSync(hostPath).isFile()) {
210
+ return "Host path must point to a file";
211
+ }
212
+ ensureSandbox();
213
+ execFileSync("docker", ["cp", "-L", `slave-sandbox:${sbxPath}`, hostPath], { encoding: "utf8" });
214
+ return "OK";
215
+ },
216
+ /*system: ({ command }) => {
141
217
  //process.exit(1);
142
218
  return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
143
- },
219
+ }*/
144
220
  };
145
221
 
146
- let tools = [
147
- {
148
- type: "function",
149
- name: "ls",
150
- description: "Lists files",
151
- parameters: {
152
- type: "object",
153
- properties: {
154
- path: { type: ["string", "null"] },
222
+ const tools = [];
223
+ const initTools = (fs, sandbox, web_search) => {
224
+ if (fs) {
225
+ tools.push(
226
+ {
227
+ type: "function",
228
+ name: "ls",
229
+ description: "Lists files",
230
+ parameters: {
231
+ type: "object",
232
+ properties: {
233
+ path: { type: ["string", "null"] },
234
+ },
235
+ required: [ "path" ],
236
+ additionalProperties: false,
237
+ },
238
+ strict: true,
155
239
  },
156
- required: [ "path" ],
157
- additionalProperties: false,
158
- },
159
- strict: true,
160
- },
161
- {
162
- type: "function",
163
- name: "findFiles",
164
- parameters: {
165
- type: "object",
166
- properties: {
167
- path: { type: ["string", "null"] },
168
- substring: { type: "string" },
169
- recursive: { type: ["boolean", "null"] },
240
+ {
241
+ type: "function",
242
+ name: "findFiles",
243
+ parameters: {
244
+ type: "object",
245
+ properties: {
246
+ path: { type: ["string", "null"] },
247
+ substring: { type: "string" },
248
+ recursive: { type: ["boolean", "null"] },
249
+ },
250
+ required: [ "path", "substring", "recursive" ],
251
+ additionalProperties: false,
252
+ },
253
+ strict: true,
170
254
  },
171
- required: [ "path", "substring", "recursive" ],
172
- additionalProperties: false,
173
- },
174
- strict: true,
175
- },
176
- {
177
- type: "function",
178
- name: "readFile",
179
- parameters: {
180
- type: "object",
181
- properties: {
182
- path: { type: "string" },
255
+ {
256
+ type: "function",
257
+ name: "readFile",
258
+ parameters: {
259
+ type: "object",
260
+ properties: {
261
+ path: { type: "string" },
262
+ },
263
+ required: [ "path" ],
264
+ additionalProperties: false,
265
+ },
266
+ strict: true,
183
267
  },
184
- required: [ "path" ],
185
- additionalProperties: false,
186
- },
187
- strict: true,
188
- },
189
- {
190
- type: "function",
191
- name: "writeFile",
192
- parameters: {
193
- type: "object",
194
- properties: {
195
- path: { type: "string" },
196
- data: { type: "string" },
268
+ {
269
+ type: "function",
270
+ name: "writeFile",
271
+ parameters: {
272
+ type: "object",
273
+ properties: {
274
+ path: { type: "string" },
275
+ data: { type: "string" },
276
+ },
277
+ required: [ "path", "data" ],
278
+ additionalProperties: false,
279
+ },
280
+ strict: true,
197
281
  },
198
- required: [ "path", "data" ],
199
- additionalProperties: false,
200
- },
201
- strict: true,
202
- },
203
- {
204
- type: "function",
205
- name: "fileFindAndReplace",
206
- parameters: {
207
- type: "object",
208
- properties: {
209
- path: { type: "string" },
210
- substring: { type: "string" },
211
- replacement: { type: "string" },
212
- expectedMatches: { type: ["number", "null"] },
282
+ {
283
+ type: "function",
284
+ name: "fileFindAndReplace",
285
+ parameters: {
286
+ type: "object",
287
+ properties: {
288
+ path: { type: "string" },
289
+ substring: { type: "string" },
290
+ replacement: { type: "string" },
291
+ expectedMatches: { type: ["number", "null"] },
292
+ },
293
+ required: [ "path", "substring", "replacement", "expectedMatches" ],
294
+ additionalProperties: false,
295
+ },
296
+ strict: true,
213
297
  },
214
- required: [ "path", "substring", "replacement", "expectedMatches" ],
215
- additionalProperties: false,
216
- },
217
- strict: true,
218
- },
219
- // This is useful to see where the model maybe expects a better tool:
220
- /*{
221
- type: "function",
222
- name: "system",
223
- parameters: {
224
- type: "object",
225
- properties: {
226
- command: { type: "string" },
298
+ );
299
+ }
300
+ if (sandbox) {
301
+ tools.push({
302
+ type: "function",
303
+ name: "sandboxExec",
304
+ description: "Runs a command without Bash in an isolated sandbox.",
305
+ parameters: {
306
+ type: "object",
307
+ properties: {
308
+ command: { type: "string" },
309
+ args: { type: "array", items: { type: "string" } },
310
+ },
311
+ required: [ "command", "args" ],
312
+ additionalProperties: false,
227
313
  },
228
- required: [ "command" ],
229
- additionalProperties: false,
230
- },
231
- strict: true,
232
- },*/
233
- ];
234
-
235
- const generateResponseOptionallyUsingTools = async (input, previous_response_id) => {
236
- const model = "gpt-6-luna";
314
+ strict: true,
315
+ });
316
+ if (fs) {
317
+ tools.push(
318
+ {
319
+ type: "function",
320
+ name: "sandboxCopyIn",
321
+ parameters: {
322
+ type: "object",
323
+ properties: {
324
+ hostPath: { type: "string" },
325
+ sbxPath: { type: "string" },
326
+ },
327
+ required: [ "hostPath", "sbxPath" ],
328
+ additionalProperties: false,
329
+ },
330
+ strict: true,
331
+ },
332
+ {
333
+ type: "function",
334
+ name: "sandboxCopyOut",
335
+ parameters: {
336
+ type: "object",
337
+ properties: {
338
+ sbxPath: { type: "string" },
339
+ hostPath: { type: "string" },
340
+ },
341
+ required: [ "sbxPath", "hostPath" ],
342
+ additionalProperties: false,
343
+ },
344
+ strict: true,
345
+ },
346
+ );
347
+ }
348
+ }
349
+ if (web_search) {
350
+ tools.push({ type: "web_search" });
351
+ }
352
+ };
353
+
354
+ const toolToAnthropic = (t) => {
355
+ if (t.type == "function") {
356
+ return {
357
+ name: t.name,
358
+ description: t.description,
359
+ input_schema: {
360
+ type: t.parameters.type,
361
+ properties: t.parameters.properties,
362
+ }
363
+ };
364
+ }
365
+ else if (t.type == "web_search") {
366
+ return {
367
+ type: "web_search_20260318",
368
+ name: "web_search"
369
+ };
370
+ }
371
+ };
372
+
373
+ const useTool = (name, args, argsStr) => {
374
+ console.log("$ " + name + argsStr);
375
+ const output = String(functions[name](args));
376
+ let consoleOutput = output.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>");
377
+ if (name == "readFile" && consoleOutput.length > 80) {
378
+ consoleOutput = consoleOutput.substring(0, 77) + "...";
379
+ }
380
+ console.log(consoleOutput);
381
+ return output;
382
+ };
383
+
384
+ const openai_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
237
385
  let response = await client.responses.create({
238
386
  model,
239
- previous_response_id,
387
+ previous_response_id: state,
240
388
  input,
241
389
  tools,
242
390
  });
243
391
  let used_tools = false;
244
392
 
245
393
  while (true) {
246
- const toolCalls = response.output.filter((item) => item.type === "function_call");
394
+ const toolCalls = response.output.filter((item) => item.type == "function_call");
247
395
  if (toolCalls.length == 0) {
248
396
  break;
249
397
  }
250
398
 
251
399
  const outputs = toolCalls.map((call) => {
252
- console.log("$ " + call.name + call.arguments);
253
- const output = String(functions[call.name](JSON.parse(call.arguments)));
254
- let consoleOutput = output.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>");
255
- if (call.name == "readFile" && consoleOutput.length > 80) {
256
- consoleOutput = consoleOutput.substring(0, 77) + "...";
257
- }
258
- console.log(consoleOutput);
259
400
  return {
260
401
  type: "function_call_output",
261
402
  call_id: call.call_id,
262
- output,
403
+ output: useTool(call.name, JSON.parse(call.arguments), call.arguments)
263
404
  };
264
405
  });
265
406
 
@@ -272,62 +413,154 @@ const generateResponseOptionallyUsingTools = async (input, previous_response_id)
272
413
  used_tools = true;
273
414
  }
274
415
 
275
- response.used_tools = used_tools;
276
- return response;
416
+ return {
417
+ output_text: response.output_text,
418
+ state: response.id,
419
+ used_tools,
420
+ };
421
+ };
422
+
423
+ const anthropic_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
424
+ state = JSON.parse(state || "[]");
425
+ state.push({ role: "user", content: input });
426
+ let used_tools = false;
427
+
428
+ const anthropicTools = tools.map(t => toolToAnthropic(t));
429
+
430
+ let message = await client.messages.create({
431
+ model,
432
+ max_tokens: 4096,
433
+ cache_control: { type: "ephemeral" },
434
+ messages: state,
435
+ tools: anthropicTools,
436
+ });
437
+ state.push({ role: message.role, content: message.content });
438
+
439
+ while (message.stop_reason == "tool_use" || message.stop_reason == "max_tokens" || message.stop_reason == "pause_turn") {
440
+ const toolCalls = message.content.filter((item) => item.type == "tool_use");
441
+ if (toolCalls.length) {
442
+ const outputs = toolCalls.map((call) => {
443
+ return {
444
+ type: "tool_result",
445
+ tool_use_id: call.id,
446
+ content: useTool(call.name, call.input, JSON.stringify(call.input))
447
+ };
448
+ });
449
+ state.push({ role: "user", content: outputs });
450
+ used_tools = true;
451
+ }
452
+
453
+ message = await client.messages.create({
454
+ model,
455
+ max_tokens: 4096,
456
+ cache_control: { type: "ephemeral" },
457
+ messages: state,
458
+ tools: anthropicTools,
459
+ });
460
+ state.push({ role: message.role, content: message.content });
461
+ }
462
+
463
+ //console.log(message);
464
+ return {
465
+ output_text: message.content.find((item) => item.type == "text").text,
466
+ state: JSON.stringify(state),
467
+ used_tools,
468
+ };
277
469
  };
278
470
 
279
471
  marked.use(markedTerminal());
280
472
  const markdownToConsoleText = (md) => {
281
473
  const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
282
474
  console.error = undefined;
283
- const text = marked.parse(md);
475
+ const text = marked.parse(md).trimEnd() + "\n";
284
476
  console.error = _console_error;
285
477
  return text;
286
478
  };
287
479
 
288
- let i = 2;
289
- let previous_response_id;
290
- const overrides = [];
291
- const processFlag = (flag) => {
292
- if (flag == "-c" || flag == "--cont" || flag == "--continue") {
293
- if (fs.existsSync(path.join(configDir, ".state.json"))) {
294
- previous_response_id = JSON.parse(fs.readFileSync(path.join(configDir, ".state.json"), "utf8")).previous_response_id;
295
- }
296
- }
297
- else if (flag == "-w" || flag == "--web") {
298
- if (!overrides.includes("web search enabled")) {
299
- tools.push({ type: "web_search" });
300
- overrides.push("web search enabled");
301
- }
480
+
481
+ const program = new Command();
482
+ program
483
+ .name("slave")
484
+ .usage("[options] [--] [prompt...]")
485
+ .description("Vibe coding without the kaboom factor.")
486
+ .argument("[prompt...]")
487
+ .option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
488
+ .option("-a, --amend", "retry the previous invocation, using the context from before it")
489
+ .option("-w, --web", "allow the model to search the web")
490
+ .option("-n, --no-fs", "disable filesystem access").option("--nofs")
491
+ .option("-s, --sandbox", "allow the model to use a docker container")
492
+ .option("-p, --profile <name>", "profile name", "default")
493
+ .option("--create-profile <name> <provider> <model>", "create a profile")
494
+ .option("--delete-profile <name>", "delete a profile") // TODO
495
+ .option("--update-api-key <provider>", "update an API key") // TODO
496
+ .helpOption("-?, --help", "display help")
497
+ .parse();
498
+ const options = program.opts();
499
+
500
+ if (options.createProfile) {
501
+ if (!program.args[0] || !program.args[1]) {
502
+ console.log("Syntax: slave --create-profile <name> <provider> <model>");
503
+ process.exit(1);
302
504
  }
303
- else if (flag == "-n" || flag == "--no-fs" || flag == "--nofs") {
304
- if (!overrides.includes("no filesystem access")) {
305
- tools = tools.filter(tool => tool.type != "function");
306
- overrides.push("no filesystem access");
307
- }
505
+ const provider = program.args[0];
506
+ if (provider != "openai" && provider != "anthropic" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
507
+ console.log(`Invalid provider: ${provider}`);
508
+ process.exit(1);
308
509
  }
309
- else {
310
- console.log(`Unknown flag: ${flag}`);
510
+ const isCreate = !(options.createProfile in state.profiles);
511
+ state.profiles[options.createProfile] = { provider, model: program.args[1] };
512
+ if (!state.apiKeys[provider]) {
513
+ await promptForApiKey(provider);
311
514
  }
312
- };
313
- for (; i != process.argv.length; ++i) {
314
- const argument = process.argv[i];
315
- if (!argument.startsWith("-")) {
316
- break;
317
- }
318
- if (argument == "--") {
319
- ++i;
320
- break;
515
+ saveState();
516
+ console.log(`${isCreate ? "Created" : "Updated"} ${options.createProfile} profile.`);
517
+ process.exit(0);
518
+ }
519
+ if (options.deleteProfile) {
520
+ delete state.profiles[options.deleteProfile];
521
+ saveState();
522
+ console.log(`Deleted ${options.deleteProfile} profile.`);
523
+ process.exit(0);
524
+ }
525
+ if (options.updateApiKey) {
526
+ await promptForApiKey(options.updateApiKey);
527
+ saveState();
528
+ console.log("Saved.");
529
+ process.exit(0);
530
+ }
531
+
532
+ const profile = state.profiles[options.profile];
533
+ if (!profile) {
534
+ if (options.profile == "default") {
535
+ console.log(markdownToConsoleText("You did not specify a profile, but a default profile does not exist. Use e.g. `slave --create-profile default openai gpt-6-luna` to create one."));
321
536
  }
322
- // Short flags can be combined (for example, -cw is equivalent to -c -w).
323
- const flags = argument.startsWith("--") || argument.length <= 2
324
- ? [argument]
325
- : [...argument.slice(1)].map((flag) => `-${flag}`);
326
- for (const flag of flags) {
327
- processFlag(flag);
537
+ else {
538
+ console.log(markdownToConsoleText(`No such profile. Use e.g. \`slave --create-profile ${options.profile} openai gpt-6-luna\` to create it.`));
328
539
  }
540
+ process.exit(1);
541
+ }
542
+
543
+ let prompt = program.args.join(" ").trim();
544
+ let previous_provider_state;
545
+ const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
546
+ state.providerStates[profile.provider] ??= {};
547
+ if (options.continue || options.cont) {
548
+ previous_provider_state = state.providerStates[profile.provider].previous;
549
+ }
550
+ else if (options.amend) {
551
+ previous_provider_state = state.providerStates[profile.provider].previous_previous;
552
+ }
553
+ const filesystemEnabled = options.fs !== false && !options.nofs;
554
+ initTools(filesystemEnabled, options.sandbox, options.web);
555
+ if (options.web) {
556
+ overrides.push("web search enabled");
557
+ }
558
+ if (!filesystemEnabled) {
559
+ overrides.push("no filesystem access");
560
+ }
561
+ if (options.sandbox) {
562
+ overrides.push("sandbox enabled");
329
563
  }
330
- let prompt = process.argv.slice(i).join(" ").trim();
331
564
  let stdinText = "";
332
565
  if (!process.stdin.isTTY) {
333
566
  const chunks = [];
@@ -337,22 +570,42 @@ if (!process.stdin.isTTY) {
337
570
  stdinText = Buffer.concat(chunks).toString("utf8");
338
571
  }
339
572
  if (!prompt) {
340
- console.log("Syntax: slave [flags] [--] <prompt>");
573
+ console.log("No prompt given.");
341
574
  process.exit(1);
342
575
  }
343
576
 
344
- console.log((previous_response_id ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
577
+ console.log((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
345
578
  if (stdinText) {
346
579
  overrides.push("stdin provided");
347
580
  prompt = `${stdinText}\n\n${prompt}`;
348
581
  }
349
- if (overrides.length) {
350
- console.log(`(${overrides.join(", ")})`);
582
+ console.log(`(${overrides.join(", ")})\n`);
583
+
584
+ //console.log({ model: profile.model, prompt, previous_provider_state });
585
+
586
+ const clientOpts = {
587
+ apiKey: state.apiKeys[profile.provider],
588
+ };
589
+ let response;
590
+ if (profile.provider == "anthropic") {
591
+ const client = new Anthropic(clientOpts);
592
+ response = await anthropic_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
351
593
  }
352
- console.log();
353
- const response = await generateResponseOptionallyUsingTools(prompt, previous_response_id);
354
- fs.writeFileSync(path.join(configDir, ".state.json"), JSON.stringify({ previous_response_id: response.id }), "utf8");
594
+ else {
595
+ if (profile.provider != "openai") {
596
+ clientOpts.baseURL = profile.provider;
597
+ clientOpts.apiKey ||= "dummy";
598
+ }
599
+ const client = new OpenAI(clientOpts);
600
+ response = await openai_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
601
+ }
602
+ state.providerStates[profile.provider].previous_previous = previous_provider_state;
603
+ state.providerStates[profile.provider].previous = response.state;
604
+ saveState();
355
605
  if (response.used_tools) {
606
+ if (used_docker) {
607
+ execFileSync("docker", ["rm", "-f", "slave-sandbox"], { encoding: "utf8" });
608
+ }
356
609
  console.log();
357
610
  }
358
- console.log(markdownToConsoleText(response.output_text).trimEnd() + "\n");
611
+ console.log(markdownToConsoleText(response.output_text) + "\n");
package/package.json CHANGED
@@ -4,12 +4,15 @@
4
4
  "type": "git",
5
5
  "url": "git+https://github.com/calamity-inc/slave.git"
6
6
  },
7
- "version": "0.1.1",
7
+ "version": "0.3.0",
8
8
  "type": "module",
9
9
  "bin": {
10
- "slave": "./index.js"
10
+ "slave": "index.js"
11
11
  },
12
12
  "dependencies": {
13
+ "@anthropic-ai/sdk": "^1.3.0",
14
+ "commander": "^15.0.0",
15
+ "env-paths": "^4.0.0",
13
16
  "ignore": "^7.0.13",
14
17
  "marked": "^12",
15
18
  "marked-terminal": "7.0.0",