@calamity-inc/slave 0.1.0 → 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,42 +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
+ };
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
+ };
31
36
 
32
- fs.writeFileSync(path.join(configDir, ".env"), `OPENAI_API_KEY=${env.OPENAI_API_KEY}`, { encoding: "utf8", mode: 0o600 });
33
- 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"));
34
40
  }
35
- const client = new OpenAI({
36
- apiKey: env.OPENAI_API_KEY,
37
- });
38
-
39
41
  const gitignore = ignore();
40
42
  if (fs.existsSync(".gitignore")) {
41
43
  gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
42
44
  }
43
-
44
45
  const isOutOfReach = (p, type) => {
45
46
  const rel = path.relative(".", p);
46
47
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
@@ -50,12 +51,26 @@ const isOutOfReach = (p, type) => {
50
51
  if (segs.includes(".env") || segs.includes(".git")) {
51
52
  return true;
52
53
  }
53
- if (type == "write" && rel && gitignore.ignores(rel)) {
54
+ if (rel && (slaveignore.ignores(rel) || (type == "write" && gitignore.ignores(rel)))) {
54
55
  return true;
55
56
  }
56
57
  return false;
57
58
  };
58
59
 
60
+ const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
61
+ const writeFileFromModel = (filePath, data, usesCRLF) => {
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 });
69
+ }
70
+ const normalized = data.replaceAll("\r\n", "\n");
71
+ fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
72
+ };
73
+
59
74
  const functions = {
60
75
  ls: ({ path }) => {
61
76
  path ??= ".";
@@ -93,7 +108,7 @@ const functions = {
93
108
  .filter(file => !isOutOfReach(file));
94
109
  const res = [];
95
110
  for (const file of files) {
96
- const matches = fs.readFileSync(file, "utf8").split(substring).length - 1;
111
+ const matches = readFileForModel(file).split(substring).length - 1;
97
112
  if (matches) {
98
113
  res.push(`${file} has ${matches} match${matches == 1 ? "" : "es"}`);
99
114
  }
@@ -104,25 +119,27 @@ const functions = {
104
119
  if (isOutOfReach(path)) {
105
120
  return "Out of your reach";
106
121
  }
107
- return fs.readFileSync(path, "utf8");
122
+ return readFileForModel(path);
108
123
  },
109
124
  writeFile: ({ path, data }) => {
110
125
  if (isOutOfReach(path, "write")) {
111
126
  return "Out of your reach";
112
127
  }
113
- fs.writeFileSync(path, data, "utf8");
128
+ writeFileFromModel(path, data);
114
129
  return "OK";
115
130
  },
116
131
  fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
117
132
  if (isOutOfReach(path, "write")) {
118
133
  return "Out of your reach";
119
134
  }
120
- const arr = fs.readFileSync(path, "utf8").split(substring);
135
+ const fileContent = fs.readFileSync(path, "utf8");
136
+ const usesCRLF = fileContent.includes("\r\n");
137
+ const arr = fileContent.replaceAll("\r\n", "\n").split(substring);
121
138
  const matches = arr.length - 1;
122
139
  if (matches == 0 || (expectedMatches && matches != expectedMatches)) {
123
140
  return `There were ${matches} match${matches == 1 ? "" : "es"}`;
124
141
  }
125
- fs.writeFileSync(path, arr.join(replacement), "utf8");
142
+ writeFileFromModel(path, arr.join(replacement), usesCRLF);
126
143
  return "OK";
127
144
  },
128
145
  system: ({ command }) => {
@@ -220,8 +237,7 @@ let tools = [
220
237
  },*/
221
238
  ];
222
239
 
223
- const generateResponseOptionallyUsingTools = async (input, previous_response_id) => {
224
- const model = "gpt-6-luna";
240
+ const generateResponseOptionallyUsingTools = async (client, model, input, previous_response_id) => {
225
241
  let response = await client.responses.create({
226
242
  model,
227
243
  previous_response_id,
@@ -268,54 +284,90 @@ marked.use(markedTerminal());
268
284
  const markdownToConsoleText = (md) => {
269
285
  const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
270
286
  console.error = undefined;
271
- const text = marked.parse(md);
287
+ const text = marked.parse(md).trimEnd() + "\n";
272
288
  console.error = _console_error;
273
289
  return text;
274
290
  };
275
291
 
276
- let i = 2;
277
- let previous_response_id;
278
- const overrides = [];
279
- const processFlag = (flag) => {
280
- if (flag == "-c" || flag == "--cont" || flag == "--continue") {
281
- if (fs.existsSync(path.join(configDir, ".state.json"))) {
282
- previous_response_id = JSON.parse(fs.readFileSync(path.join(configDir, ".state.json"), "utf8")).previous_response_id;
283
- }
284
- }
285
- else if (flag == "-w" || flag == "--web") {
286
- if (!overrides.includes("web search enabled")) {
287
- tools.push({ type: "web_search" });
288
- overrides.push("web search enabled");
289
- }
290
- }
291
- else if (flag == "-n" || flag == "--no-fs" || flag == "--nofs") {
292
- if (!overrides.includes("no filesystem access")) {
293
- tools = tools.filter(tool => tool.type != "function");
294
- overrides.push("no filesystem access");
295
- }
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);
296
315
  }
297
- else {
298
- 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);
299
320
  }
300
- };
301
- for (; i != process.argv.length; ++i) {
302
- const argument = process.argv[i];
303
- if (!argument.startsWith("-")) {
304
- 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);
305
325
  }
306
- if (argument == "--") {
307
- ++i;
308
- 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."));
309
347
  }
310
- // Short flags can be combined (for example, -cw is equivalent to -c -w).
311
- const flags = argument.startsWith("--") || argument.length <= 2
312
- ? [argument]
313
- : [...argument.slice(1)].map((flag) => `-${flag}`);
314
- for (const flag of flags) {
315
- 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.`));
316
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");
317
370
  }
318
- let prompt = process.argv.slice(i).join(" ").trim();
319
371
  let stdinText = "";
320
372
  if (!process.stdin.isTTY) {
321
373
  const chunks = [];
@@ -325,7 +377,7 @@ if (!process.stdin.isTTY) {
325
377
  stdinText = Buffer.concat(chunks).toString("utf8");
326
378
  }
327
379
  if (!prompt) {
328
- console.log("Syntax: slave [flags] [--] <prompt>");
380
+ console.log("No prompt given.");
329
381
  process.exit(1);
330
382
  }
331
383
 
@@ -334,13 +386,23 @@ if (stdinText) {
334
386
  overrides.push("stdin provided");
335
387
  prompt = `${stdinText}\n\n${prompt}`;
336
388
  }
337
- if (overrides.length) {
338
- 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";
339
399
  }
340
- console.log();
341
- const response = await generateResponseOptionallyUsingTools(prompt, previous_response_id);
342
- 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();
343
405
  if (response.used_tools) {
344
406
  console.log();
345
407
  }
346
- 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.0",
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",