@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 +1 -0
- package/README.md +15 -7
- package/calamity-inc-slave-0.2.0.tgz +0 -0
- package/index.js +119 -69
- package/package.json +3 -2
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
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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 [
|
|
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
|
-
|
|
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` —
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
26
|
+
state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
|
|
23
27
|
}
|
|
24
28
|
finally {
|
|
25
29
|
readline.close();
|
|
26
30
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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" &&
|
|
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 (
|
|
63
|
-
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 });
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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("
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
const response = await generateResponseOptionallyUsingTools(prompt, previous_response_id);
|
|
354
|
-
|
|
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)
|
|
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.
|
|
7
|
+
"version": "0.2.0",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
|
-
"slave": "
|
|
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",
|