@flekh/review 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @flekh/review
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Upload a file from your computer to [Flekh](https://flekh.com), from Claude Code or any AI agent with a terminal.
4
+
5
+ You don't run this yourself. Connect Flekh to your AI (connector URL `https://api.flekh.com/mcp`) and ask:
6
+
7
+ > upload ~/Movies/cut_v3.mp4 to Acme Studio as version 2 of Summer teaser
8
+
9
+ The connector's `start_upload(path)` checks the project's Agents rules and gives your AI one command with a one-time
10
+ ticket; your AI runs it:
11
+
12
+ ```bash
13
+ npx -y @flekh/review upload ~/Movies/cut_v3.mp4 --ticket rvw_…
14
+ ```
15
+
16
+ It sends the file straight to Flekh's storage in 16 MB parts (4 at a time, retried), then finishes the upload and
17
+ prints the file's link. Big files are fine (up to your plan's file limit).
18
+
19
+ - **Ticket:** one file, upload only, as you and your AI connection. Start it within 30 minutes; once started it
20
+ lasts as long as the upload (24 h). It can't read anything in Flekh.
21
+ - **Resume:** run the same command again; it sends only the parts Flekh doesn't have.
22
+ - **Ctrl-C** cancels the upload; Flekh removes what it made.
23
+ - **Options:** `--api <url>` (or `FLEKH_API`) for a non-production Flekh, `--json` for one line of JSON.
24
+ - **Exit codes:** 0 uploaded · 1 refused by Flekh (message says why) · 2 network or storage problem (run again to resume).
25
+
26
+ Needs Node 18 or newer. No dependencies.
27
+
28
+ ## Develop
29
+
30
+ ```bash
31
+ npm test # vitest, fake Flekh + S3
32
+ npm run build # dist/
33
+ npm publish # @flekh org, public (prepublishOnly builds)
34
+ ```
35
+
36
+ API: `Flekh-Backend-Service/docs/api/agents/02-agent-actions-contract.md` §1 "Upload tickets".
package/dist/cli.js ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // flekh-review upload <path> --ticket rvw_… [--api URL] [--json]
3
+ // Run by an AI agent (Claude Code, Codex, Cursor…) as the Flekh connector's start_upload(path) tells it to.
4
+ import { ReviewError, cancel, upload } from "./upload.js";
5
+ const HELP = `Upload a file from this computer to Flekh.
6
+
7
+ npx -y @flekh/review upload <path> --ticket <rvw_…> [--api <url>] [--json]
8
+
9
+ Ask your AI (with the Flekh connector) to upload a file by its path; it gets a one-time ticket and runs this for you.
10
+ Exit codes: 0 uploaded · 1 refused by Flekh (see the message) · 2 network or S3 problem (run it again to resume).`;
11
+ function args(argv) {
12
+ const out = { json: false, help: false, version: false };
13
+ const rest = [];
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i];
16
+ if (a === "--ticket")
17
+ out.ticket = argv[++i];
18
+ else if (a.startsWith("--ticket="))
19
+ out.ticket = a.slice(9);
20
+ else if (a === "--api")
21
+ out.api = argv[++i];
22
+ else if (a.startsWith("--api="))
23
+ out.api = a.slice(6);
24
+ else if (a === "--json")
25
+ out.json = true;
26
+ else if (a === "-h" || a === "--help")
27
+ out.help = true;
28
+ else if (a === "-v" || a === "--version")
29
+ out.version = true;
30
+ else
31
+ rest.push(a);
32
+ }
33
+ [out.cmd, out.path] = rest;
34
+ return out;
35
+ }
36
+ export function bytes(n) {
37
+ const u = ["B", "KB", "MB", "GB", "TB"];
38
+ let i = 0;
39
+ while (n >= 1000 && i < u.length - 1) {
40
+ n /= 1000;
41
+ i++;
42
+ }
43
+ return `${i === 0 ? n : n.toFixed(n >= 100 ? 0 : 1)} ${u[i]}`;
44
+ }
45
+ async function main() {
46
+ const a = args(process.argv.slice(2));
47
+ if (a.version) {
48
+ console.log("0.1.0");
49
+ return 0;
50
+ }
51
+ if (a.help || a.cmd !== "upload" || !a.path || !a.ticket) {
52
+ console.log(HELP);
53
+ return a.help ? 0 : 1;
54
+ }
55
+ const api = a.api || process.env.FLEKH_API;
56
+ const tty = process.stderr.isTTY && !a.json;
57
+ let lastPct = -1;
58
+ const onProgress = (p) => {
59
+ const pct = Math.floor((p.sent / p.size) * 100);
60
+ if (tty)
61
+ process.stderr.write(`\r${pct}% · ${bytes(p.sent)} of ${bytes(p.size)} · part ${p.partsDone}/${p.partsTotal} `);
62
+ else if (!a.json && pct >= lastPct + 10) {
63
+ console.log(`${pct}% (${p.partsDone}/${p.partsTotal} parts)`);
64
+ lastPct = pct - (pct % 10);
65
+ }
66
+ };
67
+ let finished = false;
68
+ process.once("SIGINT", () => {
69
+ if (finished)
70
+ process.exit(130);
71
+ console.error("\nCancelling…");
72
+ void cancel({ ticket: a.ticket, api }).finally(() => process.exit(130));
73
+ });
74
+ try {
75
+ const r = await upload({ path: a.path, ticket: a.ticket, api, onProgress });
76
+ finished = true;
77
+ if (tty)
78
+ process.stderr.write("\n");
79
+ if (a.json)
80
+ console.log(JSON.stringify({ ok: true, ...r }));
81
+ else
82
+ console.log(`✓ ${r.name} (${bytes(r.size)})${r.version ? ` → v${r.version}` : ""}${r.project ? ` in ${r.project}` : ""}${r.link ? ` · ${r.link}` : ""}\n Flekh is processing it now (thumbnail and preview in a minute).`);
83
+ return 0;
84
+ }
85
+ catch (e) {
86
+ finished = true;
87
+ if (tty)
88
+ process.stderr.write("\n");
89
+ const err = e instanceof ReviewError ? e : new ReviewError(e.message, "ERROR");
90
+ if (a.json)
91
+ console.log(JSON.stringify({ ok: false, error: err.code, message: err.message }));
92
+ else
93
+ console.error(`✗ ${err.message}`);
94
+ return err.refused ? 1 : 2;
95
+ }
96
+ }
97
+ main().then((code) => process.exit(code));
package/dist/upload.js ADDED
@@ -0,0 +1,153 @@
1
+ // One file from this computer into Flekh with a one-time upload ticket (made by the Flekh connector's start_upload(path)).
2
+ // start (size, type) → part URLs in batches → PUT 16 MiB parts straight to S3, a few at a time, with retries → complete.
3
+ // A re-run with the same ticket resumes: Flekh says which parts it already has. Signed URLs never leave this process.
4
+ import { open, stat } from "node:fs/promises";
5
+ import { basename, extname } from "node:path";
6
+ export const PROD_API = "https://api.flekh.com";
7
+ const BATCH = 32; // part URLs per /parts call (Flekh's cap)
8
+ const TRIES = 4;
9
+ export class ReviewError extends Error {
10
+ code;
11
+ refused;
12
+ constructor(message, code, refused = false) {
13
+ super(message);
14
+ this.code = code;
15
+ this.refused = refused;
16
+ }
17
+ }
18
+ const MIME = {
19
+ ".mp4": "video/mp4", ".m4v": "video/x-m4v", ".mov": "video/quicktime", ".webm": "video/webm", ".mkv": "video/x-matroska",
20
+ ".avi": "video/x-msvideo", ".mxf": "application/mxf", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".aac": "audio/aac",
21
+ ".m4a": "audio/mp4", ".flac": "audio/flac", ".ogg": "audio/ogg", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
22
+ ".gif": "image/gif", ".webp": "image/webp", ".heic": "image/heic", ".tif": "image/tiff", ".tiff": "image/tiff", ".pdf": "application/pdf",
23
+ ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv",
24
+ };
25
+ export function mimeOf(path) {
26
+ return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
27
+ }
28
+ export async function upload(o) {
29
+ const f = o.fetch ?? fetch;
30
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
31
+ const api = (o.api || PROD_API).replace(/\/+$/, "");
32
+ const st = await stat(o.path).catch(() => null);
33
+ if (!st || !st.isFile())
34
+ throw new ReviewError(`No file at ${o.path}`, "NO_FILE", true);
35
+ if (st.size === 0)
36
+ throw new ReviewError(`${o.path} is empty`, "EMPTY_FILE", true);
37
+ const size = st.size;
38
+ const flekh = async (method, path, body, okCodes = []) => {
39
+ for (let i = 1;; i++) {
40
+ let res;
41
+ try {
42
+ res = await f(`${api}/v1/upload-tickets${path}`, {
43
+ method,
44
+ headers: { Authorization: `Ticket ${o.ticket}`, "Content-Type": "application/json" },
45
+ body: body === undefined ? undefined : JSON.stringify(body),
46
+ });
47
+ }
48
+ catch (e) {
49
+ if (i < TRIES) {
50
+ await sleep(500 * 2 ** i);
51
+ continue;
52
+ }
53
+ throw new ReviewError(`Can't reach Flekh (${api}): ${e.message}`, "NETWORK");
54
+ }
55
+ const json = (await res.json().catch(() => ({})));
56
+ if (res.ok || okCodes.includes(res.status))
57
+ return { status: res.status, json };
58
+ if (res.status >= 500 && i < TRIES) {
59
+ await sleep(500 * 2 ** i);
60
+ continue;
61
+ }
62
+ throw new ReviewError(json.message || `Flekh answered ${res.status}`, json.error || `HTTP_${res.status}`, res.status < 500);
63
+ }
64
+ };
65
+ // start, or resume a run of the same ticket
66
+ let s;
67
+ const started = await flekh("POST", "/start", { name: basename(o.path), size, mime: mimeOf(o.path) }, [409]);
68
+ if (started.status === 409 && started.json.error === "ALREADY_STARTED") {
69
+ s = (await flekh("GET", "/status")).json;
70
+ if (s.size !== undefined && s.size !== size) {
71
+ throw new ReviewError("This ticket's upload is for a different file (size changed). Ask the AI to start the upload again.", "SIZE_CHANGED", true);
72
+ }
73
+ }
74
+ else if (started.status === 409) {
75
+ throw new ReviewError(started.json.message || "This upload ticket was already used.", started.json.error || "CONFLICT", true);
76
+ }
77
+ else {
78
+ s = started.json;
79
+ }
80
+ const partSize = s.part_size;
81
+ const total = s.parts_total;
82
+ const done = new Set(Array.isArray(s.parts_done) ? s.parts_done : []);
83
+ const urls = new Map((s.parts ?? []).map((p) => [p.n, p.url]));
84
+ const lenOf = (n) => (n === total ? size - (n - 1) * partSize : partSize);
85
+ let sent = 0;
86
+ for (const n of done)
87
+ sent += lenOf(n);
88
+ const report = () => o.onProgress?.({ sent, size, partsDone: done.size, partsTotal: total });
89
+ report();
90
+ const todo = Array.from({ length: total }, (_, i) => i + 1).filter((n) => !done.has(n));
91
+ let refill = null;
92
+ const urlFor = async (n, fresh = false) => {
93
+ if (fresh)
94
+ urls.delete(n);
95
+ while (!urls.has(n)) {
96
+ if (!refill) {
97
+ const want = [n, ...todo.filter((m) => m !== n && !urls.has(m) && !done.has(m))].slice(0, BATCH);
98
+ refill = flekh("POST", "/parts", { parts: want })
99
+ .then((r) => { for (const p of r.json.parts)
100
+ urls.set(p.n, p.url); })
101
+ .finally(() => { refill = null; });
102
+ }
103
+ await refill;
104
+ }
105
+ return urls.get(n);
106
+ };
107
+ const fh = await open(o.path, "r");
108
+ try {
109
+ const sendPart = async (n) => {
110
+ const len = lenOf(n);
111
+ const buf = Buffer.alloc(len);
112
+ const { bytesRead } = await fh.read(buf, 0, len, (n - 1) * partSize);
113
+ if (bytesRead !== len)
114
+ throw new ReviewError(`${o.path} changed while uploading`, "FILE_CHANGED", true);
115
+ for (let i = 1;; i++) {
116
+ const url = await urlFor(n, i > 1);
117
+ let status = 0;
118
+ try {
119
+ const res = await f(url, { method: "PUT", body: buf, headers: { "Content-Length": String(len) } });
120
+ status = res.status;
121
+ if (res.ok)
122
+ break;
123
+ }
124
+ catch { /* network: retry */ }
125
+ if (i >= TRIES)
126
+ throw new ReviewError(`Part ${n} of ${total} didn't upload${status ? ` (S3 ${status})` : ""}. Run the same command again to resume.`, "PART_FAILED");
127
+ await sleep(500 * 2 ** i);
128
+ }
129
+ urls.delete(n);
130
+ done.add(n);
131
+ sent += len;
132
+ report();
133
+ };
134
+ const queue = [...todo];
135
+ const workers = Math.max(1, Math.min(o.parallel ?? s.max_parallel ?? 4, 8, queue.length || 1));
136
+ await Promise.all(Array.from({ length: workers }, async () => {
137
+ for (let n = queue.shift(); n !== undefined; n = queue.shift())
138
+ await sendPart(n);
139
+ }));
140
+ }
141
+ finally {
142
+ await fh.close();
143
+ }
144
+ const card = (await flekh("POST", "/complete")).json;
145
+ const a = card.asset ?? {};
146
+ return { name: a.name ?? s.asset_name ?? basename(o.path), version: a.version ?? s.version, size, link: a.link, project: a.project?.name, status: card.status };
147
+ }
148
+ /** Cancel the ticket's upload (Ctrl-C): Flekh removes what it made. */
149
+ export async function cancel(o) {
150
+ const f = o.fetch ?? fetch;
151
+ const api = (o.api || PROD_API).replace(/\/+$/, "");
152
+ await f(`${api}/v1/upload-tickets/cancel`, { method: "POST", headers: { Authorization: `Ticket ${o.ticket}` } }).catch(() => undefined);
153
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@flekh/review",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Upload files from your computer to Flekh, from Claude Code or any AI agent with a terminal",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "flekh-review": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "homepage": "https://flekh.com",
18
+ "keywords": [
19
+ "flekh",
20
+ "upload",
21
+ "mcp",
22
+ "claude",
23
+ "review"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "prepublishOnly": "npm run build",
28
+ "test": "vitest run --root ."
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }