@modelworks/quack 0.0.0-stage → 0.2.8

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,154 @@
1
+ /**
2
+ * Run history — save and list circuit run records locally.
3
+ *
4
+ * After each circuit request through the proxy, the run UID (from the
5
+ * `X-Circuit-Run-Uid` response header) is used to fetch the full run
6
+ * record from the API and save it as a JSON file under `.quack/runs/`.
7
+ * These files are committed to the repo (team-shared).
8
+ *
9
+ * @module
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { quackDir } from "./project.js";
14
+ /** Path to `.quack/runs/` directory. */
15
+ export function runsDir() {
16
+ return path.join(quackDir(), "runs");
17
+ }
18
+ /** Path to a single run file. */
19
+ export function runFilePath(runUid) {
20
+ return path.join(runsDir(), `${runUid}.json`);
21
+ }
22
+ /**
23
+ * Fetch a circuit run record from the API and map it to a `RunRecord`.
24
+ *
25
+ * Retries up to 3 times with a 500ms delay if the run hasn't reached a
26
+ * terminal state (COMPLETED/EXCEPTION/FAILED) — the engine might not
27
+ * have finalized the DB record the instant the stream ends.
28
+ */
29
+ export async function fetchRunFromApi(runUid, credentials, stateCircuits, callerUsername) {
30
+ const base = credentials.api_base;
31
+ const token = credentials.access_token;
32
+ for (let attempt = 0; attempt < 3; attempt++) {
33
+ try {
34
+ const res = await fetch(`${base}/api/circuit-runs/${runUid}`, {
35
+ headers: { Authorization: `Bearer ${token}` },
36
+ });
37
+ if (!res.ok) {
38
+ if (attempt < 2) {
39
+ await sleep(500);
40
+ continue;
41
+ }
42
+ return null;
43
+ }
44
+ const body = (await res.json());
45
+ const run = body.payload;
46
+ if (!run)
47
+ return null;
48
+ const state = run.state ?? "UNKNOWN";
49
+ const terminal = ["COMPLETED", "Completed", "EXCEPTION", "FAILED"];
50
+ if (!terminal.includes(state) && attempt < 2) {
51
+ await sleep(500);
52
+ continue;
53
+ }
54
+ const circuitUid = run.circuit_uid ?? "";
55
+ const versionUid = run.circuit_version_uid ?? "";
56
+ const createdAt = run.created_at ?? "";
57
+ const endedAt = run.ended_at ?? "";
58
+ // Parse result_blob for cost/tokens/duration.
59
+ const resultBlob = run.result_blob;
60
+ let costCredits = null;
61
+ let inputTokens = null;
62
+ let outputTokens = null;
63
+ let durationMs = null;
64
+ if (resultBlob) {
65
+ const billingEntries = resultBlob.billing_entries;
66
+ if (billingEntries && billingEntries.length > 0) {
67
+ costCredits = billingEntries[0].cost ?? null;
68
+ inputTokens = billingEntries[0].total_input_tokens ?? null;
69
+ outputTokens = billingEntries[0].total_output_tokens ?? null;
70
+ }
71
+ const perfReports = resultBlob.performance_reports;
72
+ if (perfReports) {
73
+ // Take the first performance report's time_taken.
74
+ const firstKey = Object.keys(perfReports)[0];
75
+ if (firstKey) {
76
+ durationMs = perfReports[firstKey].time_taken ?? null;
77
+ }
78
+ }
79
+ }
80
+ // Resolve circuit_name and owner_username from state.
81
+ const stateEntry = stateCircuits[circuitUid];
82
+ const circuitName = stateEntry?.circuit_name ?? circuitUid;
83
+ const ownerUsername = stateEntry?.owner_username ?? "";
84
+ return {
85
+ run_uid: runUid,
86
+ circuit_uid: circuitUid,
87
+ circuit_name: circuitName,
88
+ owner_username: ownerUsername,
89
+ caller_username: callerUsername,
90
+ version_uid: versionUid,
91
+ status: state,
92
+ created_at: createdAt,
93
+ completed_at: endedAt,
94
+ duration_ms: durationMs,
95
+ cost_credits: costCredits,
96
+ input_tokens: inputTokens,
97
+ output_tokens: outputTokens,
98
+ };
99
+ }
100
+ catch {
101
+ if (attempt < 2) {
102
+ await sleep(500);
103
+ continue;
104
+ }
105
+ return null;
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+ /** Save a run record to `.quack/runs/cr-xxx.json`. */
111
+ export function saveRunRecord(record) {
112
+ const dir = runsDir();
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ fs.writeFileSync(runFilePath(record.run_uid), JSON.stringify(record, null, 2) + "\n");
115
+ }
116
+ /**
117
+ * List all run records, sorted by `created_at` descending.
118
+ *
119
+ * Reads every `*.json` file in `.quack/runs/` and parses them.
120
+ * Returns an empty array if the directory doesn't exist.
121
+ */
122
+ export function listRunRecords() {
123
+ const dir = runsDir();
124
+ if (!fs.existsSync(dir))
125
+ return [];
126
+ const records = [];
127
+ for (const file of fs.readdirSync(dir)) {
128
+ if (!file.endsWith(".json"))
129
+ continue;
130
+ try {
131
+ const raw = fs.readFileSync(path.join(dir, file), "utf8");
132
+ records.push(JSON.parse(raw));
133
+ }
134
+ catch {
135
+ // Skip malformed files.
136
+ }
137
+ }
138
+ records.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
139
+ return records;
140
+ }
141
+ /** Get a single run record by UID, or `null` if not found. */
142
+ export function getRunRecord(runUid) {
143
+ const file = runFilePath(runUid);
144
+ try {
145
+ const raw = fs.readFileSync(file, "utf8");
146
+ return JSON.parse(raw);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * quack installer — Node phase.
4
+ *
5
+ * Called by `install/install.sh` after the bash bootstrap has:
6
+ * 1. Ensured Node 18+ is available (system or bundled at `~/.quack/node`)
7
+ * 2. Downloaded and extracted the `@modelworks/quack` npm tarball to a temp dir
8
+ *
9
+ * This script (from the extracted tarball's `dist/install.js`):
10
+ * 1. Copies `dist/` to `~/.quack/dist`
11
+ * 2. Creates the `~/.quack/bin/quack` wrapper
12
+ * 3. Configures PATH (shell config file)
13
+ * 4. Handles GitHub Actions (`$GITHUB_PATH`)
14
+ * 5. Prints a success summary
15
+ *
16
+ * Each step shows a spinner while running, then ✓ (green) or ✗ (red).
17
+ *
18
+ * Usage:
19
+ * node dist/install.js [--target <dir>] [--no-modify-path] [--version <ver>] [--bundled-node]
20
+ *
21
+ * @module
22
+ */
23
+ export {};
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * quack installer — Node phase.
4
+ *
5
+ * Called by `install/install.sh` after the bash bootstrap has:
6
+ * 1. Ensured Node 18+ is available (system or bundled at `~/.quack/node`)
7
+ * 2. Downloaded and extracted the `@modelworks/quack` npm tarball to a temp dir
8
+ *
9
+ * This script (from the extracted tarball's `dist/install.js`):
10
+ * 1. Copies `dist/` to `~/.quack/dist`
11
+ * 2. Creates the `~/.quack/bin/quack` wrapper
12
+ * 3. Configures PATH (shell config file)
13
+ * 4. Handles GitHub Actions (`$GITHUB_PATH`)
14
+ * 5. Prints a success summary
15
+ *
16
+ * Each step shows a spinner while running, then ✓ (green) or ✗ (red).
17
+ *
18
+ * Usage:
19
+ * node dist/install.js [--target <dir>] [--no-modify-path] [--version <ver>] [--bundled-node]
20
+ *
21
+ * @module
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import os from "node:os";
26
+ import { fileURLToPath } from "node:url";
27
+ // ---------------------------------------------------------------------------
28
+ // ANSI colors
29
+ // ---------------------------------------------------------------------------
30
+ const isTTY = process.stderr.isTTY ?? false;
31
+ const C = isTTY
32
+ ? {
33
+ reset: "\x1b[0m",
34
+ bold: "\x1b[1m",
35
+ dim: "\x1b[2m",
36
+ red: "\x1b[31m",
37
+ green: "\x1b[32m",
38
+ yellow: "\x1b[33m",
39
+ blue: "\x1b[34m",
40
+ gray: "\x1b[90m",
41
+ hide: "\x1b[?25l",
42
+ show: "\x1b[?25h",
43
+ clearLine: "\x1b[2K",
44
+ }
45
+ : {
46
+ reset: "",
47
+ bold: "",
48
+ dim: "",
49
+ red: "",
50
+ green: "",
51
+ yellow: "",
52
+ blue: "",
53
+ gray: "",
54
+ hide: "",
55
+ show: "",
56
+ clearLine: "",
57
+ };
58
+ // ---------------------------------------------------------------------------
59
+ // Spinner
60
+ // ---------------------------------------------------------------------------
61
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
62
+ class Spinner {
63
+ timer = null;
64
+ frame = 0;
65
+ msg = "";
66
+ start(msg) {
67
+ this.msg = msg;
68
+ this.stop();
69
+ if (isTTY)
70
+ process.stderr.write(C.hide);
71
+ this.timer = setInterval(() => {
72
+ process.stderr.write(`\r${C.clearLine}${C.gray}${FRAMES[this.frame % FRAMES.length]}${C.reset} ${this.msg}`);
73
+ this.frame++;
74
+ }, 80);
75
+ }
76
+ succeed(msg) {
77
+ this.stop();
78
+ process.stderr.write(`\r${C.clearLine}${C.green}✓${C.reset} ${msg ?? this.msg}\n`);
79
+ }
80
+ fail(msg) {
81
+ this.stop();
82
+ process.stderr.write(`\r${C.clearLine}${C.red}✗${C.reset} ${msg ?? this.msg}\n`);
83
+ }
84
+ warn(msg) {
85
+ this.stop();
86
+ process.stderr.write(`\r${C.clearLine}${C.yellow}!${C.reset} ${msg}\n`);
87
+ }
88
+ stop() {
89
+ if (this.timer) {
90
+ clearInterval(this.timer);
91
+ this.timer = null;
92
+ }
93
+ if (isTTY)
94
+ process.stderr.write(C.show);
95
+ }
96
+ }
97
+ function parseArgs(argv) {
98
+ const args = { noModifyPath: false, bundledNode: false };
99
+ for (let i = 0; i < argv.length; i++) {
100
+ switch (argv[i]) {
101
+ case "--target":
102
+ args.target = argv[++i];
103
+ break;
104
+ case "--no-modify-path":
105
+ args.noModifyPath = true;
106
+ break;
107
+ case "--version":
108
+ args.version = argv[++i];
109
+ break;
110
+ case "--bundled-node":
111
+ args.bundledNode = true;
112
+ break;
113
+ case "--help":
114
+ case "-h":
115
+ console.log("Usage: quack-install [--target <dir>] [--no-modify-path] [--version <ver>] [--bundled-node]");
116
+ process.exit(0);
117
+ break;
118
+ }
119
+ }
120
+ return args;
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // File helpers
124
+ // ---------------------------------------------------------------------------
125
+ /** Recursively copy a directory. */
126
+ function copyDir(src, dest) {
127
+ fs.mkdirSync(dest, { recursive: true });
128
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
129
+ const srcPath = path.join(src, entry.name);
130
+ const destPath = path.join(dest, entry.name);
131
+ if (entry.isDirectory()) {
132
+ copyDir(srcPath, destPath);
133
+ }
134
+ else {
135
+ fs.copyFileSync(srcPath, destPath);
136
+ }
137
+ }
138
+ }
139
+ function configurePath(binDir) {
140
+ // Already on PATH?
141
+ const pathParts = (process.env.PATH ?? "").split(":");
142
+ if (pathParts.includes(binDir)) {
143
+ return { added: false, already: true, noConfig: false };
144
+ }
145
+ const home = os.homedir();
146
+ const shell = path.basename(process.env.SHELL ?? "");
147
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
148
+ let configFiles;
149
+ let pathCmd;
150
+ switch (shell) {
151
+ case "fish":
152
+ configFiles = [path.join(home, ".config/fish/config.fish")];
153
+ pathCmd = `fish_add_path ${binDir}`;
154
+ break;
155
+ case "zsh":
156
+ configFiles = [
157
+ path.join(process.env.ZDOTDIR ?? home, ".zshrc"),
158
+ path.join(process.env.ZDOTDIR ?? home, ".zshenv"),
159
+ path.join(xdgConfig, "zsh/.zshrc"),
160
+ path.join(xdgConfig, "zsh/.zshenv"),
161
+ ];
162
+ pathCmd = `export PATH="${binDir}:$PATH"`;
163
+ break;
164
+ case "bash":
165
+ configFiles = [
166
+ path.join(home, ".bashrc"),
167
+ path.join(home, ".bash_profile"),
168
+ path.join(home, ".profile"),
169
+ ];
170
+ pathCmd = `export PATH="${binDir}:$PATH"`;
171
+ break;
172
+ default:
173
+ configFiles = [
174
+ path.join(home, ".bashrc"),
175
+ path.join(home, ".bash_profile"),
176
+ path.join(home, ".profile"),
177
+ ];
178
+ pathCmd = `export PATH="${binDir}:$PATH"`;
179
+ }
180
+ // Find first existing config file.
181
+ const configFile = configFiles.find((f) => fs.existsSync(f));
182
+ if (!configFile) {
183
+ return { added: false, already: false, noConfig: true };
184
+ }
185
+ // Already in config?
186
+ const content = fs.readFileSync(configFile, "utf8");
187
+ if (content.includes(pathCmd)) {
188
+ return { added: false, already: true, noConfig: false };
189
+ }
190
+ // Append.
191
+ fs.appendFileSync(configFile, `\n# quack\n${pathCmd}\n`);
192
+ return { added: true, already: false, noConfig: false, file: configFile };
193
+ }
194
+ // ---------------------------------------------------------------------------
195
+ // Wrapper script
196
+ // ---------------------------------------------------------------------------
197
+ const WRAPPER_SCRIPT = `#!/bin/sh
198
+ # quack wrapper — resolves Node (system or bundled) and launches quack.
199
+ set -e
200
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
201
+ QUACK_HOME="$(cd "$SELF_DIR/.." && pwd)"
202
+ if command -v node >/dev/null 2>&1; then
203
+ NODE_BIN="node"
204
+ elif [ -x "$QUACK_HOME/node/node" ]; then
205
+ NODE_BIN="$QUACK_HOME/node/node"
206
+ else
207
+ echo "quack: Node 18+ not found. Reinstall:" >&2
208
+ echo " curl -fsSL https://raw.githubusercontent.com/modelworks-ai/quack/main/install/install.sh | bash" >&2
209
+ exit 1
210
+ fi
211
+ exec "$NODE_BIN" "$QUACK_HOME/dist/bin.js" "$@"
212
+ `;
213
+ // ---------------------------------------------------------------------------
214
+ // Summary
215
+ // ---------------------------------------------------------------------------
216
+ function printSummary(args, binDir) {
217
+ const version = args.version ?? "latest";
218
+ const shell = path.basename(process.env.SHELL ?? "");
219
+ console.log("");
220
+ console.log(` ${C.green}${C.bold}╔══════════════════════════════════════╗${C.reset}`);
221
+ console.log(` ${C.green}${C.bold}║ quack installed! 🦆 ║${C.reset}`);
222
+ console.log(` ${C.green}${C.bold}╚══════════════════════════════════════╝${C.reset}`);
223
+ console.log("");
224
+ console.log(` ${C.dim}Version:${C.reset} ${version}`);
225
+ console.log(` ${C.dim}Location:${C.reset} ${binDir}/quack`);
226
+ if (args.bundledNode) {
227
+ console.log(` ${C.dim}Node:${C.reset} bundled`);
228
+ }
229
+ console.log("");
230
+ console.log(` ${C.bold}Next steps:${C.reset}`);
231
+ console.log(` quack login ${C.dim}# Authenticate via OAuth${C.reset}`);
232
+ console.log(` cd <project> && quack init ${C.dim}# Initialize a project${C.reset}`);
233
+ console.log(` quack add <circuit-url> ${C.dim}# Add a circuit${C.reset}`);
234
+ console.log(` quack opencode ${C.dim}# Launch OpenCode${C.reset}`);
235
+ console.log("");
236
+ if (!args.noModifyPath) {
237
+ console.log(` ${C.dim}Open a new terminal or run:${C.reset}`);
238
+ if (shell) {
239
+ console.log(` ${C.dim} source ~/.${shell}rc # or restart your shell${C.reset}`);
240
+ }
241
+ console.log(` ${C.dim}to use quack.${C.reset}`);
242
+ }
243
+ else {
244
+ console.log(` ${C.dim}Add to PATH: export PATH="${binDir}:$PATH"${C.reset}`);
245
+ }
246
+ console.log("");
247
+ console.log(` ${C.dim}Docs: https://github.com/modelworks-ai/quack${C.reset}`);
248
+ console.log("");
249
+ }
250
+ // ---------------------------------------------------------------------------
251
+ // Main
252
+ // ---------------------------------------------------------------------------
253
+ async function main() {
254
+ const args = parseArgs(process.argv.slice(2));
255
+ const installDir = args.target ?? path.join(os.homedir(), ".quack");
256
+ const binDir = path.join(installDir, "bin");
257
+ const distDir = path.join(installDir, "dist");
258
+ // Source dist files are in the same directory as this script (the
259
+ // extracted tarball's dist/).
260
+ const sourceDist = path.dirname(fileURLToPath(import.meta.url));
261
+ const spinner = new Spinner();
262
+ // Step 1: Copy dist to install dir.
263
+ spinner.start("Installing quack...");
264
+ try {
265
+ fs.mkdirSync(installDir, { recursive: true });
266
+ fs.mkdirSync(binDir, { recursive: true });
267
+ fs.rmSync(distDir, { recursive: true, force: true });
268
+ copyDir(sourceDist, distDir);
269
+ spinner.succeed(`Installed to ${installDir}`);
270
+ }
271
+ catch (err) {
272
+ spinner.fail("Installation failed");
273
+ console.error(err);
274
+ process.exit(1);
275
+ }
276
+ // Step 2: Create wrapper.
277
+ spinner.start("Creating wrapper script...");
278
+ try {
279
+ fs.writeFileSync(path.join(binDir, "quack"), WRAPPER_SCRIPT, {
280
+ mode: 0o755,
281
+ });
282
+ spinner.succeed(`Created ${path.join(binDir, "quack")}`);
283
+ }
284
+ catch (err) {
285
+ spinner.fail("Failed to create wrapper");
286
+ console.error(err);
287
+ process.exit(1);
288
+ }
289
+ // Step 3: Configure PATH.
290
+ if (!args.noModifyPath) {
291
+ spinner.start("Configuring PATH...");
292
+ try {
293
+ const result = configurePath(binDir);
294
+ if (result.added) {
295
+ spinner.succeed(`Added to PATH in ${result.file}`);
296
+ }
297
+ else if (result.already) {
298
+ spinner.succeed("Already on PATH");
299
+ }
300
+ else if (result.noConfig) {
301
+ spinner.warn(`No shell config found. Add manually:\n export PATH="${binDir}:$PATH"`);
302
+ }
303
+ }
304
+ catch (err) {
305
+ spinner.fail("PATH configuration failed");
306
+ console.error(err);
307
+ }
308
+ }
309
+ // Step 4: GitHub Actions.
310
+ if (process.env.GITHUB_ACTIONS === "true" && process.env.GITHUB_PATH) {
311
+ spinner.start("Adding to $GITHUB_PATH...");
312
+ try {
313
+ fs.appendFileSync(process.env.GITHUB_PATH, binDir + "\n");
314
+ spinner.succeed(`Added ${binDir} to $GITHUB_PATH`);
315
+ }
316
+ catch {
317
+ spinner.warn("Could not write to $GITHUB_PATH");
318
+ }
319
+ }
320
+ // Summary.
321
+ printSummary(args, binDir);
322
+ }
323
+ main().catch((err) => {
324
+ console.error(`${C.red}Error: ${err.message}${C.reset}`);
325
+ process.exit(1);
326
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Project-level `.quack/` directory management: init, config/state read/write.
3
+ *
4
+ * @module
5
+ */
6
+ import type { CircuitConfigEntry, CircuitStateEntry, QuackConfig, QuackState } from "./types.js";
7
+ /** The `.quack/` directory name, placed at the project root. */
8
+ export declare const QUACK_DIR = ".quack";
9
+ /** Path to the `.quack/` directory in the current working directory. */
10
+ export declare function quackDir(): string;
11
+ /** Path to `.quack/config.json`. */
12
+ export declare function configPath(): string;
13
+ /** Path to `.quack/state.json`. */
14
+ export declare function statePath(): string;
15
+ /** Does a `.quack/` directory exist in the current project? */
16
+ export declare function isQuackProject(): boolean;
17
+ /** Assert the current directory is a quack project; throw a friendly error if not. */
18
+ export declare function requireQuackProject(): void;
19
+ /**
20
+ * `quack init` — create the `.quack/` directory with empty config,
21
+ * state, and `.gitignore`.
22
+ *
23
+ * Errors if `.quack/` already exists, unless `force` is `true`.
24
+ */
25
+ export declare function initProject(force?: boolean): void;
26
+ /** Read the project config. Throws if `.quack/` doesn't exist. */
27
+ export declare function readConfig(): QuackConfig;
28
+ /** Write the project config (atomic-ish: write then sync). */
29
+ export declare function writeConfig(config: QuackConfig): void;
30
+ /**
31
+ * Read the project state.
32
+ *
33
+ * Returns an empty state if `.quack/` doesn't exist or `state.json`
34
+ * is missing (e.g. a freshly cloned repo where only `config.json`
35
+ * was committed). The caller is responsible for auto-syncing state
36
+ * from config when needed.
37
+ */
38
+ export declare function readState(): QuackState;
39
+ /** Write the project state. */
40
+ export declare function writeState(state: QuackState): void;
41
+ /** Add a circuit entry to the config (no-op if already present). Returns `true` if added. */
42
+ export declare function addCircuitToConfig(entry: CircuitConfigEntry): boolean;
43
+ /** Remove a circuit from config by `circuit_uid`. Returns `true` if removed. */
44
+ export declare function removeCircuitFromConfig(circuitUid: string): boolean;
45
+ /** Upsert a circuit's state entry (keyed by `circuit_uid`). */
46
+ export declare function upsertCircuitState(entry: CircuitStateEntry): void;
47
+ /** Remove a circuit's state entry. Returns `true` if removed. */
48
+ export declare function removeCircuitState(circuitUid: string): boolean;
49
+ /** Get a circuit's state entry, or `null` if not present. */
50
+ export declare function getCircuitState(circuitUid: string): CircuitStateEntry | null;