portable-agent-layer 0.85.24 → 0.86.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 +7 -1
- package/assets/agents/gemini-researcher.md +2 -2
- package/assets/agents/grok-researcher.md +2 -2
- package/assets/agents/perplexity-researcher.md +1 -1
- package/assets/agents/subagent-author.md +1 -1
- package/assets/skills/analyze-youtube/SKILL.md +1 -1
- package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -0
- package/assets/skills/create-subagent/SKILL.md +2 -2
- package/assets/skills/create-subagent/authoring-guide.md +13 -1
- package/assets/skills/deep-research/SKILL.md +1 -0
- package/assets/skills/deep-research/tools/gemini-search.ts +1 -0
- package/assets/skills/deep-research/tools/grok-search.ts +1 -0
- package/assets/skills/deep-research/tools/perplexity-search.ts +1 -1
- package/assets/skills/fyzz-chat-api/SKILL.md +2 -2
- package/assets/skills/fyzz-chat-api/tools/fyzz-api.ts +1 -1
- package/package.json +1 -1
- package/src/cli/doctor/agents.ts +16 -1
- package/src/cli/doctor/health.ts +7 -6
- package/src/cli/doctor/inference.ts +89 -11
- package/src/cli/index.ts +0 -0
- package/src/cli/skill.ts +10 -2
- package/src/hooks/lib/agent-definition.ts +57 -0
- package/src/hooks/lib/frontmatter.ts +21 -8
- package/src/hooks/lib/inference.ts +40 -9
- package/src/hooks/lib/log.ts +10 -4
- package/src/hooks/lib/pal-env.ts +51 -0
- package/src/hooks/lib/paths.ts +7 -1
- package/src/targets/agent-render.ts +103 -0
- package/src/targets/codex/install.ts +6 -1
- package/src/targets/codex/uninstall.ts +8 -1
- package/src/targets/lib.ts +72 -89
- package/src/tools/control-room/static.ts +3 -8
- package/src/tools/lib/skill-doctor.ts +4 -4
- package/src/tools/subagent-doctor.ts +50 -40
|
@@ -39,15 +39,16 @@ import {
|
|
|
39
39
|
isFixedModelRoute,
|
|
40
40
|
} from "./models";
|
|
41
41
|
import { opencodeTierModel, writeInstructionFreeConfig } from "./opencode-config";
|
|
42
|
+
import { withPalEnv } from "./pal-env";
|
|
42
43
|
import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
|
|
43
44
|
import { findBinaryOnPath } from "./which";
|
|
44
45
|
|
|
45
46
|
export function hasApiKey(): boolean {
|
|
46
|
-
return !!
|
|
47
|
+
return !!withPalEnv().PAL_ANTHROPIC_API_KEY;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function hasOpenAiKey(): boolean {
|
|
50
|
-
return !!
|
|
51
|
+
return !!withPalEnv().PAL_OPENAI_API_KEY;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
type InferenceRoute =
|
|
@@ -304,6 +305,27 @@ export function buildClaudeArgs(
|
|
|
304
305
|
return args;
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
type ClaudeAuthMode = "token" | "native";
|
|
309
|
+
|
|
310
|
+
function claudeAuthMode(): ClaudeAuthMode {
|
|
311
|
+
return withPalEnv().CLAUDE_CODE_OAUTH_TOKEN ? "token" : "native";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Lines logged before spawns were tagged came from the native login. */
|
|
315
|
+
export function loggedClaudeAuthMode(message: string): ClaudeAuthMode {
|
|
316
|
+
return /\bauth=token\b/.test(message) ? "token" : "native";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** https://code.claude.com/docs/en/authentication#authentication-precedence */
|
|
320
|
+
const WITHOUT_KEYS_OUTRANKING_TOKEN = {
|
|
321
|
+
ANTHROPIC_API_KEY: undefined,
|
|
322
|
+
ANTHROPIC_AUTH_TOKEN: undefined,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function credentialOverridesFor(mode: ClaudeAuthMode) {
|
|
326
|
+
return mode === "token" ? WITHOUT_KEYS_OUTRANKING_TOKEN : {};
|
|
327
|
+
}
|
|
328
|
+
|
|
307
329
|
/**
|
|
308
330
|
* Claude keeps a real system prompt, unlike the other agents, so the system text
|
|
309
331
|
* reaches it through --system-prompt-file rather than --system-prompt. Only the
|
|
@@ -317,16 +339,22 @@ async function inferenceViaClaudeSpawn(
|
|
|
317
339
|
bin: string,
|
|
318
340
|
opts: InferenceOptions
|
|
319
341
|
): Promise<InferenceResult> {
|
|
342
|
+
const mode = claudeAuthMode();
|
|
343
|
+
const spawnClaude = (args: string[]) =>
|
|
344
|
+
inferenceViaCliSpawn(bin, args, opts.user, opts, undefined, {
|
|
345
|
+
env: credentialOverridesFor(mode),
|
|
346
|
+
logTag: `auth=${mode}`,
|
|
347
|
+
});
|
|
320
348
|
const system = opts.jsonSchema
|
|
321
349
|
? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
|
|
322
350
|
: opts.system;
|
|
323
|
-
if (!system) return
|
|
351
|
+
if (!system) return spawnClaude(buildClaudeArgs(opts));
|
|
324
352
|
|
|
325
353
|
const dir = await mkdtemp(join(tmpdir(), "pal-system-"));
|
|
326
354
|
try {
|
|
327
355
|
const file = join(dir, "system-prompt.md");
|
|
328
356
|
await writeFile(file, system, "utf-8");
|
|
329
|
-
return await
|
|
357
|
+
return await spawnClaude(buildClaudeArgs(opts, file));
|
|
330
358
|
} finally {
|
|
331
359
|
await rm(dir, { recursive: true, force: true });
|
|
332
360
|
}
|
|
@@ -653,7 +681,8 @@ async function singleCliAttempt(
|
|
|
653
681
|
|
|
654
682
|
interface SpawnPlace {
|
|
655
683
|
cwd?: string;
|
|
656
|
-
env?: Record<string, string>;
|
|
684
|
+
env?: Record<string, string | undefined>;
|
|
685
|
+
logTag?: string;
|
|
657
686
|
}
|
|
658
687
|
|
|
659
688
|
/**
|
|
@@ -670,11 +699,13 @@ async function inferenceViaCliSpawn(
|
|
|
670
699
|
place: SpawnPlace = {}
|
|
671
700
|
): Promise<InferenceResult> {
|
|
672
701
|
const timeout = opts.timeout ?? 15000;
|
|
673
|
-
const env = { ...buildSpawnGuardEnv(process.env), ...place.env };
|
|
702
|
+
const env = { ...buildSpawnGuardEnv(withPalEnv(process.env)), ...place.env };
|
|
674
703
|
const started = Date.now();
|
|
675
704
|
const caller = opts.caller ?? "anonymous";
|
|
676
705
|
const session = opts.sessionId ?? "-";
|
|
677
|
-
const tag = `caller=${caller} sessionId=${session}
|
|
706
|
+
const tag = [`caller=${caller}`, `sessionId=${session}`, place.logTag]
|
|
707
|
+
.filter(Boolean)
|
|
708
|
+
.join(" ");
|
|
678
709
|
// Friendly name for logs — strip path + extension so cross-platform diffs
|
|
679
710
|
// (e.g. C:\…\claude.cmd vs /usr/local/bin/claude) read the same in debug.log.
|
|
680
711
|
const binaryName = basename(binary).replace(/\.(cmd|bat|exe|com)$/i, "");
|
|
@@ -746,7 +777,7 @@ async function inferenceViaCliSpawn(
|
|
|
746
777
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
747
778
|
|
|
748
779
|
async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult> {
|
|
749
|
-
const apiKey =
|
|
780
|
+
const apiKey = withPalEnv().PAL_ANTHROPIC_API_KEY;
|
|
750
781
|
if (!apiKey) return { success: false };
|
|
751
782
|
|
|
752
783
|
const { system, user, maxTokens = 200, timeout = 5000, jsonSchema } = opts;
|
|
@@ -814,7 +845,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
|
|
|
814
845
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
815
846
|
|
|
816
847
|
async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceResult> {
|
|
817
|
-
const apiKey =
|
|
848
|
+
const apiKey = withPalEnv().PAL_OPENAI_API_KEY;
|
|
818
849
|
if (!apiKey) return { success: false };
|
|
819
850
|
|
|
820
851
|
const { system, user, maxTokens = 500, timeout = 15000, jsonSchema } = opts;
|
package/src/hooks/lib/log.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface HookErrorGroup {
|
|
|
72
72
|
count: number;
|
|
73
73
|
last: string;
|
|
74
74
|
lastAt: number;
|
|
75
|
+
/** The newest error's whole message, for tags `last` trims away. */
|
|
76
|
+
lastMessage?: string;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
interface LoggedError {
|
|
@@ -97,8 +99,11 @@ function readAllLogs(): string {
|
|
|
97
99
|
.join("\n");
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
const LOGGED_LINE = /^\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\] /;
|
|
103
|
+
const ERROR_LINE = new RegExp(`${LOGGED_LINE.source}ERROR `);
|
|
104
|
+
|
|
100
105
|
function loggedAt(line: string): number {
|
|
101
|
-
const match =
|
|
106
|
+
const match = LOGGED_LINE.exec(line);
|
|
102
107
|
return match ? Date.parse(`${match[1]}T${match[2]}Z`) : 0;
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -114,7 +119,8 @@ function groupBySource(errors: LoggedError[]): HookErrorGroup[] {
|
|
|
114
119
|
const source = /^([^:]+):/.exec(message)?.[1] ?? "unknown";
|
|
115
120
|
const group = groups.get(source) ?? { source, count: 0, last: "", lastAt: 0 };
|
|
116
121
|
group.count++;
|
|
117
|
-
group.
|
|
122
|
+
group.lastMessage = message.slice(source.length + 1).trim();
|
|
123
|
+
group.last = whatTheAgentSaid(group.lastMessage).slice(0, 120);
|
|
118
124
|
group.lastAt = at;
|
|
119
125
|
groups.set(source, group);
|
|
120
126
|
}
|
|
@@ -125,10 +131,10 @@ export function recentHookErrors(now: number = Date.now()): HookErrorGroup[] {
|
|
|
125
131
|
try {
|
|
126
132
|
const recent = readAllLogs()
|
|
127
133
|
.split("\n")
|
|
128
|
-
.filter((line) =>
|
|
134
|
+
.filter((line) => ERROR_LINE.test(line) && loggedAt(line) > now - DAY_MS)
|
|
129
135
|
.map((line) => ({
|
|
130
136
|
at: loggedAt(line),
|
|
131
|
-
message: line.replace(
|
|
137
|
+
message: line.replace(ERROR_LINE, ""),
|
|
132
138
|
}));
|
|
133
139
|
return groupBySource(recent);
|
|
134
140
|
} catch {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials PAL reads from ~/.pal/.env. A variable already set in the
|
|
3
|
+
* environment wins; the file fills the gaps. Agents launched from a desktop
|
|
4
|
+
* app never read a shell profile, so hooks they spawn see only this file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { palHome } from "./paths";
|
|
10
|
+
|
|
11
|
+
type Env = Record<string, string | undefined>;
|
|
12
|
+
|
|
13
|
+
export function palEnvPath(): string {
|
|
14
|
+
return resolve(palHome(), ".env");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function withoutExportPrefix(line: string): string {
|
|
18
|
+
return line.startsWith("export ") ? line.slice("export ".length).trimStart() : line;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unquoted(value: string): string {
|
|
22
|
+
return value.replace(/^["']|["']$/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parsePalEnv(content: string): Record<string, string> {
|
|
26
|
+
const values: Record<string, string> = {};
|
|
27
|
+
for (const raw of content.split("\n")) {
|
|
28
|
+
const line = withoutExportPrefix(raw.trim());
|
|
29
|
+
if (!line || line.startsWith("#")) continue;
|
|
30
|
+
const eq = line.indexOf("=");
|
|
31
|
+
if (eq <= 0) continue;
|
|
32
|
+
values[line.slice(0, eq).trim()] = unquoted(line.slice(eq + 1).trim());
|
|
33
|
+
}
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readPalEnvFile(): Record<string, string> {
|
|
38
|
+
try {
|
|
39
|
+
return parsePalEnv(readFileSync(palEnvPath(), "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function withPalEnv(env: Env = process.env): Env {
|
|
46
|
+
const merged: Env = { ...env };
|
|
47
|
+
for (const [key, value] of Object.entries(readPalEnvFile())) {
|
|
48
|
+
if (!merged[key]) merged[key] = value;
|
|
49
|
+
}
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Turn a path string that came from outside — argv, a flag, an env override, a
|
|
@@ -36,6 +36,12 @@ export function namesAPath(input: string): boolean {
|
|
|
36
36
|
return input.startsWith("~") || input.includes("/") || input.includes("\\");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Compared as paths, not strings — a separator differs by platform. */
|
|
40
|
+
export function isInside(parent: string, target: string): boolean {
|
|
41
|
+
const rel = relative(parent, target);
|
|
42
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
/** An env override names a path the same way a flag does, so it gets the same treatment. */
|
|
40
46
|
function envPath(name: string, fallback: string): string {
|
|
41
47
|
const override = process.env[name];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders one merged subagent definition into the file each agent reads:
|
|
3
|
+
* Markdown with that platform's frontmatter, or a Codex agent-role TOML.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type AgentDefinition,
|
|
8
|
+
type AgentPlatform,
|
|
9
|
+
parseAgentDefinition,
|
|
10
|
+
} from "../hooks/lib/agent-definition";
|
|
11
|
+
|
|
12
|
+
const CODEX_OWNED_KEYS = new Set(["name", "description", "developer_instructions"]);
|
|
13
|
+
|
|
14
|
+
export function agentFileName(stem: string, platform: AgentPlatform): string {
|
|
15
|
+
return `${stem}.${platform === "codex" ? "toml" : "md"}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderAgentForPlatform(content: string, platform: AgentPlatform): string {
|
|
19
|
+
const definition = parseAgentDefinition(content);
|
|
20
|
+
if (platform === "codex") return renderCodexAgent(definition);
|
|
21
|
+
if (!definition.hasFrontmatter) return content;
|
|
22
|
+
const frontmatter = [...definition.global, ...(definition.platforms[platform] ?? [])];
|
|
23
|
+
return `---\n${frontmatter.join("\n")}\n---${definition.body}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderCodexAgent(definition: AgentDefinition): string {
|
|
27
|
+
if (!definition.hasFrontmatter)
|
|
28
|
+
throw new Error("no frontmatter to build a Codex agent from");
|
|
29
|
+
const { name, description, ...unmapped } = yamlMapping(
|
|
30
|
+
definition.global,
|
|
31
|
+
"frontmatter"
|
|
32
|
+
);
|
|
33
|
+
rejectUnmappedGlobals(Object.keys(unmapped));
|
|
34
|
+
const overrides = Object.entries(
|
|
35
|
+
yamlMapping(definition.platforms.codex ?? [], "codex block")
|
|
36
|
+
);
|
|
37
|
+
const instructions = `${definition.body.trim()}\n`;
|
|
38
|
+
const lines = [
|
|
39
|
+
`name = ${tomlString(requiredText(name, "name"))}`,
|
|
40
|
+
`description = ${tomlString(requiredText(description, "description"))}`,
|
|
41
|
+
...overrides.map(
|
|
42
|
+
([key, value]) => `${tomlKey(key)} = ${codexOverrideValue(key, value)}`
|
|
43
|
+
),
|
|
44
|
+
`developer_instructions = ${tomlText(instructions)}`,
|
|
45
|
+
];
|
|
46
|
+
return `${lines.join("\n")}\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function yamlMapping(lines: string[], where: string): Record<string, unknown> {
|
|
50
|
+
if (lines.length === 0) return {};
|
|
51
|
+
const parsed = Bun.YAML.parse(lines.join("\n"));
|
|
52
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
53
|
+
throw new Error(`the ${where} is not a YAML mapping`);
|
|
54
|
+
}
|
|
55
|
+
return parsed as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rejectUnmappedGlobals(keys: string[]): void {
|
|
59
|
+
if (keys.length === 0) return;
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Codex has no field for global ${keys.join(", ")}; move it into a platform block`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requiredText(value: unknown, field: string): string {
|
|
66
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
67
|
+
throw new Error(`Codex needs a non-empty ${field}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function codexOverrideValue(key: string, value: unknown): string {
|
|
71
|
+
if (CODEX_OWNED_KEYS.has(key)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`codex.${key} is built from the shared definition and cannot be overridden`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === "string") return tomlString(value);
|
|
77
|
+
if (typeof value === "boolean") return String(value);
|
|
78
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
79
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
80
|
+
return `[${value.map(tomlString).join(", ")}]`;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`codex.${key} must be a string, number, boolean or list of strings`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tomlKey(key: string): string {
|
|
86
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tomlString(value: string): string {
|
|
90
|
+
return JSON.stringify(value).replaceAll("\u007f", String.raw`\u007f`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tomlText(text: string): string {
|
|
94
|
+
return fitsLiteralString(text) ? `'''\n${text}'''` : tomlString(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fitsLiteralString(text: string): boolean {
|
|
98
|
+
if (text.includes("'''")) return false;
|
|
99
|
+
return Array.from(text).every((char) => {
|
|
100
|
+
const code = char.charCodeAt(0);
|
|
101
|
+
return char === "\t" || char === "\n" || (code >= 0x20 && code !== 0x7f);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PAL — Codex target installer
|
|
3
3
|
* Merges PAL hooks into ~/.codex/hooks.json (never overwrites user hooks).
|
|
4
|
-
* Symlinks skills. Ensures AGENTS.md symlink via regenerateIfNeeded().
|
|
4
|
+
* Symlinks skills, renders agents to TOML. Ensures AGENTS.md symlink via regenerateIfNeeded().
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
@@ -15,6 +15,7 @@ import { resolve } from "node:path";
|
|
|
15
15
|
import { assets, palPkg, platform } from "../../hooks/lib/paths";
|
|
16
16
|
import {
|
|
17
17
|
addCodexStatuslineConfig,
|
|
18
|
+
copyAgentsForCodex,
|
|
18
19
|
copySkills,
|
|
19
20
|
countSkills,
|
|
20
21
|
loadCodexHooksTemplate,
|
|
@@ -101,6 +102,10 @@ const codexSkillsDir = resolve(CODEX_DIR, "skills");
|
|
|
101
102
|
copySkills(codexSkillsDir);
|
|
102
103
|
log.success(`${countSkills()} skills → ~/.codex/skills/`);
|
|
103
104
|
|
|
105
|
+
// --- Render agents to ~/.codex/agents/ ---
|
|
106
|
+
const agentCount = copyAgentsForCodex(resolve(CODEX_DIR, "agents"));
|
|
107
|
+
log.success(`${agentCount} agents → ~/.codex/agents/`);
|
|
108
|
+
|
|
104
109
|
// --- Enable hooks in config.toml ---
|
|
105
110
|
const CONFIG_FILE = resolve(CODEX_DIR, "config.toml");
|
|
106
111
|
enableCodexHooks(CONFIG_FILE);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PAL — Codex uninstaller
|
|
3
3
|
* Removes only PAL-owned hooks from ~/.codex/hooks.json. Preserves user hooks.
|
|
4
|
-
* Removes PAL skill symlinks.
|
|
4
|
+
* Removes PAL skill symlinks and PAL agents.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
loadCodexHooksTemplate,
|
|
12
12
|
log,
|
|
13
13
|
readJson,
|
|
14
|
+
removeAgentsFromCodex,
|
|
14
15
|
removeCodexStatuslineConfig,
|
|
15
16
|
removeSkills,
|
|
16
17
|
unmergeCodexHooks,
|
|
@@ -87,6 +88,12 @@ if (removed.length > 0) {
|
|
|
87
88
|
log.info("No PAL skills found");
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// --- Remove PAL agents ---
|
|
92
|
+
const removedAgents = removeAgentsFromCodex(resolve(CODEX_DIR, "agents"));
|
|
93
|
+
if (removedAgents.length > 0) {
|
|
94
|
+
log.success(`Removed ${removedAgents.length} agent(s): ${removedAgents.join(", ")}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
90
97
|
// --- Disable hooks in config.toml ---
|
|
91
98
|
const CONFIG_FILE = resolve(CODEX_DIR, "config.toml");
|
|
92
99
|
disableCodexHooks(CONFIG_FILE);
|
package/src/targets/lib.ts
CHANGED
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
} from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
21
|
+
import { AGENT_PLATFORMS, type AgentPlatform } from "../hooks/lib/agent-definition";
|
|
21
22
|
import { assets, palHome, platform } from "../hooks/lib/paths";
|
|
22
23
|
import { declaredTriggers } from "../hooks/lib/skill-triggers";
|
|
24
|
+
import { agentFileName, renderAgentForPlatform } from "./agent-render";
|
|
23
25
|
|
|
24
26
|
// --- Colored logging ---
|
|
25
27
|
|
|
@@ -993,7 +995,7 @@ export function removeSkills(claudeSkillsDir: string): string[] {
|
|
|
993
995
|
|
|
994
996
|
// --- Agents ---
|
|
995
997
|
|
|
996
|
-
const claudeAgentsDir = () =>
|
|
998
|
+
const claudeAgentsDir = () => nativeAgentsDir("claude");
|
|
997
999
|
|
|
998
1000
|
/**
|
|
999
1001
|
* Install PAL agent definitions into ~/.claude/agents/.
|
|
@@ -1031,81 +1033,28 @@ export function countAgents(): number {
|
|
|
1031
1033
|
}
|
|
1032
1034
|
}
|
|
1033
1035
|
|
|
1034
|
-
// --- Agent platform
|
|
1036
|
+
// --- Agent platform rendering ---
|
|
1035
1037
|
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
* Extract a platform-specific agent file from the unified agent format.
|
|
1041
|
-
*
|
|
1042
|
-
* Each agent .md defines platform blocks at the top level:
|
|
1043
|
-
* claude: → fields for Claude Code
|
|
1044
|
-
* opencode: → fields for opencode
|
|
1045
|
-
* cursor: → fields for Cursor
|
|
1046
|
-
*
|
|
1047
|
-
* Global fields (name, description) are always included.
|
|
1048
|
-
* The target platform block is un-indented and merged into the root.
|
|
1049
|
-
* All other platform blocks are stripped.
|
|
1050
|
-
*/
|
|
1051
|
-
function extractAgentForPlatform(content: string, platform: AgentPlatform): string {
|
|
1052
|
-
const parts = content.split(/^---\s*$/m);
|
|
1053
|
-
if (parts.length < 3) return content;
|
|
1054
|
-
|
|
1055
|
-
const frontmatter = parts[1];
|
|
1056
|
-
const body = parts.slice(2).join("---");
|
|
1057
|
-
|
|
1058
|
-
const globalLines: string[] = [];
|
|
1059
|
-
const platformLines: Record<AgentPlatform, string[]> = {
|
|
1060
|
-
claude: [],
|
|
1061
|
-
opencode: [],
|
|
1062
|
-
cursor: [],
|
|
1063
|
-
copilot: [],
|
|
1064
|
-
};
|
|
1065
|
-
let currentPlatform: AgentPlatform | null = null;
|
|
1066
|
-
|
|
1067
|
-
for (const line of frontmatter.split("\n")) {
|
|
1068
|
-
if (!line.trim()) continue;
|
|
1069
|
-
|
|
1070
|
-
const platformMatch = new RegExp(/^(claude|opencode|cursor|copilot):\s*$/).exec(line);
|
|
1071
|
-
if (platformMatch) {
|
|
1072
|
-
currentPlatform = platformMatch[1] as AgentPlatform;
|
|
1073
|
-
continue;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
if (currentPlatform) {
|
|
1077
|
-
if (new RegExp(/^ {2}/).exec(line)) {
|
|
1078
|
-
platformLines[currentPlatform].push(line.slice(2)); // un-indent one level
|
|
1079
|
-
continue;
|
|
1080
|
-
}
|
|
1081
|
-
currentPlatform = null; // end of platform block
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
globalLines.push(line);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
const newFrontmatter = [...globalLines, ...platformLines[platform]]
|
|
1088
|
-
.filter((l) => l.trim())
|
|
1089
|
-
.join("\n");
|
|
1038
|
+
const shippedAgentStems = () =>
|
|
1039
|
+
readdirSync(assets.agents())
|
|
1040
|
+
.filter((f) => f.endsWith(".md"))
|
|
1041
|
+
.map((f) => f.replace(/\.md$/, ""));
|
|
1090
1042
|
|
|
1091
|
-
|
|
1092
|
-
}
|
|
1043
|
+
const shippedAgentSource = (stem: string) =>
|
|
1044
|
+
readFileSync(resolve(assets.agents(), `${stem}.md`), "utf-8");
|
|
1093
1045
|
|
|
1094
1046
|
/** Install agents for a platform into a target directory. Always overwrites. */
|
|
1095
1047
|
function installAgents(targetDir: string, platform: AgentPlatform): number {
|
|
1096
|
-
|
|
1097
|
-
if (!existsSync(agentsDir)) return 0;
|
|
1048
|
+
if (!existsSync(assets.agents())) return 0;
|
|
1098
1049
|
|
|
1099
1050
|
mkdirSync(targetDir, { recursive: true });
|
|
1100
1051
|
let count = 0;
|
|
1101
1052
|
|
|
1102
|
-
for (const
|
|
1103
|
-
const
|
|
1104
|
-
const installed = reportOnlyOnFailure(`${platform} agent ${name}`, () => {
|
|
1105
|
-
const content = readFileSync(resolve(agentsDir, file), "utf-8");
|
|
1053
|
+
for (const stem of shippedAgentStems()) {
|
|
1054
|
+
const installed = reportOnlyOnFailure(`${platform} agent ${stem}`, () => {
|
|
1106
1055
|
writeFileSync(
|
|
1107
|
-
resolve(targetDir,
|
|
1108
|
-
|
|
1056
|
+
resolve(targetDir, agentFileName(stem, platform)),
|
|
1057
|
+
renderAgentForPlatform(shippedAgentSource(stem), platform),
|
|
1109
1058
|
"utf-8"
|
|
1110
1059
|
);
|
|
1111
1060
|
});
|
|
@@ -1114,18 +1063,31 @@ function installAgents(targetDir: string, platform: AgentPlatform): number {
|
|
|
1114
1063
|
return count;
|
|
1115
1064
|
}
|
|
1116
1065
|
|
|
1066
|
+
/** Shipped agents in targetDir that differ from what installAgents would write there. */
|
|
1067
|
+
export function staleShippedAgents(targetDir: string, agent: AgentPlatform): string[] {
|
|
1068
|
+
if (!existsSync(assets.agents())) return [];
|
|
1069
|
+
const isStale = (stem: string) => {
|
|
1070
|
+
const installed = resolve(targetDir, agentFileName(stem, agent));
|
|
1071
|
+
return (
|
|
1072
|
+
!existsSync(installed) ||
|
|
1073
|
+
readFileSync(installed, "utf-8") !==
|
|
1074
|
+
renderAgentForPlatform(shippedAgentSource(stem), agent)
|
|
1075
|
+
);
|
|
1076
|
+
};
|
|
1077
|
+
return shippedAgentStems().filter(isStale).sort();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1117
1080
|
/** Remove PAL agents from a directory. */
|
|
1118
|
-
function uninstallAgents(targetDir: string,
|
|
1119
|
-
|
|
1120
|
-
if (!existsSync(agentsDir)) return [];
|
|
1081
|
+
function uninstallAgents(targetDir: string, platform: AgentPlatform): string[] {
|
|
1082
|
+
if (!existsSync(assets.agents())) return [];
|
|
1121
1083
|
|
|
1122
1084
|
const removed: string[] = [];
|
|
1123
|
-
for (const
|
|
1124
|
-
const dst = resolve(targetDir,
|
|
1085
|
+
for (const stem of shippedAgentStems()) {
|
|
1086
|
+
const dst = resolve(targetDir, agentFileName(stem, platform));
|
|
1125
1087
|
if (existsSync(dst)) {
|
|
1126
1088
|
unlinkSync(dst);
|
|
1127
|
-
removed.push(
|
|
1128
|
-
log.detail(`Removed ${
|
|
1089
|
+
removed.push(stem);
|
|
1090
|
+
log.detail(`Removed ${platform} agent: ${stem}`);
|
|
1129
1091
|
}
|
|
1130
1092
|
}
|
|
1131
1093
|
return removed;
|
|
@@ -1147,6 +1109,10 @@ export function copyAgentsForCopilot(copilotAgentsDir: string): number {
|
|
|
1147
1109
|
return installAgents(copilotAgentsDir, "copilot");
|
|
1148
1110
|
}
|
|
1149
1111
|
|
|
1112
|
+
export function copyAgentsForCodex(codexAgentsDir: string): number {
|
|
1113
|
+
return installAgents(codexAgentsDir, "codex");
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1150
1116
|
export function removeAgentsFromCursor(cursorAgentsDir: string): string[] {
|
|
1151
1117
|
return uninstallAgents(cursorAgentsDir, "cursor");
|
|
1152
1118
|
}
|
|
@@ -1155,6 +1121,10 @@ export function removeAgentsFromCopilot(copilotAgentsDir: string): string[] {
|
|
|
1155
1121
|
return uninstallAgents(copilotAgentsDir, "copilot");
|
|
1156
1122
|
}
|
|
1157
1123
|
|
|
1124
|
+
export function removeAgentsFromCodex(codexAgentsDir: string): string[] {
|
|
1125
|
+
return uninstallAgents(codexAgentsDir, "codex");
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1158
1128
|
// --- Personal subagents (user-authored, under ~/.pal/agents/) ---
|
|
1159
1129
|
|
|
1160
1130
|
/**
|
|
@@ -1169,12 +1139,19 @@ const palAgentsStore = () => resolve(palHome(), "agents");
|
|
|
1169
1139
|
* exists (mirrors linkPersonalSkill's per-agent gate).
|
|
1170
1140
|
*/
|
|
1171
1141
|
function personalSubagentTargets(): { agent: AgentPlatform; dir: string }[] {
|
|
1172
|
-
return
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1142
|
+
return AGENT_PLATFORMS.map((agent) => ({ agent, dir: nativeAgentsDir(agent) }));
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** The directory each agent reads its subagent definitions from. */
|
|
1146
|
+
export function nativeAgentsDir(agent: AgentPlatform): string {
|
|
1147
|
+
const home = {
|
|
1148
|
+
claude: platform.claudeDir,
|
|
1149
|
+
opencode: platform.opencodeDir,
|
|
1150
|
+
cursor: platform.cursorDir,
|
|
1151
|
+
copilot: platform.copilotDir,
|
|
1152
|
+
codex: platform.codexDir,
|
|
1153
|
+
}[agent];
|
|
1154
|
+
return resolve(home(), "agents");
|
|
1178
1155
|
}
|
|
1179
1156
|
|
|
1180
1157
|
/** Names of the subagents PAL ships (assets/agents/*.md). */
|
|
@@ -1215,17 +1192,23 @@ export function installPersonalSubagent(name: string): string[] {
|
|
|
1215
1192
|
);
|
|
1216
1193
|
}
|
|
1217
1194
|
const content = readFileSync(src, "utf-8");
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
resolve(dir,
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1195
|
+
const renders = personalSubagentTargets()
|
|
1196
|
+
.filter(({ dir }) => existsSync(dir))
|
|
1197
|
+
.map(({ agent, dir }) => ({
|
|
1198
|
+
agent,
|
|
1199
|
+
file: resolve(dir, agentFileName(name, agent)),
|
|
1200
|
+
text: renderForPersonalSubagent(content, agent),
|
|
1201
|
+
}));
|
|
1202
|
+
for (const { file, text } of renders) writeFileSync(file, text, "utf-8");
|
|
1203
|
+
return renders.map(({ agent }) => agent);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function renderForPersonalSubagent(content: string, agent: AgentPlatform): string {
|
|
1207
|
+
try {
|
|
1208
|
+
return renderAgentForPlatform(content, agent);
|
|
1209
|
+
} catch (e) {
|
|
1210
|
+
throw new Error(`Cannot build the ${agent} subagent: ${(e as Error).message}`);
|
|
1227
1211
|
}
|
|
1228
|
-
return installed;
|
|
1229
1212
|
}
|
|
1230
1213
|
|
|
1231
1214
|
/** Load and resolve the Copilot hooks template, substituting PKG_ROOT */
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import {
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { isInside } from "../../hooks/lib/paths";
|
|
10
11
|
|
|
11
12
|
const UI_DIST = resolve(import.meta.dir, "ui", "dist");
|
|
12
13
|
|
|
@@ -57,12 +58,6 @@ export function indexHtml(): Response {
|
|
|
57
58
|
*/
|
|
58
59
|
export function staticAsset(pathname: string): Response | null {
|
|
59
60
|
const target = resolve(UI_DIST, `.${pathname}`);
|
|
60
|
-
if (!
|
|
61
|
+
if (!isInside(UI_DIST, target)) return null;
|
|
61
62
|
return existsSync(target) ? new Response(Bun.file(target)) : null;
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
/** Compared as paths, not strings — a separator differs by platform. */
|
|
65
|
-
function isInsideDist(target: string): boolean {
|
|
66
|
-
const rel = relative(UI_DIST, target);
|
|
67
|
-
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
68
|
-
}
|