@linkedclaw/cli 0.1.2 → 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 +248 -48
- package/dist/bin.js +8099 -4778
- package/dist/bin.js.map +1 -1
- package/package.json +17 -32
- 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 +33 -0
- package/src/commands/agent.ts +264 -0
- package/src/commands/arena.ts +393 -0
- package/src/commands/auth.ts +116 -0
- package/src/commands/converge.ts +969 -0
- package/src/commands/provider.ts +245 -0
- package/src/commands/requester.ts +479 -0
- package/src/config.ts +85 -0
- package/src/context.ts +27 -0
- 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/errors.ts +41 -0
- package/src/handlers/subprocess.ts +185 -0
- package/src/output.ts +57 -0
- package/src/types.ts +90 -0
- 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 +82 -0
- 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
- package/test/hire-flags.test.ts +55 -0
- package/test/recv-flags.test.ts +83 -0
- package/test/register-browser.test.ts +55 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +25 -0
- package/vitest.config.ts +8 -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { configPath, readFileConfig, writeFileConfig, DEFAULT_CLOUD_URL } from "../config.js";
|
|
3
|
+
import { buildContext } from "../context.js";
|
|
4
|
+
import { runCommand, readLine } from "../output.js";
|
|
5
|
+
|
|
6
|
+
export function registerAuthCommands(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command("login")
|
|
9
|
+
.description("Store API key in ~/.linkedclaw/config.yaml")
|
|
10
|
+
.option("--api-key <key>", "API key (otherwise read from stdin)")
|
|
11
|
+
.option("--cloud-url <url>", "Override cloud URL")
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
await runCommand(async () => {
|
|
14
|
+
let apiKey = opts.apiKey as string | undefined;
|
|
15
|
+
if (!apiKey) {
|
|
16
|
+
apiKey = await readLine("Paste API key: ");
|
|
17
|
+
}
|
|
18
|
+
if (!apiKey) throw new Error("empty api key");
|
|
19
|
+
const prev = readFileConfig();
|
|
20
|
+
const next = { ...prev, apiKey, ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}) };
|
|
21
|
+
writeFileConfig(next);
|
|
22
|
+
return { ok: true, path: configPath() };
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command("register")
|
|
28
|
+
.description("Open browser to create a LinkedClaw account, then paste your API key")
|
|
29
|
+
.option("--no-browser", "Print URL instead of attempting to open the browser")
|
|
30
|
+
.option("--cloud-url <url>", "Override cloud URL")
|
|
31
|
+
.action(async (opts) => {
|
|
32
|
+
await runCommand(async () => {
|
|
33
|
+
const prev = readFileConfig();
|
|
34
|
+
const cloudUrl =
|
|
35
|
+
(opts.cloudUrl as string | undefined) ??
|
|
36
|
+
(prev.cloudUrl as string | undefined) ??
|
|
37
|
+
process.env.LINKEDCLAW_CLOUD_URL ??
|
|
38
|
+
DEFAULT_CLOUD_URL;
|
|
39
|
+
const portalUrl = cloudUrl.replace(/\/$/, "") + "/register";
|
|
40
|
+
|
|
41
|
+
let opened = false;
|
|
42
|
+
if (opts.browser !== false) {
|
|
43
|
+
try {
|
|
44
|
+
const open = (await import("open")).default;
|
|
45
|
+
await open(portalUrl);
|
|
46
|
+
opened = true;
|
|
47
|
+
} catch {
|
|
48
|
+
// headless / no DISPLAY / package missing → fall through to URL print
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!opened) {
|
|
52
|
+
process.stderr.write(`Open this URL in a browser to register:\n ${portalUrl}\n\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const apiKey = await readLine("Paste your API key (from portal Settings → API Keys): ");
|
|
56
|
+
if (!apiKey) throw new Error("empty api key");
|
|
57
|
+
|
|
58
|
+
const next = {
|
|
59
|
+
...prev,
|
|
60
|
+
apiKey,
|
|
61
|
+
cloudUrl,
|
|
62
|
+
};
|
|
63
|
+
writeFileConfig(next);
|
|
64
|
+
return { ok: true, path: configPath(), opened };
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
program
|
|
69
|
+
.command("whoami")
|
|
70
|
+
.description("Print current user info")
|
|
71
|
+
.option("--human", "Human-readable output")
|
|
72
|
+
.action(async (opts) => {
|
|
73
|
+
await runCommand(async () => {
|
|
74
|
+
const { consumer } = buildContext();
|
|
75
|
+
return consumer.getMe();
|
|
76
|
+
}, { human: opts.human });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const config = program
|
|
80
|
+
.command("config")
|
|
81
|
+
.description("Inspect or edit local config file");
|
|
82
|
+
|
|
83
|
+
config
|
|
84
|
+
.command("show")
|
|
85
|
+
.description("Print the config file contents (api key redacted)")
|
|
86
|
+
.action(async () => {
|
|
87
|
+
await runCommand(async () => {
|
|
88
|
+
const raw = readFileConfig();
|
|
89
|
+
const redacted = { ...raw };
|
|
90
|
+
if (redacted.apiKey) redacted.apiKey = "lc_***" + String(redacted.apiKey).slice(-4);
|
|
91
|
+
return { path: configPath(), config: redacted };
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
config
|
|
96
|
+
.command("set <key> <value>")
|
|
97
|
+
.description("Set a config key (simple top-level keys only)")
|
|
98
|
+
.action(async (key: string, value: string) => {
|
|
99
|
+
await runCommand(async () => {
|
|
100
|
+
const prev = readFileConfig();
|
|
101
|
+
const next: Record<string, unknown> = { ...prev };
|
|
102
|
+
const parsed = tryParseJson(value);
|
|
103
|
+
next[key] = parsed;
|
|
104
|
+
writeFileConfig(next);
|
|
105
|
+
return { ok: true, key, value: parsed };
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tryParseJson(v: string): unknown {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(v);
|
|
113
|
+
} catch {
|
|
114
|
+
return v;
|
|
115
|
+
}
|
|
116
|
+
}
|