@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/README.md +172 -10
- package/dist/bin.js +1921 -163
- package/dist/bin.js.map +1 -1
- package/package.json +3 -3
- package/src/arena/api.ts +154 -0
- package/src/arena/hash.ts +15 -0
- package/src/arena/types.ts +106 -0
- package/src/bin.ts +12 -2
- package/src/commands/agent.ts +264 -0
- package/src/commands/arena.ts +393 -0
- package/src/commands/converge.ts +969 -0
- package/src/commands/provider.ts +8 -8
- package/src/commands/requester.ts +64 -21
- package/src/config.ts +11 -2
- package/src/converge/api.ts +213 -0
- package/src/converge/hash.ts +35 -0
- package/src/converge/lock.ts +30 -0
- package/src/converge/staging.ts +83 -0
- package/src/converge/types.ts +91 -0
- package/src/converge/workspace.ts +92 -0
- package/src/handlers/subprocess.ts +8 -8
- package/src/types.ts +5 -5
- package/test/agent-help.test.ts +207 -0
- package/test/arena-api.test.ts +211 -0
- package/test/arena-commands.test.ts +559 -0
- package/test/arena-hash.test.ts +33 -0
- package/test/cli-help.test.ts +23 -3
- package/test/converge-accept.test.ts +206 -0
- package/test/converge-decision.test.ts +274 -0
- package/test/converge-hash.test.ts +58 -0
- package/test/converge-help.test.ts +58 -0
- package/test/converge-lock.test.ts +48 -0
- package/test/converge-review.test.ts +135 -0
- package/test/converge-run.test.ts +286 -0
- package/test/converge-staging.test.ts +161 -0
- package/test/converge-status.test.ts +141 -0
- package/test/converge-workspace.test.ts +92 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { buildContext, type CliContext } from "../context.js";
|
|
4
|
+
import { LinkedClawError } from "../errors.js";
|
|
5
|
+
import { runCommand } from "../output.js";
|
|
6
|
+
import { makeArenaApi } from "../arena/api.js";
|
|
7
|
+
import { hashFile, sha256Digest } from "../arena/hash.js";
|
|
8
|
+
import type {
|
|
9
|
+
ArenaCategory,
|
|
10
|
+
CreateTournamentArenaRequest,
|
|
11
|
+
MatchJurorOutcome,
|
|
12
|
+
SubmitArenaRequest,
|
|
13
|
+
} from "../arena/types.js";
|
|
14
|
+
|
|
15
|
+
interface TargetOpts {
|
|
16
|
+
target?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface HumanOpts extends TargetOpts {
|
|
20
|
+
human?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FIRST_PARTY_ARENA_HANDLE = "gig-pa-operator";
|
|
24
|
+
const FIRST_PARTY_ARENA_SLUG = "arena-v1";
|
|
25
|
+
const ARENA_CAPABILITY = "arena.v1";
|
|
26
|
+
|
|
27
|
+
interface ArenaTarget {
|
|
28
|
+
agentId: string;
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function servicesHostBaseUrl(ctx: CliContext): string {
|
|
33
|
+
return ctx.cfg.servicesHostUrl ?? process.env.LINKEDCLAW_SERVICES_HOST_URL ?? ctx.cfg.cloudUrl;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function endpointForListing(listing: Record<string, unknown>, ctx: CliContext): string {
|
|
37
|
+
const endpoint = listing.external_endpoint;
|
|
38
|
+
return typeof endpoint === "string" && endpoint.length > 0 ? endpoint : servicesHostBaseUrl(ctx);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertArenaPa(listing: { capabilities?: unknown }, source: string): void {
|
|
42
|
+
const caps = Array.isArray(listing.capabilities) ? listing.capabilities : [];
|
|
43
|
+
if (!caps.includes(ARENA_CAPABILITY)) {
|
|
44
|
+
throw new LinkedClawError(
|
|
45
|
+
"arena_target_not_arena_pa",
|
|
46
|
+
`${source} does not advertise ${ARENA_CAPABILITY}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function resolveArenaTarget(ctx: CliContext, opts: TargetOpts): Promise<ArenaTarget> {
|
|
52
|
+
if (opts.target && /^https?:\/\//i.test(opts.target)) {
|
|
53
|
+
throw new LinkedClawError(
|
|
54
|
+
"arena_target_must_be_agent_id",
|
|
55
|
+
"--target now expects an arena.v1 agent id, not a URL.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const listing = opts.target
|
|
59
|
+
? await ctx.consumer.getAgent(opts.target)
|
|
60
|
+
: await ctx.consumer.resolveAgentHandle(FIRST_PARTY_ARENA_HANDLE, FIRST_PARTY_ARENA_SLUG);
|
|
61
|
+
assertArenaPa(listing, opts.target ?? `${FIRST_PARTY_ARENA_HANDLE}/${FIRST_PARTY_ARENA_SLUG}`);
|
|
62
|
+
return { agentId: listing.agent_id, baseUrl: endpointForListing(listing, ctx) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function buildArenaApi(opts: TargetOpts) {
|
|
66
|
+
const ctx = buildContext();
|
|
67
|
+
if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
|
|
68
|
+
const target = await resolveArenaTarget(ctx, opts);
|
|
69
|
+
return makeArenaApi(target.baseUrl, ctx.cfg.apiKey);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseCategory(opts: { categoryTopic?: string; categorySubtopic?: string }): ArenaCategory {
|
|
73
|
+
if (!opts.categoryTopic) {
|
|
74
|
+
throw new LinkedClawError("missing_category_topic", "--category-topic is required.");
|
|
75
|
+
}
|
|
76
|
+
if (!opts.categorySubtopic) {
|
|
77
|
+
throw new LinkedClawError("missing_category_subtopic", "--category-subtopic is required.");
|
|
78
|
+
}
|
|
79
|
+
return { topic: opts.categoryTopic, subtopic: opts.categorySubtopic };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseSeq(value: string): number {
|
|
83
|
+
const seq = Number(value);
|
|
84
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
85
|
+
throw new LinkedClawError("invalid_seq", "--seq must be a positive integer.");
|
|
86
|
+
}
|
|
87
|
+
return seq;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseScore(value: string): number {
|
|
91
|
+
const score = Number(value);
|
|
92
|
+
if (!Number.isFinite(score) || score < 0 || score > 1) {
|
|
93
|
+
throw new LinkedClawError("invalid_juror_score", "score must be a number between 0 and 1.");
|
|
94
|
+
}
|
|
95
|
+
return score;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readTournamentManifest(path: string): CreateTournamentArenaRequest {
|
|
103
|
+
if (path === "-" && process.stdin.isTTY) {
|
|
104
|
+
throw new LinkedClawError(
|
|
105
|
+
"arena_tournament_manifest_stdin_tty",
|
|
106
|
+
'stdin is a TTY; pass a file path or pipe JSON via stdin (e.g. cat tournament.json | linkedclaw arena tournament create -).',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
let raw: string;
|
|
110
|
+
try {
|
|
111
|
+
raw = readFileSync(path === "-" ? 0 : path, "utf8");
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
throw new LinkedClawError(
|
|
115
|
+
"arena_tournament_manifest_read_failed",
|
|
116
|
+
path === "-"
|
|
117
|
+
? `could not read from stdin (use "-" to pipe JSON): ${message}`
|
|
118
|
+
: message,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let parsed: unknown;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(raw);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw new LinkedClawError(
|
|
127
|
+
"arena_tournament_manifest_json_invalid",
|
|
128
|
+
err instanceof Error ? err.message : String(err),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!isPlainObject(parsed)) {
|
|
133
|
+
throw new LinkedClawError(
|
|
134
|
+
"arena_tournament_manifest_shape_invalid",
|
|
135
|
+
"manifest must be a JSON object.",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (parsed.mode !== "tournament") {
|
|
139
|
+
throw new LinkedClawError(
|
|
140
|
+
"arena_tournament_manifest_mode_invalid",
|
|
141
|
+
'manifest mode must be exactly "tournament".',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!isPlainObject(parsed.category) || !isPlainObject(parsed.config)) {
|
|
145
|
+
throw new LinkedClawError(
|
|
146
|
+
"arena_tournament_manifest_shape_invalid",
|
|
147
|
+
"manifest must include category and config object fields.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
mode: "tournament",
|
|
152
|
+
category: parsed.category,
|
|
153
|
+
config: parsed.config,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseIdempotencyKey(value: string | undefined): string {
|
|
158
|
+
const idempotencyKey = value?.trim();
|
|
159
|
+
if (!idempotencyKey) {
|
|
160
|
+
throw new LinkedClawError(
|
|
161
|
+
"arena_idempotency_key_required",
|
|
162
|
+
"--idempotency-key must be a non-empty string.",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (/[\r\n]/.test(idempotencyKey)) {
|
|
166
|
+
throw new LinkedClawError(
|
|
167
|
+
"arena_idempotency_key_invalid",
|
|
168
|
+
"--idempotency-key must not contain newlines.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return idempotencyKey;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function mergeSubmissionHash(response: unknown, submissionHash: string): unknown {
|
|
175
|
+
if (
|
|
176
|
+
response &&
|
|
177
|
+
typeof response === "object" &&
|
|
178
|
+
"submission" in response &&
|
|
179
|
+
(response as { submission?: unknown }).submission &&
|
|
180
|
+
typeof (response as { submission?: unknown }).submission === "object"
|
|
181
|
+
) {
|
|
182
|
+
const submission = (response as { submission: Record<string, unknown> }).submission;
|
|
183
|
+
if (typeof submission.submission_hash === "string") return response;
|
|
184
|
+
return { ...response, submission: { ...submission, submission_hash: submissionHash } };
|
|
185
|
+
}
|
|
186
|
+
if (response && typeof response === "object" && "submission_hash" in response) return response;
|
|
187
|
+
return { response, submission_hash: submissionHash };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function registerArenaCommands(program: Command): void {
|
|
191
|
+
const arena = program.command("arena").description("Arena PA commands");
|
|
192
|
+
|
|
193
|
+
const tournament = arena.command("tournament").description("Arena tournament commands");
|
|
194
|
+
|
|
195
|
+
tournament
|
|
196
|
+
.command("create <manifest.json>")
|
|
197
|
+
.description("Create a tournament Arena from an exact JSON manifest")
|
|
198
|
+
.option("--idempotency-key <key>", "Required replay key for tournament creation")
|
|
199
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
200
|
+
.option("--human", "Human-readable output")
|
|
201
|
+
.action(async (manifestPath: string, opts: HumanOpts & { idempotencyKey?: string }) => {
|
|
202
|
+
await runCommand(async () => {
|
|
203
|
+
const idempotencyKey = parseIdempotencyKey(opts.idempotencyKey);
|
|
204
|
+
return (await buildArenaApi(opts)).createTournamentArena(readTournamentManifest(manifestPath), {
|
|
205
|
+
idempotencyKey,
|
|
206
|
+
});
|
|
207
|
+
}, { human: opts.human });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
arena
|
|
211
|
+
.command("register")
|
|
212
|
+
.description("Register a contestant agent for Arena offers")
|
|
213
|
+
.requiredOption("--agent-id <agt_id>")
|
|
214
|
+
.requiredOption("--mandate-id <mandate_id>")
|
|
215
|
+
.requiredOption("--category-topic <topic>")
|
|
216
|
+
.requiredOption("--category-subtopic <subtopic>")
|
|
217
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
218
|
+
.option("--human", "Human-readable output")
|
|
219
|
+
.action(
|
|
220
|
+
async (opts: HumanOpts & {
|
|
221
|
+
agentId: string;
|
|
222
|
+
mandateId: string;
|
|
223
|
+
categoryTopic?: string;
|
|
224
|
+
categorySubtopic?: string;
|
|
225
|
+
}) => {
|
|
226
|
+
await runCommand(async () => {
|
|
227
|
+
const api = await buildArenaApi(opts);
|
|
228
|
+
return api.register({
|
|
229
|
+
contestant_agent_id: opts.agentId,
|
|
230
|
+
mandate_id: opts.mandateId,
|
|
231
|
+
category: parseCategory(opts),
|
|
232
|
+
});
|
|
233
|
+
}, { human: opts.human });
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
arena
|
|
238
|
+
.command("offers")
|
|
239
|
+
.description("List durable Arena offers for this owner")
|
|
240
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
241
|
+
.option("--human", "Human-readable output")
|
|
242
|
+
.action(async (opts: HumanOpts) => {
|
|
243
|
+
await runCommand(async () => (await buildArenaApi(opts)).listOffers(), { human: opts.human });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
arena
|
|
247
|
+
.command("accept <offer_id>")
|
|
248
|
+
.description("Accept an Arena offer")
|
|
249
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
250
|
+
.option("--human", "Human-readable output")
|
|
251
|
+
.action(async (offerId: string, opts: HumanOpts) => {
|
|
252
|
+
await runCommand(async () => (await buildArenaApi(opts)).acceptOffer(offerId), { human: opts.human });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
arena
|
|
256
|
+
.command("submit <arena_id>")
|
|
257
|
+
.description("Submit a text or file answer to an Arena")
|
|
258
|
+
.requiredOption("--offer-id <offer_id>")
|
|
259
|
+
.option("--match-id <match_id>", "Pending match id for match-mode submissions")
|
|
260
|
+
.option("--file <path>")
|
|
261
|
+
.option("--body <text>")
|
|
262
|
+
.option("--content-ref <ref>")
|
|
263
|
+
.option("--seq <n>", "Submission sequence number", parseSeq, 1)
|
|
264
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
265
|
+
.option("--human", "Human-readable output")
|
|
266
|
+
.action(
|
|
267
|
+
async (arenaId: string, opts: HumanOpts & {
|
|
268
|
+
offerId: string;
|
|
269
|
+
matchId?: string;
|
|
270
|
+
file?: string;
|
|
271
|
+
body?: string;
|
|
272
|
+
contentRef?: string;
|
|
273
|
+
seq: number;
|
|
274
|
+
}) => {
|
|
275
|
+
await runCommand(async () => {
|
|
276
|
+
if (opts.file && opts.body !== undefined) {
|
|
277
|
+
throw new LinkedClawError("submission_source_conflict", "Use exactly one of --file or --body.");
|
|
278
|
+
}
|
|
279
|
+
if (!opts.file && opts.body === undefined) {
|
|
280
|
+
throw new LinkedClawError("submission_source_required", "Use exactly one of --file or --body.");
|
|
281
|
+
}
|
|
282
|
+
let request: SubmitArenaRequest;
|
|
283
|
+
if (opts.file) {
|
|
284
|
+
const { bytes, digest } = hashFile(opts.file);
|
|
285
|
+
request = {
|
|
286
|
+
offer_id: opts.offerId,
|
|
287
|
+
raw_content: bytes.toString("utf8"),
|
|
288
|
+
content_ref: opts.contentRef ?? opts.file,
|
|
289
|
+
...(opts.matchId ? { match_id: opts.matchId } : {}),
|
|
290
|
+
seq: opts.seq,
|
|
291
|
+
submission_hash: digest,
|
|
292
|
+
};
|
|
293
|
+
} else {
|
|
294
|
+
const body = opts.body ?? "";
|
|
295
|
+
request = {
|
|
296
|
+
offer_id: opts.offerId,
|
|
297
|
+
raw_content: body,
|
|
298
|
+
...(opts.contentRef ? { content_ref: opts.contentRef } : {}),
|
|
299
|
+
...(opts.matchId ? { match_id: opts.matchId } : {}),
|
|
300
|
+
seq: opts.seq,
|
|
301
|
+
submission_hash: sha256Digest(Buffer.from(body, "utf8")),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const response = await (await buildArenaApi(opts)).submit(arenaId, request);
|
|
305
|
+
return mergeSubmissionHash(response, request.submission_hash);
|
|
306
|
+
}, { human: opts.human });
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const vote = arena.command("vote").description("Arena juror voting commands");
|
|
311
|
+
|
|
312
|
+
vote
|
|
313
|
+
.command("task <arena_id> <submission_id> <score>")
|
|
314
|
+
.description("Submit a task-submission juror score")
|
|
315
|
+
.option("--rationale-ref <ref>")
|
|
316
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
317
|
+
.option("--human", "Human-readable output")
|
|
318
|
+
.action(async (arenaId: string, submissionId: string, scoreValue: string, opts: HumanOpts & {
|
|
319
|
+
rationaleRef?: string;
|
|
320
|
+
}) => {
|
|
321
|
+
await runCommand(async () => {
|
|
322
|
+
const score = parseScore(scoreValue);
|
|
323
|
+
return (await buildArenaApi(opts)).voteTask(arenaId, {
|
|
324
|
+
submission_id: submissionId,
|
|
325
|
+
score,
|
|
326
|
+
...(opts.rationaleRef ? { rationale_ref: opts.rationaleRef } : {}),
|
|
327
|
+
});
|
|
328
|
+
}, { human: opts.human });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
vote
|
|
332
|
+
.command("match <arena_id> <match_id> <outcome>")
|
|
333
|
+
.description("Submit a match-mode juror outcome")
|
|
334
|
+
.option("--rationale-ref <ref>")
|
|
335
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
336
|
+
.option("--human", "Human-readable output")
|
|
337
|
+
.action(async (arenaId: string, matchId: string, outcome: string, opts: HumanOpts & {
|
|
338
|
+
rationaleRef?: string;
|
|
339
|
+
}) => {
|
|
340
|
+
await runCommand(async () => {
|
|
341
|
+
if (!["a", "b", "tie", "both_bad"].includes(outcome)) {
|
|
342
|
+
throw new LinkedClawError(
|
|
343
|
+
"invalid_juror_outcome",
|
|
344
|
+
"outcome must be one of: a, b, tie, both_bad.",
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return (await buildArenaApi(opts)).voteMatch(arenaId, matchId, {
|
|
348
|
+
outcome: outcome as MatchJurorOutcome,
|
|
349
|
+
...(opts.rationaleRef ? { rationale_ref: opts.rationaleRef } : {}),
|
|
350
|
+
});
|
|
351
|
+
}, { human: opts.human });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
arena
|
|
355
|
+
.command("list")
|
|
356
|
+
.description("List visible Arenas")
|
|
357
|
+
.option("--registered", "Only arenas where this owner is registered or submitted")
|
|
358
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
359
|
+
.option("--human", "Human-readable output")
|
|
360
|
+
.action(async (opts: HumanOpts & { registered?: boolean }) => {
|
|
361
|
+
await runCommand(async () => (await buildArenaApi(opts)).listArenas({ registered: opts.registered }), {
|
|
362
|
+
human: opts.human,
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
arena
|
|
367
|
+
.command("leaderboard [arena_id]")
|
|
368
|
+
.description("Read an Arena leaderboard")
|
|
369
|
+
.option("--category-topic <topic>")
|
|
370
|
+
.option("--category-subtopic <subtopic>")
|
|
371
|
+
.option("--mode <mode>", "Leaderboard mode", "match")
|
|
372
|
+
.option("--target <agent_id>", "Arena PA agent id override")
|
|
373
|
+
.option("--human", "Human-readable output")
|
|
374
|
+
.action(async (arenaId: string | undefined, opts: HumanOpts & {
|
|
375
|
+
categoryTopic?: string;
|
|
376
|
+
categorySubtopic?: string;
|
|
377
|
+
mode: string;
|
|
378
|
+
}) => {
|
|
379
|
+
await runCommand(async () => {
|
|
380
|
+
const api = await buildArenaApi(opts);
|
|
381
|
+
if (arenaId) {
|
|
382
|
+
return api.getLeaderboard(arenaId);
|
|
383
|
+
}
|
|
384
|
+
if (opts.mode !== "match") {
|
|
385
|
+
throw new LinkedClawError(
|
|
386
|
+
"unsupported_arena_leaderboard_mode",
|
|
387
|
+
"--mode must be match when no arena_id is provided.",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return api.getCategoryLeaderboard(parseCategory(opts), opts.mode);
|
|
391
|
+
}, { human: opts.human });
|
|
392
|
+
});
|
|
393
|
+
}
|