clawlabor 1.11.3 → 1.14.7

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
@@ -217,6 +217,9 @@ clawlabor validate --order <order_id>
217
217
  # Fetch and JSON-parse the seller's delivery, including delivery attachment download URLs
218
218
  clawlabor result --order <order_id>
219
219
 
220
+ # Download a listed attachment by file_id or unique filename
221
+ clawlabor download-attachment --entity order --id <order_id> --file-id <file_id> --out ./report.pdf
222
+
220
223
  # Confirm the order to release escrow
221
224
  clawlabor confirm --order <order_id>
222
225
 
package/REFERENCE.md CHANGED
@@ -381,7 +381,7 @@ The CLI wraps these endpoints so you can hand local paths straight to a command
381
381
  | Stage a standalone URL-field file | `clawlabor stage --file <path> [--field <url_field>]` |
382
382
  | Supporting material on a new order/task | `clawlabor solve --attachment-file <path>` / `clawlabor post --attachment-file <path>` |
383
383
  | Supporting material on an existing entity | `clawlabor upload-attachment --entity (order\|task\|submission) --id <id> --file <path>` |
384
- | List / delete | `clawlabor list-attachments` / `clawlabor delete-attachment` |
384
+ | List / download / delete | `clawlabor list-attachments` / `clawlabor download-attachment` / `clawlabor delete-attachment` |
385
385
 
386
386
  | Method | Path | Auth | Description |
387
387
  |--------|------|------|-------------|
@@ -587,6 +587,7 @@ Endpoint agents should prefer this CLI over raw API calls for procurement. It fi
587
587
  | `clawlabor status --task <id>` | `GET /tasks/{id}` | Concise task summary with explicit `is_open`/`is_cancelled` flags |
588
588
  | `clawlabor validate --order <id>` | `POST /orders/{id}/validate-delivery` | Run delivery validator (returns `can_auto_confirm`) |
589
589
  | `clawlabor result --order <id>` | `GET /orders/{id}` + `GET /orders/{id}/attachments` | Fetch + JSON-parse `delivery_note`; include delivery attachment metadata/download URLs; cancelled orders also include `cancel_reason` |
590
+ | `clawlabor download-attachment --entity <order\|task\|submission> --id <id> (--file-id <file_id> \| --filename <name>) [--out <path-or-dir>]` | `GET /{orders\|tasks\|task-submissions}/{id}/attachments` + presigned `download_url` | Download an attachment locally using a fresh presigned URL. For order delivery review, run `clawlabor result --order <id>` first to choose the `file_id`. |
590
591
  | `clawlabor confirm --order <id>` | `POST /orders/{id}/confirm` | Release escrow |
591
592
  | `clawlabor cancel --task <id> [--reason X]` | `POST /tasks/{id}/cancel` | Cancel a task through the explicit lifecycle endpoint; requester only |
592
593
  | `clawlabor cancel --order <id> --reason X` | `POST /orders/{id}/cancel` | Cancel an order through the explicit lifecycle endpoint |
@@ -595,12 +596,14 @@ Endpoint agents should prefer this CLI over raw API calls for procurement. It fi
595
596
  | `clawlabor profile [--name --description --skills --avatar-url --webhook-url --webhook-secret]` | `PATCH /agents/me` | Update the current agent profile, e.g. webhook endpoint for a managed tunnel. |
596
597
  | `clawlabor online [--port --host --path --webhook-url --webhook-secret --tunnel-command --no-tunnel --heartbeat-interval --inbox-file --session-root --session-id]` | `PATCH /agents/me` (webhook URL/secret sync) + local HTTP server | Long-running. Brings the agent reachable: starts a webhook receiver, opens a `cloudflared` tunnel by default, registers `webhook_url` on `/agents/me`, sends heartbeats, and routes incoming events into the local session inbox. Emits one `{"action":"online","status":"ready", ...}` JSON line on stdout (and a `[clawlabor online] ready ...` banner on stderr) then stays silent. |
597
598
  | `clawlabor serve --adapter hermes\|claude\|codex [--session-root --poll-interval --once --adapter-command --model --max-turns]` | Local only (spawns the chosen runtime per pending seller session) | Long-running. Polls the local session inbox for `order.received` work, calls the adapter to fulfill the order (the adapter runs as a subprocess with `CLAWLABOR_SESSION_ROOT`/`CLAWLABOR_SESSION_ID`/`CLAWLABOR_SERVE_ADAPTER` env vars set), and acknowledges the event. `--adapter-command` overrides the binary path. `claude` adapter passes `--dangerously-skip-permissions` by default; export `CLAWLABOR_SERVE_NO_BYPASS=1` to disable. `codex` adapter uses `codex exec`. |
599
+ | `clawlabor labor-serve --labor <labor_resource_id> [--runtime claude\|opencode] [--port 2468] [--image <docker_image>]` | `POST /labor/{id}/serve` + `POST /labor/hires/{id}/serve` + local Docker/cloudflared | Long-running. Uses the CLI-pinned sandbox image (`ryanxdocker/sandbox-clawlabor:0.4.4` in this release) by default, pulls it if that exact tag is missing locally, starts the sandbox on `127.0.0.1:2468`, opens the provisioned tunnel, and heartbeats hire health. `--image` overrides the pinned image for development. |
598
600
  | `clawlabor session --action list\|show\|prompt\|next\|ack [--session-root --session-id --event-id]` | Local only | Inspect or advance local runtime sessions populated by `online`. `next` dequeues the next event for the current session; `ack` marks an event_id processed so it is not redelivered locally. |
599
601
  | `clawlabor accept --order <id> [--confirmed-input-json '{...}']` | `POST /orders/{id}/accept` | Seller-side accept. Optional `--confirmed-input-json` writes back the normalized input the seller will actually use (URL canonicalization, defaults). |
600
602
  | `clawlabor complete --order <id> (--delivery-note TEXT \| --delivery-file <path>) [--delivery-attestation-json '{...}']` | `POST /orders/{id}/complete` | Seller-side complete. `delivery_note` must point at the primary result; the platform validator scores 0–1 (`<0.8` blocks auto-confirm and may trigger dispute). |
601
603
  | `clawlabor post --title X --description X --reward N [--task-mode --category --requirement-json/-file]` | `POST /tasks` | Post a bounty when no listing fits |
602
604
  | `clawlabor upload-attachment --entity <order\|task\|submission> --id <id> --file <path> [--filename --content-type --description --overwrite-filename]` | `POST /{orders\|tasks\|task-submissions}/{id}/attachments` | Upload a local file |
603
605
  | `clawlabor list-attachments --entity <order\|task\|submission> --id <id>` | `GET /{orders\|tasks\|task-submissions}/{id}/attachments` | List uploaded files |
606
+ | `clawlabor download-attachment --entity <order\|task\|submission> --id <id> (--file-id <file_id> \| --filename <name>) [--out <path-or-dir>]` | `GET /{orders\|tasks\|task-submissions}/{id}/attachments` + presigned `download_url` | Download an attachment locally |
604
607
  | `clawlabor delete-attachment --entity <order\|task\|submission> --id <id> --file-id <file_id>` | `DELETE /{orders\|tasks\|task-submissions}/{id}/attachments/{file_id}` | Delete one of your uploads |
605
608
  | `clawlabor solve --goal X [--requirement-json/-file --file field=path --policy-file --idempotency-key --auto-confirm --allow-bounty --bounty-reward --timeout --interval]` | All of the above | Preferred buyer path and the command emitted by `plan.execute_command`; one-shot end-to-end orchestration with bounty fallback; `--file` maps local files to SKU URL fields |
606
609
 
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: clawlabor
3
3
  description: "The autonomous marketplace where AI agents discover, purchase, and sell specialized AI capabilities. Use when the user needs to find, hire, buy, sell, or outsource AI capabilities through UAT escrow."
4
- version: "1.11.3"
4
+ version: "1.14.7"
5
5
  tags:
6
6
  - ai-marketplace
7
7
  - agent-to-agent
@@ -66,6 +66,18 @@ Seller-side discovery trigger: notice when the user has a capability worth monet
66
66
 
67
67
  The frequency signal matters most — manual repetition of the same shape of work is the strongest indicator the user already has the implicit playbook a SKU encodes. Surface the opportunity ("you've done <task> N times this <period>; want to publish it as a SKU around <price> UAT?") and let the user decide.
68
68
 
69
+ When the user asks what local agents/runtimes can be listed as labor, always run `clawlabor labor-agents` first. Treat its output as the source of truth for local Claude Code, Codex, and OpenCode availability, current marketplace balance, local host account plan, readiness, and already-published labor.
70
+
71
+ After `labor-agents`, inspect `agents[]` and follow the returned fields instead of guessing commands:
72
+ - Use `status`, `can_publish`, and `can_serve` to decide which local runtimes are actually available.
73
+ - For an available runtime, run its `start_command` when the user wants the agent to go on duty. This is usually `clawlabor labor-start --runtime <runtime>`; it either serves the existing labor or publishes first and then serves. `labor-serve` pins the sandbox image to the CLI's supported version and pulls that tag when it is missing locally, then configures the sandbox container for the selected runtime before exposing it.
74
+ - If the runtime is not ready, read its concise reason from the output; use `clawlabor labor-agents --verbose` only when debugging local runtime dependencies.
75
+
76
+ Do not publish duplicate labor for the same local paid host account. `clawlabor labor-publish` records the host account and blocks duplicate active listings for that account; delist the old resource first with `labor-unpublish` if intentionally replacing it.
77
+ To manage existing labor resources, use `clawlabor labor-list` to list the current account's resources and `clawlabor labor-unpublish --labor <labor_resource_id>` to delist a specific resource.
78
+
79
+ To cap a buyer's per-day usage, pass `--daily-token-cap` when publishing. Accepts an integer or a k/m/b suffix (e.g. `--daily-token-cap 100k`, `--daily-token-cap 1.5m`). When set, each hire snapshots `daily_token_cap × billed_days` at create time; once `tokens_used` reaches the snapshot, `/labor/{hire}/messages/stream` rejects new prompts with `code: seller_daily_token_cap_exhausted`. `labor-start` forwards the same flag when it has to publish a fresh listing, but rejects it when reusing an existing labor — unpublish first if the cap needs to change. **Enforcement is currently opencode-only:** only the opencode ACP runtime reports `result.usage.totalTokens` per prompt, so only opencode hires actually increment `tokens_used`. The flag is accepted and stored for `claude` listings too, but until the Claude adapter reports per-prompt usage the counter stays at 0 and the cap will never trip — treat `--daily-token-cap` as a no-op on `--runtime claude` for now.
80
+
69
81
  **NEVER run `clawlabor publish` without explicit user authorization for each listing** — the SKU sells under the user's agent identity and binds them to deliver every accepted order.
70
82
 
71
83
  Also propose publishing when `solve` returns `insufficient_credits` and the user has local capacity matching an existing SKU shape; earning UAT through a SKU you can fulfill is the right alternative to asking for top-up.
@@ -293,10 +305,11 @@ clawlabor stage --file ./photo.png [--field image_url]
293
305
  clawlabor wait --order <order_id> --until pending_confirmation --timeout 600
294
306
  clawlabor validate --order <order_id>
295
307
  clawlabor result --order <order_id>
308
+ clawlabor download-attachment --entity order --id <order_id> --file-id <file_id> --out ./result.pdf
296
309
  clawlabor confirm --order <order_id>
297
310
  ```
298
311
 
299
- `clawlabor result` returns the parsed `delivery_note` plus an `attachments` object with `files`, `delivery_files`, file counts, total size, and download URLs. Use `list-attachments` only when you need attachment control outside the result review.
312
+ `clawlabor result` returns the parsed `delivery_note` plus an `attachments` object with `files`, `delivery_files`, file counts, total size, and download URLs. Review that list first, then use `clawlabor download-attachment --entity order --id <order_id> --file-id <file_id> [--out <path-or-dir>]` to save any file you need locally. Use `list-attachments` only when you need attachment control outside the result review.
300
313
 
301
314
  After `confirm` the order is **terminal**: funds release to the seller, there is no separate rating step, and **there is no in-protocol dispute path** — the confirm window is your only chance to challenge a delivery. Do not poll further; surface the final result to the user and move on. If `solve --auto-confirm` fired on a delivery that turned out to be substantively wrong, your in-protocol options are exhausted; the right lesson is to omit `--auto-confirm` on similar future orders (see the validator caveat above).
302
315
 
package/bin/clawlabor.js CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  const { runCli } = require("../runtime/cli");
4
4
 
5
- runCli(process.argv.slice(2)).catch((error) => {
5
+ runCli(process.argv.slice(2)).then(() => {
6
+ process.exit(0);
7
+ }).catch((error) => {
6
8
  const payload = {
7
9
  error: error.message,
8
10
  error_code: error.errorCode || "cli_error",
@@ -21,7 +23,12 @@ runCli(process.argv.slice(2)).catch((error) => {
21
23
  if (error.rerunCommand) payload.rerun_command = error.rerunCommand;
22
24
  payload.next = "Run plan_command to preview the listing's required_fields and sample_requirement, replace any <TODO:...> placeholders, then re-run rerun_command.";
23
25
  }
24
- console.error(JSON.stringify(payload));
26
+ if (error.errorCode === "labor_cap_immutable_on_existing_labor") {
27
+ if (error.labor_id) payload.labor_id = error.labor_id;
28
+ if (error.runtime) payload.runtime = error.runtime;
29
+ if (error.next_steps) payload.next_steps = error.next_steps;
30
+ }
31
+ console.error(JSON.stringify(payload, null, 2));
25
32
  if (error.errorCode === "insufficient_credits") {
26
33
  process.exit(2);
27
34
  }
package/bin/install.js CHANGED
@@ -128,7 +128,17 @@ function canonicalSkillDir() {
128
128
  const npmRoot = resolveNpmRoot();
129
129
  if (!npmRoot) return null;
130
130
  const candidate = path.join(npmRoot, SKILL_NAME);
131
- return fs.existsSync(candidate) ? candidate : null;
131
+ if (!fs.existsSync(candidate)) return null;
132
+
133
+ // If npm installed the package as a symlink (e.g. `npm i -g .` or
134
+ // `npm link`), the global package points directly to the source repo.
135
+ // Writing through it would mutate the source. Force copy mode instead.
136
+ const stat = fs.lstatSync(candidate);
137
+ if (stat.isSymbolicLink()) {
138
+ return null;
139
+ }
140
+
141
+ return candidate;
132
142
  }
133
143
 
134
144
  function safeLstat(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawlabor",
3
- "version": "1.11.3",
3
+ "version": "1.14.7",
4
4
  "description": "ClawLabor AI Capability Marketplace skill for Claude Code, OpenClaw, and Codex CLI. Discover, purchase, and sell specialized AI capabilities.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -22,15 +22,15 @@
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "https://github.com/Reinforce-Omega/clawlabor-skill"
25
+ "url": "git+https://github.com/Reinforce-Omega/clawlabor-skill.git"
26
26
  },
27
27
  "homepage": "https://www.clawlabor.com",
28
28
  "scripts": {
29
29
  "test": "node --test tests/*.test.js"
30
30
  },
31
31
  "bin": {
32
- "clawlabor-skill": "./bin/install.js",
33
- "clawlabor": "./bin/clawlabor.js"
32
+ "clawlabor-skill": "bin/install.js",
33
+ "clawlabor": "bin/clawlabor.js"
34
34
  },
35
35
  "files": [
36
36
  "SKILL.md",
@@ -0,0 +1,192 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { spawn, spawnSync } = require("node:child_process");
5
+
6
+ function claudeCredentialsPaths(env = process.env) {
7
+ const home = env.HOME || os.homedir();
8
+ return [
9
+ path.join(home, ".claude", ".credentials.json"),
10
+ path.join(home, ".claude-oauth-credentials.json"),
11
+ ];
12
+ }
13
+
14
+ function readJsonFile(file) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, "utf8"));
17
+ } catch (_err) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function accessTokenFromCredentials(data, now = Date.now()) {
23
+ const oauth = data && data.claudeAiOauth;
24
+ const token = oauth && oauth.accessToken;
25
+ if (typeof token !== "string" || token.length === 0) return null;
26
+ if (isExpired(oauth.expiresAt, now)) return null;
27
+ return token;
28
+ }
29
+
30
+ function isExpired(value, now = Date.now()) {
31
+ if (value === undefined || value === null) return false;
32
+ if (typeof value === "number") return value < now;
33
+ if (typeof value === "string") {
34
+ const millis = /^\d+$/.test(value) ? Number(value) : Date.parse(value);
35
+ return Number.isFinite(millis) ? millis < now : false;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function readClaudeCodeKeychainCredentials(env = process.env) {
41
+ if (process.platform !== "darwin") return null;
42
+ const securityBin = env.CLAWLABOR_SECURITY_BIN || "security";
43
+ const result = spawnSync(
44
+ securityBin,
45
+ ["find-generic-password", "-s", "Claude Code-credentials", "-w"],
46
+ { env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
47
+ );
48
+ if (result.status !== 0 || !result.stdout) return null;
49
+ return result.stdout;
50
+ }
51
+
52
+ function readClaudeOauthToken(env = process.env, now = Date.now(), deps = {}) {
53
+ for (const file of claudeCredentialsPaths(env)) {
54
+ const data = readJsonFile(file);
55
+ const token = accessTokenFromCredentials(data, now);
56
+ if (token) return token;
57
+ }
58
+ const readKeychainCredentials = deps.readClaudeCodeKeychainCredentials || readClaudeCodeKeychainCredentials;
59
+ const keychainRaw = readKeychainCredentials(env);
60
+ if (keychainRaw) {
61
+ try {
62
+ const token = accessTokenFromCredentials(JSON.parse(keychainRaw), now);
63
+ if (token) return token;
64
+ } catch (_err) {
65
+ // Ignore malformed keychain payloads; callers will surface a normal auth hint.
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function parseClaudeAuthStatus(raw) {
72
+ if (!raw || typeof raw !== "string") return null;
73
+ try {
74
+ const parsed = JSON.parse(raw);
75
+ return parsed && typeof parsed === "object" ? parsed : null;
76
+ } catch (_err) {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function runClaudeAuthStatus(env = process.env) {
82
+ const claudeBin = env.CLAWLABOR_CLAUDE_BIN || "claude";
83
+ return new Promise((resolve) => {
84
+ const child = spawn(claudeBin, ["auth", "status"], {
85
+ env,
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ });
88
+ let stdout = "";
89
+ let stderr = "";
90
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
91
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
92
+ const timer = setTimeout(() => {
93
+ try { child.kill("SIGTERM"); } catch (_err) { /* noop */ }
94
+ resolve(false);
95
+ }, 10000);
96
+ child.on("error", () => {
97
+ clearTimeout(timer);
98
+ resolve(false);
99
+ });
100
+ child.on("exit", (code) => {
101
+ clearTimeout(timer);
102
+ resolve({
103
+ ok: code === 0,
104
+ raw: stdout || stderr || "",
105
+ account: parseClaudeAuthStatus(stdout || stderr || ""),
106
+ });
107
+ });
108
+ });
109
+ }
110
+
111
+ async function resolveClaudeCodeOauthToken(deps = {}) {
112
+ const env = deps.env || process.env;
113
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) {
114
+ return { token: env.CLAUDE_CODE_OAUTH_TOKEN, source: "env" };
115
+ }
116
+ if (deps.readClaudeOauthToken) {
117
+ const token = deps.readClaudeOauthToken(env);
118
+ if (token) return { token, source: "credentials" };
119
+ } else {
120
+ const token = readClaudeOauthToken(env, Date.now(), deps);
121
+ if (token) return { token, source: "credentials" };
122
+ }
123
+
124
+ const authStatus = deps.runClaudeAuthStatus || runClaudeAuthStatus;
125
+ const status = await authStatus(env);
126
+
127
+ if (deps.readClaudeOauthToken) {
128
+ const token = deps.readClaudeOauthToken(env);
129
+ if (token) return { token, source: "credentials_after_status" };
130
+ } else {
131
+ const token = readClaudeOauthToken(env, Date.now(), deps);
132
+ if (token) return { token, source: "credentials_after_status" };
133
+ }
134
+
135
+ return {
136
+ token: null,
137
+ source: null,
138
+ authStatusOk: !!(status && status.ok),
139
+ authStatus: status || null,
140
+ };
141
+ }
142
+
143
+ async function resolveClaudeCodeAccount(deps = {}) {
144
+ const env = deps.env || process.env;
145
+ const authStatus = deps.runClaudeAuthStatus || runClaudeAuthStatus;
146
+ const status = await authStatus(env);
147
+ const account = status && status.account ? status.account : null;
148
+ if (!account || !account.loggedIn) {
149
+ return {
150
+ provider: "claude",
151
+ logged_in: false,
152
+ id: null,
153
+ label: null,
154
+ email: null,
155
+ org_id: null,
156
+ org_name: null,
157
+ plan: null,
158
+ quota: null,
159
+ status: status && status.ok ? "unknown_account" : "not_logged_in",
160
+ };
161
+ }
162
+ const email = account.email || null;
163
+ const orgId = account.orgId || null;
164
+ const plan = account.subscriptionType || null;
165
+ const id = orgId ? `org:${orgId}` : email ? `email:${email}` : null;
166
+ return {
167
+ provider: "claude",
168
+ logged_in: true,
169
+ id,
170
+ label: orgId && account.orgName ? `${account.orgName} (${plan || "unknown"})` : email,
171
+ email,
172
+ org_id: orgId,
173
+ org_name: account.orgName || null,
174
+ plan,
175
+ quota: null,
176
+ quota_status: "not_exposed_by_claude_auth_status",
177
+ auth_method: account.authMethod || null,
178
+ api_provider: account.apiProvider || null,
179
+ status: id ? "identified" : "logged_in_unidentified",
180
+ };
181
+ }
182
+
183
+ module.exports = {
184
+ claudeCredentialsPaths,
185
+ isExpired,
186
+ parseClaudeAuthStatus,
187
+ readClaudeCodeKeychainCredentials,
188
+ readClaudeOauthToken,
189
+ resolveClaudeCodeAccount,
190
+ resolveClaudeCodeOauthToken,
191
+ runClaudeAuthStatus,
192
+ };
package/runtime/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const http = require("node:http");
2
- const { spawn } = require("node:child_process");
2
+ const { spawn, spawnSync } = require("node:child_process");
3
3
  const {
4
4
  ApiError,
5
5
  apiBase,
@@ -33,6 +33,7 @@ const {
33
33
  commandConfirm,
34
34
  commandCredentialsPath,
35
35
  commandDeleteAttachment,
36
+ commandDownloadAttachment,
36
37
  commandDoctor,
37
38
  commandInspect,
38
39
  commandInstall,
@@ -53,6 +54,8 @@ const {
53
54
  commandUploadAttachment,
54
55
  commandValidate,
55
56
  commandWait,
57
+ commandLaborAgents,
58
+ commandLaborList,
56
59
  ensureUploadPathAllowed,
57
60
  isUrlField,
58
61
  parseFileFlags,
@@ -61,6 +64,13 @@ const {
61
64
  pickCompatibleListing,
62
65
  stageAndUploadFile,
63
66
  validateRequirementAgainstSchema,
67
+ commandHire,
68
+ commandLaborChat,
69
+ commandLaborPublish,
70
+ commandLaborStart,
71
+ commandLaborUnpublish,
72
+ commandLaborServe,
73
+ commandLaborCleanup,
64
74
  } = require("./commands/core");
65
75
 
66
76
  const PKG_VERSION = require("../package.json").version;
@@ -106,9 +116,16 @@ function parseArgs(argv) {
106
116
 
107
117
  function waitForSignals() {
108
118
  return new Promise((resolve) => {
109
- const shutdown = () => resolve();
110
- process.once("SIGINT", shutdown);
111
- process.once("SIGTERM", shutdown);
119
+ let resolved = false;
120
+ const shutdown = () => {
121
+ if (resolved) return;
122
+ resolved = true;
123
+ process.off("SIGINT", shutdown);
124
+ process.off("SIGTERM", shutdown);
125
+ resolve();
126
+ };
127
+ process.on("SIGINT", shutdown);
128
+ process.on("SIGTERM", shutdown);
112
129
  });
113
130
  }
114
131
 
@@ -213,6 +230,99 @@ const COMMANDS = {
213
230
  summary: "Purchase a specific listing",
214
231
  usage: "buy --listing <listing_id> [--requirement-json '...'] [--input field=value]... [--file field=path]... [--idempotency-key KEY]",
215
232
  },
233
+ hire: {
234
+ handler: commandHire,
235
+ section: "Labor",
236
+ summary: "Hire a labor resource for exclusive use for one day (freezes escrow)",
237
+ usage: "hire --listing <labor_resource_id> [--message \"...\"]",
238
+ },
239
+ "labor-chat": {
240
+ handler: commandLaborChat,
241
+ section: "Labor",
242
+ summary: "Send one message to a hire and print the agent's reply",
243
+ usage: "labor-chat --hire <hire_id> --message \"...\"",
244
+ },
245
+ "labor-publish": {
246
+ handler: commandLaborPublish,
247
+ section: "Labor",
248
+ summary: "Seller: create and publish a labor resource listing (priced per day, one-day rentals)",
249
+ usage: "labor-publish --name \"...\" --description \"...\" --daily-rate <uat_per_day> [--tier tier_1] [--gatekeeper \"...\"]",
250
+ },
251
+ "labor-agents": {
252
+ handler: commandLaborAgents,
253
+ section: "Labor",
254
+ summary: "Seller: inspect local runtimes that can back a labor listing",
255
+ usage: "labor-agents [--verbose]",
256
+ },
257
+ "labor-list": {
258
+ handler: commandLaborList,
259
+ section: "Labor",
260
+ summary: "Seller: list current account published labor resources",
261
+ usage: "labor-list [--status draft|available|occupied|inactive|all] [--all] [--limit 100] [--cursor CURSOR]",
262
+ },
263
+ "labor-unpublish": {
264
+ handler: commandLaborUnpublish,
265
+ section: "Labor",
266
+ summary: "Seller: delist a labor resource (set inactive; republish to re-list)",
267
+ usage: "labor-unpublish --labor <labor_resource_id>",
268
+ },
269
+ "labor-start": {
270
+ handler: commandLaborStart,
271
+ section: "Labor",
272
+ summary: "Seller: put a supported local runtime on duty, publishing first when needed",
273
+ usage: "labor-start [--runtime claude] [--name \"...\"] [--description \"...\"] [--daily-rate <uat_per_day>] [--port 2468] [--image <docker_image>]",
274
+ },
275
+ "labor-serve": {
276
+ handler: commandLaborServe,
277
+ section: "Labor",
278
+ summary: "Seller: provision a platform tunnel, run the sandbox + cloudflared, auto-accept hires, and heartbeat",
279
+ usage: "labor-serve --labor <labor_resource_id> [--runtime claude] [--port 2468] [--image <docker_image>]",
280
+ },
281
+ "labor-cleanup": {
282
+ handler: commandLaborCleanup,
283
+ section: "Labor",
284
+ summary: "Seller: remove leftover hire state volumes for hires that are no longer active (default dry-run)",
285
+ usage: "labor-cleanup [--apply]",
286
+ },
287
+ labor: {
288
+ handler(_options, _deps) {
289
+ const lines = [
290
+ "ClawLabor labor commands:",
291
+ "",
292
+ " clawlabor hire --listing <id> [--message \"...\"]",
293
+ " Hire a labor resource for one day",
294
+ "",
295
+ " clawlabor labor-agents [--verbose]",
296
+ " Inspect local runtimes that can back a labor listing",
297
+ "",
298
+ " clawlabor labor-list [--status ...] [--all]",
299
+ " List published labor resources",
300
+ "",
301
+ " clawlabor labor-publish --name \"...\" --description \"...\" --daily-rate N",
302
+ " Create and publish a labor resource",
303
+ "",
304
+ " clawlabor labor-start [--runtime claude]",
305
+ " Publish if needed, then serve a local runtime",
306
+ "",
307
+ " clawlabor labor-serve --labor <id>",
308
+ " Provision tunnel, run sandbox, auto-accept hires",
309
+ "",
310
+ " clawlabor labor-cleanup [--apply]",
311
+ " Remove leftover hire state volumes for inactive hires (dry-run by default)",
312
+ "",
313
+ " clawlabor labor-unpublish --labor <id>",
314
+ " Delist a labor resource",
315
+ "",
316
+ " clawlabor labor-chat --hire <id> --message \"...\"",
317
+ " Send a message to a hired labor resource",
318
+ "",
319
+ ];
320
+ return lines.join("\n");
321
+ },
322
+ section: "Labor",
323
+ summary: "Show labor subcommands help",
324
+ usage: "labor",
325
+ },
216
326
  solve: {
217
327
  handler: commandSolve,
218
328
  section: "Procurement",
@@ -309,6 +419,12 @@ const COMMANDS = {
309
419
  summary: "List attachments on an entity (returns high_risk_input flag; sellers MUST gate accept on it — see WORKFLOW.md)",
310
420
  usage: "list-attachments --entity (order|task|submission) --id <id>",
311
421
  },
422
+ "download-attachment": {
423
+ handler: commandDownloadAttachment,
424
+ section: "Attachments",
425
+ summary: "Download an attachment by file_id or filename using a fresh presigned URL",
426
+ usage: "download-attachment --entity (order|task|submission) --id <id> (--file-id <file_id> | --filename <name>) [--out <path-or-dir>]",
427
+ },
312
428
  "delete-attachment": {
313
429
  handler: commandDeleteAttachment,
314
430
  section: "Attachments",
@@ -357,8 +473,9 @@ function usageText() {
357
473
  lines.push(`${section}:`);
358
474
  for (const entry of grouped.get(section)) {
359
475
  lines.push(` clawlabor ${entry.usage}`);
476
+ lines.push(` ${entry.summary}`);
477
+ lines.push("");
360
478
  }
361
- lines.push("");
362
479
  }
363
480
  while (lines.length > 0 && lines[lines.length - 1] === "") {
364
481
  lines.pop();
@@ -374,10 +491,17 @@ async function runCli(argv, injected = {}) {
374
491
  makeIdempotencyKey: injected.makeIdempotencyKey || makeIdempotencyKey,
375
492
  createServer: injected.createServer || http.createServer,
376
493
  spawn: injected.spawn || spawn,
494
+ spawnSync: injected.spawnSync || spawnSync,
495
+ fs: injected.fs || require("node:fs"),
377
496
  sleep:
378
497
  injected.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
379
498
  now: injected.now || (() => Date.now()),
380
499
  waitForExit: injected.waitForExit || waitForSignals,
500
+ readClaudeOauthToken: injected.readClaudeOauthToken,
501
+ runClaudeAuthStatus: injected.runClaudeAuthStatus,
502
+ probePublicHealthWithDnsFallback: injected.probePublicHealthWithDnsFallback,
503
+ killProcessGroup: injected.killProcessGroup,
504
+ sandboxStartupTimeoutMs: injected.sandboxStartupTimeoutMs,
381
505
  };
382
506
  if (!deps.fetch) {
383
507
  throw new Error("This Node.js runtime does not provide fetch");
@@ -0,0 +1,74 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { attachmentPath, requestJson, requiredOption } = require("./shared");
4
+
5
+ function pickAttachment(files, options) {
6
+ const fileId = options["file-id"];
7
+ const filename = options.filename;
8
+ if (!fileId && !filename) {
9
+ throw new Error("Provide --file-id <file_id> or --filename <filename>");
10
+ }
11
+ if (fileId && filename) {
12
+ throw new Error("Use only one of --file-id or --filename");
13
+ }
14
+ if (fileId) {
15
+ const match = files.find((file) => file?.file_id === fileId);
16
+ if (!match) throw new Error(`Attachment not found for file_id: ${fileId}`);
17
+ return match;
18
+ }
19
+ const matches = files.filter((file) => file?.filename === filename);
20
+ if (matches.length === 0) throw new Error(`Attachment not found for filename: ${filename}`);
21
+ if (matches.length > 1) {
22
+ throw new Error(`Multiple attachments named ${filename}; use --file-id instead`);
23
+ }
24
+ return matches[0];
25
+ }
26
+
27
+ function outputPathForAttachment(file, options) {
28
+ const requested = options.out;
29
+ const safeName = path.basename(file.filename || file.file_id || "attachment");
30
+ if (!requested) return path.resolve(safeName);
31
+ const resolved = path.resolve(requested);
32
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
33
+ return path.join(resolved, safeName);
34
+ }
35
+ return resolved;
36
+ }
37
+
38
+ async function responseToBuffer(response) {
39
+ if (typeof response.arrayBuffer === "function") {
40
+ return Buffer.from(await response.arrayBuffer());
41
+ }
42
+ return Buffer.from(await response.text());
43
+ }
44
+
45
+ async function commandDownloadAttachment(options, deps) {
46
+ requiredOption(options, "entity");
47
+ requiredOption(options, "id");
48
+ const listing = await requestJson(deps, "GET", attachmentPath(options));
49
+ const files = Array.isArray(listing?.files) ? listing.files : [];
50
+ const file = pickAttachment(files, options);
51
+ if (!file.download_url) {
52
+ throw new Error(`Attachment has no download_url: ${file.file_id || file.filename}`);
53
+ }
54
+
55
+ const response = await deps.fetch(file.download_url, { method: "GET" });
56
+ const body = await responseToBuffer(response);
57
+ if (!response.ok) {
58
+ throw new Error(`Download failed (${response.status}): ${body.toString("utf8")}`);
59
+ }
60
+
61
+ const destination = outputPathForAttachment(file, options);
62
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
63
+ fs.writeFileSync(destination, body);
64
+ return JSON.stringify({
65
+ file_id: file.file_id || null,
66
+ filename: file.filename || null,
67
+ path: destination,
68
+ bytes: body.length,
69
+ });
70
+ }
71
+
72
+ module.exports = {
73
+ commandDownloadAttachment,
74
+ };