@calamity-inc/slave 0.3.2 → 0.4.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/src/log.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { logDir } = require("./paths.js");
6
+
7
+ const rotateLogs = () => {
8
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
9
+ for (const name of fs.readdirSync(logDir)) {
10
+ try {
11
+ if (fs.statSync(path.join(logDir, name)).mtimeMs < cutoff) {
12
+ fs.unlinkSync(path.join(logDir, name));
13
+ }
14
+ }
15
+ catch (e) {
16
+ // another process may have removed it already; ignore.
17
+ }
18
+ }
19
+ };
20
+
21
+ let logFh;
22
+ const logWrite = (line) => {
23
+ if (!logFh) {
24
+ const now = new Date();
25
+ const pad = (n) => String(n).padStart(2, '0');
26
+ const logfileName = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}Z.txt`;
27
+ fs.mkdirSync(logDir, { recursive: true });
28
+ logFh = fs.openSync(path.join(logDir, logfileName), "a", 0o600);
29
+ rotateLogs();
30
+ }
31
+ fs.writeSync(logFh, line + "\n");
32
+ };
33
+ const logWriteBoth = (line) => {
34
+ console.log(line);
35
+ logWrite(line);
36
+ };
37
+
38
+ module.exports = { logWrite, logWriteBoth };
package/src/main.js ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ async function main() {
5
+ const { Command } = require("commander");
6
+
7
+ const program = new Command();
8
+ program
9
+ .name("slave")
10
+ .usage("[options] [--] [prompt...]")
11
+ .description("Vibe coding without the kaboom factor.")
12
+ .argument("[prompt...]")
13
+ .option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
14
+ .option("-a, --amend", "retry the previous invocation, using the context from before it")
15
+ .option("-w, --web", "allow the model to search the web")
16
+ .option("-n, --no-fs", "disable filesystem access").option("--nofs")
17
+ .option("-s, --sandbox", "allow the model to use a docker container")
18
+ .option("-p, --profile <name>", "profile name")
19
+ .option("--create-profile <name> <provider> <model>", "create a profile")
20
+ .option("--update-profile <name> <provider> <model>", "update a profile")
21
+ .option("--set-default", "set default profile")
22
+ .option("--list-profiles", "list all profiles")
23
+ .option("--delete-profile <name>", "delete a profile")
24
+ .option("--update-api-key <provider>", "update an API key")
25
+ .helpOption("-?, --help", "display help")
26
+ .parse();
27
+ const options = program.opts();
28
+
29
+ const { state, promptForApiKey, saveState, profileToString } = require("./state.js");
30
+
31
+ if (options.createProfile || options.updateProfile) {
32
+ if (!program.args[0] || !program.args[1]) {
33
+ console.error("Syntax: slave --create-profile <name> <provider> <model>");
34
+ process.exit(1);
35
+ }
36
+ const provider = program.args[0];
37
+ if (provider != "openai" && provider != "anthropic" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
38
+ console.error(`Invalid provider: ${provider}`);
39
+ process.exit(1);
40
+ }
41
+ const name = options.createProfile || options.updateProfile;
42
+ if (name in state.profiles) {
43
+ if (options.createProfile) {
44
+ console.error("Profile already exists. Did you mean --update-profile?");
45
+ process.exit(1);
46
+ }
47
+ }
48
+ else {
49
+ if (options.updateProfile) {
50
+ console.error("No such profile. Did you mean --create-profile?");
51
+ process.exit(1);
52
+ }
53
+ }
54
+ state.profiles[name] = { provider, model: program.args[1] };
55
+ if (options.setDefault) {
56
+ state.defaultProfile = name;
57
+ }
58
+ if (!state.apiKeys[provider]) {
59
+ await promptForApiKey(provider);
60
+ }
61
+ saveState();
62
+ console.log(`${options.createProfile ? "Created" : "Updated"} ${name} profile${options.setDefault ? " and set as your default" : ""}.`);
63
+ process.exit(0);
64
+ }
65
+ if (options.setDefault) {
66
+ if (!program.args[0]) {
67
+ console.error("No profile name given.");
68
+ process.exit(1);
69
+ }
70
+ state.defaultProfile = program.args[0];
71
+ saveState();
72
+ console.log(`Set ${program.args[0]} as your default profile.`);
73
+ process.exit(0);
74
+ }
75
+ if (options.listProfiles) {
76
+ const names = Object.keys(state.profiles);
77
+ if (names.length == 0) {
78
+ console.log("No profiles.");
79
+ }
80
+ for (const name of names) {
81
+ if (state.defaultProfile == name) {
82
+ console.log(`${name} (default): ${profileToString(state.profiles[name])}`);
83
+ }
84
+ else {
85
+ console.log(`${name}: ${profileToString(state.profiles[name])}`);
86
+ }
87
+ }
88
+ process.exit(0);
89
+ }
90
+ if (options.deleteProfile) {
91
+ if (options.deleteProfile in state.profiles) {
92
+ delete state.profiles[options.deleteProfile];
93
+ saveState();
94
+ console.log(`Deleted ${options.deleteProfile} profile.`);
95
+ }
96
+ else {
97
+ console.log("No such profile.");
98
+ }
99
+ process.exit(0);
100
+ }
101
+ if (options.updateApiKey) {
102
+ await promptForApiKey(options.updateApiKey);
103
+ saveState();
104
+ console.log("Saved.");
105
+ process.exit(0);
106
+ }
107
+
108
+ const { markdownToConsoleText } = require("./markdown.js");
109
+
110
+ const profile = state.profiles[options.profile ?? state.defaultProfile];
111
+ if (!profile) {
112
+ if (Object.keys(state.profiles).length == 0) {
113
+ console.error(markdownToConsoleText("You don't have any profiles. Use e.g. `slave --create-profile luna openai gpt-6-luna --set-default` to create one."));
114
+ }
115
+ else if (options.profile) {
116
+ console.error(markdownToConsoleText(`No such profile. Use e.g. \`slave --create-profile ${options.profile} openai gpt-6-luna\` to create it.`));
117
+ }
118
+ else {
119
+ console.error(markdownToConsoleText(`You did not specify a profile and no profile is set as your default. Use e.g. \`slave --set-default ${Object.keys(state.profiles)[0]}\` to set **${Object.keys(state.profiles)[0]}** as your default profile.`));
120
+ }
121
+ process.exit(1);
122
+ }
123
+
124
+ let prompt = program.args.join(" ").trim();
125
+ if (!prompt) {
126
+ console.error("No prompt given.");
127
+ process.exit(1);
128
+ }
129
+
130
+ const { execFileSync } = require("node:child_process");
131
+
132
+ const { default: Anthropic } = require("@anthropic-ai/sdk");
133
+ const { default: OpenAI } = require("openai");
134
+
135
+ const { initTools, getSandboxName, setSandboxName, anthropic_generateResponseOptionallyUsingTools, openai_generateResponseOptionallyUsingTools } = require("./core.js");
136
+ const { logWrite, logWriteBoth } = require("./log.js");
137
+
138
+ let previous_provider_state;
139
+ const overrides = [profileToString(profile)];
140
+ state.providerStates[profile.provider] ??= {};
141
+ if (options.continue || options.cont) {
142
+ previous_provider_state = state.providerStates[profile.provider].previous;
143
+ setSandboxName(state.providerStates[profile.provider].sandbox_name);
144
+ }
145
+ else if (options.amend) {
146
+ previous_provider_state = state.providerStates[profile.provider].previous_previous;
147
+ setSandboxName(state.providerStates[profile.provider].sandbox_name);
148
+ }
149
+ const filesystemEnabled = options.fs !== false && !options.nofs;
150
+ initTools(filesystemEnabled, options.sandbox, options.web);
151
+ if (options.web) {
152
+ overrides.push("web search enabled");
153
+ }
154
+ if (!filesystemEnabled) {
155
+ overrides.push("no filesystem access");
156
+ }
157
+ if (options.sandbox) {
158
+ overrides.push("sandbox enabled");
159
+ }
160
+
161
+ logWriteBoth((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
162
+ if (!process.stdin.isTTY) {
163
+ const chunks = [];
164
+ for await (const chunk of process.stdin) {
165
+ chunks.push(chunk);
166
+ }
167
+ prompt = `${Buffer.concat(chunks).toString("utf8")}\n\n${prompt}`;
168
+ overrides.push("stdin provided");
169
+ }
170
+ logWriteBoth(`(${overrides.join(", ")})\n`);
171
+
172
+ //console.log({ model: profile.model, prompt, previous_provider_state });
173
+
174
+ const clientOpts = {
175
+ apiKey: state.apiKeys[profile.provider],
176
+ };
177
+ let response;
178
+ if (profile.provider == "anthropic") {
179
+ const client = new Anthropic(clientOpts);
180
+ response = await anthropic_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
181
+ }
182
+ else {
183
+ if (profile.provider != "openai") {
184
+ clientOpts.baseURL = profile.provider;
185
+ clientOpts.apiKey ||= "dummy";
186
+ }
187
+ const client = new OpenAI(clientOpts);
188
+ response = await openai_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
189
+ }
190
+ state.providerStates[profile.provider].previous_previous = previous_provider_state;
191
+ state.providerStates[profile.provider].previous = response.state;
192
+ if (state.providerStates[profile.provider].sandbox_name && state.providerStates[profile.provider].sandbox_name != getSandboxName()) {
193
+ execFileSync("docker", ["rm", "-f", state.providerStates[profile.provider].sandbox_name], { encoding: "utf8" });
194
+ }
195
+ state.providerStates[profile.provider].sandbox_name = getSandboxName();
196
+ saveState();
197
+ if (response.used_tools) {
198
+ logWriteBoth("");
199
+ }
200
+ console.log(markdownToConsoleText(response.output_text) + "\n");
201
+ logWrite(response.output_text);
202
+ }
203
+
204
+ main().catch((err) => {
205
+ console.error(err);
206
+ process.exit(1);
207
+ });
@@ -0,0 +1,13 @@
1
+ const { marked } = require("marked");
2
+ const { markedTerminal } = require("marked-terminal");
3
+
4
+ marked.use(markedTerminal());
5
+ const markdownToConsoleText = (md) => {
6
+ const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
7
+ console.error = undefined;
8
+ const text = marked.parse(md).trimEnd() + "\n";
9
+ console.error = _console_error;
10
+ return text;
11
+ };
12
+
13
+ module.exports = { markdownToConsoleText };
package/src/paths.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+
3
+ const envPaths = require("env-paths").default;
4
+
5
+ const { data, log } = envPaths("slave");
6
+ module.exports = {
7
+ configDir: data,
8
+ logDir: log,
9
+ };
package/src/state.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { createInterface } = require("node:readline/promises");
6
+ const { configDir } = require("./paths.js");
7
+
8
+ const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
9
+ state.profiles ??= {};
10
+ state.apiKeys ??= {};
11
+ state.providerStates ??= {};
12
+
13
+ const promptForApiKey = async (provider) => {
14
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
15
+ try {
16
+ state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
17
+ }
18
+ finally {
19
+ readline.close();
20
+ }
21
+ };
22
+
23
+ const saveState = () => {
24
+ fs.mkdirSync(configDir, { recursive: true });
25
+ fs.writeFileSync(path.join(configDir, "state.json"), JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
26
+ };
27
+
28
+ const profileToString = (profile) => {
29
+ return profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`;
30
+ };
31
+
32
+ module.exports = { state, promptForApiKey, saveState, profileToString };