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
package/README.md
CHANGED
|
@@ -137,6 +137,12 @@ PAL routes inference through the host agent's subscription CLI by default. API k
|
|
|
137
137
|
| `PAL_GEMINI_API_KEY` | For YouTube video analysis and web search skill |
|
|
138
138
|
| `PAL_XAI_API_KEY` | For Grok real-time research skill (X/web search) |
|
|
139
139
|
| `PAL_PERPLEXITY_API_KEY` | For Perplexity deep research skill |
|
|
140
|
+
| `PAL_FYZZ_API_KEY` | For the Fyzz Chat skill |
|
|
141
|
+
| `PAL_FYZZ_BASE_URL` | Fyzz Chat server for that skill (default: `http://localhost:3000`) |
|
|
142
|
+
|
|
143
|
+
### `~/.pal/.env`
|
|
144
|
+
|
|
145
|
+
Agents launched from a desktop app never read your shell profile, so the hooks they spawn cannot see variables exported there. PAL's background inference and the skills shipped with PAL also read `~/.pal/.env`, one `KEY=value` per line (`export KEY=value` works too); a variable already set in the environment wins. Put `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`) and the API keys above there; `pal cli doctor` warns about any of the API keys above still set in your shell. Personal skills made with `create-skill` never receive the file. It stays on this machine: `pal cli export` never includes it.
|
|
140
146
|
|
|
141
147
|
### Path overrides
|
|
142
148
|
|
|
@@ -225,7 +231,7 @@ Your setup should be able to travel with you.
|
|
|
225
231
|
## Features
|
|
226
232
|
|
|
227
233
|
- **Cross-platform**: works on Windows, macOS, and Linux
|
|
228
|
-
- **Cross-agent**: full support for Claude Code, opencode, Cursor, GitHub Copilot, and Codex
|
|
234
|
+
- **Cross-agent**: full support for Claude Code, opencode, Cursor, GitHub Copilot, and Codex
|
|
229
235
|
- **Subscription-first inference**: background inference routes through whichever subscription CLI is active — no API key needed by default
|
|
230
236
|
- **Portable knowledge**: export and import accumulated knowledge
|
|
231
237
|
- **A morning screen, not a dashboard**: `pal cli server` ranks your projects *and* your written goals in one urgent/important grid. Urgency is read off the files — blockers, an unfinished handoff, a date in a next step, an important thing gone quiet. Importance comes from one fact per project: whether it serves a goal you wrote down, is a way the work could pay, or is kept for its own sake. PAL guesses that once at session stop; you correct it on the page or with `project.ts serves <name> goal|revenue|fun`, and the correction survives every later guess
|
|
@@ -29,7 +29,7 @@ pal cli skill run deep-research gemini-search -- "<query>"
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
- If it returns results → **continue using Gemini Search** for remaining queries
|
|
32
|
-
- If it errors about `PAL_GEMINI_API_KEY` → **fall back to WebSearch/WebFetch** for all queries using the fallback methodology below
|
|
32
|
+
- If it errors about `PAL_GEMINI_API_KEY` → **fall back to WebSearch/WebFetch** for all queries using the fallback methodology below, and **set a flag** to include the fallback footnote in your output
|
|
33
33
|
|
|
34
34
|
The tool has a built-in academic system prompt that prioritizes scholarly sources, but you should still craft queries to target academic content:
|
|
35
35
|
- Include author names, paper titles, or venue names when known
|
|
@@ -83,5 +83,5 @@ If you fell back to WebSearch because the Gemini API was unavailable, you MUST a
|
|
|
83
83
|
|
|
84
84
|
```markdown
|
|
85
85
|
---
|
|
86
|
-
> **
|
|
86
|
+
> **Tell the user:** This research used WebSearch fallback instead of Gemini Search. The `PAL_GEMINI_API_KEY` environment variable is not set. To enable Gemini-grounded search, add `PAL_GEMINI_API_KEY=...` to `~/.pal/.env` (get one at https://aistudio.google.com/apikey)
|
|
87
87
|
```
|
|
@@ -52,7 +52,7 @@ If the grok-search tool fails (missing `PAL_XAI_API_KEY` or API error), fall bac
|
|
|
52
52
|
1. **Search** using WebSearch with time-sensitive queries — prepend "2026" or "latest" or "today" to queries
|
|
53
53
|
2. **Prioritize** news sources, social media aggregators, and live blogs
|
|
54
54
|
3. **Fetch** the most recent results with WebFetch to extract detail
|
|
55
|
-
4. **
|
|
55
|
+
4. **Set a flag** to include the fallback footnote in your output
|
|
56
56
|
|
|
57
57
|
## Methodology
|
|
58
58
|
|
|
@@ -105,5 +105,5 @@ If you fell back to WebSearch because the Grok API was unavailable, you MUST app
|
|
|
105
105
|
|
|
106
106
|
```markdown
|
|
107
107
|
---
|
|
108
|
-
> **
|
|
108
|
+
> **Tell the user:** This research used WebSearch fallback instead of Grok Search. The `PAL_XAI_API_KEY` environment variable is not set. To enable Grok real-time search, add `PAL_XAI_API_KEY=...` to `~/.pal/.env` (get one at https://console.x.ai/)
|
|
109
109
|
```
|
|
@@ -77,5 +77,5 @@ If you fell back to WebSearch because the Perplexity API was unavailable, you MU
|
|
|
77
77
|
|
|
78
78
|
```markdown
|
|
79
79
|
---
|
|
80
|
-
> **
|
|
80
|
+
> **Tell the user:** This research used WebSearch fallback instead of Perplexity Search. The `PAL_PERPLEXITY_API_KEY` environment variable is not set. To enable Perplexity-grounded search, add `PAL_PERPLEXITY_API_KEY=pplx-...` to `~/.pal/.env` (get one at https://www.perplexity.ai/settings/api)
|
|
81
81
|
```
|
|
@@ -29,7 +29,7 @@ You author a single new personal subagent for this user. You are handed a **suba
|
|
|
29
29
|
cat ~/.pal/skills/create-subagent/authoring-guide.md
|
|
30
30
|
```
|
|
31
31
|
2. Validate the name: lowercase-kebab, no spaces. It must not collide with an existing personal subagent (`pal cli subagent list`) or a shipped one. If it collides or is malformed, stop and report the conflict instead of overwriting.
|
|
32
|
-
3. Write `~/.pal/agents/<name>.md` (create `~/.pal/agents/` if missing), populated per the guide's merged schema: global `name` + `description`, then only the platform blocks that matter (`claude:`, `opencode:`, `cursor:`, `copilot:`), then the system prompt body. Remember: `skills:` is Claude-only and preloads — for other agents, name intended skills in the body.
|
|
32
|
+
3. Write `~/.pal/agents/<name>.md` (create `~/.pal/agents/` if missing), populated per the guide's merged schema: global `name` + `description`, then only the platform blocks that matter (`claude:`, `opencode:`, `cursor:`, `copilot:`, `codex:`), then the system prompt body. Remember: `skills:` is Claude-only and preloads — for other agents, name intended skills in the body.
|
|
33
33
|
4. Install it into every installed agent:
|
|
34
34
|
```bash
|
|
35
35
|
pal cli subagent link <name>
|
|
@@ -44,4 +44,4 @@ Follow the user's request. Common tasks:
|
|
|
44
44
|
- For long videos, consider asking a focused question via `--prompt` rather than a full analysis
|
|
45
45
|
- Gemini sees both visuals and audio — mention on-screen content (slides, code, diagrams) when relevant
|
|
46
46
|
- Quote speakers verbatim when the user asks about specific statements
|
|
47
|
-
- If the tool reports a missing API key, tell the user to get one at https://aistudio.google.com/apikey and
|
|
47
|
+
- If the tool reports a missing API key, tell the user to get one at https://aistudio.google.com/apikey and add `PAL_GEMINI_API_KEY=...` to `~/.pal/.env`
|
|
@@ -29,6 +29,7 @@ function loadApiKey(): string {
|
|
|
29
29
|
if (!key) {
|
|
30
30
|
console.error("Error: PAL_GEMINI_API_KEY environment variable is not set.");
|
|
31
31
|
console.error("Get a free key at https://aistudio.google.com/apikey");
|
|
32
|
+
console.error("and add it to ~/.pal/.env as PAL_GEMINI_API_KEY=...");
|
|
32
33
|
process.exit(1);
|
|
33
34
|
}
|
|
34
35
|
return key;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: create-subagent
|
|
3
3
|
license: MIT
|
|
4
|
-
description: "Create a user-scoped subagent for every installed agent (Claude Code, opencode, Cursor, Copilot) from one merged definition, then install and run the doctor on it. Use when the user asks to create a subagent, add a custom agent, delegate a role to a specialized agent, or \"make a subagent that…\"."
|
|
4
|
+
description: "Create a user-scoped subagent for every installed agent (Claude Code, opencode, Cursor, Copilot, Codex) from one merged definition, then install and run the doctor on it. Use when the user asks to create a subagent, add a custom agent, delegate a role to a specialized agent, or \"make a subagent that…\"."
|
|
5
5
|
argument-hint: <subagent name> <what it does + when to delegate>
|
|
6
6
|
metadata:
|
|
7
7
|
source: portable-agent-layer
|
|
@@ -38,7 +38,7 @@ cat ~/.pal/skills/create-subagent/authoring-guide.md
|
|
|
38
38
|
cannot reasonably infer, then confirm your draft:
|
|
39
39
|
- **model** — a flagship model or `inherit` the caller's model (can differ per agent).
|
|
40
40
|
- **tools** — which tools it may use (a comma list for Claude/Copilot; a permission
|
|
41
|
-
map for opencode). Default: inherit all tools.
|
|
41
|
+
map for opencode; a `sandbox_mode` for Codex). Default: inherit all tools.
|
|
42
42
|
- **skills** — skills to PRELOAD. Claude Code only; the other agents have no such
|
|
43
43
|
field (see the guide). Record intended skills in the body for the others.
|
|
44
44
|
- **system prompt** — the role and behavior. Draft it and confirm with the user.
|
|
@@ -18,7 +18,9 @@ A personal subagent is stored once as `~/.pal/agents/<name>.md` with **merged
|
|
|
18
18
|
multi-platform frontmatter**. On `pal cli subagent link <name>`, PAL splits it per
|
|
19
19
|
platform and writes a native file into each installed agent's agents directory. Global
|
|
20
20
|
fields (`name`, `description`) always ship; each platform block is un-indented into that
|
|
21
|
-
agent's frontmatter and the other blocks are stripped.
|
|
21
|
+
agent's frontmatter and the other blocks are stripped. Codex is the exception in format
|
|
22
|
+
only: it gets `~/.codex/agents/<name>.toml`, with the body as `developer_instructions`
|
|
23
|
+
and the `codex:` block's keys at the top level.
|
|
22
24
|
|
|
23
25
|
```markdown
|
|
24
26
|
---
|
|
@@ -44,6 +46,10 @@ cursor:
|
|
|
44
46
|
copilot:
|
|
45
47
|
model: inherit
|
|
46
48
|
tools: read, edit # comma list
|
|
49
|
+
codex:
|
|
50
|
+
model: gpt-6-sol # omit to inherit the parent session's model
|
|
51
|
+
model_reasoning_effort: high
|
|
52
|
+
sandbox_mode: read-only # read-only | workspace-write | danger-full-access
|
|
47
53
|
---
|
|
48
54
|
|
|
49
55
|
<system prompt — second-person instructions to the subagent>
|
|
@@ -67,6 +73,12 @@ with just `name` + `description` (a valid, minimal subagent).
|
|
|
67
73
|
field — emitting one there is ignored. If a subagent should lean on specific skills on
|
|
68
74
|
those agents, name them in the system prompt body instead.
|
|
69
75
|
- **cursor** — `readonly` and `is_background` are booleans (`true`/`false`).
|
|
76
|
+
- **codex** — every key is optional and inherits from the parent session when omitted.
|
|
77
|
+
Values must be strings, numbers, booleans or lists of strings. Codex has **no** `tools`
|
|
78
|
+
allow-list or `skills` list (both are config tables there, and a list makes Codex skip
|
|
79
|
+
the agent); restrict access with `sandbox_mode` and name skills in the body. The
|
|
80
|
+
global frontmatter may hold only `name` and `description`, because nothing else maps
|
|
81
|
+
to a Codex key.
|
|
70
82
|
|
|
71
83
|
## What makes a good subagent
|
|
72
84
|
|
|
@@ -92,6 +92,7 @@ After collecting agent results, synthesize into:
|
|
|
92
92
|
4. **Conflicts** — where agents disagree, with both sides presented
|
|
93
93
|
5. **Gaps** — what remains unknown or needs further investigation
|
|
94
94
|
6. **Sources** — deduplicated list of verified URLs from all agents
|
|
95
|
+
7. **Missing API keys** — only when a researcher ended with a WebSearch fallback footnote: tell the user which researcher ran without its API, which key it needs, where to get one, and to add it to `~/.pal/.env`. One line per missing key, never dropped from the synthesis
|
|
95
96
|
|
|
96
97
|
Keep total output under 1500 words unless the user asks for more.
|
|
97
98
|
|
|
@@ -53,6 +53,7 @@ function loadApiKey(): string {
|
|
|
53
53
|
if (!key) {
|
|
54
54
|
console.error("Error: PAL_GEMINI_API_KEY environment variable is not set.");
|
|
55
55
|
console.error("Get an API key at https://aistudio.google.com/apikey");
|
|
56
|
+
console.error("and add it to ~/.pal/.env as PAL_GEMINI_API_KEY=...");
|
|
56
57
|
process.exit(1);
|
|
57
58
|
}
|
|
58
59
|
return key;
|
|
@@ -49,6 +49,7 @@ function loadApiKey(): string {
|
|
|
49
49
|
if (!key) {
|
|
50
50
|
console.error("Error: PAL_XAI_API_KEY environment variable is not set.");
|
|
51
51
|
console.error("Get an API key at https://console.x.ai/");
|
|
52
|
+
console.error("and add it to ~/.pal/.env as PAL_XAI_API_KEY=...");
|
|
52
53
|
process.exit(1);
|
|
53
54
|
}
|
|
54
55
|
return key;
|
|
@@ -39,7 +39,7 @@ function loadApiKey(): string {
|
|
|
39
39
|
"Error: PAL_PERPLEXITY_API_KEY environment variable is not set.\n" +
|
|
40
40
|
"The Perplexity API could not be reached. The researcher agent should fall back to WebSearch.\n" +
|
|
41
41
|
"To enable Perplexity search, get an API key at https://www.perplexity.ai/settings/api\n" +
|
|
42
|
-
"and
|
|
42
|
+
"and add it to ~/.pal/.env as PAL_PERPLEXITY_API_KEY=pplx-..."
|
|
43
43
|
);
|
|
44
44
|
process.exit(1);
|
|
45
45
|
}
|
|
@@ -41,8 +41,8 @@ pal cli skill run fyzz-chat-api fyzz-api -- projects
|
|
|
41
41
|
If the tool reports a missing API key:
|
|
42
42
|
|
|
43
43
|
1. Ask the user to create one in Fyzz Chat → Settings → API Keys
|
|
44
|
-
2. They should
|
|
45
|
-
3. Optionally
|
|
44
|
+
2. They should add `PAL_FYZZ_API_KEY=...` to `~/.pal/.env`
|
|
45
|
+
3. Optionally add `PAL_FYZZ_BASE_URL=...` there too (defaults to `http://localhost:3000`)
|
|
46
46
|
|
|
47
47
|
## Guidelines
|
|
48
48
|
|
|
@@ -17,7 +17,7 @@ function loadApiKey(): string {
|
|
|
17
17
|
const key = process.env.PAL_FYZZ_API_KEY;
|
|
18
18
|
if (!key) {
|
|
19
19
|
console.error("Error: PAL_FYZZ_API_KEY environment variable is not set.");
|
|
20
|
-
console.error("
|
|
20
|
+
console.error("Add it to ~/.pal/.env as PAL_FYZZ_API_KEY=...");
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
return key;
|
package/package.json
CHANGED
package/src/cli/doctor/agents.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import type { AgentPlatform } from "../../hooks/lib/agent-definition";
|
|
4
5
|
import { palPkg, platform } from "../../hooks/lib/paths";
|
|
6
|
+
import { nativeAgentsDir, staleShippedAgents } from "../../targets/lib";
|
|
5
7
|
import { NO_SESSION_AGENT_MESSAGE } from "../session-agent";
|
|
6
8
|
import { type Finding, type Fix, failing, optional, passed, warning } from "./finding";
|
|
7
9
|
|
|
@@ -274,6 +276,18 @@ function opencodePluginFindings(): Finding[] {
|
|
|
274
276
|
return [passed("opencode.hooks", "opencode plugin installed and current")];
|
|
275
277
|
}
|
|
276
278
|
|
|
279
|
+
function subagentFinding(agent: AgentPlatform): Finding {
|
|
280
|
+
const label = LAYOUT[agent].label;
|
|
281
|
+
const stale = staleShippedAgents(nativeAgentsDir(agent), agent);
|
|
282
|
+
if (stale.length === 0)
|
|
283
|
+
return passed(`${agent}.subagents`, `${label}: subagents match this PAL version`);
|
|
284
|
+
return warning(
|
|
285
|
+
`${agent}.subagents`,
|
|
286
|
+
`${label}: installed subagents differ from this PAL version — ${stale.join(", ")}`,
|
|
287
|
+
reinstall(agent)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
277
291
|
function countSkills(dir: string): number {
|
|
278
292
|
if (!existsSync(dir)) return 0;
|
|
279
293
|
return readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length;
|
|
@@ -290,7 +304,8 @@ function oneAgentFindings(agent: AgentName): Finding[] {
|
|
|
290
304
|
findings.push(
|
|
291
305
|
...(layout.hookFile
|
|
292
306
|
? hookFindings(agent, layout.hookFile())
|
|
293
|
-
: opencodePluginFindings())
|
|
307
|
+
: opencodePluginFindings()),
|
|
308
|
+
subagentFinding(agent)
|
|
294
309
|
);
|
|
295
310
|
if (layout.instructions) {
|
|
296
311
|
const { file, name } = layout.instructions;
|
package/src/cli/doctor/health.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { cachedStatus, type UpdateCache } from "../../hooks/handlers/update-check";
|
|
3
|
+
import { loggedClaudeAuthMode } from "../../hooks/lib/inference";
|
|
3
4
|
import { type HookErrorGroup, recentHookErrors } from "../../hooks/lib/log";
|
|
5
|
+
import { palEnvPath } from "../../hooks/lib/pal-env";
|
|
4
6
|
import { palHome } from "../../hooks/lib/paths";
|
|
5
7
|
import { checkPendingMigrations } from "../migrate";
|
|
6
8
|
import { type Finding, failing, passed, warning } from "./finding";
|
|
@@ -13,7 +15,6 @@ interface PendingMigration {
|
|
|
13
15
|
|
|
14
16
|
interface ErrorContext {
|
|
15
17
|
now: number;
|
|
16
|
-
env: Record<string, string | undefined>;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const CLAUDE_LOGIN_EXPIRED = /Failed to authenticate/;
|
|
@@ -29,22 +30,22 @@ function claudeLoginExpiredFinding(
|
|
|
29
30
|
context: ErrorContext
|
|
30
31
|
): Finding {
|
|
31
32
|
const title = `Background calls to Claude could not log in — ${group.count} failed in the last 24h, last ${ago(group.lastAt, context.now)}: ${group.last}`;
|
|
32
|
-
if (
|
|
33
|
+
if (loggedClaudeAuthMode(group.lastMessage ?? "") === "token")
|
|
33
34
|
return warning(`hook-errors.${group.source}`, title, {
|
|
34
|
-
say:
|
|
35
|
+
say: `CLAUDE_CODE_OAUTH_TOKEN was rejected — it expired or is invalid. Create a new one and replace it in ${palEnvPath()}`,
|
|
35
36
|
command: "claude setup-token",
|
|
36
37
|
external: true,
|
|
37
38
|
});
|
|
38
39
|
return failing(`hook-errors.${group.source}`, title, {
|
|
39
|
-
say: "
|
|
40
|
-
command: "claude
|
|
40
|
+
say: "Log the Claude CLI in again",
|
|
41
|
+
command: "claude auth login",
|
|
41
42
|
external: true,
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function hookErrorFindings(
|
|
46
47
|
groups: HookErrorGroup[],
|
|
47
|
-
context: ErrorContext = { now: Date.now()
|
|
48
|
+
context: ErrorContext = { now: Date.now() }
|
|
48
49
|
): Finding[] {
|
|
49
50
|
if (groups.length === 0)
|
|
50
51
|
return [passed("hook-errors", "No hook errors in the last 24h")];
|
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { previewInferenceRoute } from "../../hooks/lib/inference";
|
|
4
4
|
import { opencodeBackgroundModel } from "../../hooks/lib/opencode-config";
|
|
5
|
+
import { palEnvPath, readPalEnvFile } from "../../hooks/lib/pal-env";
|
|
5
6
|
import { platform } from "../../hooks/lib/paths";
|
|
6
7
|
import type { AgentName } from "./agents";
|
|
7
8
|
import { type Finding, failing, optional, passed, warning } from "./finding";
|
|
@@ -14,14 +15,19 @@ const SUBPROCESS_ONLY = {
|
|
|
14
15
|
PAL_INFERENCE_DISABLED: "every inference call fails",
|
|
15
16
|
} as const;
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
const INFERENCE_KEYS = {
|
|
18
19
|
PAL_ANTHROPIC_API_KEY: "fallback when the claude CLI cannot answer",
|
|
19
20
|
PAL_OPENAI_API_KEY: "fallback when the codex CLI cannot answer",
|
|
20
|
-
PAL_GEMINI_API_KEY: "YouTube analysis and Gemini research",
|
|
21
|
-
PAL_XAI_API_KEY: "the Grok researcher",
|
|
22
|
-
PAL_PERPLEXITY_API_KEY: "the Perplexity researcher",
|
|
23
21
|
} as const;
|
|
24
22
|
|
|
23
|
+
const SKILL_KEYS = [
|
|
24
|
+
"PAL_GEMINI_API_KEY",
|
|
25
|
+
"PAL_XAI_API_KEY",
|
|
26
|
+
"PAL_PERPLEXITY_API_KEY",
|
|
27
|
+
"PAL_FYZZ_API_KEY",
|
|
28
|
+
"PAL_FYZZ_BASE_URL",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
25
31
|
function unsetCommand(name: string, os: NodeJS.Platform): string {
|
|
26
32
|
return os === "win32" ? `Remove-Item Env:${name}` : `unset ${name}`;
|
|
27
33
|
}
|
|
@@ -42,14 +48,85 @@ export function leakedEnvFindings(env: Env, os: NodeJS.Platform): Finding[] {
|
|
|
42
48
|
);
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
function shellKeyConsequence(shellValue: string, fileValue: string | undefined): string {
|
|
52
|
+
if (fileValue === undefined)
|
|
53
|
+
return "so agents launched outside a terminal never see it";
|
|
54
|
+
if (fileValue === shellValue) return `and duplicated in ${palEnvPath()}`;
|
|
55
|
+
return `and overrides the different value in ${palEnvPath()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shellKeyFinding(name: string, shellValue: string, fileValue?: string): Finding {
|
|
59
|
+
return warning(
|
|
60
|
+
`key.${name}`,
|
|
61
|
+
`${name} is set in your shell, ${shellKeyConsequence(shellValue, fileValue)}`,
|
|
62
|
+
{
|
|
63
|
+
say:
|
|
64
|
+
fileValue === undefined
|
|
65
|
+
? `Move it to ${palEnvPath()} and remove it from your shell profile`
|
|
66
|
+
: "Remove it from your shell profile",
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function keyFinding(
|
|
72
|
+
name: string,
|
|
73
|
+
shell: Env,
|
|
74
|
+
file: Record<string, string>,
|
|
75
|
+
ifUnset: () => Finding | null
|
|
76
|
+
): Finding | null {
|
|
77
|
+
const shellValue = shell[name];
|
|
78
|
+
if (shellValue) return shellKeyFinding(name, shellValue, file[name]);
|
|
79
|
+
if (file[name]) return passed(`key.${name}`, `${name} set`);
|
|
80
|
+
return ifUnset();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function apiKeyFindings(
|
|
84
|
+
shell: Env,
|
|
85
|
+
file: Record<string, string> = readPalEnvFile()
|
|
86
|
+
): Finding[] {
|
|
87
|
+
const inference = Object.entries(INFERENCE_KEYS).map(([name, unlocks]) =>
|
|
88
|
+
keyFinding(name, shell, file, () =>
|
|
89
|
+
optional(`key.${name}`, `${name} — ${unlocks}`, {
|
|
90
|
+
say: `add it to ${palEnvPath()}`,
|
|
91
|
+
})
|
|
92
|
+
)
|
|
52
93
|
);
|
|
94
|
+
const skills = SKILL_KEYS.map((name) => keyFinding(name, shell, file, () => null));
|
|
95
|
+
return [...inference, ...skills].filter((f): f is Finding => f !== null);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const OAUTH_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
99
|
+
|
|
100
|
+
function isMalformedToken(value: string): boolean {
|
|
101
|
+
return value === "" || /[\s"']/.test(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Silent unless a token is set; it is optional, and the native login needs none of this. */
|
|
105
|
+
export function oauthTokenFindings(
|
|
106
|
+
shell: Env,
|
|
107
|
+
file: Record<string, string> = readPalEnvFile()
|
|
108
|
+
): Finding[] {
|
|
109
|
+
const shellToken = shell[OAUTH_TOKEN];
|
|
110
|
+
const fileToken = file[OAUTH_TOKEN];
|
|
111
|
+
if (shellToken === undefined && fileToken === undefined) return [];
|
|
112
|
+
const findings: Finding[] = [];
|
|
113
|
+
if ([shellToken, fileToken].some((t) => t !== undefined && isMalformedToken(t)))
|
|
114
|
+
findings.push(
|
|
115
|
+
warning("oauth.malformed", `${OAUTH_TOKEN} is empty or contains spaces or quotes`, {
|
|
116
|
+
say: `Paste the token from 'claude setup-token' as a bare value in ${palEnvPath()}`,
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
if (shellToken && fileToken && shellToken !== fileToken)
|
|
120
|
+
findings.push(
|
|
121
|
+
warning(
|
|
122
|
+
"oauth.shadowed",
|
|
123
|
+
`Your shell's ${OAUTH_TOKEN} differs from the one in ${palEnvPath()}, and the shell's wins`,
|
|
124
|
+
{
|
|
125
|
+
say: "Keep one copy: drop it from your shell, or have your shell read it from the file",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
return findings.length > 0 ? findings : [passed("oauth", `${OAUTH_TOKEN} set`)];
|
|
53
130
|
}
|
|
54
131
|
|
|
55
132
|
type Preview = ReturnType<typeof previewInferenceRoute>;
|
|
@@ -131,5 +208,6 @@ export function inferenceFindings(agents: AgentName[]): Finding[] {
|
|
|
131
208
|
...(agents.includes("cursor") ? cursorFindings() : []),
|
|
132
209
|
...leakedEnvFindings(process.env, process.platform),
|
|
133
210
|
...apiKeyFindings(process.env),
|
|
211
|
+
...oauthTokenFindings(process.env),
|
|
134
212
|
];
|
|
135
213
|
}
|
package/src/cli/index.ts
CHANGED
|
File without changes
|
package/src/cli/skill.ts
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
19
19
|
import { resolve } from "node:path";
|
|
20
20
|
import { getActiveAgent } from "../hooks/lib/agent";
|
|
21
21
|
import { flagshipAuthorModel } from "../hooks/lib/models";
|
|
22
|
-
import {
|
|
22
|
+
import { withPalEnv } from "../hooks/lib/pal-env";
|
|
23
|
+
import { isInside, palHome, palPkg } from "../hooks/lib/paths";
|
|
23
24
|
import { linkPersonalSkill, log } from "../targets/lib";
|
|
24
25
|
import {
|
|
25
26
|
formatReport,
|
|
@@ -93,10 +94,17 @@ function runSkillTool(skill: string, tool: string, toolArgs: string[]): number {
|
|
|
93
94
|
}
|
|
94
95
|
const { status } = spawnSync("bun", [path, ...toolArgs], {
|
|
95
96
|
stdio: "inherit",
|
|
97
|
+
env: isShippedTool(path) ? withPalEnv(process.env) : process.env,
|
|
96
98
|
});
|
|
97
99
|
return status ?? 1;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/** Real paths, so a personal skill symlinked from elsewhere never passes as shipped. */
|
|
103
|
+
function isShippedTool(path: string): boolean {
|
|
104
|
+
const shipped = resolve(palPkg(), "assets", "skills");
|
|
105
|
+
return existsSync(shipped) && isInside(realpathSync(shipped), realpathSync(path));
|
|
106
|
+
}
|
|
107
|
+
|
|
100
108
|
export async function runSkill(args: string[]): Promise<number> {
|
|
101
109
|
const [sub, name] = args;
|
|
102
110
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merged multi-platform subagent format shared by assets/agents/*.md and
|
|
3
|
+
* ~/.pal/agents/*.md: global frontmatter lines, one indented block per platform,
|
|
4
|
+
* and a body that becomes the subagent's system prompt.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { splitFrontmatter } from "./frontmatter";
|
|
8
|
+
|
|
9
|
+
export const AGENT_PLATFORMS = [
|
|
10
|
+
"claude",
|
|
11
|
+
"opencode",
|
|
12
|
+
"cursor",
|
|
13
|
+
"copilot",
|
|
14
|
+
"codex",
|
|
15
|
+
] as const;
|
|
16
|
+
export type AgentPlatform = (typeof AGENT_PLATFORMS)[number];
|
|
17
|
+
|
|
18
|
+
export interface AgentDefinition {
|
|
19
|
+
hasFrontmatter: boolean;
|
|
20
|
+
global: string[];
|
|
21
|
+
platforms: Partial<Record<AgentPlatform, string[]>>;
|
|
22
|
+
body: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PLATFORM_BLOCK_HEADER = new RegExp(
|
|
26
|
+
String.raw`^(${AGENT_PLATFORMS.join("|")}):\s*$`
|
|
27
|
+
);
|
|
28
|
+
const BLOCK_INDENT = " ";
|
|
29
|
+
|
|
30
|
+
export function parseAgentDefinition(content: string): AgentDefinition {
|
|
31
|
+
const split = splitFrontmatter(content);
|
|
32
|
+
if (!split) {
|
|
33
|
+
return { hasFrontmatter: false, global: [], platforms: {}, body: content };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const global: string[] = [];
|
|
37
|
+
const platforms: Partial<Record<AgentPlatform, string[]>> = {};
|
|
38
|
+
let current: AgentPlatform | null = null;
|
|
39
|
+
|
|
40
|
+
for (const line of split.frontmatter.split("\n")) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
const header = PLATFORM_BLOCK_HEADER.exec(line);
|
|
43
|
+
if (header) {
|
|
44
|
+
current = header[1] as AgentPlatform;
|
|
45
|
+
platforms[current] ??= [];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (current && line.startsWith(BLOCK_INDENT)) {
|
|
49
|
+
platforms[current]?.push(line.slice(BLOCK_INDENT.length));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
current = null;
|
|
53
|
+
global.push(line);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { hasFrontmatter: true, global, platforms, body: split.body };
|
|
57
|
+
}
|
|
@@ -10,23 +10,36 @@ interface Parsed<T = Record<string, string>> {
|
|
|
10
10
|
body: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const DELIMITER =
|
|
13
|
+
const DELIMITER = /^---[ \t]*$/m;
|
|
14
|
+
|
|
15
|
+
interface FrontmatterSplit {
|
|
16
|
+
leading: string;
|
|
17
|
+
frontmatter: string;
|
|
18
|
+
body: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Split text at its first two `---` lines. The body keeps every character after
|
|
23
|
+
* the closing delimiter, so later `---` rules and the lines around them survive.
|
|
24
|
+
*/
|
|
25
|
+
export function splitFrontmatter(content: string): FrontmatterSplit | null {
|
|
26
|
+
const parts = content.split(DELIMITER);
|
|
27
|
+
if (parts.length < 3) return null;
|
|
28
|
+
return { leading: parts[0], frontmatter: parts[1], body: parts.slice(2).join("---") };
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
/**
|
|
16
32
|
* Parse frontmatter from a markdown string.
|
|
17
33
|
* Returns typed meta + body. If no frontmatter found, meta is empty and body is the full content.
|
|
18
34
|
*/
|
|
19
35
|
export function parse<T = Record<string, string>>(content: string): Parsed<T> {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
// Need at least 3 parts: before --- | frontmatter | after ---
|
|
23
|
-
// parts[0] should be empty (content starts with ---)
|
|
24
|
-
if (parts.length < 3 || parts[0].trim() !== "") {
|
|
36
|
+
const split = splitFrontmatter(content);
|
|
37
|
+
if (!split || split.leading.trim() !== "") {
|
|
25
38
|
return { meta: {} as T, body: content };
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
const rawMeta =
|
|
29
|
-
const body =
|
|
41
|
+
const rawMeta = split.frontmatter;
|
|
42
|
+
const body = split.body.trim();
|
|
30
43
|
|
|
31
44
|
const meta: Record<string, unknown> = {};
|
|
32
45
|
for (const line of rawMeta.split("\n")) {
|