@linkedclaw/cli 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linkedclaw/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "LinkedClaw command-line interface",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,9 +15,9 @@
15
15
  "open": "^10.1.0",
16
16
  "ws": "^8.0.0",
17
17
  "@linkedclaw/provider-runtime": "^0.9.1",
18
- "@linkedclaw/consumer-runtime": "^0.9.1",
18
+ "@linkedclaw/consumer": "^0.9.1",
19
19
  "@linkedclaw/provider": "^0.9.1",
20
- "@linkedclaw/consumer": "^0.9.1"
20
+ "@linkedclaw/consumer-runtime": "^0.9.2"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/js-yaml": "^4.0.9",
@@ -0,0 +1,154 @@
1
+ import { ApiError, LinkedClawError } from "../errors.js";
2
+ import type {
3
+ ArenaCategory,
4
+ ArenaCategoryLeaderboard,
5
+ ArenaJuror,
6
+ ArenaJurorVote,
7
+ ArenaLeaderboard,
8
+ ArenaOffer,
9
+ ArenaOffersResponse,
10
+ ArenaRecord,
11
+ ArenaRegistration,
12
+ ArenaSubmission,
13
+ CommitArenaJurorRequest,
14
+ CreateTournamentArenaRequest,
15
+ CreateTournamentArenaResponse,
16
+ MatchJurorVoteRequest,
17
+ RegisterContestantRequest,
18
+ SubmitArenaRequest,
19
+ TaskJurorVoteRequest,
20
+ } from "./types.js";
21
+
22
+ function errorDetail(body: unknown): string {
23
+ if (body && typeof body === "object" && "detail" in body) {
24
+ const detail = (body as { detail?: unknown }).detail;
25
+ return typeof detail === "string" ? detail : (JSON.stringify(detail) ?? String(detail));
26
+ }
27
+ if (body == null) return "";
28
+ return typeof body === "string" ? body : JSON.stringify(body);
29
+ }
30
+
31
+ export function makeArenaApi(targetUrl: string, apiKey: string) {
32
+ async function apiFetch(path: string, opts: RequestInit = {}): Promise<unknown> {
33
+ const url = targetUrl.replace(/\/$/, "") + path;
34
+ const res = await fetch(url, {
35
+ ...opts,
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ Authorization: `Bearer ${apiKey}`,
39
+ "X-CSRF-Token": apiKey,
40
+ ...(opts.headers ?? {}),
41
+ },
42
+ });
43
+ let body: unknown;
44
+ try {
45
+ body = await res.json();
46
+ } catch {
47
+ body = null;
48
+ }
49
+ if (!res.ok) {
50
+ try {
51
+ throw new ApiError(res.status, errorDetail(body), path);
52
+ } catch (err) {
53
+ if (err instanceof ApiError) throw err;
54
+ throw new LinkedClawError(`api_${res.status}`, `HTTP ${res.status}`);
55
+ }
56
+ }
57
+ return body;
58
+ }
59
+
60
+ return {
61
+ async createTournamentArena(
62
+ body: CreateTournamentArenaRequest,
63
+ opts: { idempotencyKey: string },
64
+ ): Promise<CreateTournamentArenaResponse> {
65
+ return apiFetch("/api/v1/arena/arenas", {
66
+ method: "POST",
67
+ headers: { "Idempotency-Key": opts.idempotencyKey },
68
+ body: JSON.stringify(body),
69
+ }) as Promise<CreateTournamentArenaResponse>;
70
+ },
71
+
72
+ async register(body: RegisterContestantRequest): Promise<{ registration: ArenaRegistration }> {
73
+ return apiFetch("/api/v1/arena/contestants/register", {
74
+ method: "POST",
75
+ body: JSON.stringify(body),
76
+ }) as Promise<{ registration: ArenaRegistration }>;
77
+ },
78
+
79
+ async listOffers(): Promise<ArenaOffersResponse> {
80
+ return apiFetch("/api/v1/arena/offers", { method: "GET" }) as Promise<ArenaOffersResponse>;
81
+ },
82
+
83
+ async acceptOffer(offerId: string): Promise<{ offer: ArenaOffer }> {
84
+ return apiFetch(`/api/v1/arena/offers/${encodeURIComponent(offerId)}/accept`, {
85
+ method: "POST",
86
+ body: JSON.stringify({}),
87
+ }) as Promise<{ offer: ArenaOffer }>;
88
+ },
89
+
90
+ async submit(arenaId: string, body: SubmitArenaRequest): Promise<{ submission: ArenaSubmission }> {
91
+ const { submission_hash: _submissionHash, ...wireBody } = body;
92
+ return apiFetch(`/api/v1/arena/arenas/${encodeURIComponent(arenaId)}/submissions`, {
93
+ method: "POST",
94
+ body: JSON.stringify(wireBody),
95
+ }) as Promise<{ submission: ArenaSubmission }>;
96
+ },
97
+
98
+ async commitJuror(
99
+ arenaId: string,
100
+ body: CommitArenaJurorRequest,
101
+ ): Promise<{ juror: ArenaJuror }> {
102
+ return apiFetch(`/api/v1/arena/arenas/${encodeURIComponent(arenaId)}/jurors/commit`, {
103
+ method: "POST",
104
+ body: JSON.stringify(body),
105
+ }) as Promise<{ juror: ArenaJuror }>;
106
+ },
107
+
108
+ async voteTask(
109
+ arenaId: string,
110
+ body: TaskJurorVoteRequest,
111
+ ): Promise<{ vote: ArenaJurorVote }> {
112
+ return apiFetch(`/api/v1/arena/arenas/${encodeURIComponent(arenaId)}/juror-votes`, {
113
+ method: "POST",
114
+ body: JSON.stringify(body),
115
+ }) as Promise<{ vote: ArenaJurorVote }>;
116
+ },
117
+
118
+ async voteMatch(
119
+ arenaId: string,
120
+ matchId: string,
121
+ body: MatchJurorVoteRequest,
122
+ ): Promise<{ vote: ArenaJurorVote }> {
123
+ return apiFetch(
124
+ `/api/v1/arena/arenas/${encodeURIComponent(arenaId)}/matches/${encodeURIComponent(matchId)}/juror-votes`,
125
+ { method: "POST", body: JSON.stringify(body) },
126
+ ) as Promise<{ vote: ArenaJurorVote }>;
127
+ },
128
+
129
+ async listArenas(opts: { registered?: boolean } = {}): Promise<{ arenas: ArenaRecord[] }> {
130
+ const suffix = opts.registered ? "?registered=true" : "";
131
+ return apiFetch(`/api/v1/arena/arenas${suffix}`, { method: "GET" }) as Promise<{ arenas: ArenaRecord[] }>;
132
+ },
133
+
134
+ async getLeaderboard(arenaId: string): Promise<ArenaLeaderboard> {
135
+ return apiFetch(`/api/v1/arena/arenas/${encodeURIComponent(arenaId)}/leaderboard`, {
136
+ method: "GET",
137
+ }) as Promise<ArenaLeaderboard>;
138
+ },
139
+
140
+ async getCategoryLeaderboard(
141
+ category: ArenaCategory,
142
+ mode = "match",
143
+ ): Promise<ArenaCategoryLeaderboard> {
144
+ const q = new URLSearchParams({
145
+ category_topic: category.topic,
146
+ category_subtopic: category.subtopic,
147
+ mode,
148
+ });
149
+ return apiFetch(`/api/v1/arena/leaderboard?${q.toString()}`, {
150
+ method: "GET",
151
+ }) as Promise<ArenaCategoryLeaderboard>;
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ export function sha256Hex(bytes: Buffer | string): string {
5
+ return createHash("sha256").update(bytes).digest("hex");
6
+ }
7
+
8
+ export function sha256Digest(bytes: Buffer | string): string {
9
+ return `sha256:${sha256Hex(bytes)}`;
10
+ }
11
+
12
+ export function hashFile(path: string): { bytes: Buffer; digest: string } {
13
+ const bytes = readFileSync(path);
14
+ return { bytes, digest: sha256Digest(bytes) };
15
+ }
@@ -0,0 +1,106 @@
1
+ export interface ArenaCategory {
2
+ topic: string;
3
+ subtopic: string;
4
+ }
5
+
6
+ export interface RegisterContestantRequest {
7
+ contestant_agent_id: string;
8
+ mandate_id: string;
9
+ category: ArenaCategory;
10
+ }
11
+
12
+ export interface CreateTournamentArenaRequest {
13
+ mode: "tournament";
14
+ category: Record<string, unknown>;
15
+ config: Record<string, unknown>;
16
+ }
17
+
18
+ export interface CreateTournamentArenaResponse {
19
+ arena: ArenaRecord;
20
+ replayed: boolean;
21
+ }
22
+
23
+ export interface SubmitArenaRequest {
24
+ offer_id: string;
25
+ raw_content: string;
26
+ content_ref?: string;
27
+ match_id?: string;
28
+ seq?: number;
29
+ submission_hash: string;
30
+ }
31
+
32
+ export interface CommitArenaJurorRequest {
33
+ juror_agent_id: string;
34
+ mandate_id?: string;
35
+ }
36
+
37
+ export interface ArenaRecord extends Record<string, unknown> {
38
+ arena_id?: string;
39
+ }
40
+
41
+ export interface ArenaJuror extends Record<string, unknown> {
42
+ juror_id?: string;
43
+ arena_id?: string;
44
+ juror_agent_id?: string;
45
+ mode?: "task_submission" | "match";
46
+ status?: string;
47
+ }
48
+
49
+ export interface ArenaOffer extends Record<string, unknown> {
50
+ offer_id?: string;
51
+ }
52
+
53
+ export interface ArenaPendingMatch extends Record<string, unknown> {
54
+ arena_id?: string;
55
+ match_id?: string;
56
+ prompt_nonce?: string;
57
+ attempt_n?: number;
58
+ prompt?: string;
59
+ prompt_hash?: string;
60
+ expires_at?: string;
61
+ status?: string;
62
+ }
63
+
64
+ export interface ArenaOffersResponse {
65
+ offers: ArenaOffer[];
66
+ pending_matches?: ArenaPendingMatch[];
67
+ }
68
+
69
+ export interface ArenaRegistration extends Record<string, unknown> {
70
+ registration_id?: string;
71
+ }
72
+
73
+ export interface ArenaSubmission extends Record<string, unknown> {
74
+ submission_id?: string;
75
+ submission_hash?: string;
76
+ }
77
+
78
+ export interface TaskJurorVoteRequest {
79
+ submission_id: string;
80
+ score: number;
81
+ rationale_ref?: string;
82
+ }
83
+
84
+ export type MatchJurorOutcome = "a" | "b" | "tie" | "both_bad";
85
+
86
+ export interface MatchJurorVoteRequest {
87
+ outcome: MatchJurorOutcome;
88
+ rationale_ref?: string;
89
+ }
90
+
91
+ export interface ArenaJurorVote extends Record<string, unknown> {
92
+ vote_id?: string;
93
+ arena_id?: string;
94
+ juror_id?: string;
95
+ }
96
+
97
+ export interface ArenaLeaderboard extends Record<string, unknown> {
98
+ arena_id?: string;
99
+ leaderboard?: unknown[];
100
+ }
101
+
102
+ export interface ArenaCategoryLeaderboard extends Record<string, unknown> {
103
+ category?: ArenaCategory;
104
+ mode?: string;
105
+ leaderboard?: unknown[];
106
+ }
package/src/bin.ts CHANGED
@@ -1,19 +1,29 @@
1
1
  import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { registerAgentCommands } from "./commands/agent.js";
6
+ import { registerArenaCommands } from "./commands/arena.js";
2
7
  import { registerAuthCommands } from "./commands/auth.js";
8
+ import { registerConvergeCommands } from "./commands/converge.js";
3
9
  import { registerProviderCommands } from "./commands/provider.js";
4
10
  import { registerRequesterCommands } from "./commands/requester.js";
5
11
 
6
- const CLI_VERSION = "0.1.2";
12
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
13
+ const CLI_VERSION = JSON.parse(readFileSync(pkgPath, "utf8")).version;
7
14
 
8
15
  const program = new Command();
9
16
  program
10
17
  .name("linkedclaw")
11
- .description("Official LinkedClaw CLI — any agent can shell out to hire providers, invoke, or broadcast")
18
+ .description("Official LinkedClaw CLI — any agent can shell out to hire providers, invoke, or gig task")
12
19
  .version(`cli ${CLI_VERSION}`);
13
20
 
14
21
  registerAuthCommands(program);
15
22
  registerRequesterCommands(program);
16
23
  registerProviderCommands(program);
24
+ registerConvergeCommands(program);
25
+ registerArenaCommands(program);
26
+ registerAgentCommands(program);
17
27
 
18
28
  program.parseAsync(process.argv).catch((err) => {
19
29
  process.stderr.write(
@@ -0,0 +1,264 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+
6
+ type AgentRunOptions = {
7
+ config?: string;
8
+ watch: string[];
9
+ once?: boolean;
10
+ pythonCommand?: string;
11
+ };
12
+
13
+ type AgentRotateMandateOptions = {
14
+ config?: string;
15
+ oldMandateId?: string;
16
+ expiresAt?: string;
17
+ pythonCommand?: string;
18
+ };
19
+
20
+ export function registerAgentCommands(program: Command): void {
21
+ const agent = program.command("agent").description("Owner-agent runtime commands");
22
+
23
+ agent
24
+ .command("run")
25
+ .description("Run a long-lived owner agent from a local config")
26
+ .option("--config <path>", "Owner-agent config path")
27
+ .option("--watch <debate_id:commons_log_id>", "Watch a debate Commons Log; repeatable", collect, [])
28
+ .option("--once", "Process pending local tasks once, then exit")
29
+ .option("--python-command <cmd>", "Python executable to use")
30
+ .action((opts: AgentRunOptions) => runOwnerAgent(opts));
31
+
32
+ agent
33
+ .command("rotate-mandate")
34
+ .description("Issue a replacement owner-agent mandate, update local config, then revoke the old one")
35
+ .option("--config <path>", "Owner-agent config path")
36
+ .option("--old-mandate-id <id>", "Mandate id to replace; defaults to the configured transport mandate")
37
+ .option("--expires-at <iso>", "Replacement mandate expiry timestamp")
38
+ .option("--python-command <cmd>", "Python executable to use")
39
+ .action((opts: AgentRotateMandateOptions) => rotateOwnerAgentMandate(opts));
40
+ }
41
+
42
+ function collect(value: string, previous: string[]): string[] {
43
+ return [...previous, value];
44
+ }
45
+
46
+ function runOwnerAgent(opts: AgentRunOptions): void {
47
+ let pythonCommand: string;
48
+ let watches: string[];
49
+ let configPath: string | undefined;
50
+ try {
51
+ pythonCommand = resolvePythonCommand(opts.pythonCommand ?? process.env.LINKEDCLAW_OWNER_AGENT_PYTHON ?? "python3");
52
+ watches = (opts.watch ?? []).map(validateWatch);
53
+ const rawConfigPath = opts.config ?? process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
54
+ configPath = rawConfigPath === undefined ? undefined : validateConfigPath(rawConfigPath);
55
+ } catch (err) {
56
+ process.stderr.write(
57
+ JSON.stringify({
58
+ error: "invalid_agent_run_option",
59
+ message: err instanceof Error ? err.message : String(err),
60
+ }) + "\n",
61
+ );
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ const args = ["-m", "linkedclaw.owner_agent.cli", "run"];
67
+
68
+ if (configPath !== undefined) {
69
+ args.push("--config", configPath);
70
+ }
71
+ for (const watch of watches) {
72
+ args.push("--watch", watch);
73
+ }
74
+ if (opts.once) {
75
+ args.push("--once");
76
+ }
77
+
78
+ spawnOwnerAgentPython(args, pythonCommand);
79
+ }
80
+
81
+ function rotateOwnerAgentMandate(opts: AgentRotateMandateOptions): void {
82
+ let pythonCommand: string;
83
+ let configPath: string;
84
+ try {
85
+ pythonCommand = resolvePythonCommand(opts.pythonCommand ?? process.env.LINKEDCLAW_OWNER_AGENT_PYTHON ?? "python3");
86
+ const rawConfigPath = opts.config ?? process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
87
+ if (rawConfigPath === undefined) {
88
+ throw new Error("Config path required; pass --config or set LINKEDCLAW_OWNER_AGENT_CONFIG");
89
+ }
90
+ configPath = validateConfigPath(rawConfigPath);
91
+ if (opts.oldMandateId !== undefined) {
92
+ validateNonEmptyNoNul(opts.oldMandateId, "--old-mandate-id");
93
+ }
94
+ if (opts.expiresAt !== undefined) {
95
+ validateNonEmptyNoNul(opts.expiresAt, "--expires-at");
96
+ }
97
+ } catch (err) {
98
+ process.stderr.write(
99
+ JSON.stringify({
100
+ error: "invalid_agent_rotate_mandate_option",
101
+ message: err instanceof Error ? err.message : String(err),
102
+ }) + "\n",
103
+ );
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+
108
+ const args = [
109
+ "-m",
110
+ "linkedclaw.owner_agent.cli",
111
+ "rotate-mandate",
112
+ "--config",
113
+ configPath,
114
+ ];
115
+ if (opts.oldMandateId !== undefined) {
116
+ args.push("--old-mandate-id", opts.oldMandateId);
117
+ }
118
+ if (opts.expiresAt !== undefined) {
119
+ args.push("--expires-at", opts.expiresAt);
120
+ }
121
+
122
+ spawnOwnerAgentPython(args, pythonCommand);
123
+ }
124
+
125
+ function spawnOwnerAgentPython(args: string[], pythonCommand: string): void {
126
+ const child = spawn(pythonCommand, args, { stdio: "inherit" });
127
+ const forwardSignal = (signal: NodeJS.Signals) => {
128
+ if (!child.killed) {
129
+ child.kill(signal);
130
+ }
131
+ };
132
+ const onSigterm = () => forwardSignal("SIGTERM");
133
+ const onSigint = () => forwardSignal("SIGINT");
134
+ const cleanupSignals = () => {
135
+ process.off("SIGTERM", onSigterm);
136
+ process.off("SIGINT", onSigint);
137
+ };
138
+
139
+ process.on("SIGTERM", onSigterm);
140
+ process.on("SIGINT", onSigint);
141
+
142
+ child.on("error", (err) => {
143
+ cleanupSignals();
144
+ process.stderr.write(
145
+ JSON.stringify({ error: "owner_agent_python_unavailable", message: err.message }) + "\n",
146
+ );
147
+ process.exitCode = 1;
148
+ });
149
+
150
+ child.on("exit", (code, signal) => {
151
+ cleanupSignals();
152
+ process.exitCode = signal ? 1 : (code ?? 1);
153
+ });
154
+ }
155
+
156
+ function validateNonEmptyNoNul(value: string, label: string): void {
157
+ if (value.length === 0 || value.trim().length === 0) {
158
+ throw new Error(`${label} must be non-empty`);
159
+ }
160
+ if (value.includes("\0")) {
161
+ throw new Error(`${label} must not contain NUL bytes`);
162
+ }
163
+ }
164
+
165
+ function validateWatch(value: string): string {
166
+ const separator = value.indexOf(":");
167
+ if (separator <= 0 || separator === value.length - 1 || value.indexOf(":", separator + 1) !== -1) {
168
+ throw new Error("--watch must use debate_id:commons_log_id with both ids non-empty");
169
+ }
170
+ const debateId = value.slice(0, separator).trim();
171
+ const commonsLogId = value.slice(separator + 1).trim();
172
+ if (!debateId || !commonsLogId) {
173
+ throw new Error("--watch must use debate_id:commons_log_id with both ids non-empty");
174
+ }
175
+ return `${debateId}:${commonsLogId}`;
176
+ }
177
+
178
+ function validateConfigPath(value: string): string {
179
+ if (value.length === 0 || value.trim().length === 0) {
180
+ throw new Error("Config path must be a non-empty file path");
181
+ }
182
+ if (value.includes("\0")) {
183
+ throw new Error("Config path must not contain NUL bytes");
184
+ }
185
+ try {
186
+ if (!statSync(value).isFile()) {
187
+ throw new Error(`Config path is not a regular file: ${value}`);
188
+ }
189
+ } catch (err) {
190
+ if (err instanceof Error && err.message.startsWith("Config path is not a regular file:")) {
191
+ throw err;
192
+ }
193
+ throw new Error(`Config path does not exist or cannot be read: ${value}`);
194
+ }
195
+ return value;
196
+ }
197
+
198
+ function resolvePythonCommand(command: string): string {
199
+ const trimmed = command.trim();
200
+ if (!trimmed) {
201
+ throw new Error("Python command must be a non-empty executable name or absolute path");
202
+ }
203
+ if (trimmed.includes("\0") || /[\s;&|<>`$\n\r]/.test(trimmed)) {
204
+ throw new Error("Python command must be an executable only; arguments and shell metacharacters are rejected");
205
+ }
206
+
207
+ const isAbsolute = path.isAbsolute(trimmed) || path.win32.isAbsolute(trimmed);
208
+ const hasPathSeparator = trimmed.includes("/") || trimmed.includes("\\");
209
+ if (hasPathSeparator && !isAbsolute) {
210
+ throw new Error("Python command path must be absolute");
211
+ }
212
+
213
+ if (isAbsolute) {
214
+ assertExecutable(trimmed);
215
+ return trimmed;
216
+ }
217
+
218
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) {
219
+ throw new Error("Python command must be a bare executable name or absolute path");
220
+ }
221
+
222
+ const resolved = findExecutable(trimmed);
223
+ if (resolved === null) {
224
+ throw new Error(`Python command not found on PATH: ${trimmed}`);
225
+ }
226
+ return resolved;
227
+ }
228
+
229
+ export const _test = { validateWatch, resolvePythonCommand, validateConfigPath };
230
+
231
+ function findExecutable(command: string): string | null {
232
+ const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
233
+ const extensions = process.platform === "win32"
234
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
235
+ : [""];
236
+
237
+ for (const dir of pathEntries) {
238
+ for (const ext of extensions) {
239
+ const candidate = path.join(dir, command.toLowerCase().endsWith(ext.toLowerCase()) ? command : `${command}${ext}`);
240
+ if (isExecutable(candidate)) {
241
+ return candidate;
242
+ }
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function assertExecutable(file: string): void {
249
+ if (!isExecutable(file)) {
250
+ throw new Error(`Python command is not an executable file: ${file}`);
251
+ }
252
+ }
253
+
254
+ function isExecutable(file: string): boolean {
255
+ try {
256
+ if (!statSync(file).isFile()) {
257
+ return false;
258
+ }
259
+ accessSync(file, constants.X_OK);
260
+ return true;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }