portable-agent-layer 0.63.3 → 0.65.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.
- package/README.md +8 -4
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/analyze-pdf/SKILL.md +11 -0
- package/assets/skills/analyze-youtube/SKILL.md +12 -0
- package/assets/skills/consulting-report/SKILL.md +9 -0
- package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
- package/assets/skills/council/SKILL.md +32 -0
- package/assets/skills/create-pdf/SKILL.md +13 -0
- package/assets/skills/create-skill/SKILL.md +14 -2
- package/assets/skills/create-skill/authoring-guide.md +10 -1
- package/assets/skills/create-subagent/SKILL.md +22 -4
- package/assets/skills/{research → deep-research}/SKILL.md +32 -1
- package/assets/skills/entities/SKILL.md +10 -0
- package/assets/skills/extract-wisdom/SKILL.md +12 -0
- package/assets/skills/first-principles/SKILL.md +8 -0
- package/assets/skills/frontend-design/SKILL.md +14 -0
- package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
- package/assets/skills/humanize/SKILL.md +13 -1
- package/assets/skills/opinion/SKILL.md +11 -0
- package/assets/skills/pal-analyze/SKILL.md +11 -0
- package/assets/skills/pal-reflect/SKILL.md +10 -0
- package/assets/skills/playwright/SKILL.md +15 -2
- package/assets/skills/playwright/tools/shot.ts +6 -7
- package/assets/skills/presentation/SKILL.md +12 -0
- package/assets/skills/projects/SKILL.md +20 -1
- package/assets/skills/reflect/SKILL.md +13 -0
- package/assets/skills/telos/SKILL.md +12 -0
- package/assets/skills/think/SKILL.md +9 -0
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
- package/assets/templates/pal-settings.json +1 -0
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +15 -4
- package/src/cli/index.ts +95 -9
- package/src/cli/migrate.ts +69 -3
- package/src/cli/skill.ts +47 -3
- package/src/hooks/handlers/inject-retrieval.ts +20 -10
- package/src/hooks/lib/anchor.ts +90 -0
- package/src/hooks/lib/bindings.ts +117 -0
- package/src/hooks/lib/export.ts +38 -1
- package/src/hooks/lib/import-merge.ts +220 -0
- package/src/hooks/lib/inference.ts +113 -72
- package/src/hooks/lib/machine.ts +176 -0
- package/src/hooks/lib/projects.ts +223 -15
- package/src/hooks/lib/readme-sync.ts +30 -10
- package/src/hooks/lib/relationship.ts +3 -1
- package/src/hooks/lib/remote.ts +58 -0
- package/src/hooks/lib/retrieval.ts +8 -2
- package/src/hooks/lib/signals.ts +2 -1
- package/src/hooks/lib/skill-match.ts +129 -0
- package/src/hooks/lib/skill-triggers.ts +82 -0
- package/src/hooks/lib/stop.ts +5 -2
- package/src/targets/lib.ts +137 -35
- package/src/targets/opencode/plugin.ts +2 -6
- package/src/tools/agent/algorithm-reflect.ts +45 -11
- package/src/tools/agent/project.ts +148 -23
- package/src/tools/agent/thread.ts +7 -2
- package/src/tools/skill-doctor.ts +130 -5
- package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
- package/assets/skills/playwright/tools/shot.mjs +0 -89
- package/assets/skills/review/SKILL.md +0 -20
- package/assets/skills/summarize/SKILL.md +0 -16
- /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* yet wired and currently fall through to the API path.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { basename, join } from "node:path";
|
|
24
26
|
import {
|
|
25
27
|
getActiveAgent,
|
|
26
28
|
isClaude,
|
|
@@ -151,14 +153,14 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
|
|
|
151
153
|
"inference",
|
|
152
154
|
`${tag} route=claude-spawn agent=${agent} model=${opts.model ?? HAIKU_MODEL}`
|
|
153
155
|
);
|
|
154
|
-
return
|
|
156
|
+
return inferenceViaClaudeSpawn(bin, opts);
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
if (isCodex()) {
|
|
158
160
|
const bin = getCodexBinary();
|
|
159
161
|
if (bin) {
|
|
160
162
|
logDebug("inference", `${tag} route=codex-spawn agent=${agent}`);
|
|
161
|
-
return inferenceViaCliSpawn(bin, buildCodexArgs(opts),
|
|
163
|
+
return inferenceViaCliSpawn(bin, buildCodexArgs(opts), buildCliPrompt(opts), opts);
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
if (isCodex() && hasOpenAiKey()) {
|
|
@@ -172,7 +174,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
|
|
|
172
174
|
return inferenceViaCliSpawn(
|
|
173
175
|
bin,
|
|
174
176
|
buildOpencodeArgs(opts),
|
|
175
|
-
|
|
177
|
+
buildCliPrompt(opts),
|
|
176
178
|
opts,
|
|
177
179
|
extractOpencodeText
|
|
178
180
|
);
|
|
@@ -182,14 +184,19 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
|
|
|
182
184
|
const bin = getCopilotBinary();
|
|
183
185
|
if (bin) {
|
|
184
186
|
logDebug("inference", `${tag} route=copilot-spawn agent=${agent}`);
|
|
185
|
-
return inferenceViaCliSpawn(
|
|
187
|
+
return inferenceViaCliSpawn(
|
|
188
|
+
bin,
|
|
189
|
+
buildCopilotArgs(opts),
|
|
190
|
+
buildCliPrompt(opts),
|
|
191
|
+
opts
|
|
192
|
+
);
|
|
186
193
|
}
|
|
187
194
|
}
|
|
188
195
|
if (isCursor()) {
|
|
189
196
|
const bin = getCursorBinary();
|
|
190
197
|
if (bin) {
|
|
191
198
|
logDebug("inference", `${tag} route=cursor-spawn agent=${agent}`);
|
|
192
|
-
return inferenceViaCliSpawn(bin, buildCursorArgs(opts),
|
|
199
|
+
return inferenceViaCliSpawn(bin, buildCursorArgs(opts), buildCliPrompt(opts), opts);
|
|
193
200
|
}
|
|
194
201
|
}
|
|
195
202
|
if (hasApiKey()) {
|
|
@@ -284,16 +291,23 @@ export function _resetCursorBinaryCache(): void {
|
|
|
284
291
|
cursorBinaryCache = undefined;
|
|
285
292
|
}
|
|
286
293
|
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Build the argv for `claude --print …`. Pure.
|
|
296
|
+
*
|
|
297
|
+
* `--system-prompt` is deliberately absent: PAL's system prompts run to several
|
|
298
|
+
* paragraphs, and an argv element cannot carry a newline on Windows once
|
|
299
|
+
* Bun.spawn resolves claude to its .cmd shim and cmd.exe re-parses the command
|
|
300
|
+
* line. System, user and any JSON-schema instruction all travel together on
|
|
301
|
+
* stdin instead, the same way every other agent receives them.
|
|
302
|
+
*/
|
|
303
|
+
export function buildClaudeArgs(
|
|
304
|
+
opts: InferenceOptions,
|
|
305
|
+
systemPromptFile?: string
|
|
306
|
+
): string[] {
|
|
293
307
|
const args = [
|
|
294
308
|
"--print",
|
|
295
309
|
"--model",
|
|
296
|
-
model,
|
|
310
|
+
opts.model ?? HAIKU_MODEL,
|
|
297
311
|
"--tools",
|
|
298
312
|
"",
|
|
299
313
|
"--output-format",
|
|
@@ -301,12 +315,38 @@ export function buildClaudeArgs(opts: InferenceOptions): string[] {
|
|
|
301
315
|
"--setting-sources",
|
|
302
316
|
"",
|
|
303
317
|
];
|
|
304
|
-
if (system)
|
|
305
|
-
args.push("--system-prompt", system);
|
|
306
|
-
}
|
|
318
|
+
if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile);
|
|
307
319
|
return args;
|
|
308
320
|
}
|
|
309
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Claude keeps a real system prompt, unlike the other agents, so the system text
|
|
324
|
+
* reaches it through --system-prompt-file rather than --system-prompt. Only the
|
|
325
|
+
* path travels in argv, which is what makes this work on Windows: an argv
|
|
326
|
+
* element cannot carry a newline once Bun.spawn resolves claude to its .cmd
|
|
327
|
+
* shim, and PAL's system prompts run to several paragraphs. Folding the system
|
|
328
|
+
* text into the user message instead is not an option — Claude treats
|
|
329
|
+
* instructions embedded in message content as an injection attempt and refuses.
|
|
330
|
+
*/
|
|
331
|
+
async function inferenceViaClaudeSpawn(
|
|
332
|
+
bin: string,
|
|
333
|
+
opts: InferenceOptions
|
|
334
|
+
): Promise<InferenceResult> {
|
|
335
|
+
const system = opts.jsonSchema
|
|
336
|
+
? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
|
|
337
|
+
: opts.system;
|
|
338
|
+
if (!system) return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
|
|
339
|
+
|
|
340
|
+
const dir = await mkdtemp(join(tmpdir(), "pal-system-"));
|
|
341
|
+
try {
|
|
342
|
+
const file = join(dir, "system-prompt.md");
|
|
343
|
+
await writeFile(file, system, "utf-8");
|
|
344
|
+
return await inferenceViaCliSpawn(bin, buildClaudeArgs(opts, file), opts.user, opts);
|
|
345
|
+
} finally {
|
|
346
|
+
await rm(dir, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
310
350
|
/**
|
|
311
351
|
* Build the argv for `codex exec …` from inference options. Pure.
|
|
312
352
|
*
|
|
@@ -316,20 +356,16 @@ export function buildClaudeArgs(opts: InferenceOptions): string[] {
|
|
|
316
356
|
* --sandbox read-only → child cannot execute shell commands even if it tries
|
|
317
357
|
* --ephemeral → no session persistence; one-shot only
|
|
318
358
|
*
|
|
319
|
-
* Codex has no --system-prompt equivalent
|
|
320
|
-
*
|
|
321
|
-
* prompt
|
|
359
|
+
* Codex has no --system-prompt equivalent, so system + user + JSON-schema become
|
|
360
|
+
* one prompt. That prompt goes in on stdin, not as a positional argument, because
|
|
361
|
+
* a PAL prompt spans several paragraphs and an argv element cannot carry a
|
|
362
|
+
* newline on Windows: Bun.spawn resolves the CLI to its .cmd shim, cmd.exe
|
|
363
|
+
* re-parses the command line, and the child exits non-zero having written
|
|
364
|
+
* nothing. Codex reads its instructions from stdin when no positional prompt is
|
|
365
|
+
* given, so this costs nothing on POSIX and is the only thing that works on
|
|
366
|
+
* Windows. Do not move the prompt back into argv.
|
|
322
367
|
*/
|
|
323
|
-
export function buildCodexArgs(
|
|
324
|
-
const parts: string[] = [];
|
|
325
|
-
if (opts.system) parts.push(opts.system);
|
|
326
|
-
parts.push(opts.user);
|
|
327
|
-
if (opts.jsonSchema) {
|
|
328
|
-
parts.push(
|
|
329
|
-
`Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
const prompt = parts.join("\n\n");
|
|
368
|
+
export function buildCodexArgs(_opts: InferenceOptions): string[] {
|
|
333
369
|
return [
|
|
334
370
|
"exec",
|
|
335
371
|
"--color",
|
|
@@ -340,7 +376,6 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
|
|
|
340
376
|
"--sandbox",
|
|
341
377
|
"read-only",
|
|
342
378
|
"--ephemeral",
|
|
343
|
-
prompt,
|
|
344
379
|
];
|
|
345
380
|
}
|
|
346
381
|
|
|
@@ -354,21 +389,14 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
|
|
|
354
389
|
* text via extractOpencodeText() rather than wading through
|
|
355
390
|
* decoration ("> build · provider/model" banner etc).
|
|
356
391
|
*
|
|
357
|
-
* opencode (like codex) has no --system-prompt equivalent
|
|
358
|
-
*
|
|
359
|
-
*
|
|
392
|
+
* opencode (like codex) has no --system-prompt equivalent, so system + user +
|
|
393
|
+
* JSON-schema are concatenated and delivered on stdin rather than as the
|
|
394
|
+
* positional message, for the same reason as codex: a multi-paragraph argv
|
|
395
|
+
* element does not survive cmd.exe on Windows. Provider/model is left unset so
|
|
396
|
+
* opencode uses the user's configured default.
|
|
360
397
|
*/
|
|
361
|
-
export function buildOpencodeArgs(
|
|
362
|
-
|
|
363
|
-
if (opts.system) parts.push(opts.system);
|
|
364
|
-
parts.push(opts.user);
|
|
365
|
-
if (opts.jsonSchema) {
|
|
366
|
-
parts.push(
|
|
367
|
-
`Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
const prompt = parts.join("\n\n");
|
|
371
|
-
return ["run", "--pure", "--format", "json", prompt];
|
|
398
|
+
export function buildOpencodeArgs(_opts: InferenceOptions): string[] {
|
|
399
|
+
return ["run", "--pure", "--format", "json"];
|
|
372
400
|
}
|
|
373
401
|
|
|
374
402
|
/**
|
|
@@ -385,23 +413,16 @@ export function buildOpencodeArgs(opts: InferenceOptions): string[] {
|
|
|
385
413
|
* of running inference. Safe to pair with --mode ask
|
|
386
414
|
* because that mode disallows tool calls anyway.
|
|
387
415
|
*
|
|
388
|
-
* cursor-agent has no --system-prompt flag
|
|
389
|
-
* concatenated into
|
|
416
|
+
* cursor-agent has no --system-prompt flag, so system + user + JSON-schema are
|
|
417
|
+
* concatenated into one prompt delivered on stdin rather than as the trailing
|
|
418
|
+
* positional argument: a multi-paragraph argv element cannot survive cmd.exe on
|
|
419
|
+
* Windows. `-p` stays because for cursor-agent it means --print, not --prompt.
|
|
390
420
|
*
|
|
391
421
|
* Auth note: cursor-agent picks up either `cursor-agent login` credentials or
|
|
392
422
|
* `CURSOR_API_KEY` env var. PAL doesn't manage these — that's the user's setup.
|
|
393
423
|
*/
|
|
394
|
-
export function buildCursorArgs(
|
|
395
|
-
|
|
396
|
-
if (opts.system) parts.push(opts.system);
|
|
397
|
-
parts.push(opts.user);
|
|
398
|
-
if (opts.jsonSchema) {
|
|
399
|
-
parts.push(
|
|
400
|
-
`Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
const prompt = parts.join("\n\n");
|
|
404
|
-
return ["-p", "--mode", "ask", "--output-format", "text", "--trust", prompt];
|
|
424
|
+
export function buildCursorArgs(_opts: InferenceOptions): string[] {
|
|
425
|
+
return ["-p", "--mode", "ask", "--output-format", "text", "--trust"];
|
|
405
426
|
}
|
|
406
427
|
|
|
407
428
|
/**
|
|
@@ -417,22 +438,14 @@ export function buildCursorArgs(opts: InferenceOptions): string[] {
|
|
|
417
438
|
* --allow-all-tools → REQUIRED for non-interactive mode (without it,
|
|
418
439
|
* copilot prompts for tool-use confirmation)
|
|
419
440
|
*
|
|
420
|
-
* Copilot has no --system-prompt flag
|
|
421
|
-
* concatenated into
|
|
441
|
+
* Copilot has no --system-prompt flag, so system + user + JSON-schema are
|
|
442
|
+
* concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
|
|
443
|
+
* absent: it takes the prompt inline, and a multi-paragraph argv element cannot
|
|
444
|
+
* survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim.
|
|
445
|
+
* Piping stdin keeps copilot non-interactive, so dropping -p costs nothing.
|
|
422
446
|
*/
|
|
423
|
-
export function buildCopilotArgs(
|
|
424
|
-
const parts: string[] = [];
|
|
425
|
-
if (opts.system) parts.push(opts.system);
|
|
426
|
-
parts.push(opts.user);
|
|
427
|
-
if (opts.jsonSchema) {
|
|
428
|
-
parts.push(
|
|
429
|
-
`Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
const prompt = parts.join("\n\n");
|
|
447
|
+
export function buildCopilotArgs(_opts: InferenceOptions): string[] {
|
|
433
448
|
return [
|
|
434
|
-
"-p",
|
|
435
|
-
prompt,
|
|
436
449
|
"--no-custom-instructions",
|
|
437
450
|
"--disable-builtin-mcps",
|
|
438
451
|
"--no-auto-update",
|
|
@@ -465,12 +478,40 @@ export function extractOpencodeText(rawStdout: string): string {
|
|
|
465
478
|
return texts.join("").trim();
|
|
466
479
|
}
|
|
467
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Render a JSON schema for a prompt without double quotes.
|
|
483
|
+
*
|
|
484
|
+
* Bun.spawn resolves an agent CLI on Windows to its `.cmd` shim, which npm
|
|
485
|
+
* installs it as, and cmd.exe re-parses the command line it is handed. A double
|
|
486
|
+
* quote inside an argv element does not survive that round trip: the child exits
|
|
487
|
+
* non-zero having written nothing, so the dispatcher sees an empty abort and
|
|
488
|
+
* gives up. Single quotes carry the same shape to a model and are inert to
|
|
489
|
+
* cmd.exe, so the schema travels intact on every platform.
|
|
490
|
+
*/
|
|
491
|
+
function schemaForPrompt(schema: Record<string, unknown>): string {
|
|
492
|
+
return JSON.stringify(schema).replaceAll('"', "'");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** The one instruction line that asks a CLI agent for schema-shaped JSON. */
|
|
496
|
+
export function schemaInstruction(schema: Record<string, unknown>): string {
|
|
497
|
+
return `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${schemaForPrompt(schema)}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** system + user + schema instruction, the single prompt a CLI agent receives. */
|
|
501
|
+
export function buildCliPrompt(opts: InferenceOptions): string {
|
|
502
|
+
const parts: string[] = [];
|
|
503
|
+
if (opts.system) parts.push(opts.system);
|
|
504
|
+
parts.push(opts.user);
|
|
505
|
+
if (opts.jsonSchema) parts.push(schemaInstruction(opts.jsonSchema));
|
|
506
|
+
return parts.join("\n\n");
|
|
507
|
+
}
|
|
508
|
+
|
|
468
509
|
/** Append a JSON-schema instruction to the system prompt (PAI pattern). */
|
|
469
510
|
export function injectJsonSchemaInstruction(
|
|
470
511
|
systemPrompt: string,
|
|
471
512
|
schema: Record<string, unknown>
|
|
472
513
|
): string {
|
|
473
|
-
const schemaLine =
|
|
514
|
+
const schemaLine = schemaInstruction(schema);
|
|
474
515
|
return systemPrompt ? `${systemPrompt}\n\n${schemaLine}` : schemaLine;
|
|
475
516
|
}
|
|
476
517
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine identity — who this install is, and how a record's origin becomes a
|
|
3
|
+
* name at display time.
|
|
4
|
+
*
|
|
5
|
+
* Records store the id and never the label. Resolution happens on read, so
|
|
6
|
+
* renaming a machine is a one-file edit that no stored record notices, and two
|
|
7
|
+
* machines sharing a label is a display concern rather than a data collision.
|
|
8
|
+
*
|
|
9
|
+
* `machine.json` lives at the PAL_HOME root, outside every exported directory,
|
|
10
|
+
* because importing it would give two installs one id and silently break every
|
|
11
|
+
* origin-scoped read built on top of it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { platform as osPlatform } from "node:os";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { parse, stringify } from "./frontmatter";
|
|
18
|
+
import { palHome, paths } from "./paths";
|
|
19
|
+
|
|
20
|
+
export interface MachineIdentity {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
os: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SHORT_ID_LENGTH = 4;
|
|
28
|
+
|
|
29
|
+
export function machineFilePath(home: string = palHome()): string {
|
|
30
|
+
return resolve(home, "machine.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function machinesDir(): string {
|
|
34
|
+
const dir = resolve(paths.memory(), "machines");
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** First segment of the uuid — enough to disambiguate two same-labelled machines. */
|
|
40
|
+
export function shortId(id: string): string {
|
|
41
|
+
return id.replaceAll("-", "").slice(0, SHORT_ID_LENGTH);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Neutral default label. Deliberately not derived from the hostname: a hostname
|
|
46
|
+
* routinely carries the owner's real name, and the label travels in every
|
|
47
|
+
* exported registry entry.
|
|
48
|
+
*/
|
|
49
|
+
export function defaultLabel(id: string): string {
|
|
50
|
+
return `machine-${shortId(id)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function newIdentity(): MachineIdentity {
|
|
54
|
+
const id = crypto.randomUUID();
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
label: defaultLabel(id),
|
|
58
|
+
os: osPlatform(),
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasUsableId(value: unknown): value is Partial<MachineIdentity> & { id: string } {
|
|
64
|
+
const v = value as Partial<MachineIdentity> | null;
|
|
65
|
+
return typeof v?.id === "string" && v.id.length > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fill in whatever a stored identity is missing. Only the id is irreplaceable —
|
|
70
|
+
* discarding one orphans every record that referenced it — so a file carrying a
|
|
71
|
+
* usable id is repaired rather than regenerated.
|
|
72
|
+
*/
|
|
73
|
+
function repair(stored: Partial<MachineIdentity> & { id: string }): MachineIdentity {
|
|
74
|
+
return {
|
|
75
|
+
id: stored.id,
|
|
76
|
+
label: stored.label?.trim() || defaultLabel(stored.id),
|
|
77
|
+
os: stored.os || osPlatform(),
|
|
78
|
+
createdAt: stored.createdAt || new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* This install's identity, created on first call and stable afterwards. The id
|
|
84
|
+
* is never regenerated once the file exists — a changed id orphans every record
|
|
85
|
+
* that referenced the old one.
|
|
86
|
+
*/
|
|
87
|
+
export function loadMachine(home: string = palHome()): MachineIdentity {
|
|
88
|
+
const file = machineFilePath(home);
|
|
89
|
+
if (existsSync(file)) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown;
|
|
92
|
+
if (hasUsableId(parsed)) return repair(parsed);
|
|
93
|
+
} catch {
|
|
94
|
+
/* fall through to regeneration below */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const identity = newIdentity();
|
|
98
|
+
mkdirSync(home, { recursive: true });
|
|
99
|
+
writeFileSync(file, `${JSON.stringify(identity, null, 2)}\n`);
|
|
100
|
+
return identity;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Rename this machine. No stored record is touched — labels resolve on read. */
|
|
104
|
+
export function setLabel(label: string, home: string = palHome()): MachineIdentity {
|
|
105
|
+
const current = loadMachine(home);
|
|
106
|
+
const updated = { ...current, label: label.trim() || current.label };
|
|
107
|
+
writeFileSync(machineFilePath(home), `${JSON.stringify(updated, null, 2)}\n`);
|
|
108
|
+
return updated;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RegistryEntry {
|
|
112
|
+
id: string;
|
|
113
|
+
label: string;
|
|
114
|
+
os: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function registryPath(id: string): string {
|
|
118
|
+
return resolve(machinesDir(), `${id}.md`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Write (or refresh) a machine's registry entry. Registry entries are exported. */
|
|
122
|
+
export function writeRegistryEntry(entry: RegistryEntry, body = ""): string {
|
|
123
|
+
const file = registryPath(entry.id);
|
|
124
|
+
const existingBody = existsSync(file) ? parse(readFileSync(file, "utf-8")).body : "";
|
|
125
|
+
const content = stringify(
|
|
126
|
+
{
|
|
127
|
+
id: entry.id,
|
|
128
|
+
label: entry.label,
|
|
129
|
+
os: entry.os,
|
|
130
|
+
updated: new Date().toISOString(),
|
|
131
|
+
},
|
|
132
|
+
body || existingBody
|
|
133
|
+
);
|
|
134
|
+
writeFileSync(file, content);
|
|
135
|
+
return file;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Every known machine, this one and any that arrived via import. */
|
|
139
|
+
export function readRegistry(): RegistryEntry[] {
|
|
140
|
+
const dir = machinesDir();
|
|
141
|
+
const entries: RegistryEntry[] = [];
|
|
142
|
+
for (const name of readdirSync(dir)) {
|
|
143
|
+
if (!name.endsWith(".md")) continue;
|
|
144
|
+
try {
|
|
145
|
+
const meta = parse<Record<string, string>>(
|
|
146
|
+
readFileSync(resolve(dir, name), "utf-8")
|
|
147
|
+
).meta;
|
|
148
|
+
if (meta.id && meta.label) {
|
|
149
|
+
entries.push({ id: meta.id, label: meta.label, os: meta.os ?? "" });
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
/* a malformed entry must not hide the rest of the registry */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return entries;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Name for a record's origin id. Unknown ids fall back to the short id so a
|
|
160
|
+
* record from a machine whose entry has not arrived yet still reads sensibly.
|
|
161
|
+
* A label shared by two machines is suffixed rather than deduplicated — the
|
|
162
|
+
* registry is not always reachable, so uniqueness can never be enforced.
|
|
163
|
+
*/
|
|
164
|
+
export function displayName(id: string, registry: RegistryEntry[]): string {
|
|
165
|
+
const entry = registry.find((e) => e.id === id);
|
|
166
|
+
if (!entry) return shortId(id);
|
|
167
|
+
const sharesLabel = registry.some((e) => e.id !== id && e.label === entry.label);
|
|
168
|
+
return sharesLabel ? `${entry.label}·${shortId(id)}` : entry.label;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Register this install so its label can be resolved on any machine. */
|
|
172
|
+
export function ensureRegistered(home: string = palHome()): MachineIdentity {
|
|
173
|
+
const identity = loadMachine(home);
|
|
174
|
+
writeRegistryEntry({ id: identity.id, label: identity.label, os: identity.os });
|
|
175
|
+
return identity;
|
|
176
|
+
}
|