@forgeailab/smith 0.0.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/README.md +27 -0
- package/bin/cli.js +361 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Smith npm bootstrapper
|
|
2
|
+
|
|
3
|
+
Run Smith from npm without installing a platform binary first:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @forgeailab/smith
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The package downloads the matching Smith GitHub release archive for macOS,
|
|
10
|
+
glibc Linux, or musl Linux, verifies it against `SHA256SUMS` when available,
|
|
11
|
+
caches it under `~/.smith/npx`, and starts the `smith` binary.
|
|
12
|
+
|
|
13
|
+
Useful commands:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @forgeailab/smith -p "explain this repo" # one headless turn
|
|
17
|
+
npx @forgeailab/smith setup # guided provider/model setup
|
|
18
|
+
npx @forgeailab/smith --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
By default, the wrapper downloads the GitHub release tag that matches the npm
|
|
22
|
+
package version. For testing a different release:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @forgeailab/smith --release latest
|
|
26
|
+
SMITH_NPX_TAG=v0.1.0 npx @forgeailab/smith
|
|
27
|
+
```
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const childProcess = require("node:child_process");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const https = require("node:https");
|
|
8
|
+
const os = require("node:os");
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
const process = require("node:process");
|
|
11
|
+
|
|
12
|
+
const PACKAGE = require("../package.json");
|
|
13
|
+
|
|
14
|
+
const REPO = "ForgeAILab/smith";
|
|
15
|
+
const CACHE_ROOT = path.join(os.homedir(), ".smith", "npx");
|
|
16
|
+
const USER_AGENT = `${PACKAGE.name}/${PACKAGE.version}`;
|
|
17
|
+
|
|
18
|
+
function usage() {
|
|
19
|
+
const version = PACKAGE.version;
|
|
20
|
+
console.log(`Smith npm bootstrapper ${version}
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
npx ${PACKAGE.name} [smith-options]
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
npx ${PACKAGE.name} # interactive TUI
|
|
27
|
+
npx ${PACKAGE.name} -p "explain this repo" # one headless turn
|
|
28
|
+
npx ${PACKAGE.name} setup # guided provider/model setup
|
|
29
|
+
|
|
30
|
+
Options handled by the bootstrapper:
|
|
31
|
+
--release <tag|latest> Download a specific GitHub release tag
|
|
32
|
+
--version Show the npm bootstrapper version
|
|
33
|
+
--help Show this help
|
|
34
|
+
|
|
35
|
+
All other options are passed through to the smith binary.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isHelp(args) {
|
|
39
|
+
return args.includes("--help") || args.includes("-h");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isVersion(args) {
|
|
43
|
+
return args.includes("--version") || args.includes("-V");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const args = [...argv];
|
|
48
|
+
let release =
|
|
49
|
+
process.env.SMITH_NPX_TAG ||
|
|
50
|
+
(PACKAGE.version === "0.0.0" ? "latest" : `v${PACKAGE.version}`);
|
|
51
|
+
const passthrough = [];
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
if (arg === "--release") {
|
|
56
|
+
const value = args[i + 1];
|
|
57
|
+
if (!value) {
|
|
58
|
+
throw new Error("--release requires a tag value");
|
|
59
|
+
}
|
|
60
|
+
release = value;
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (arg.startsWith("--release=")) {
|
|
65
|
+
release = arg.slice("--release=".length);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
passthrough.push(arg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { passthrough, release };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function platformInfo(platform = process.platform, archInput = process.arch) {
|
|
75
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Unsupported platform ${platform}. Smith release archives currently support macOS and Linux.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let osName = platform === "darwin" ? "macos" : "linux";
|
|
82
|
+
let arch = archInput;
|
|
83
|
+
if (arch === "x64") arch = "x86_64";
|
|
84
|
+
if (arch === "arm64") arch = "aarch64";
|
|
85
|
+
|
|
86
|
+
if (arch !== "x86_64" && arch !== "aarch64") {
|
|
87
|
+
throw new Error(`Unsupported architecture ${archInput}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (platform === "linux" && linuxLibc() === "musl") {
|
|
91
|
+
osName = "linux-musl";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const artifact = `smith-${arch}-${osName}`;
|
|
95
|
+
return { artifact, archiveName: `${artifact}.tar.gz` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function linuxLibc() {
|
|
99
|
+
const override = process.env.SMITH_NPX_LIBC;
|
|
100
|
+
if (override === "gnu" || override === "musl") {
|
|
101
|
+
return override;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const report = process.report?.getReport?.();
|
|
105
|
+
if (report?.header?.glibcVersionRuntime) {
|
|
106
|
+
return "gnu";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let output = "";
|
|
110
|
+
try {
|
|
111
|
+
output = childProcess.execFileSync("ldd", ["--version"], {
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
output = `${error.stdout || ""}\n${error.stderr || ""}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return /musl/i.test(output) ? "musl" : "gnu";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function request(url, redirects = 0) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const req = https.get(
|
|
125
|
+
url,
|
|
126
|
+
{
|
|
127
|
+
headers: {
|
|
128
|
+
"user-agent": USER_AGENT,
|
|
129
|
+
accept: "application/vnd.github+json, application/octet-stream, */*",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
(res) => {
|
|
133
|
+
const location = res.headers.location;
|
|
134
|
+
if (
|
|
135
|
+
location &&
|
|
136
|
+
[301, 302, 303, 307, 308].includes(res.statusCode || 0)
|
|
137
|
+
) {
|
|
138
|
+
res.resume();
|
|
139
|
+
if (redirects > 5) {
|
|
140
|
+
reject(new Error(`Too many redirects for ${url}`));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
request(new URL(location, url).toString(), redirects + 1)
|
|
144
|
+
.then(resolve)
|
|
145
|
+
.catch(reject);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
|
|
150
|
+
res.resume();
|
|
151
|
+
reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
resolve(res);
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
req.on("error", reject);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function fetchText(url) {
|
|
163
|
+
const res = await request(url);
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
let body = "";
|
|
166
|
+
res.setEncoding("utf8");
|
|
167
|
+
res.on("data", (chunk) => {
|
|
168
|
+
body += chunk;
|
|
169
|
+
});
|
|
170
|
+
res.on("end", () => resolve(body));
|
|
171
|
+
res.on("error", reject);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function resolveReleaseTag(release) {
|
|
176
|
+
if (release !== "latest") {
|
|
177
|
+
return release;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const body = await fetchText(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
181
|
+
const json = JSON.parse(body);
|
|
182
|
+
if (!json.tag_name) {
|
|
183
|
+
throw new Error("GitHub latest release response did not include tag_name");
|
|
184
|
+
}
|
|
185
|
+
return json.tag_name;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseChecksum(sums, archiveName) {
|
|
189
|
+
for (const line of sums.split(/\r?\n/)) {
|
|
190
|
+
const match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
191
|
+
if (match && match[2].trim() === archiveName) {
|
|
192
|
+
return match[1].toLowerCase();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function downloadFile(url, dest, expectedSha256) {
|
|
199
|
+
const temp = `${dest}.tmp-${process.pid}`;
|
|
200
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
201
|
+
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
request(url)
|
|
204
|
+
.then((res) => {
|
|
205
|
+
const total = Number(res.headers["content-length"] || 0);
|
|
206
|
+
let downloaded = 0;
|
|
207
|
+
const hash = crypto.createHash("sha256");
|
|
208
|
+
const file = fs.createWriteStream(temp);
|
|
209
|
+
|
|
210
|
+
const cleanup = (error) => {
|
|
211
|
+
file.destroy();
|
|
212
|
+
try {
|
|
213
|
+
fs.unlinkSync(temp);
|
|
214
|
+
} catch {}
|
|
215
|
+
reject(error);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
res.on("data", (chunk) => {
|
|
219
|
+
downloaded += chunk.length;
|
|
220
|
+
hash.update(chunk);
|
|
221
|
+
if (total > 0) {
|
|
222
|
+
const pct = Math.round((downloaded / total) * 100);
|
|
223
|
+
process.stderr.write(`\rDownloading Smith release: ${pct}%`);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
res.on("error", cleanup);
|
|
228
|
+
file.on("error", cleanup);
|
|
229
|
+
file.on("finish", () => {
|
|
230
|
+
const actual = hash.digest("hex");
|
|
231
|
+
if (expectedSha256 && actual !== expectedSha256) {
|
|
232
|
+
cleanup(
|
|
233
|
+
new Error(
|
|
234
|
+
`Checksum mismatch for ${path.basename(dest)}: expected ${expectedSha256}, got ${actual}`
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
fs.renameSync(temp, dest);
|
|
240
|
+
if (total > 0) {
|
|
241
|
+
process.stderr.write("\n");
|
|
242
|
+
}
|
|
243
|
+
resolve();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
res.pipe(file);
|
|
247
|
+
})
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
try {
|
|
250
|
+
fs.unlinkSync(temp);
|
|
251
|
+
} catch {}
|
|
252
|
+
reject(error);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function runTar(archive, dest) {
|
|
258
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
259
|
+
childProcess.execFileSync("tar", ["-xzf", archive, "-C", dest], {
|
|
260
|
+
stdio: "pipe",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function ensureRelease(release) {
|
|
265
|
+
const { artifact, archiveName } = platformInfo();
|
|
266
|
+
const tag = await resolveReleaseTag(release);
|
|
267
|
+
const installDir = path.join(CACHE_ROOT, "releases", tag, artifact);
|
|
268
|
+
const readyFile = path.join(installDir, ".ready");
|
|
269
|
+
const binaryPath = path.join(installDir, "smith");
|
|
270
|
+
|
|
271
|
+
if (fs.existsSync(readyFile) && fs.existsSync(binaryPath)) {
|
|
272
|
+
return { binaryPath, installDir, tag };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const archive = path.join(CACHE_ROOT, "archives", tag, archiveName);
|
|
276
|
+
const releaseBase = `https://github.com/${REPO}/releases/download/${tag}`;
|
|
277
|
+
|
|
278
|
+
let expectedSha256 = null;
|
|
279
|
+
try {
|
|
280
|
+
const sums = await fetchText(`${releaseBase}/SHA256SUMS`);
|
|
281
|
+
expectedSha256 = parseChecksum(sums, archiveName);
|
|
282
|
+
} catch {}
|
|
283
|
+
|
|
284
|
+
if (!fs.existsSync(archive)) {
|
|
285
|
+
console.error(`Fetching Smith ${tag} for ${artifact}...`);
|
|
286
|
+
await downloadFile(`${releaseBase}/${archiveName}`, archive, expectedSha256);
|
|
287
|
+
} else if (expectedSha256) {
|
|
288
|
+
const hash = crypto.createHash("sha256");
|
|
289
|
+
hash.update(fs.readFileSync(archive));
|
|
290
|
+
const actual = hash.digest("hex");
|
|
291
|
+
if (actual !== expectedSha256) {
|
|
292
|
+
fs.unlinkSync(archive);
|
|
293
|
+
console.error(`Cached Smith archive checksum changed; refetching ${archiveName}...`);
|
|
294
|
+
await downloadFile(`${releaseBase}/${archiveName}`, archive, expectedSha256);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const tempDir = `${installDir}.tmp-${process.pid}`;
|
|
299
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
300
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
301
|
+
runTar(archive, tempDir);
|
|
302
|
+
|
|
303
|
+
if (!fs.existsSync(path.join(tempDir, "smith"))) {
|
|
304
|
+
throw new Error("Release archive did not include smith");
|
|
305
|
+
}
|
|
306
|
+
fs.chmodSync(path.join(tempDir, "smith"), 0o755);
|
|
307
|
+
|
|
308
|
+
fs.rmSync(installDir, { recursive: true, force: true });
|
|
309
|
+
fs.renameSync(tempDir, installDir);
|
|
310
|
+
fs.writeFileSync(readyFile, `${new Date().toISOString()}\n`);
|
|
311
|
+
|
|
312
|
+
return { binaryPath, installDir, tag };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function runBinary(binary, args, env) {
|
|
316
|
+
const child = childProcess.spawn(binary, args, {
|
|
317
|
+
env,
|
|
318
|
+
stdio: "inherit",
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
child.on("exit", (code, signal) => {
|
|
322
|
+
if (signal) {
|
|
323
|
+
process.kill(process.pid, signal);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
process.exit(code || 0);
|
|
327
|
+
});
|
|
328
|
+
child.on("error", (error) => {
|
|
329
|
+
console.error(`Failed to start smith: ${error.message}`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
334
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function main() {
|
|
338
|
+
const rawArgs = process.argv.slice(2);
|
|
339
|
+
if (isHelp(rawArgs)) {
|
|
340
|
+
usage();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (isVersion(rawArgs)) {
|
|
344
|
+
console.log(PACKAGE.version);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const options = parseArgs(rawArgs);
|
|
349
|
+
const release = await ensureRelease(options.release);
|
|
350
|
+
runBinary(release.binaryPath, options.passthrough, { ...process.env });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (require.main === module) {
|
|
354
|
+
main().catch((error) => {
|
|
355
|
+
console.error(`smith npm bootstrap failed: ${error.message}`);
|
|
356
|
+
if (process.env.SMITH_NPX_DEBUG && error.stack) {
|
|
357
|
+
console.error(error.stack);
|
|
358
|
+
}
|
|
359
|
+
process.exit(1);
|
|
360
|
+
});
|
|
361
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeailab/smith",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "NPX bootstrapper for the Smith terminal-first coding agent",
|
|
5
|
+
"license": "(MIT OR Apache-2.0)",
|
|
6
|
+
"homepage": "https://github.com/ForgeAILab/smith#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ForgeAILab/smith.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ForgeAILab/smith/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"smith": "bin/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"ai",
|
|
29
|
+
"agent",
|
|
30
|
+
"cli",
|
|
31
|
+
"coding-agent",
|
|
32
|
+
"smith",
|
|
33
|
+
"tui"
|
|
34
|
+
]
|
|
35
|
+
}
|