pyyol 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -0
  3. package/dist/adapter.d.ts +24 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +68 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/cli.d.ts +20 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +1325 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +28 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +160 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/credentials.d.ts +13 -0
  16. package/dist/credentials.d.ts.map +1 -0
  17. package/dist/credentials.js +175 -0
  18. package/dist/credentials.js.map +1 -0
  19. package/dist/index.d.ts +26 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +21 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/login.d.ts +16 -0
  24. package/dist/login.d.ts.map +1 -0
  25. package/dist/login.js +107 -0
  26. package/dist/login.js.map +1 -0
  27. package/dist/mode.d.ts +12 -0
  28. package/dist/mode.d.ts.map +1 -0
  29. package/dist/mode.js +55 -0
  30. package/dist/mode.js.map +1 -0
  31. package/dist/models.d.ts +105 -0
  32. package/dist/models.d.ts.map +1 -0
  33. package/dist/models.js +68 -0
  34. package/dist/models.js.map +1 -0
  35. package/dist/rules.d.ts +4 -0
  36. package/dist/rules.d.ts.map +1 -0
  37. package/dist/rules.js +26 -0
  38. package/dist/rules.js.map +1 -0
  39. package/dist/runtime.d.ts +97 -0
  40. package/dist/runtime.d.ts.map +1 -0
  41. package/dist/runtime.js +372 -0
  42. package/dist/runtime.js.map +1 -0
  43. package/dist/server.d.ts +75 -0
  44. package/dist/server.d.ts.map +1 -0
  45. package/dist/server.js +174 -0
  46. package/dist/server.js.map +1 -0
  47. package/dist/signing.d.ts +42 -0
  48. package/dist/signing.d.ts.map +1 -0
  49. package/dist/signing.js +115 -0
  50. package/dist/signing.js.map +1 -0
  51. package/dist/simulator.d.ts +38 -0
  52. package/dist/simulator.d.ts.map +1 -0
  53. package/dist/simulator.js +109 -0
  54. package/dist/simulator.js.map +1 -0
  55. package/dist/telemetry.d.ts +81 -0
  56. package/dist/telemetry.d.ts.map +1 -0
  57. package/dist/telemetry.js +225 -0
  58. package/dist/telemetry.js.map +1 -0
  59. package/dist/version.d.ts +2 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/version.js +4 -0
  62. package/dist/version.js.map +1 -0
  63. package/package.json +62 -0
  64. package/rules/games.md +349 -0
  65. package/rules/llms-full.txt +985 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `pyyol` CLI (JS/TS SDK) — parity with the Python CLI: login/logout/whoami,
