@chvor/cli 0.1.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.
@@ -0,0 +1,84 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { input, select, password } from "@inquirer/prompts";
6
+ import { writeConfig } from "../lib/config.js";
7
+ import { validatePort } from "../lib/validate.js";
8
+ import { getDataDir, ensureDir } from "../lib/paths.js";
9
+ import { downloadRelease } from "../lib/download.js";
10
+ import { spawnServer, pollHealth } from "../lib/process.js";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
13
+ export async function onboard() {
14
+ console.log("\n Welcome to chvor \u2014 your own AI.\n Let's get you set up.\n");
15
+ const userName = await input({ message: "What's your name?" });
16
+ const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
17
+ const timezone = await input({
18
+ message: "Your timezone?",
19
+ default: detectedTimezone,
20
+ });
21
+ const provider = await select({
22
+ message: "LLM provider?",
23
+ choices: [
24
+ { name: "Anthropic (Claude)", value: "anthropic" },
25
+ { name: "OpenAI (GPT)", value: "openai" },
26
+ { name: "Google (Gemini)", value: "google-ai" },
27
+ ],
28
+ });
29
+ const apiKey = await password({ message: "API key:" });
30
+ const port = validatePort(await input({ message: "Port?", default: "3001" }));
31
+ const token = randomBytes(32).toString("hex");
32
+ writeConfig({
33
+ port,
34
+ token,
35
+ onboarded: true,
36
+ llmProvider: provider,
37
+ });
38
+ ensureDir(getDataDir());
39
+ const version = pkg.version;
40
+ await downloadRelease(version);
41
+ await spawnServer({ port });
42
+ await pollHealth(port, token);
43
+ const providerNames = {
44
+ anthropic: "Anthropic",
45
+ openai: "OpenAI",
46
+ "google-ai": "Google AI",
47
+ };
48
+ const credRes = await fetch(`http://localhost:${port}/api/credentials`, {
49
+ method: "POST",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ Authorization: `Bearer ${token}`,
53
+ },
54
+ body: JSON.stringify({
55
+ name: providerNames[provider],
56
+ type: provider,
57
+ data: { apiKey },
58
+ }),
59
+ });
60
+ if (!credRes.ok) {
61
+ console.warn(`Warning: failed to save credentials (${credRes.status}). You can add them later in the UI.`);
62
+ }
63
+ const personaRes = await fetch(`http://localhost:${port}/api/persona`, {
64
+ method: "PATCH",
65
+ headers: {
66
+ "Content-Type": "application/json",
67
+ Authorization: `Bearer ${token}`,
68
+ },
69
+ body: JSON.stringify({
70
+ name: userName,
71
+ timezone,
72
+ onboarded: true,
73
+ }),
74
+ });
75
+ if (!personaRes.ok) {
76
+ console.warn(`Warning: failed to save persona (${personaRes.status}). You can update it later in the UI.`);
77
+ }
78
+ console.log(`\n chvor is running at http://localhost:${port}`);
79
+ console.log(" Open this URL in your browser to get started.\n");
80
+ console.log(" Useful commands:");
81
+ console.log(" chvor stop Stop the server");
82
+ console.log(" chvor start Start the server");
83
+ console.log(" chvor update Update to latest version\n");
84
+ }
@@ -0,0 +1,340 @@
1
+ import { readConfig } from "../lib/config.js";
2
+ import { readFileSync } from "node:fs";
3
+ import { validateSkillForPublishing } from "@chvor/shared";
4
+ function getBaseUrl() {
5
+ const config = readConfig();
6
+ return `http://localhost:${config.port}`;
7
+ }
8
+ function getHeaders() {
9
+ const config = readConfig();
10
+ const headers = { "Content-Type": "application/json" };
11
+ if (config.token)
12
+ headers["Authorization"] = `Bearer ${config.token}`;
13
+ return headers;
14
+ }
15
+ async function apiRequest(path, init) {
16
+ const res = await fetch(`${getBaseUrl()}/api${path}`, {
17
+ ...init,
18
+ headers: { ...getHeaders(), ...init?.headers },
19
+ });
20
+ if (!res.ok) {
21
+ const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
22
+ throw new Error(body.error ?? `HTTP ${res.status}`);
23
+ }
24
+ const json = (await res.json());
25
+ return json.data;
26
+ }
27
+ // --- Skill commands ---
28
+ export async function skillSearch(query) {
29
+ return registrySearch(query, "skill");
30
+ }
31
+ export async function skillInstall(name) {
32
+ return registryInstall(name, "skill");
33
+ }
34
+ export async function skillUninstall(name) {
35
+ return registryUninstall(name);
36
+ }
37
+ export async function skillUpdate(name) {
38
+ return registryUpdate(name);
39
+ }
40
+ export async function skillList() {
41
+ try {
42
+ const skills = await apiRequest("/skills");
43
+ if (skills.length === 0) {
44
+ console.log("No skills installed.");
45
+ return;
46
+ }
47
+ console.log(`\n${skills.length} skill(s) installed:\n`);
48
+ const maxName = Math.max(...skills.map((s) => s.metadata.name.length), 4);
49
+ console.log(`${"Name".padEnd(maxName)} Version Source Enabled Category`);
50
+ console.log("-".repeat(maxName + 50));
51
+ for (const s of skills) {
52
+ console.log(`${s.metadata.name.padEnd(maxName)} ${(s.metadata.version ?? "").padEnd(9)} ${s.source.padEnd(10)} ${String(s.enabled).padEnd(8)} ${s.metadata.category ?? ""}`);
53
+ }
54
+ console.log();
55
+ }
56
+ catch (err) {
57
+ console.error("List failed:", err instanceof Error ? err.message : err);
58
+ process.exit(1);
59
+ }
60
+ }
61
+ export async function skillInfo(name) {
62
+ return registryInfo(name);
63
+ }
64
+ export async function skillPublish(filePath) {
65
+ try {
66
+ const content = readFileSync(filePath, "utf8");
67
+ const result = validateSkillForPublishing(content);
68
+ if (result.warnings.length > 0) {
69
+ console.log("\nWarnings:");
70
+ for (const w of result.warnings)
71
+ console.log(` - ${w}`);
72
+ }
73
+ if (!result.valid) {
74
+ console.log("\nValidation errors:");
75
+ for (const e of result.errors)
76
+ console.log(` - ${e}`);
77
+ console.log("\nFix these issues before publishing.");
78
+ process.exit(1);
79
+ }
80
+ console.log("\nSkill file is valid for publishing.");
81
+ // Try to submit to registry
82
+ const registryUrl = getRegistryUrl();
83
+ await submitToRegistry(registryUrl, content, "skill");
84
+ }
85
+ catch (err) {
86
+ console.error("Publish failed:", err instanceof Error ? err.message : err);
87
+ process.exit(1);
88
+ }
89
+ }
90
+ export async function toolPublish(filePath) {
91
+ try {
92
+ const content = readFileSync(filePath, "utf8");
93
+ // Use same validation — tools also need frontmatter with name/description/version/author
94
+ const result = validateSkillForPublishing(content);
95
+ if (result.warnings.length > 0) {
96
+ console.log("\nWarnings:");
97
+ for (const w of result.warnings)
98
+ console.log(` - ${w}`);
99
+ }
100
+ if (!result.valid) {
101
+ console.log("\nValidation errors:");
102
+ for (const e of result.errors)
103
+ console.log(` - ${e}`);
104
+ console.log("\nFix these issues before publishing.");
105
+ process.exit(1);
106
+ }
107
+ console.log("\nTool file is valid for publishing.");
108
+ const registryUrl = getRegistryUrl();
109
+ await submitToRegistry(registryUrl, content, "tool");
110
+ }
111
+ catch (err) {
112
+ console.error("Publish failed:", err instanceof Error ? err.message : err);
113
+ process.exit(1);
114
+ }
115
+ }
116
+ function getRegistryUrl() {
117
+ return process.env.CHVOR_REGISTRY_URL || "https://raw.githubusercontent.com/chvor-community/skill-registry/main";
118
+ }
119
+ function assertValidRegistryUrl(url) {
120
+ let parsed;
121
+ try {
122
+ parsed = new URL(url);
123
+ }
124
+ catch {
125
+ throw new Error(`Invalid registry URL: "${url}"`);
126
+ }
127
+ const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
128
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) {
129
+ throw new Error(`Registry URL must use HTTPS (got ${parsed.protocol}). HTTP is only allowed for localhost.`);
130
+ }
131
+ }
132
+ async function submitToRegistry(registryUrl, content, kind) {
133
+ assertValidRegistryUrl(registryUrl);
134
+ const { getRegistryToken, authenticate } = await import("../lib/registry-auth.js");
135
+ // Check if registry supports submissions
136
+ let supportsSubmissions = false;
137
+ try {
138
+ const healthRes = await fetch(`${registryUrl.replace(/\/v1$/, "")}/health`, { method: "GET" });
139
+ supportsSubmissions = healthRes.ok;
140
+ }
141
+ catch {
142
+ // Registry not reachable — show fallback message
143
+ }
144
+ if (!supportsSubmissions) {
145
+ console.log("\nThe community registry is not yet available for submissions.");
146
+ const dir = kind === "tool" ? "~/.chvor/tools/" : "~/.chvor/skills/";
147
+ console.log(`For now, you can use this file locally by placing it in ${dir}`);
148
+ console.log();
149
+ return;
150
+ }
151
+ // Authenticate if needed
152
+ let token = getRegistryToken(registryUrl);
153
+ if (!token) {
154
+ console.log("\nYou need to authenticate to publish.\n");
155
+ const auth = await authenticate(registryUrl);
156
+ token = auth.token;
157
+ }
158
+ // Extract ID from frontmatter
159
+ const idMatch = content.match(/^---[\s\S]*?name:\s*["']?(.+?)["']?\s*$/m);
160
+ const nameRaw = idMatch?.[1] ?? "untitled";
161
+ const id = nameRaw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
162
+ // Submit
163
+ console.log(`\nSubmitting ${kind} "${id}" to registry...`);
164
+ const res = await fetch(`${registryUrl}/submissions`, {
165
+ method: "POST",
166
+ headers: {
167
+ "Content-Type": "application/json",
168
+ "Authorization": `Bearer ${token}`,
169
+ },
170
+ body: JSON.stringify({ id, kind, content }),
171
+ });
172
+ if (!res.ok) {
173
+ const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
174
+ const errObj = body;
175
+ const msg = typeof errObj.error === "object" ? errObj.error?.message : errObj.error;
176
+ throw new Error(`Submission failed: ${msg ?? `HTTP ${res.status}`}`);
177
+ }
178
+ const data = await res.json();
179
+ console.log(`\nSubmission created (ID: ${data.id})`);
180
+ console.log(`Status: ${data.status}`);
181
+ console.log("\nYour submission will be reviewed by the registry maintainers.");
182
+ console.log("Track its status at the creator portal.");
183
+ console.log();
184
+ }
185
+ // --- Tool commands ---
186
+ export async function toolSearch(query) {
187
+ return registrySearch(query, "tool");
188
+ }
189
+ export async function toolInstall(name) {
190
+ return registryInstall(name, "tool");
191
+ }
192
+ export async function toolUninstall(name) {
193
+ return registryUninstall(name);
194
+ }
195
+ export async function toolUpdate(name) {
196
+ return registryUpdate(name);
197
+ }
198
+ export async function toolList() {
199
+ try {
200
+ const tools = await apiRequest("/tools");
201
+ if (tools.length === 0) {
202
+ console.log("No tools installed.");
203
+ return;
204
+ }
205
+ console.log(`\n${tools.length} tool(s) installed:\n`);
206
+ const maxName = Math.max(...tools.map((t) => t.metadata.name.length), 4);
207
+ console.log(`${"Name".padEnd(maxName)} Version Source Enabled Category`);
208
+ console.log("-".repeat(maxName + 50));
209
+ for (const t of tools) {
210
+ console.log(`${t.metadata.name.padEnd(maxName)} ${(t.metadata.version ?? "").padEnd(9)} ${t.source.padEnd(10)} ${String(t.enabled).padEnd(8)} ${t.metadata.category ?? ""}`);
211
+ }
212
+ console.log();
213
+ }
214
+ catch (err) {
215
+ console.error("List failed:", err instanceof Error ? err.message : err);
216
+ process.exit(1);
217
+ }
218
+ }
219
+ export async function toolInfo(name) {
220
+ return registryInfo(name);
221
+ }
222
+ // --- Shared registry operations ---
223
+ async function registrySearch(query, kind) {
224
+ try {
225
+ const results = await apiRequest(`/registry/search?q=${encodeURIComponent(query)}&kind=${kind}`);
226
+ if (results.length === 0) {
227
+ console.log(`No ${kind}s found matching your query.`);
228
+ return;
229
+ }
230
+ console.log(`\nFound ${results.length} ${kind}(s):\n`);
231
+ const maxName = Math.max(...results.map((r) => r.name.length), 4);
232
+ const maxVer = Math.max(...results.map((r) => r.version.length), 7);
233
+ console.log(`${"Name".padEnd(maxName)} ${"Version".padEnd(maxVer)} Status Category Description`);
234
+ console.log("-".repeat(maxName + maxVer + 60));
235
+ for (const r of results) {
236
+ const status = r.installed
237
+ ? r.installedVersion !== r.version
238
+ ? `update ${r.installedVersion}`
239
+ : "installed"
240
+ : "available";
241
+ console.log(`${r.name.padEnd(maxName)} ${r.version.padEnd(maxVer)} ${status.padEnd(12)} ${(r.category ?? "").padEnd(11)} ${r.description.slice(0, 50)}`);
242
+ }
243
+ console.log();
244
+ }
245
+ catch (err) {
246
+ console.error("Search failed:", err instanceof Error ? err.message : err);
247
+ process.exit(1);
248
+ }
249
+ }
250
+ async function registryInstall(name, kind) {
251
+ try {
252
+ console.log(`Installing "${name}" from registry...`);
253
+ const result = await apiRequest("/registry/install", {
254
+ method: "POST",
255
+ body: JSON.stringify({ id: name, kind }),
256
+ });
257
+ const installed = result.entry ?? result.skill;
258
+ console.log(`Installed ${installed.metadata.name} v${installed.metadata.version}`);
259
+ if (result.dependencies.length > 0) {
260
+ console.log(` Dependencies installed: ${result.dependencies.join(", ")}`);
261
+ }
262
+ }
263
+ catch (err) {
264
+ console.error("Install failed:", err instanceof Error ? err.message : err);
265
+ process.exit(1);
266
+ }
267
+ }
268
+ async function registryUninstall(name) {
269
+ try {
270
+ await apiRequest(`/registry/entry/${encodeURIComponent(name)}`, { method: "DELETE" });
271
+ console.log(`Uninstalled "${name}"`);
272
+ }
273
+ catch (err) {
274
+ console.error("Uninstall failed:", err instanceof Error ? err.message : err);
275
+ process.exit(1);
276
+ }
277
+ }
278
+ async function registryUpdate(name) {
279
+ try {
280
+ if (name) {
281
+ console.log(`Updating "${name}"...`);
282
+ const result = await apiRequest("/registry/update", { method: "POST", body: JSON.stringify({ id: name }) });
283
+ if (result.conflict) {
284
+ console.log(`Skipped — "${name}" was locally modified. Use --force to overwrite.`);
285
+ }
286
+ else if (result.updated) {
287
+ console.log(`Updated "${name}"`);
288
+ }
289
+ else {
290
+ console.log(`"${name}" is already up to date.`);
291
+ }
292
+ }
293
+ else {
294
+ console.log("Checking for updates...");
295
+ const results = await apiRequest("/registry/update", { method: "POST", body: JSON.stringify({ all: true }) });
296
+ const updated = results.filter((r) => r.updated);
297
+ const conflicts = results.filter((r) => r.conflict);
298
+ console.log(`Updated ${updated.length} entries`);
299
+ if (conflicts.length > 0) {
300
+ console.log(`Skipped ${conflicts.length} locally modified: ${conflicts.map((c) => c.id).join(", ")}`);
301
+ }
302
+ }
303
+ }
304
+ catch (err) {
305
+ console.error("Update failed:", err instanceof Error ? err.message : err);
306
+ process.exit(1);
307
+ }
308
+ }
309
+ async function registryInfo(name) {
310
+ try {
311
+ const entry = await apiRequest(`/registry/entry/${encodeURIComponent(name)}`).catch(() => null);
312
+ if (entry) {
313
+ console.log(`\n${entry.name} (${entry.id})`);
314
+ console.log(` ${entry.description}`);
315
+ console.log(` Kind: ${entry.kind}`);
316
+ console.log(` Version: ${entry.version}`);
317
+ if (entry.author)
318
+ console.log(` Author: ${entry.author}`);
319
+ if (entry.category)
320
+ console.log(` Category: ${entry.category}`);
321
+ if (entry.tags?.length)
322
+ console.log(` Tags: ${entry.tags.join(", ")}`);
323
+ if (entry.license)
324
+ console.log(` License: ${entry.license}`);
325
+ if (entry.downloads !== undefined)
326
+ console.log(` Downloads: ${entry.downloads}`);
327
+ if (entry.dependencies?.length)
328
+ console.log(` Depends: ${entry.dependencies.join(", ")}`);
329
+ console.log(` Status: ${entry.installed ? `installed (v${entry.installedVersion})` : "not installed"}`);
330
+ console.log();
331
+ }
332
+ else {
333
+ console.log(`"${name}" not found in registry.`);
334
+ }
335
+ }
336
+ catch (err) {
337
+ console.error("Info failed:", err instanceof Error ? err.message : err);
338
+ process.exit(1);
339
+ }
340
+ }
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readConfig } from "../lib/config.js";
5
+ import { downloadRelease, isInstalled } from "../lib/download.js";
6
+ import { spawnServer } from "../lib/process.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
9
+ export async function start(opts) {
10
+ const config = readConfig();
11
+ const version = pkg.version;
12
+ if (!isInstalled(version)) {
13
+ await downloadRelease(version);
14
+ }
15
+ await spawnServer({
16
+ port: opts.port ?? config.port,
17
+ foreground: opts.foreground,
18
+ });
19
+ }
@@ -0,0 +1,4 @@
1
+ import { stopServer } from "../lib/process.js";
2
+ export async function stop() {
3
+ await stopServer();
4
+ }
@@ -0,0 +1,23 @@
1
+ import { rmSync } from "node:fs";
2
+ import { readConfig, writeConfig } from "../lib/config.js";
3
+ import { getAppDir } from "../lib/paths.js";
4
+ import { downloadRelease } from "../lib/download.js";
5
+ import { isServerRunning, stopServer } from "../lib/process.js";
6
+ export async function update() {
7
+ const res = await fetch("https://api.github.com/repos/luka-zivkovic/chvor/releases/latest");
8
+ const release = (await res.json());
9
+ const version = release.tag_name.replace(/^v/, "");
10
+ const config = readConfig();
11
+ if (config.installedVersion === version) {
12
+ console.log("Already up to date.");
13
+ return;
14
+ }
15
+ if (isServerRunning().running) {
16
+ await stopServer();
17
+ }
18
+ rmSync(getAppDir(), { recursive: true, force: true });
19
+ await downloadRelease(version);
20
+ writeConfig({ ...config, installedVersion: version });
21
+ console.log(`Updated to v${version}.`);
22
+ console.log("Run `chvor start` to start the server.");
23
+ }
@@ -0,0 +1,26 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { getConfigPath, ensureDir } from "./paths.js";
4
+ const DEFAULTS = {
5
+ port: "3001",
6
+ onboarded: false,
7
+ };
8
+ export function readConfig() {
9
+ const configPath = getConfigPath();
10
+ try {
11
+ const raw = readFileSync(configPath, "utf-8");
12
+ const parsed = JSON.parse(raw);
13
+ return { ...DEFAULTS, ...parsed };
14
+ }
15
+ catch {
16
+ return { ...DEFAULTS };
17
+ }
18
+ }
19
+ export function writeConfig(config) {
20
+ const configPath = getConfigPath();
21
+ ensureDir(dirname(configPath));
22
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
23
+ }
24
+ export function isOnboarded() {
25
+ return readConfig().onboarded;
26
+ }
@@ -0,0 +1,145 @@
1
+ import { createWriteStream, createReadStream, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { Readable } from "node:stream";
5
+ import { createHash } from "node:crypto";
6
+ import { execFileSync } from "node:child_process";
7
+ import { getAppDir, getDownloadsDir, ensureDir } from "./paths.js";
8
+ import { readConfig, writeConfig } from "./config.js";
9
+ import { getAssetName, getPlatform } from "./platform.js";
10
+ const GITHUB_API = "https://api.github.com";
11
+ const REPO = "luka-zivkovic/chvor";
12
+ export async function resolveRelease(version) {
13
+ const tag = `v${version}`;
14
+ const url = `${GITHUB_API}/repos/${REPO}/releases/tags/${tag}`;
15
+ const res = await fetch(url, {
16
+ headers: {
17
+ Accept: "application/vnd.github+json",
18
+ "User-Agent": "chvor-cli",
19
+ },
20
+ });
21
+ if (!res.ok) {
22
+ throw new Error(`Failed to fetch release ${tag}: ${res.status} ${res.statusText}`);
23
+ }
24
+ const release = (await res.json());
25
+ const assetName = getAssetName(version);
26
+ const asset = release.assets.find((a) => a.name === assetName);
27
+ if (!asset) {
28
+ throw new Error(`Asset "${assetName}" not found in release ${tag}. ` +
29
+ `Available assets: ${release.assets.map((a) => a.name).join(", ")}`);
30
+ }
31
+ let checksum;
32
+ const checksumAsset = release.assets.find((a) => a.name === "SHA256SUMS.txt");
33
+ if (checksumAsset) {
34
+ const checksumRes = await fetch(checksumAsset.browser_download_url, {
35
+ headers: { "User-Agent": "chvor-cli" },
36
+ });
37
+ if (checksumRes.ok) {
38
+ const text = await checksumRes.text();
39
+ const line = text
40
+ .split("\n")
41
+ .find((l) => l.includes(assetName));
42
+ if (line) {
43
+ checksum = line.trim().split(/\s+/)[0];
44
+ }
45
+ }
46
+ }
47
+ return { url: asset.browser_download_url, checksum };
48
+ }
49
+ export function isInstalled(version) {
50
+ const appDir = getAppDir();
51
+ if (!existsSync(appDir))
52
+ return false;
53
+ const config = readConfig();
54
+ return config.installedVersion === version;
55
+ }
56
+ export async function downloadRelease(version) {
57
+ if (isInstalled(version)) {
58
+ console.log(`Chvor v${version} is already installed.`);
59
+ return;
60
+ }
61
+ console.log(`Resolving Chvor v${version} release...`);
62
+ const { url, checksum } = await resolveRelease(version);
63
+ const downloadsDir = getDownloadsDir();
64
+ ensureDir(downloadsDir);
65
+ const assetName = getAssetName(version);
66
+ const tarballPath = join(downloadsDir, assetName);
67
+ // Download the tarball
68
+ console.log(`Downloading ${assetName}...`);
69
+ const res = await fetch(url, {
70
+ headers: { "User-Agent": "chvor-cli" },
71
+ });
72
+ if (!res.ok) {
73
+ throw new Error(`Download failed: ${res.status} ${res.statusText}`);
74
+ }
75
+ if (!res.body) {
76
+ throw new Error("Download response has no body");
77
+ }
78
+ const fileStream = createWriteStream(tarballPath);
79
+ await pipeline(Readable.fromWeb(res.body), fileStream);
80
+ console.log("Download complete.");
81
+ // Verify checksum if available
82
+ if (checksum) {
83
+ console.log("Verifying checksum...");
84
+ const actual = await computeSha256(tarballPath);
85
+ if (actual !== checksum) {
86
+ throw new Error(`Checksum mismatch!\n Expected: ${checksum}\n Actual: ${actual}`);
87
+ }
88
+ console.log("Checksum verified.");
89
+ }
90
+ // Extract
91
+ const appDir = getAppDir();
92
+ ensureDir(appDir);
93
+ console.log(`Extracting to ${appDir}...`);
94
+ if (getPlatform() === "win") {
95
+ execFileSync("powershell", [
96
+ "-NoProfile", "-Command",
97
+ `Expand-Archive -Path '${tarballPath}' -DestinationPath '${appDir}' -Force`,
98
+ ], { stdio: "inherit" });
99
+ // Move contents up from the nested directory (strip-components equivalent)
100
+ const nested = join(appDir, assetName.replace(/\.zip$/, ""));
101
+ if (existsSync(nested)) {
102
+ execFileSync("powershell", [
103
+ "-NoProfile", "-Command",
104
+ `Get-ChildItem -Path '${nested}' | Move-Item -Destination '${appDir}' -Force`,
105
+ ], { stdio: "inherit" });
106
+ execFileSync("powershell", [
107
+ "-NoProfile", "-Command",
108
+ `Remove-Item -Path '${nested}' -Recurse -Force`,
109
+ ], { stdio: "inherit" });
110
+ }
111
+ }
112
+ else {
113
+ execFileSync("tar", ["-xzf", tarballPath, "-C", appDir, "--strip-components=1"], {
114
+ stdio: "inherit",
115
+ });
116
+ }
117
+ console.log("Extraction complete.");
118
+ // Install Playwright's Chromium browser (required by browser agent / Stagehand)
119
+ console.log("Installing browser engine (Chromium)...");
120
+ try {
121
+ execFileSync("node", [
122
+ join(appDir, "node_modules", "@playwright", "test", "cli.js"),
123
+ "install", "chromium",
124
+ ], { stdio: "inherit", cwd: appDir });
125
+ console.log("Browser engine installed.");
126
+ }
127
+ catch (err) {
128
+ console.warn("Warning: failed to install browser engine. " +
129
+ "The web agent won't work until you run: npx playwright install chromium");
130
+ }
131
+ // Update config
132
+ const config = readConfig();
133
+ config.installedVersion = version;
134
+ writeConfig(config);
135
+ console.log(`Chvor v${version} installed successfully.`);
136
+ }
137
+ async function computeSha256(filePath) {
138
+ return new Promise((resolve, reject) => {
139
+ const hash = createHash("sha256");
140
+ const stream = createReadStream(filePath);
141
+ stream.on("data", (chunk) => hash.update(chunk));
142
+ stream.on("end", () => resolve(hash.digest("hex")));
143
+ stream.on("error", reject);
144
+ });
145
+ }