@calamity-inc/slave 0.1.1 → 0.2.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/.slaveignore ADDED
@@ -0,0 +1 @@
1
+ .state.json
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`
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,28 @@ 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
+ - `--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
+ - `-p, --profile <name>` — use the specified profile
27
28
 
28
29
  Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
29
30
 
31
+ Subcommands:
32
+ - `--create-profile <name> <provider> <model>` — creates a profile
33
+ - `provider` can be `openai` or an OpenAI-compatible base URL, e.g. `http://127.0.0.1:1234/v1` for [LM Studio](https://lmstudio.ai/developers).
34
+ - `--delete-profile <name>` — deletes a profile
35
+ - `--update-api-key <provider>` — updates an API Key
36
+
30
37
  ## Sandboxing / Security
31
38
 
32
39
  The model gets no command prompt access and limited access to your filesystem:
33
40
  - Only within the working directory and subdirectories.
34
41
  - `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
35
42
  - Files that are `.gitignore`d can't be written to.
43
+ - 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
44
 
37
45
  However, these restrictions are only as good as your practices; you should:
38
46
  - 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.
Binary file
package/index.js CHANGED
@@ -5,43 +5,43 @@ import { fileURLToPath } from "node:url";
5
5
  import { parseEnv } from "node:util";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import ignore from "ignore";
8
+ import { Command } from "commander";
8
9
  import { marked } from "marked";
9
10
  import { markedTerminal } from "marked-terminal";
10
11
  import OpenAI from "openai";
11
12
 
12
13
  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);
14
+ const state = fs.existsSync(path.join(configDir, ".state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, ".state.json"), "utf8")) : {};
15
+ state.profiles ??= {};
16
+ state.apiKeys ??= {};
17
+ if (!state.apiKeys.openai && fs.existsSync(path.join(configDir, ".env"))) {
18
+ const env = parseEnv(fs.readFileSync(path.join(configDir, ".env"), "utf8"));
19
+ if (env.OPENAI_API_KEY) {
20
+ state.apiKeys.openai = env.OPENAI_API_KEY;
18
21
  }
19
-
22
+ }
23
+ const promptForApiKey = async (provider) => {
20
24
  const readline = createInterface({ input: process.stdin, output: process.stdout });
21
25
  try {
22
- env.OPENAI_API_KEY = (await readline.question("Enter your OpenAI API key: ")).trim();
26
+ state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
23
27
  }
24
28
  finally {
25
29
  readline.close();
26
30
  }
27
- if (!env.OPENAI_API_KEY) {
28
- console.error("An API key is required.");
29
- process.exit(1);
30
- }
31
- console.log();
31
+ };
32
+ const saveState = () => {
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
45
  const isOutOfReach = (p, type) => {
46
46
  const rel = path.relative(".", p);
47
47
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
@@ -51,7 +51,7 @@ 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) || (type == "write" && gitignore.ignores(rel)))) {
55
55
  return true;
56
56
  }
57
57
  return false;
@@ -59,8 +59,13 @@ const isOutOfReach = (p, type) => {
59
59
 
60
60
  const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
61
61
  const writeFileFromModel = (filePath, data, usesCRLF) => {
62
- if (usesCRLF === undefined) {
63
- usesCRLF = fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8").includes("\r\n");
62
+ if (fs.existsSync(filePath)) {
63
+ if (usesCRLF === undefined) {
64
+ usesCRLF = fs.readFileSync(filePath, "utf8").includes("\r\n");
65
+ }
66
+ }
67
+ else {
68
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
69
  }
65
70
  const normalized = data.replaceAll("\r\n", "\n");
66
71
  fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
@@ -232,8 +237,7 @@ let tools = [
232
237
  },*/
233
238
  ];
234
239
 
235
- const generateResponseOptionallyUsingTools = async (input, previous_response_id) => {
236
- const model = "gpt-6-luna";
240
+ const generateResponseOptionallyUsingTools = async (client, model, input, previous_response_id) => {
237
241
  let response = await client.responses.create({
238
242
  model,
239
243
  previous_response_id,
@@ -280,54 +284,90 @@ marked.use(markedTerminal());
280
284
  const markdownToConsoleText = (md) => {
281
285
  const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
282
286
  console.error = undefined;
283
- const text = marked.parse(md);
287
+ const text = marked.parse(md).trimEnd() + "\n";
284
288
  console.error = _console_error;
285
289
  return text;
286
290
  };
287
291
 
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
- }
302
- }
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
- }
292
+
293
+ const program = new Command();
294
+ program
295
+ .name("slave")
296
+ .usage("[options] [--] [prompt...]")
297
+ .description("Vibe coding without the kaboom factor.")
298
+ .argument("[prompt...]")
299
+ .option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
300
+ .option("--amend", "retry the previous invocation, using the context from before it")
301
+ .option("-w, --web", "allow the model to search the web")
302
+ .option("-n, --no-fs", "disable filesystem access").option("--nofs")
303
+ .option("-p, --profile <name>", "profile name", "default")
304
+ .option("--create-profile <name> <provider> <model>", "create a profile")
305
+ .option("--delete-profile <name>", "delete a profile") // TODO
306
+ .option("--update-api-key <provider>", "update an API key") // TODO
307
+ .helpOption("-?, --help", "display help")
308
+ .parse();
309
+ const options = program.opts();
310
+
311
+ if (options.createProfile) {
312
+ if (!program.args[0] || !program.args[1]) {
313
+ console.log("Syntax: slave --create-profile <name> <provider> <model>");
314
+ process.exit(1);
308
315
  }
309
- else {
310
- console.log(`Unknown flag: ${flag}`);
316
+ const provider = program.args[0];
317
+ if (provider != "openai" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
318
+ console.log(`Invalid provider: ${provider}`);
319
+ process.exit(1);
311
320
  }
312
- };
313
- for (; i != process.argv.length; ++i) {
314
- const argument = process.argv[i];
315
- if (!argument.startsWith("-")) {
316
- break;
321
+ const isCreate = !(options.createProfile in state.profiles);
322
+ state.profiles[options.createProfile] = { provider, model: program.args[1] };
323
+ if (!state.apiKeys[provider]) {
324
+ await promptForApiKey(provider);
317
325
  }
318
- if (argument == "--") {
319
- ++i;
320
- break;
326
+ saveState();
327
+ console.log(`${isCreate ? "Created" : "Updated"} ${options.createProfile} profile.`);
328
+ process.exit(0);
329
+ }
330
+ if (options.deleteProfile) {
331
+ delete state.profiles[options.deleteProfile];
332
+ saveState();
333
+ console.log(`Deleted ${options.deleteProfile} profile.`);
334
+ process.exit(0);
335
+ }
336
+ if (options.updateApiKey) {
337
+ await promptForApiKey(options.updateApiKey);
338
+ saveState();
339
+ console.log("Saved.");
340
+ process.exit(0);
341
+ }
342
+
343
+ const profile = state.profiles[options.profile];
344
+ if (!profile) {
345
+ if (options.profile == "default") {
346
+ 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
347
  }
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);
348
+ else {
349
+ console.log(markdownToConsoleText(`No such profile. Use e.g. \`slave --create-profile ${options.profile} openai gpt-6-luna\` to create it.`));
328
350
  }
351
+ process.exit(1);
352
+ }
353
+
354
+ let prompt = program.args.join(" ").trim();
355
+ let previous_response_id;
356
+ const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
357
+ if (options.continue || options.cont) {
358
+ previous_response_id = state.previous_response_id;
359
+ }
360
+ else if (options.amend) {
361
+ previous_response_id = state.previous_previous_response_id;
362
+ }
363
+ if (options.web) {
364
+ tools.push({ type: "web_search" });
365
+ overrides.push("web search enabled");
366
+ }
367
+ if (options.fs == false || options.nofs) {
368
+ tools = tools.filter(tool => tool.type != "function");
369
+ overrides.push("no filesystem access");
329
370
  }
330
- let prompt = process.argv.slice(i).join(" ").trim();
331
371
  let stdinText = "";
332
372
  if (!process.stdin.isTTY) {
333
373
  const chunks = [];
@@ -337,7 +377,7 @@ if (!process.stdin.isTTY) {
337
377
  stdinText = Buffer.concat(chunks).toString("utf8");
338
378
  }
339
379
  if (!prompt) {
340
- console.log("Syntax: slave [flags] [--] <prompt>");
380
+ console.log("No prompt given.");
341
381
  process.exit(1);
342
382
  }
343
383
 
@@ -346,13 +386,23 @@ if (stdinText) {
346
386
  overrides.push("stdin provided");
347
387
  prompt = `${stdinText}\n\n${prompt}`;
348
388
  }
349
- if (overrides.length) {
350
- console.log(`(${overrides.join(", ")})`);
389
+ console.log(`(${overrides.join(", ")})\n`);
390
+
391
+ //console.log({ model: profile.model, prompt, previous_response_id });
392
+
393
+ const clientOpts = {
394
+ apiKey: state.apiKeys[profile.provider],
395
+ };
396
+ if (profile.provider != "openai") {
397
+ clientOpts.baseURL = profile.provider;
398
+ clientOpts.apiKey ||= "dummy";
351
399
  }
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");
400
+ const client = new OpenAI(clientOpts);
401
+ const response = await generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_response_id);
402
+ state.previous_previous_response_id = previous_response_id;
403
+ state.previous_response_id = response.id;
404
+ saveState();
355
405
  if (response.used_tools) {
356
406
  console.log();
357
407
  }
358
- console.log(markdownToConsoleText(response.output_text).trimEnd() + "\n");
408
+ console.log(markdownToConsoleText(response.output_text) + "\n");
package/package.json CHANGED
@@ -4,12 +4,13 @@
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.2.0",
8
8
  "type": "module",
9
9
  "bin": {
10
- "slave": "./index.js"
10
+ "slave": "index.js"
11
11
  },
12
12
  "dependencies": {
13
+ "commander": "^15.0.0",
13
14
  "ignore": "^7.0.13",
14
15
  "marked": "^12",
15
16
  "marked-terminal": "7.0.0",