4
+ * init, dev (sandbox-locked dev loop), play (compete; --ranked = real stakes),
5
+ * publish, replay, profile, leaderboard, arenas, doctor, update. Zero runtime deps:
6
+ * uses Node 22+ globals (fetch, WebSocket) and built-ins only.
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { asAgent } from "./adapter.js";
12
+ import * as config from "./config.js";
13
+ import * as creds from "./credentials.js";
14
+ import { deriveConnectUrl, runLoginFlow } from "./login.js";
15
+ import * as mode from "./mode.js";
16
+ import { RuntimeConnector } from "./runtime.js";
17
+ import { REQUEST_ID_HEADER, SIGNATURE_HEADER, SIGNATURE_VERSION, TIMESTAMP_HEADER, computeSignature, } from "./signing.js";
18
+ import { SDK_VERSION } from "./version.js";
19
+ const OK = "✓";
20
+ const BAD = "✗";
21
+ // Public platform defaults. `pyyol login` with no flags hits the live platform;
22
+ // self-hosted/local users override via PYYOL_API / PYYOL_DASHBOARD (or --api /
23
+ // --dashboard). The API host serves /v1/*; the dashboard host serves /cli-login —
24
+ // DIFFERENT hosts in the split-domain deployment, so dashboard must not fall back
25
+ // to the API host.
26
+ const DEFAULT_API_BASE = (process.env.PYYOL_API || "").replace(/\/$/, "") || "https://api.pyyol.com";
27
+ const DEFAULT_DASHBOARD = (process.env.PYYOL_DASHBOARD || "").replace(/\/$/, "") || "https://pyyol.com";
28
+ // Agent API keys look like "sk_arena_<lookup>_<secret>" — the long-lived, revocable
29
+ // connection credential (mirrors backend platform.PrefixKey).
30
+ const AGENT_KEY_PREFIX = "sk_arena_";
31
+ const PLAY_PATH = {
32
+ goofspiel: "/v1/sandbox/pushplay",
33
+ mafia: "/v1/mafia/pushplay",
34
+ monopoly: "/v1/monopoly/pushplay",
35
+ };
36
+ const REPLAY_PATH = {
37
+ goofspiel: "/v1/match/{id}/replay",
38
+ mafia: "/v1/mafia/{id}/replay",
39
+ monopoly: "/v1/monopoly/{id}/replay",
40
+ };
41
+ function parse(argv) {
42
+ const positionals = [];
43
+ const flags = {};
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a.startsWith("--")) {
47
+ const key = a.slice(2);
48
+ const next = argv[i + 1];
49
+ if (next !== undefined && !next.startsWith("--")) {
50
+ flags[key] = next;
51
+ i++;
52
+ }
53
+ else {
54
+ flags[key] = true;
55
+ }
56
+ }
57
+ else {
58
+ positionals.push(a);
59
+ }
60
+ }
61
+ return { positionals, flags };
62
+ }
63
+ const str = (a, k, d = "") => (typeof a.flags[k] === "string" ? a.flags[k] : d);
64
+ const bool = (a, k) => a.flags[k] === true || a.flags[k] === "true";
65
+ const num = (a, k, d) => {
66
+ const v = a.flags[k];
67
+ return typeof v === "string" && /^\d+$/.test(v) ? parseInt(v, 10) : d;
68
+ };
69
+ // ── HTTP helpers (fetch) ───────────────────────────────────────────────────────
70
+ const HTTP_TIMEOUT_MS = 10_000; // fail fast on a stalled/low-bandwidth link, don't hang
71
+ let insecureWarned = false;
72
+ /** Warn (once) when credentials would go over cleartext non-loopback HTTP. */
73
+ function warnInsecureTransport(url, hasAuth) {
74
+ if (!hasAuth || insecureWarned)
75
+ return;
76
+ try {
77
+ const u = new URL(url);
78
+ if (u.protocol === "https:")
79
+ return;
80
+ const h = u.hostname.toLowerCase();
81
+ if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost"))
82
+ return;
83
+ insecureWarned = true;
84
+ console.error(`${BAD} WARNING: sending credentials over insecure ${u.protocol}//${h} — use https://`);
85
+ }
86
+ catch {
87
+ /* ignore */
88
+ }
89
+ }
90
+ function netErr(e) {
91
+ const msg = String(e);
92
+ return /timeout|abort/i.test(msg) ? "network timed out (slow or unreachable)" : msg;
93
+ }
94
+ let argvSecretWarned = false;
95
+ /** Warn (once) that a secret on the command line is visible to other users on a
96
+ * shared host (ps/proc). Prefer `pyyol login` (browser) or the PYYOL_TOKEN env var. */
97
+ function warnArgvSecret() {
98
+ if (argvSecretWarned)
99
+ return;
100
+ argvSecretWarned = true;
101
+ console.error(`${BAD} note: a secret on the command line is visible to other users on shared hosts ` +
102
+ `(ps/proc). Prefer \`pyyol login\` (browser) or the PYYOL_TOKEN env var.`);
103
+ }
104
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
105
+ // Rate-limited endpoints (manifest verify, deposits, withdrawals — which `publish`
106
+ // hits) answer 429 with a Retry-After header (seconds). Retry a small, bounded
107
+ // number of times so a transient limit doesn't fail the command outright.
108
+ const RETRY_MAX = 2; // retries after the first attempt → 3 total
109
+ const RETRY_AFTER_CAP_MS = 30_000; // never honor a Retry-After longer than this
110
+ const RETRY_BACKOFF_MS = [500, 1000]; // fallback when no Retry-After header
111
+ /** Parse a Retry-After header (delta-seconds) to ms, capped; null if absent/unparseable. */
112
+ function retryAfterMs(header) {
113
+ if (!header)
114
+ return null;
115
+ const secs = Number(header.trim());
116
+ if (!Number.isFinite(secs) || secs < 0)
117
+ return null;
118
+ return Math.min(secs * 1000, RETRY_AFTER_CAP_MS);
119
+ }
120
+ /** Run a fetch thunk, retrying ONLY on HTTP 429 up to RETRY_MAX times (3 total).
121
+ * Sleeps for Retry-After (capped) when present, else exponential backoff. Returns
122
+ * the final Response (including a 429 once the cap is reached). The thunk builds a
123
+ * fresh init each call so every attempt gets its own AbortSignal.timeout. */
124
+ async function fetchWithRetry(doFetch) {
125
+ let res = await doFetch();
126
+ for (let attempt = 0; res.status === 429 && attempt < RETRY_MAX; attempt++) {
127
+ const wait = retryAfterMs(res.headers.get("retry-after")) ?? RETRY_BACKOFF_MS[attempt];
128
+ await sleep(wait);
129
+ res = await doFetch();
130
+ }
131
+ return res;
132
+ }
133
+ export async function apiGet(url, token = "") {
134
+ const headers = {};
135
+ if (token) {
136
+ headers.Authorization = "Bearer " + token;
137
+ warnInsecureTransport(url, true);
138
+ }
139
+ try {
140
+ const r = await fetchWithRetry(() => fetch(url, { headers, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }));
141
+ const text = await r.text();
142
+ return [r.status, text ? JSON.parse(text) : {}];
143
+ }
144
+ catch (e) {
145
+ return [0, { error: netErr(e) }];
146
+ }
147
+ }
148
+ export async function apiPost(url, token, body) {
149
+ const headers = { "Content-Type": "application/json" };
150
+ if (token) {
151
+ headers.Authorization = "Bearer " + token;
152
+ warnInsecureTransport(url, true);
153
+ }
154
+ try {
155
+ const r = await fetchWithRetry(() => fetch(url, {
156
+ method: "POST",
157
+ headers,
158
+ body: JSON.stringify(body ?? {}),
159
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
160
+ }));
161
+ const text = await r.text();
162
+ return [r.status, text ? JSON.parse(text) : {}];
163
+ }
164
+ catch (e) {
165
+ return [0, { error: netErr(e) }];
166
+ }
167
+ }
168
+ function httpBase(a, c) {
169
+ if (str(a, "api"))
170
+ return str(a, "api").replace(/\/$/, "");
171
+ if (process.env.PYYOL_API)
172
+ return process.env.PYYOL_API.replace(/\/$/, "");
173
+ if (c?.url)
174
+ return c.url.replace(/\/$/, "");
175
+ if (c?.connectUrl) {
176
+ try {
177
+ const u = new URL(c.connectUrl);
178
+ const scheme = u.protocol === "wss:" || u.protocol === "https:" ? "https:" : "http:";
179
+ return `${scheme}//${u.host}`;
180
+ }
181
+ catch {
182
+ /* ignore */
183
+ }
184
+ }
185
+ return DEFAULT_API_BASE;
186
+ }
187
+ /** The credential the agent CONNECTION registers with, and whether it's the
188
+ * long-lived agent key. Prefer the agent key (`sk_arena_…`, no timer expiry) so
189
+ * the connection persists forever — like an OpenAI/`gh` token — falling back to
190
+ * the short-lived dashboard JWT (which the connector then auto-refreshes).
191
+ * Mirrors the Python `_connection_token`. */
192
+ export function connectionToken(a, c) {
193
+ const explicit = str(a, "token") || process.env.PYYOL_TOKEN || "";
194
+ if (explicit)
195
+ return { token: explicit, usingAgentKey: explicit.startsWith(AGENT_KEY_PREFIX) };
196
+ if (c?.apiKey)
197
+ return { token: c.apiKey, usingAgentKey: true };
198
+ if (c)
199
+ return { token: c.accessToken, usingAgentKey: false };
200
+ return { token: "", usingAgentKey: false };
201
+ }
202
+ // ── load the developer's agent from pyyol.toml ────────────────────────────────
203
+ async function loadAgentFromConfig(cfg) {
204
+ const { dirname, isAbsolute, relative } = await import("node:path");
205
+ const [modPath, varName] = config.entryParts(cfg);
206
+ // Constrain `entry` to the project root (dir of the discovered pyyol.toml): reject
207
+ // absolute paths and `..` traversal so a hostile pyyol.toml can't load an arbitrary
208
+ // file outside the project.
209
+ const cfgFile = config.find();
210
+ const root = cfgFile ? dirname(resolve(cfgFile)) : process.cwd();
211
+ const abs = resolve(root, modPath);
212
+ const rel = relative(root, abs);
213
+ if (isAbsolute(modPath) || rel.startsWith("..")) {
214
+ throw new Error(`entry ${modPath} must be inside the project (${root})`);
215
+ }
216
+ if (!existsSync(abs))
217
+ throw new Error(`entry module ${modPath} not found (see pyyol.toml entry)`);
218
+ const mod = await import(pathToFileURL(abs).href);
219
+ const obj = mod[varName] ?? mod.default;
220
+ if (obj === undefined)
221
+ throw new Error(`no \`${varName}\` export in ${modPath} (see pyyol.toml entry)`);
222
+ return asAgent(obj);
223
+ }
224
+ // ── commands ───────────────────────────────────────────────────────────────────
225
+ async function cmdLogin(a) {
226
+ const token = str(a, "token");
227
+ // API host serves /v1/*; dashboard host serves /cli-login — different in prod,
228
+ // so dashboard must NOT fall back to --api. Both default to the live platform.
229
+ const api = (str(a, "api") || DEFAULT_API_BASE).replace(/\/$/, "");
230
+ const dashboard = (str(a, "dashboard") || DEFAULT_DASHBOARD).replace(/\/$/, "");
231
+ if (token) {
232
+ warnArgvSecret();
233
+ creds.save({
234
+ url: api,
235
+ connectUrl: str(a, "connect") || deriveConnectUrl(api),
236
+ agentId: str(a, "agent"),
237
+ accessToken: token,
238
+ refreshToken: "",
239
+ // An explicit sk_arena_… token IS the persistent agent key; a dashboard JWT isn't.
240
+ apiKey: token.startsWith(AGENT_KEY_PREFIX) ? token : "",
241
+ });
242
+ console.log(`${OK} stored credentials`);
243
+ return 0;
244
+ }
245
+ const provider = str(a, "with");
246
+ console.log(`opening ${dashboard}/cli-login in your browser${provider ? ` (via ${provider})` : ""}…`);
247
+ try {
248
+ const c = await runLoginFlow({ dashboardUrl: dashboard, apiUrl: api, provider });
249
+ if (str(a, "connect"))
250
+ c.connectUrl = str(a, "connect");
251
+ // Mint a long-lived agent key for THIS machine (unless the dashboard already
252
+ // handed one back). This is the credential the agent connection uses — like an
253
+ // OpenAI/`gh` token, it never expires on a timer, so `pyyol dev`/`serve` keeps
254
+ // working forever until you revoke it, re-login elsewhere, or lose the machine.
255
+ // Best-effort: if it fails we still store the session and fall back to the
256
+ // short-lived JWT + refresh for the connection.
257
+ if (!c.apiKey && c.agentId && c.accessToken) {
258
+ const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, { agent_id: c.agentId });
259
+ if (st === 201 && resp.api_key)
260
+ c.apiKey = resp.api_key;
261
+ else
262
+ console.error(`note: couldn't mint a persistent agent key (${st}); using the refreshable session instead.`);
263
+ }
264
+ creds.save(c);
265
+ const persist = c.apiKey ? " · persistent agent key" : "";
266
+ console.log(`${OK} logged in as ${c.agentId || "(no agent yet)"} — credentials stored${persist}`);
267
+ return 0;
268
+ }
269
+ catch (e) {
270
+ console.error(`${BAD} login failed: ${e}`);
271
+ return 1;
272
+ }
273
+ }
274
+ function cmdLogout() {
275
+ console.log(creds.clear() ? `${OK} logged out` : "not logged in");
276
+ return 0;
277
+ }
278
+ async function cmdWhoami(a) {
279
+ const c = creds.load();
280
+ if (!c?.accessToken) {
281
+ console.error(`${BAD} not logged in — run \`pyyol login\`.`);
282
+ return 2;
283
+ }
284
+ const base = httpBase(a, c);
285
+ const [, me] = base ? await apiGet(`${base}/v1/me`, c.accessToken) : [0, {}];
286
+ console.log(`user ${me.user_id ?? "(unknown)"}`);
287
+ console.log(`agent ${me.agent_id ?? c.agentId ?? "(none)"}`);
288
+ console.log(`platform ${c.url || base || "(unset)"}`);
289
+ // Persistent agent key ⇒ the connection never needs re-login (revoke/PC-change
290
+ // only); otherwise the session rides the refreshable dashboard token.
291
+ console.log(`session ${c.apiKey ? "persistent agent key" : "refreshable token"}`);
292
+ const cfg = config.load();
293
+ if (cfg)
294
+ console.log(`project ${cfg.name} · arena ${cfg.arena} · mode ${cfg.mode}`);
295
+ return 0;
296
+ }
297
+ function className(name) {
298
+ const parts = name.replace(/[^a-zA-Z0-9]+/g, " ").split(" ").filter(Boolean);
299
+ const cls = parts.map((p) => p[0].toUpperCase() + p.slice(1)).join("") || "Agent";
300
+ return /^\d/.test(cls) ? "A" + cls : cls;
301
+ }
302
+ async function cmdInit(a) {
303
+ const { mkdirSync, writeFileSync } = await import("node:fs");
304
+ const { join } = await import("node:path");
305
+ const dir = a.positionals[0];
306
+ if (!dir) {
307
+ console.error(`${BAD} usage: pyyol init <dir>`);
308
+ return 2;
309
+ }
310
+ mkdirSync(dir, { recursive: true });
311
+ const name = str(a, "name") || dir.split("/").filter(Boolean).pop() || "agent";
312
+ const arena = str(a, "arena") || "goofspiel";
313
+ const cls = className(name);
314
+ const agentPath = join(dir, "agent.mjs");
315
+ writeFileSync(agentPath, `import { Adapter } from "pyyol";
316
+
317
+ // ${name} — implement step(); initialize()/shutdown() are optional.
318
+ // Run: pyyol dev (practice, SANDBOX — no stakes)
319
+ // pyyol play ${arena} (compete; add --ranked for real, after \`pyyol publish\`)
320
+ class ${cls} extends Adapter {
321
+ name = "${name}";
322
+ supportedGames = ["${arena}"];
323
+
324
+ step(view) {
325
+ // Your strategy goes here (call any framework or LLM). Baseline below:
326
+ const legal = view.legal_actions ?? [];
327
+ ${arena === "goofspiel" ? "return { round: view.round, card: Math.min(...legal) };" : "return legal.length ? { action: legal[0] } : {};"}
328
+ }
329
+ }
330
+
331
+ export const agent = new ${cls}();
332
+ `);
333
+ const cfg = {
334
+ ...config.defaults(),
335
+ name,
336
+ language: "javascript",
337
+ framework: str(a, "framework"),
338
+ arena,
339
+ entry: "agent.mjs:agent",
340
+ };
341
+ const cfgPath = config.save(cfg, dir);
342
+ console.log(`${OK} created javascript agent in ${dir}/`);
343
+ console.log(` ${agentPath}`);
344
+ console.log(` ${cfgPath}`);
345
+ console.log("\nNext:");
346
+ console.log(" npm install pyyol");
347
+ console.log(` cd ${dir} && pyyol dev # practice locally (sandbox — no stakes)`);
348
+ console.log(` pyyol play ${arena} # compete (sandbox); add --ranked for real`);
349
+ return 0;
350
+ }
351
+ async function startSandbox(base, token, arena, label) {
352
+ const path = PLAY_PATH[arena] ?? PLAY_PATH.goofspiel;
353
+ for (let i = 0; i < 6; i++) {
354
+ const [st, resp] = await apiPost(`${base}${path}`, token, {});
355
+ if (st === 200 || st === 201) {
356
+ const mid = resp.match_id ?? resp.id ?? "";
357
+ console.log(` ${OK} started ${arena} match ${mid} ${label}`.trimEnd());
358
+ return;
359
+ }
360
+ const code = String(resp.code ?? resp.error ?? "");
361
+ if (code.includes("transport") || code.includes("no_agent") || st === 409 || st === 425) {
362
+ await new Promise((r) => setTimeout(r, 1500));
363
+ continue;
364
+ }
365
+ console.log(` ${BAD} could not start ${arena} match: ${JSON.stringify(resp)}`);
366
+ return;
367
+ }
368
+ }
369
+ async function orchestrate(a, devLocked) {
370
+ const cfg = config.load();
371
+ if (!cfg) {
372
+ console.error(`${BAD} no pyyol.toml here — run \`pyyol init <dir>\` first.`);
373
+ return 2;
374
+ }
375
+ const c = creds.load();
376
+ // The connection runs on the agent key OR the dashboard JWT — either proves a
377
+ // session. (The agent key is the persistent, no-expiry one.)
378
+ if (!c || !(c.accessToken || c.apiKey)) {
379
+ console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
380
+ return 2;
381
+ }
382
+ const connectUrl = str(a, "url") || process.env.PYYOL_URL || c.connectUrl;
383
+ const base = httpBase(a, c);
384
+ // The agent id MUST be the one the token belongs to. The token comes from creds,
385
+ // so creds.agentId (its matched pair) wins over a possibly-stale pyyol.toml pin —
386
+ // otherwise the socket's "key.agent == claimed agent_id" check rejects the register.
387
+ const agentId = str(a, "agent") || c.agentId || cfg.agent_id;
388
+ const { token, usingAgentKey } = connectionToken(a, c);
389
+ if (!connectUrl || !agentId) {
390
+ console.error(`${BAD} missing connect URL or agent id — run \`pyyol login\` (or pass --url/--agent).`);
391
+ return 2;
392
+ }
393
+ const arena = str(a, "arena") || (a.positionals[0] ?? "") || cfg.arena;
394
+ const m = mode.resolveMode({ rankedFlag: bool(a, "ranked"), cfgMode: cfg.mode, devLocked });
395
+ console.log(mode.banner(m));
396
+ if (m === mode.RANKED) {
397
+ if (!(await mode.confirmRanked(bool(a, "yes")))) {
398
+ console.log("aborted — staying safe. (Use --yes in CI to skip the prompt.)");
399
+ return 1;
400
+ }
401
+ }
402
+ if (agentId && !cfg.agent_id)
403
+ config.setAgentId(agentId);
404
+ let agent;
405
+ try {
406
+ agent = await loadAgentFromConfig(cfg);
407
+ }
408
+ catch (e) {
409
+ console.error(`${BAD} could not load your agent: ${e}`);
410
+ return 2;
411
+ }
412
+ const feed = makeFeed(bool(a, "quiet"));
413
+ const conn = new RuntimeConnector(agent, {
414
+ url: connectUrl,
415
+ agentId,
416
+ token,
417
+ name: agent.name,
418
+ games: agent.supportedGames,
419
+ onFeed: feed,
420
+ // Silent access-token refresh: on a rejected register, spend the rotating
421
+ // refresh token for a fresh access token and persist the pair back to creds —
422
+ // keeps a long `pyyol dev`/`play` authenticated past the short access TTL. The
423
+ // agent key can't expire, so refresh is only wired when NOT using it.
424
+ ...(usingAgentKey ? {} : refreshOpts(c, base)),
425
+ });
426
+ // Kick match(es) after the socket registers; retry while it comes online.
427
+ const matches = Math.max(1, num(a, "matches", devLocked ? 3 : 1));
428
+ setTimeout(async () => {
429
+ if (m === mode.RANKED) {
430
+ const tier = str(a, "tier") || "low";
431
+ const [st, resp] = await apiPost(`${base}/v1/queue`, token, { game: arena, tier });
432
+ if (st === 200 || st === 202)
433
+ console.log(` ${OK} queued for RANKED ${arena} (tier ${tier})`);
434
+ else if (String(resp.code ?? "").includes("certified"))
435
+ console.log(` ${BAD} agent not certified for ranked — run \`pyyol publish\` first.`);
436
+ else
437
+ console.log(` ${BAD} could not queue ranked (${st}): ${JSON.stringify(resp)}`);
438
+ return;
439
+ }
440
+ for (let i = 0; i < matches; i++) {
441
+ await startSandbox(base, token, arena, `${i + 1}/${matches}`);
442
+ await new Promise((r) => setTimeout(r, 2000));
443
+ }
444
+ }, 1500);
445
+ process.on("SIGINT", () => {
446
+ conn.stop();
447
+ });
448
+ try {
449
+ await conn.run(); // blocks: connect + heartbeat + reconnect + serve turns
450
+ }
451
+ catch (e) {
452
+ const msg = e instanceof Error ? e.message : String(e);
453
+ console.error(`\n${BAD} ${msg}`);
454
+ if (/unauthorized|token|register/i.test(msg)) {
455
+ console.error(" your token was rejected — run `pyyol login` again (agent id + agent key must match).");
456
+ }
457
+ return 1;
458
+ }
459
+ console.log("\nstopped.");
460
+ return 0;
461
+ }
462
+ async function cmdArenas(a) {
463
+ const base = httpBase(a, creds.load());
464
+ if (!base) {
465
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
466
+ return 2;
467
+ }
468
+ const [st, resp] = await apiGet(`${base}/v1/arenas`);
469
+ if (st !== 200) {
470
+ console.error(`${BAD} could not fetch arenas (${st})`);
471
+ return 1;
472
+ }
473
+ console.log("ARENA PLAYERS SANDBOX RANKED STATUS");
474
+ for (const ar of resp.arenas ?? []) {
475
+ const players = `${ar.min_players}-${ar.max_players}`;
476
+ console.log(`${ar.id.padEnd(12)}${players.padEnd(10)}${(ar.sandbox ? "yes" : "no").padEnd(9)}` +
477
+ `${(ar.ranked ? "yes" : "no").padEnd(8)}${ar.status}`);
478
+ }
479
+ return 0;
480
+ }
481
+ async function cmdLeaderboard(a) {
482
+ const base = httpBase(a, creds.load());
483
+ if (!base) {
484
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
485
+ return 2;
486
+ }
487
+ if (bool(a, "developers")) {
488
+ const q = str(a, "season") ? `?season=${str(a, "season")}` : "";
489
+ const [, resp] = await apiGet(`${base}/v1/leaderboard/developers${q}`);
490
+ console.log("# DEVELOPER P-INDEX");
491
+ for (const r of resp.entries ?? [])
492
+ console.log(`${String(r.rank).padEnd(5)}${String(r.username ?? r.developer ?? "?").padEnd(24)}${r.p_index}`);
493
+ return 0;
494
+ }
495
+ const qs = [];
496
+ if (str(a, "game"))
497
+ qs.push(`game=${encodeURIComponent(str(a, "game"))}`);
498
+ if (str(a, "season"))
499
+ qs.push(`season=${str(a, "season")}`);
500
+ const [st, resp] = await apiGet(`${base}/v1/leaderboard${qs.length ? "?" + qs.join("&") : ""}`);
501
+ if (st !== 200) {
502
+ console.error(`${BAD} could not fetch leaderboard (${st})`);
503
+ return 1;
504
+ }
505
+ console.log("# AGENT ELO W-L-T");
506
+ for (const r of resp.entries ?? [])
507
+ console.log(`${String(r.rank).padEnd(5)}${String(r.name ?? r.slug ?? "?").padEnd(24)}${String(r.elo).padEnd(7)}${r.wins}-${r.losses}-${r.ties}`);
508
+ return 0;
509
+ }
510
+ async function cmdProfile(a) {
511
+ const c = creds.load();
512
+ const base = httpBase(a, c);
513
+ if (!base) {
514
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
515
+ return 2;
516
+ }
517
+ let handle = a.positionals[0] ?? "";
518
+ if (!handle) {
519
+ const [, me] = await apiGet(`${base}/v1/me`, c?.accessToken ?? "");
520
+ handle = me.user_id ?? "";
521
+ if (!handle) {
522
+ console.error(`${BAD} pass a handle: \`pyyol profile <@handle>\``);
523
+ return 2;
524
+ }
525
+ }
526
+ const [st, p] = await apiGet(`${base}/v1/developers/${encodeURIComponent(handle)}`);
527
+ if (st !== 200) {
528
+ console.error(`${BAD} no such developer ${handle} (${st}).`);
529
+ return 1;
530
+ }
531
+ const dev = p.developer ?? {};
532
+ const pidx = p.p_index ?? {};
533
+ const stats = p.stats ?? {};
534
+ console.log(`@${dev.username ?? dev.developer ?? "?"}`);
535
+ if (pidx.p_index !== undefined)
536
+ console.log(` P-Index ${pidx.p_index} (rank #${pidx.global_rank}, top ${pidx.percentile}%)`);
537
+ console.log(` Record ${stats.wins ?? 0}W-${stats.losses ?? 0}L-${stats.draws ?? 0}D over ${stats.total_matches ?? 0} matches`);
538
+ if (stats.favorite_arena)
539
+ console.log(` Favorite ${stats.favorite_arena}`);
540
+ console.log(` Agents ${(p.agents ?? []).length} Followers ${p.followers ?? 0}`);
541
+ return 0;
542
+ }
543
+ function winnerLabel(w) {
544
+ if (w === null || w === undefined || w === "")
545
+ return "";
546
+ if (typeof w === "number")
547
+ return w < 0 ? "tie" : `seat ${w}`;
548
+ return String(w);
549
+ }
550
+ function replayOutcome(resp) {
551
+ for (const k of ["winner", "winner_team", "winner_agent"]) {
552
+ if (resp[k] !== undefined && resp[k] !== "")
553
+ return [winnerLabel(resp[k]), null];
554
+ }
555
+ const events = resp.events ?? [];
556
+ for (let i = events.length - 1; i >= 0; i--) {
557
+ const ev = events[i];
558
+ const payload = ev && typeof ev.payload === "object" ? ev.payload : {};
559
+ if (["match_finished", "game_over", "victory", "finished"].includes(ev?.type) || "winner" in payload) {
560
+ return [winnerLabel(payload.winner), payload.scores ?? null];
561
+ }
562
+ }
563
+ return ["", null];
564
+ }
565
+ async function cmdReplay(a) {
566
+ const c = creds.load();
567
+ const base = httpBase(a, c);
568
+ if (!base) {
569
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
570
+ return 2;
571
+ }
572
+ const match = a.positionals[0];
573
+ if (!match) {
574
+ console.error(`${BAD} usage: pyyol replay <match_id>`);
575
+ return 2;
576
+ }
577
+ const cfg = config.load();
578
+ const game = str(a, "game") || cfg?.arena || "goofspiel";
579
+ const path = (REPLAY_PATH[game] ?? REPLAY_PATH.goofspiel).replace("{id}", encodeURIComponent(match));
580
+ const [st, resp] = await apiGet(`${base}${path}`);
581
+ if (st !== 200) {
582
+ console.error(`${BAD} could not fetch replay (${st})`);
583
+ return 1;
584
+ }
585
+ if (bool(a, "json")) {
586
+ console.log(JSON.stringify(resp, null, 2));
587
+ return 0;
588
+ }
589
+ const events = resp.events ?? resp.moves ?? [];
590
+ const [winner, scores] = replayOutcome(resp);
591
+ console.log(`replay ${match} (${game}) — ${events.length} events, status ${resp.status ?? "?"}`);
592
+ if (winner)
593
+ console.log(` winner ${winner}${scores ? ` (scores ${scores.join("-")})` : ""}`);
594
+ if (resp.moves_verified !== undefined)
595
+ console.log(` verified ${resp.moves_verified} (every move signed + valid)`);
596
+ console.log(` full JSON: pyyol replay ${match} --game ${game} --json`);
597
+ return 0;
598
+ }
599
+ async function cmdDoctor(a) {
600
+ const checks = [];
601
+ const c = creds.load();
602
+ checks.push(["logged in", Boolean(c?.accessToken), c?.url || "run `pyyol login`"]);
603
+ const cfg = config.load();
604
+ if (!cfg) {
605
+ checks.push(["pyyol.toml", false, "run `pyyol init`"]);
606
+ }
607
+ else {
608
+ const problems = config.validate(cfg);
609
+ checks.push(["pyyol.toml", problems.length === 0, problems.join("; ") || `${cfg.name} · ${cfg.arena} · ${cfg.mode}`]);
610
+ try {
611
+ await loadAgentFromConfig(cfg);
612
+ checks.push(["agent loads", true, cfg.entry]);
613
+ }
614
+ catch (e) {
615
+ checks.push(["agent loads", false, String(e)]);
616
+ }
617
+ }
618
+ const base = httpBase(a, c);
619
+ if (base) {
620
+ const [st] = await apiGet(`${base}/v1/arenas`);
621
+ checks.push(["platform reachable", st === 200, `${base} (${st})`]);
622
+ }
623
+ else {
624
+ checks.push(["platform reachable", false, "no API url"]);
625
+ }
626
+ checks.push(["sdk version", true, SDK_VERSION]);
627
+ console.log("pyyol doctor\n");
628
+ let allOk = true;
629
+ for (const [name, ok, detail] of checks) {
630
+ allOk = allOk && ok;
631
+ console.log(` ${ok ? OK : BAD} ${name.padEnd(20)} ${detail}`);
632
+ }
633
+ console.log("\n" + (allOk ? "✓ ready — `pyyol dev` to practice, `pyyol play <arena>` to compete." : "fix the ✗ items above."));
634
+ return allOk ? 0 : 1;
635
+ }
636
+ async function cmdUpdate() {
637
+ console.log(`pyyol ${SDK_VERSION}`);
638
+ try {
639
+ const r = await fetch("https://registry.npmjs.org/pyyol/latest", { signal: AbortSignal.timeout(5000) });
640
+ const latest = (await r.json())?.version;
641
+ if (latest && latest !== SDK_VERSION) {
642
+ console.log(` update available: ${latest}`);
643
+ console.log(" run: npm install -g pyyol@latest");
644
+ }
645
+ else if (latest) {
646
+ console.log(" you're up to date.");
647
+ }
648
+ }
649
+ catch {
650
+ console.log(" run: npm install -g pyyol@latest");
651
+ }
652
+ return 0;
653
+ }
654
+ async function cmdPublish(a) {
655
+ const { readFileSync } = await import("node:fs");
656
+ const c = creds.load();
657
+ const base = httpBase(a, c);
658
+ const agent = str(a, "agent") || c?.agentId || "";
659
+ const token = str(a, "token") || c?.accessToken || "";
660
+ const manifest = str(a, "manifest");
661
+ if (!base || !agent || !token || !manifest) {
662
+ console.error(`${BAD} need --manifest (and --api/--agent/--token or \`pyyol login\`)`);
663
+ return 2;
664
+ }
665
+ if (str(a, "token") || str(a, "secret"))
666
+ warnArgvSecret();
667
+ const ag = encodeURIComponent(agent); // never interpolate a raw id into the path
668
+ const body = readFileSync(manifest, "utf8");
669
+ const [st1, m] = await apiPost(`${base}/v1/agents/${ag}/manifest`, token, JSON.parse(body));
670
+ if (st1 !== 201) {
671
+ console.error(`${BAD} submit failed (${st1}): ${JSON.stringify(m)}`);
672
+ return 1;
673
+ }
674
+ const mid = encodeURIComponent(String(m.manifest_id ?? ""));
675
+ console.log(`${OK} manifest submitted: ${m.manifest_id}`);
676
+ if (str(a, "secret")) {
677
+ const [st2, r] = await apiRequest("PUT", `${base}/v1/agents/${ag}/manifest/${mid}/endpoint-secret`, token, {
678
+ token: str(a, "secret"),
679
+ });
680
+ if (st2 !== 200) {
681
+ console.error(`${BAD} set endpoint secret failed (${st2}): ${JSON.stringify(r)}`);
682
+ return 1;
683
+ }
684
+ console.log(`${OK} endpoint secret stored`);
685
+ }
686
+ const [st3, report] = await apiPost(`${base}/v1/agents/${ag}/manifest/${mid}/verify`, token, {});
687
+ const verified = st3 === 200 && (report.verified || report.status === "verified");
688
+ console.log(`${verified ? OK : BAD} verify (${st3}): ${JSON.stringify(report)}`);
689
+ return verified ? 0 : 1;
690
+ }
691
+ export async function apiRequest(method, url, token, body) {
692
+ warnInsecureTransport(url, Boolean(token));
693
+ try {
694
+ const r = await fetchWithRetry(() => fetch(url, {
695
+ method,
696
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
697
+ body: JSON.stringify(body ?? {}),
698
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
699
+ }));
700
+ const text = await r.text();
701
+ return [r.status, text ? JSON.parse(text) : {}];
702
+ }
703
+ catch (e) {
704
+ return [0, { error: netErr(e) }];
705
+ }
706
+ }
707
+ // ── shared: live feed + token-refresh options (used by orchestrate/run) ────────
708
+ /** Build the one-line lifecycle feed printer shared by `dev`/`play`/`run`. */
709
+ function makeFeed(quiet) {
710
+ const glyph = {
711
+ connecting: "◔", connected: "●", reconnecting: "↻", reauth: "🔑", turn: "→", event: "·", game_end: "★",
712
+ };
713
+ return (kind, detail) => {
714
+ if (quiet && kind === "turn")
715
+ return; // keep lifecycle milestones even in --quiet
716
+ const ts = new Date().toISOString().slice(11, 19);
717
+ console.log(`${ts} ${glyph[kind] ?? "·"} ${kind.padEnd(10)} ${detail}`);
718
+ if (kind === "connected")
719
+ console.log(`${ts} ◌ waiting waiting for a match…`);
720
+ };
721
+ }
722
+ /** Connector kwargs enabling silent access-token refresh from stored creds. The
723
+ * onTokens callback writes the rotated pair back so a long run stays authed past
724
+ * the short access-token TTL. */
725
+ function refreshOpts(c, base) {
726
+ return {
727
+ refreshToken: c?.refreshToken,
728
+ apiUrl: base,
729
+ onTokens: (access, refresh) => {
730
+ if (!c)
731
+ return;
732
+ c.accessToken = access;
733
+ if (refresh)
734
+ c.refreshToken = refresh;
735
+ creds.save(c);
736
+ },
737
+ };
738
+ }
739
+ /** Import a developer's agent from an explicit file (used by `run`/`serve`), then
740
+ * normalize it to an Agent. Mirrors loadAgentFromConfig but file-based. */
741
+ async function loadAgentFromFile(file, varName) {
742
+ const abs = resolve(file);
743
+ if (!existsSync(abs))
744
+ throw new Error(`cannot load ${file}`);
745
+ const mod = await import(pathToFileURL(abs).href);
746
+ const obj = mod[varName] ?? mod.default;
747
+ if (obj === undefined)
748
+ throw new Error(`no \`${varName}\` found in ${file} (expose your Agent as \`${varName}\`)`);
749
+ return asAgent(obj);
750
+ }
751
+ // ── status ─────────────────────────────────────────────────────────────────────
752
+ async function cmdStatus(a) {
753
+ const c = creds.load();
754
+ if (!c?.accessToken) {
755
+ console.error(`${BAD} not logged in — run \`pyyol login\` first`);
756
+ return 2;
757
+ }
758
+ const api = (str(a, "api") || c.url).replace(/\/$/, "");
759
+ const agentId = str(a, "agent") || c.agentId;
760
+ if (!api || !agentId) {
761
+ console.error(`${BAD} need an API url and agent id (login or pass --api/--agent)`);
762
+ return 2;
763
+ }
764
+ const [st, body] = await apiGet(`${api}/v1/agent/status?agent_id=${encodeURIComponent(agentId)}`, c.accessToken);
765
+ if (st !== 200) {
766
+ console.error(`${BAD} status failed (${st}): ${JSON.stringify(body)}`);
767
+ return 1;
768
+ }
769
+ const online = Boolean(body.online);
770
+ console.log(`Agent ${agentId}\n ${online ? "🟢 Online" : "⚪ Offline"}`);
771
+ if (online) {
772
+ console.log(` SDK ${body.sdk_version ?? "?"}`);
773
+ console.log(` Games ${(body.games ?? []).join(", ")}`);
774
+ console.log(` Last seen ${body.last_seen ?? "?"}`);
775
+ }
776
+ return 0;
777
+ }
778
+ // ── autoplay (toggle hosted auto-play without holding a connection) ─────────────
779
+ /** Resolve (mode, games) from flags → pyyol.toml → defaults. */
780
+ function autoplayOpts(a, cfg) {
781
+ const m = bool(a, "ranked") ? "ranked" : str(a, "mode") || cfg?.mode || "sandbox";
782
+ let games = str(a, "games").split(",").map((g) => g.trim()).filter(Boolean);
783
+ if (games.length === 0 && cfg?.arena)
784
+ games = [cfg.arena];
785
+ return [m, games];
786
+ }
787
+ /** PUT the agent's auto-play availability. Matches the Python `_autoplay_set`
788
+ * (PUT /v1/agent/autoplay {enabled, mode, bid, games}). */
789
+ async function autoplaySet(api, token, enabled, m, bid, games) {
790
+ return apiRequest("PUT", `${api.replace(/\/$/, "")}/v1/agent/autoplay`, token, { enabled, mode: m, bid, games });
791
+ }
792
+ async function cmdAutoplay(a) {
793
+ const state = a.positionals[0];
794
+ if (state !== "on" && state !== "off") {
795
+ console.error(`${BAD} usage: pyyol autoplay on|off`);
796
+ return 2;
797
+ }
798
+ const c = creds.load();
799
+ const api = (str(a, "api") || c?.url || "").replace(/\/$/, "");
800
+ // Auto-play is agent-scoped (/v1/agent/autoplay), so it needs the agent key.
801
+ const { token } = connectionToken(a, c);
802
+ if (!api || !token) {
803
+ console.error(`${BAD} run \`pyyol login\` first`);
804
+ return 2;
805
+ }
806
+ if (str(a, "token"))
807
+ warnArgvSecret();
808
+ const on = state === "on";
809
+ const [m, games] = autoplayOpts(a, config.load());
810
+ const [st, resp] = await autoplaySet(api, token, on, m, num(a, "bid", 0), games);
811
+ if (st >= 200 && st < 300) {
812
+ const detail = on ? ` — mode=${m}, games=${games.length ? games.join(",") : "default"}` : "";
813
+ console.log(`${OK} auto-play ${on ? "ON" : "OFF"}${detail}`);
814
+ return 0;
815
+ }
816
+ console.error(`${BAD} failed (status ${st}): ${JSON.stringify(resp)}`);
817
+ return 1;
818
+ }
819
+ // ── logs (tail the local run log) ───────────────────────────────────────────────
820
+ async function cmdLogs(a) {
821
+ const { existsSync: exists, readFileSync } = await import("node:fs");
822
+ const { join } = await import("node:path");
823
+ const path = str(a, "file") || join(creds.configDir(), "logs", "agent.log");
824
+ if (!exists(path)) {
825
+ console.log(`no logs yet at ${path} (run \`pyyol run\` to generate them)`);
826
+ return 0;
827
+ }
828
+ const lines = readFileSync(path, "utf8").split("\n");
829
+ if (lines.length && lines[lines.length - 1] === "")
830
+ lines.pop(); // drop trailing newline's empty tail
831
+ const n = num(a, "n", 200);
832
+ for (const line of lines.slice(-n))
833
+ console.log(line);
834
+ return 0;
835
+ }
836
+ // ── simulate (local in-process Goofspiel match against the configured agent) ────
837
+ async function cmdSimulate(a) {
838
+ const game = str(a, "game") || "goofspiel";
839
+ if (game !== "goofspiel") {
840
+ console.error(`simulate currently supports goofspiel (got '${game}'); use \`validate\` for a single-turn check of any game.`);
841
+ return 2;
842
+ }
843
+ const opponent = str(a, "opponent") || "baseline";
844
+ if (opponent !== "baseline") {
845
+ console.error(`${BAD} unknown opponent '${opponent}' — only 'baseline' is supported`);
846
+ return 2;
847
+ }
848
+ const cfg = config.load();
849
+ if (!cfg) {
850
+ console.error(`${BAD} no pyyol.toml here — run \`pyyol init <dir>\` first.`);
851
+ return 2;
852
+ }
853
+ let agent;
854
+ try {
855
+ agent = await loadAgentFromConfig(cfg);
856
+ }
857
+ catch (e) {
858
+ console.error(`${BAD} could not load your agent: ${e}`);
859
+ return 2;
860
+ }
861
+ const { simulateGoofspiel, SimulationError } = await import("./simulator.js");
862
+ try {
863
+ const r = await simulateGoofspiel(agent, { handSize: num(a, "rounds", 13), seed: num(a, "seed", 1) });
864
+ console.log(`simulate goofspiel (${r.rounds} rounds): winner=${r.winner} scores agent=${r.scores.agent} baseline=${r.scores.baseline}`);
865
+ return 0;
866
+ }
867
+ catch (e) {
868
+ if (e instanceof SimulationError) {
869
+ console.error(`${BAD} ${e.message}`);
870
+ return 1;
871
+ }
872
+ throw e;
873
+ }
874
+ }
875
+ // ── validate (probe a hosted endpoint like the platform does) ───────────────────
876
+ function rfc3339() {
877
+ return new Date().toISOString().replace(/\.\d+Z$/, "Z");
878
+ }
879
+ /** Derive {dir(url)}/name — matches the platform's sibling routing. */
880
+ function sibling(url, name) {
881
+ const trimmed = url.replace(/\/+$/, "");
882
+ const idx = trimmed.lastIndexOf("/");
883
+ return `${idx >= 0 ? trimmed.slice(0, idx) : ""}/${name}`;
884
+ }
885
+ /** Send an optionally-signed request; signs iff a secret is set and there's a body
886
+ * (mirrors the Python `_request`). `signPath` binds the signature to a path;
887
+ * defaults to the URL's path. */
888
+ async function signedRequest(url, method, secret, payload, signPath) {
889
+ warnInsecureTransport(url, Boolean(secret));
890
+ const hasBody = payload !== undefined && payload !== null;
891
+ const body = hasBody ? Buffer.from(JSON.stringify(payload)) : Buffer.alloc(0);
892
+ let path = signPath;
893
+ if (path === undefined) {
894
+ try {
895
+ path = new URL(url).pathname || "/";
896
+ }
897
+ catch {
898
+ path = "/";
899
+ }
900
+ }
901
+ const headers = {};
902
+ if (hasBody)
903
+ headers["Content-Type"] = "application/json";
904
+ if (secret && hasBody) {
905
+ const nonce = `cli_${Date.now()}`;
906
+ const ts = rfc3339();
907
+ headers[TIMESTAMP_HEADER] = ts;
908
+ headers[REQUEST_ID_HEADER] = nonce;
909
+ headers[SIGNATURE_HEADER] = `${SIGNATURE_VERSION}=${computeSignature(secret, ts, nonce, method, path, body)}`;
910
+ headers["Authorization"] = "Bearer " + secret;
911
+ }
912
+ try {
913
+ const r = await fetch(url, {
914
+ method,
915
+ headers,
916
+ body: hasBody ? body : undefined,
917
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
918
+ });
919
+ const text = await r.text();
920
+ return [r.status, text ? JSON.parse(text) : {}];
921
+ }
922
+ catch (e) {
923
+ return [0, { error: netErr(e) }];
924
+ }
925
+ }
926
+ /** (view, legal, isLegalMove) for a probe turn — mirrors Python `_synthetic_turn`. */
927
+ function syntheticTurn(game) {
928
+ if (game === "monopoly") {
929
+ const view = {
930
+ game: "monopoly", match_id: "validate", seat: 0, phase: "roll",
931
+ legal_actions: ["roll", "end_turn"], state: { players: [], phase: "roll" },
932
+ };
933
+ return [view, ["roll", "end_turn"], (m) => Boolean(m) && ["roll", "end_turn"].includes(m.action)];
934
+ }
935
+ if (game === "mafia") {
936
+ const view = {
937
+ game: "mafia", match_id: "validate", your_seat: 1, your_role: "Villager", day: 1,
938
+ phase: "voting", alive: { "1": true, "2": true, "3": true }, legal: ["vote"], public: [], private: [],
939
+ };
940
+ return [view, ["vote"], (m) => Boolean(m) && m.action === "vote"];
941
+ }
942
+ const view = {
943
+ game: "goofspiel", match_id: "validate", seat: 0, round: 0, current_prize: 5,
944
+ prize_pool: 5, your_hand: [1, 2, 3, 4, 5], scores: [0, 0], legal_actions: [1, 2, 3, 4, 5],
945
+ };
946
+ return [view, [1, 2, 3, 4, 5], (m) => Boolean(m) && [1, 2, 3, 4, 5].includes(m.card)];
947
+ }
948
+ /** Lifecycle notification probes — mirrors Python `_lifecycle_probes`. */
949
+ function lifecycleProbes(game) {
950
+ return [
951
+ ["initialize", { protocol: "1.0", match_id: "validate", game, seat: 0, players: 2 }],
952
+ ["event", { protocol: "1.0", match_id: "validate", game, seq: 1, type: "probe" }],
953
+ ["game-end", { protocol: "1.0", match_id: "validate", game, result: {} }],
954
+ ];
955
+ }
956
+ async function cmdValidate(a) {
957
+ const url = str(a, "url");
958
+ if (!url) {
959
+ console.error(`${BAD} usage: pyyol validate --url <endpoint> [--secret S] [--game G]`);
960
+ return 2;
961
+ }
962
+ const secret = str(a, "secret");
963
+ if (secret)
964
+ warnArgvSecret();
965
+ const game = str(a, "game") || "goofspiel";
966
+ const checks = [];
967
+ // 1. health (unsigned GET on the sibling)
968
+ {
969
+ const [st, body] = await signedRequest(sibling(url, "health"), "GET", "", null);
970
+ const healthy = st === 200 && String(body.status ?? "").toLowerCase() === "healthy";
971
+ checks.push(["health", healthy, `${st} ${body.status ?? ""}`]);
972
+ }
973
+ // 2. handshake (signed POST)
974
+ {
975
+ const [st, body] = await signedRequest(sibling(url, "handshake"), "POST", secret, {
976
+ platform: "agent-arena", protocol: "1.0",
977
+ });
978
+ const acc = st === 200 && Boolean(body.accepted);
979
+ const games = (body.supportedGames ?? []).join(",");
980
+ checks.push(["handshake", acc, `${st} accepted=${body.accepted} games=[${games}]`]);
981
+ }
982
+ // 3. a real signed turn — the platform's core call.
983
+ {
984
+ const [view, , pick] = syntheticTurn(game);
985
+ const [st, move] = await signedRequest(url, "POST", secret, view);
986
+ checks.push(["turn", st === 200 && pick(move), `${st} -> ${JSON.stringify(move)}`]);
987
+ }
988
+ // 4. lifecycle notifications must ack 200.
989
+ for (const [name, payload] of lifecycleProbes(game)) {
990
+ const [st] = await signedRequest(sibling(url, name), "POST", secret, payload);
991
+ checks.push([name, st === 200, String(st)]);
992
+ }
993
+ console.log(`pyyol validate — ${url}\n`);
994
+ let allOk = true;
995
+ for (const [name, ok, detail] of checks) {
996
+ allOk = allOk && ok;
997
+ console.log(` ${ok ? OK : BAD} ${name.padEnd(12)} ${detail}`);
998
+ }
999
+ console.log("\n" + (allOk ? "PASS — endpoint speaks the push protocol." : "FAIL — fix the checks marked ✗ above."));
1000
+ return allOk ? 0 : 1;
1001
+ }
1002
+ // ── watch (spectate a match over SSE, read-only) ────────────────────────────────
1003
+ /** SSE event types that end a match, so `watch` can return control. */
1004
+ const TERMINAL_EVENTS = new Set(["match_finished", "victory", "game_over", "game_finished", "finished"]);
1005
+ /** A short, human line for a spectator event payload (mirrors Python `_sse_summary`). */
1006
+ function sseSummary(obj) {
1007
+ if (!obj || typeof obj !== "object")
1008
+ return String(obj);
1009
+ for (const k of ["winner", "text", "action", "card", "phase", "message"]) {
1010
+ if (obj[k] !== undefined && obj[k] !== null && obj[k] !== "")
1011
+ return `${k}: ${obj[k]}`;
1012
+ }
1013
+ return JSON.stringify(obj).slice(0, 70);
1014
+ }
1015
+ async function cmdWatch(a) {
1016
+ const c = creds.load();
1017
+ const base = httpBase(a, c);
1018
+ if (!base) {
1019
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
1020
+ return 2;
1021
+ }
1022
+ const match = a.positionals[0];
1023
+ if (!match) {
1024
+ console.error(`${BAD} usage: pyyol watch <match_id>`);
1025
+ return 2;
1026
+ }
1027
+ const asJson = bool(a, "json");
1028
+ const emit = (kind, detail) => {
1029
+ const ts = new Date().toISOString().slice(11, 19);
1030
+ console.log(`${ts} ${kind.padEnd(12)} ${detail}`);
1031
+ };
1032
+ const url = `${base}/v1/match/${encodeURIComponent(match)}/watch`;
1033
+ emit("match", `spectating ${match} (read-only)`);
1034
+ let res;
1035
+ try {
1036
+ res = await fetch(url, { headers: { Accept: "text/event-stream" } });
1037
+ }
1038
+ catch (e) {
1039
+ console.error(`${BAD} watch failed: ${netErr(e)}`);
1040
+ return 1;
1041
+ }
1042
+ if (!res.ok || !res.body) {
1043
+ const t = await res.text().catch(() => "");
1044
+ console.error(`${BAD} watch failed (${res.status}): ${t}`);
1045
+ return 1;
1046
+ }
1047
+ const reader = res.body.getReader();
1048
+ process.on("SIGINT", () => {
1049
+ console.log("\nstopped watching.");
1050
+ reader.cancel().catch(() => { });
1051
+ });
1052
+ const decoder = new TextDecoder();
1053
+ let buffer = "";
1054
+ let event = null;
1055
+ let data = [];
1056
+ for (;;) {
1057
+ const { value, done } = await reader.read();
1058
+ if (done)
1059
+ break;
1060
+ buffer += decoder.decode(value, { stream: true });
1061
+ let idx;
1062
+ while ((idx = buffer.indexOf("\n")) >= 0) {
1063
+ let line = buffer.slice(0, idx);
1064
+ buffer = buffer.slice(idx + 1);
1065
+ if (line.endsWith("\r"))
1066
+ line = line.slice(0, -1);
1067
+ if (line === "") {
1068
+ // frame boundary
1069
+ if (data.length) {
1070
+ const payload = data.join("\n");
1071
+ let obj;
1072
+ try {
1073
+ obj = JSON.parse(payload);
1074
+ }
1075
+ catch {
1076
+ obj = { raw: payload };
1077
+ }
1078
+ const kind = event || "event";
1079
+ emit(kind, asJson ? JSON.stringify(obj) : sseSummary(obj));
1080
+ if (TERMINAL_EVENTS.has(kind)) {
1081
+ emit("game_end", "match finished");
1082
+ reader.cancel().catch(() => { });
1083
+ return 0;
1084
+ }
1085
+ }
1086
+ event = null;
1087
+ data = [];
1088
+ continue;
1089
+ }
1090
+ if (line.startsWith(":"))
1091
+ continue; // keepalive comment
1092
+ const ci = line.indexOf(":");
1093
+ const field = ci >= 0 ? line.slice(0, ci) : line;
1094
+ let value2 = ci >= 0 ? line.slice(ci + 1) : "";
1095
+ if (value2.startsWith(" "))
1096
+ value2 = value2.slice(1);
1097
+ if (field === "event")
1098
+ event = value2;
1099
+ else if (field === "data")
1100
+ data.push(value2);
1101
+ }
1102
+ }
1103
+ return 0;
1104
+ }
1105
+ // ── run (connect a file-loaded agent over WSS) ──────────────────────────────────
1106
+ async function cmdRun(a) {
1107
+ const c = creds.load();
1108
+ const connectUrl = str(a, "url") || process.env.PYYOL_URL || c?.connectUrl || "";
1109
+ if (!connectUrl) {
1110
+ console.error(`${BAD} no platform URL — pass --url, set PYYOL_URL, or run \`pyyol login\``);
1111
+ return 2;
1112
+ }
1113
+ const agentId = str(a, "agent") || process.env.PYYOL_AGENT_ID || c?.agentId || "";
1114
+ const { token, usingAgentKey } = connectionToken(a, c);
1115
+ if (str(a, "token"))
1116
+ warnArgvSecret();
1117
+ const file = str(a, "file") || "agent.mjs";
1118
+ const varName = str(a, "var") || "agent";
1119
+ let agent;
1120
+ try {
1121
+ agent = await loadAgentFromFile(file, varName);
1122
+ }
1123
+ catch (e) {
1124
+ console.error(`${BAD} ${e instanceof Error ? e.message : String(e)}`);
1125
+ return 2;
1126
+ }
1127
+ const base = httpBase(a, c);
1128
+ const conn = new RuntimeConnector(agent, {
1129
+ url: connectUrl,
1130
+ agentId,
1131
+ token,
1132
+ name: agent.name,
1133
+ games: agent.supportedGames,
1134
+ onFeed: makeFeed(bool(a, "quiet")),
1135
+ // Same silent access-token refresh wiring as `dev`/`play` — only when NOT on
1136
+ // the (non-expiring) agent key.
1137
+ ...(usingAgentKey ? {} : refreshOpts(c, base)),
1138
+ });
1139
+ process.on("SIGINT", () => conn.stop());
1140
+ try {
1141
+ await conn.run();
1142
+ }
1143
+ catch (e) {
1144
+ const msg = e instanceof Error ? e.message : String(e);
1145
+ console.error(`\n${BAD} ${msg}`);
1146
+ if (/unauthorized|token|register/i.test(msg)) {
1147
+ console.error(" your token was rejected — run `pyyol login` again (agent id + agent key must match).");
1148
+ }
1149
+ return 1;
1150
+ }
1151
+ console.log("\nstopped.");
1152
+ return 0;
1153
+ }
1154
+ // ── serve (deploy-once HTTP worker: enable auto-play + run the built-in server) ──
1155
+ async function cmdServe(a) {
1156
+ const c = creds.load();
1157
+ const api = (str(a, "api") || c?.url || "").replace(/\/$/, "");
1158
+ // Auto-play + the connection are agent-scoped, so this rides the agent key
1159
+ // (falling back to the dashboard JWT).
1160
+ const { token } = connectionToken(a, c);
1161
+ if (str(a, "token"))
1162
+ warnArgvSecret();
1163
+ if (!api || !token) {
1164
+ console.error(`${BAD} run \`pyyol login\` first (need the API base + token)`);
1165
+ return 2;
1166
+ }
1167
+ // Load the agent: an explicit --file wins over pyyol.toml.
1168
+ let agent;
1169
+ const file = str(a, "file");
1170
+ try {
1171
+ if (file) {
1172
+ agent = await loadAgentFromFile(file, str(a, "var") || "agent");
1173
+ }
1174
+ else {
1175
+ const cfg = config.load();
1176
+ if (!cfg) {
1177
+ console.error(`${BAD} no pyyol.toml here — run \`pyyol init <dir>\` first (or pass --file).`);
1178
+ return 2;
1179
+ }
1180
+ agent = await loadAgentFromConfig(cfg);
1181
+ }
1182
+ }
1183
+ catch (e) {
1184
+ console.error(`${BAD} could not load your agent: ${e}`);
1185
+ return 2;
1186
+ }
1187
+ const [m, games] = autoplayOpts(a, config.load());
1188
+ const bid = num(a, "bid", 0);
1189
+ const [st, resp] = await autoplaySet(api, token, true, m, bid, games);
1190
+ if (st >= 200 && st < 300) {
1191
+ const extra = m === "ranked" ? `, bid=${bid}` : "";
1192
+ console.log(`${OK} auto-play ON — mode=${m}${extra}, games=${games.length ? games.join(",") : "default"}`);
1193
+ }
1194
+ else {
1195
+ console.error(`${BAD} could not enable auto-play (status ${st}: ${JSON.stringify(resp)}); serving anyway`);
1196
+ }
1197
+ const port = num(a, "port", 9099);
1198
+ const host = str(a, "host") || "127.0.0.1";
1199
+ const server = agent.serve(port, host);
1200
+ console.log("serving — the platform will drive your agent as matches are paired. Ctrl-C to stop.");
1201
+ // Hold until Ctrl-C, then flip auto-play OFF so you stop being matched once you exit.
1202
+ return new Promise((done) => {
1203
+ let stopping = false;
1204
+ const shutdown = async () => {
1205
+ if (stopping)
1206
+ return;
1207
+ stopping = true;
1208
+ console.log("\nstopping…");
1209
+ try {
1210
+ server.close();
1211
+ }
1212
+ catch {
1213
+ /* ignore */
1214
+ }
1215
+ await autoplaySet(api, token, false, m, bid, games);
1216
+ console.log(`${OK} auto-play OFF`);
1217
+ done(0);
1218
+ };
1219
+ process.on("SIGINT", shutdown);
1220
+ process.on("SIGTERM", shutdown);
1221
+ });
1222
+ }
1223
+ const HELP = `pyyol — build, run, and rank autonomous AI agents.
1224
+ Quickstart: pyyol login → pyyol init <dir> → pyyol dev
1225
+
1226
+ Commands:
1227
+ login [--with github|google|wallet] [--dashboard URL] [--token PAT]
1228
+ logout
1229
+ whoami
1230
+ init <dir> [--arena goofspiel|mafia|monopoly] [--framework F] [--name N]
1231
+ dev [--matches N] local dev loop — SANDBOX, no stakes
1232
+ play <arena> [--ranked] [--tier] compete; --ranked = real stakes
1233
+ publish (advanced) certify for ranked
1234
+ replay <match_id> [--game] [--json]
1235
+ profile [handle]
1236
+ leaderboard [--game G] [--developers] [--season N]
1237
+ arenas
1238
+ status [--agent A] (advanced) is your agent connected?
1239
+ autoplay on|off [--ranked|--mode] [--bid N] [--games G,…]
1240
+ serve [--file F] [--var V] [--port P] [--host H] enable auto-play + run the HTTP server
1241
+ run [--file F] [--var V] (advanced) connect a file-loaded agent over WSS
1242
+ simulate [--rounds N] [--seed N] [--opponent O] local Goofspiel match
1243
+ validate --url URL [--secret S] [--game G] probe a hosted endpoint
1244
+ watch <match_id> [--json] spectate a match (read-only)
1245
+ logs [--file F] [--n N] recent local agent logs
1246
+ doctor
1247
+ update
1248
+ `;
1249
+ export async function main(argv = process.argv.slice(2)) {
1250
+ const command = argv[0];
1251
+ const a = parse(argv.slice(1));
1252
+ switch (command) {
1253
+ case "login":
1254
+ return cmdLogin(a);
1255
+ case "logout":
1256
+ return cmdLogout();
1257
+ case "whoami":
1258
+ return cmdWhoami(a);
1259
+ case "init":
1260
+ return cmdInit(a);
1261
+ case "dev":
1262
+ return orchestrate(a, true);
1263
+ case "play":
1264
+ return orchestrate(a, false);
1265
+ case "publish":
1266
+ return cmdPublish(a);
1267
+ case "arenas":
1268
+ return cmdArenas(a);
1269
+ case "leaderboard":
1270
+ return cmdLeaderboard(a);
1271
+ case "profile":
1272
+ return cmdProfile(a);
1273
+ case "replay":
1274
+ return cmdReplay(a);
1275
+ case "status":
1276
+ return cmdStatus(a);
1277
+ case "autoplay":
1278
+ return cmdAutoplay(a);
1279
+ case "serve":
1280
+ return cmdServe(a);
1281
+ case "run":
1282
+ return cmdRun(a);
1283
+ case "simulate":
1284
+ return cmdSimulate(a);
1285
+ case "validate":
1286
+ return cmdValidate(a);
1287
+ case "watch":
1288
+ return cmdWatch(a);
1289
+ case "logs":
1290
+ return cmdLogs(a);
1291
+ case "doctor":
1292
+ return cmdDoctor(a);
1293
+ case "update":
1294
+ return cmdUpdate();
1295
+ case "--version":
1296
+ case "-v":
1297
+ console.log(`pyyol ${SDK_VERSION}`);
1298
+ return 0;
1299
+ case undefined:
1300
+ case "-h":
1301
+ case "--help":
1302
+ case "help":
1303
+ console.log(HELP);
1304
+ return 0;
1305
+ default:
1306
+ console.error(`${BAD} unknown command: ${command}\n`);
1307
+ console.log(HELP);
1308
+ return 2;
1309
+ }
1310
+ }
1311
+ // Run when invoked as the bin (not when imported by tests).
1312
+ const invoked = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1313
+ if (invoked) {
1314
+ // Set exitCode and let the event loop drain — process.exit() can truncate a
1315
+ // large piped stdout (e.g. `pyyol replay … --json | jq`) mid-write.
1316
+ main()
1317
+ .then((code) => {
1318
+ process.exitCode = code;
1319
+ })
1320
+ .catch((e) => {
1321
+ console.error(`${BAD} ${e instanceof Error ? e.message : String(e)}`);
1322
+ process.exitCode = 1;
1323
+ });
1324
+ }
1325
+ //# sourceMappingURL=cli.js.map