portable-agent-layer 0.46.0 → 0.47.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 CHANGED
@@ -85,6 +85,7 @@ pal cli status # check your setup
85
85
  | `pal cli migrate` | Run pending data migrations (non-destructive) |
86
86
  | `pal cli usage` | Summarize token usage and estimated cost |
87
87
  | `pal cli knowledge` | Query & manage the knowledge store (search, graph, stats, hubs, find, show, add, ls, ingest) |
88
+ | `pal cli skill link <name>` | Link a personal `~/.pal/skills/<name>/` into every installed agent so it is discoverable |
88
89
 
89
90
  ### Target flags
90
91
 
@@ -1,69 +1,72 @@
1
1
  ---
2
2
  name: create-skill
3
- description: Scaffold a new PAL skill from a description. Use when creating a new skill, adding a capability, or building a custom command. Enforces general, assistant-facing, personally-clean skill writing.
3
+ description: Create a new personal skill for this user, scaffolded into their own ~/.pal/skills/ and linked into every installed agent. Use when the user asks to create a skill, add a capability, build a custom command, or "make a skill that…".
4
4
  argument-hint: <skill name> <skill description>
5
5
  ---
6
6
 
7
- ## What makes a good PAL skill
7
+ # Create a personal skill
8
8
 
9
- Before writing any skill, internalize these three rules. A skill that violates them gets re-written, not committed.
9
+ This scaffolds a personal skill into the user's own `~/.pal/skills/<name>/` and links it into every installed agent so it is immediately discoverable.
10
10
 
11
- 1. **Pointed at the assistant, not the user.** A skill is *instructions you (the assistant) follow*. Write in second person addressing yourself ("read X", "extract Y", "output Z"). Prescriptive verbs, deterministic flow. It is **not** a user tutorial, marketing blurb, or README. The user reads `README.md`; you read `SKILL.md`.
11
+ ## What makes a good skill
12
12
 
13
- 2. **Zero personal information.** No usernames, real names, employer names, project codenames, dataset names, hostnames, absolute home paths (`C:\Users\someone\…`), email addresses, or anecdotes about the author's own work. A skill must be reusable by any user. If the rule is "don't expand `~` on Windows cmd," that is the rule — don't add "because $username's primary shell is cmd." Personal context belongs in the user's private memory, never in the skill body.
13
+ 1. **Pointed at the assistant, not the user.** A skill is *instructions you (the assistant) follow*. Write in second person addressing yourself ("read X", "run Y", "output Z"). Prescriptive verbs, deterministic flow not a tutorial or marketing blurb.
14
14
 
15
- 3. **General, not user-specific.** A skill describes a *workflow class* (e.g. "build a deck," "summarize a PDF," "lint a python file"), not a single user's habits. Use placeholders (`<deck-dir>`, `<input.pdf>`), not hardcoded paths. If the workflow only makes sense for one person, it is a memory entry or a personal hook, not a skill.
15
+ 2. **One skill, one job.** A skill describes a single workflow. If it needs branches like "for case A do X, for case B do Y," it is two skills.
16
+
17
+ 3. **Concise and concrete.** Assume the model is already smart — only add what it doesn't already know. Every step has a verb and an object; no "as needed" or "appropriately." Keep the SKILL.md body well under 500 lines; push long reference material into sibling files linked one level deep.
18
+
19
+ A personal skill **may** contain this user's own context — their paths, project names, preferences, conventions. That is the point of a personal skill.
16
20
 
17
21
  ## Skill anatomy
18
22
 
19
23
  ```markdown
20
24
  ---
21
25
  name: <slug> # the slash-command name; lowercase-kebab
22
- description: <one sentence trigger> # WHEN to invoke (used by the dispatcher), not WHAT it does
26
+ description: <what it does + WHEN to invoke> # the dispatcher matches on this
23
27
  argument-hint: <args> # optional; how the user passes input
24
28
  ---
25
29
 
26
30
  ## Overview / Workflow
27
31
 
28
- Numbered steps the assistant follows on invocation. Each step is concrete:
29
- read this file, run this command, ask this question, output this format.
32
+ Numbered, concrete steps the assistant follows on invocation.
30
33
 
31
34
  ## Output format
32
35
 
33
- Exactly what the assistant returns to the user. Specify structure if the
34
- caller will parse it; specify tone if the caller is a human.
36
+ Exactly what the assistant returns structure if it will be parsed, tone if a human reads it.
35
37
 
36
38
  ## When to use / Do NOT use
37
39
 
38
- Two short lists. The "do not" list disambiguates this skill from neighbours
39
- that would otherwise also match the user's request.
40
+ Two short lists; the "do not" list disambiguates this skill from neighbours.
40
41
  ```
41
42
 
42
- ## Workflow when the user invokes `/create-skill <name> <description>`
43
-
44
- 1. Sanity-check the name and description against the three rules above. If the description leaks personal info ("a skill for me to clean my Notion db"), rewrite it to the general form ("clean a Notion database via the API") before scaffolding.
45
- 2. Create `assets/skills/<name>/SKILL.md` in the repo (the canonical source). The installed copy at `~/.pal/skills/<name>/SKILL.md` may be a junction to this path — verify the user's setup before assuming.
46
- 3. Populate the SKILL.md from the anatomy above. Required fields: `name`, `description`, body sections (Workflow, Output format, When to use). Add `argument-hint` if the skill takes arguments.
47
- 4. If the skill needs runtime tooling (TypeScript, scripts, vendored assets), scaffold a `tools/` subdir alongside SKILL.md. Otherwise leave the skill markdown-only.
48
- 5. Validate before declaring done:
49
- - **Trigger clarity** — could a model decide *not* to invoke this skill from the description alone? If yes, tighten the description.
50
- - **Step concreteness** — every step has a verb and an object; no "as needed" or "appropriately."
51
- - **Output specification** — caller knows what they get back.
52
- - **Scope discipline** one skill, one job. If the skill needs section headers like "for case A do X, for case B do Y," it is two skills.
53
- - **Personal-info scan** — grep the new SKILL.md for usernames, real names, absolute home paths, employer or project codenames; remove any hits.
54
- - **Generality test** — could a stranger with the same workflow need use this unchanged? If no, factor the user-specific bits into memory.
55
-
56
- ## Anti-patterns to refuse
57
-
58
- - A SKILL.md whose description starts with "I want…" or "My …" that's a journal entry, not a skill.
59
- - Hardcoded paths under `C:\Users\<name>\…` or `/Users/<name>/…` — use `~` (and document the cmd.exe `%USERPROFILE%` alternative if Windows is in scope).
60
- - Brand- or company-specific defaults baked into the skill body. Default brand colors, footer strings, etc. belong in *templates* (user data) or *config*, never in the skill.
61
- - A description longer than ~30 words, or one that explains the implementation rather than the trigger.
62
- - Skills that duplicate an existing skill's trigger surface. Read the existing skills index before scaffolding.
43
+ The `description` should state **both what the skill does and when to invoke it**, in third person, with the trigger terms a model would match on. A vague description ("helps with documents") will not trigger reliably.
44
+
45
+ ## Workflow when invoked with `<name> <description>`
46
+
47
+ 1. Validate the name: lowercase-kebab, no spaces, not colliding with an existing skill (check `~/.pal/skills/` and the active skill list).
48
+ 2. Confirm the trigger with the user if the description is ambiguous about *when* the skill should fire.
49
+ 3. Create the directory and SKILL.md at the user's PAL home:
50
+ ```bash
51
+ mkdir -p ~/.pal/skills/<name>
52
+ ```
53
+ Write `~/.pal/skills/<name>/SKILL.md` populated from the anatomy above (frontmatter + Workflow + Output format + When to use).
54
+ 4. If the skill needs runtime tooling, scaffold a `tools/` subdir alongside SKILL.md and write the scripts there.
55
+ 5. Link the new skill into every installed agent so it is discoverable:
56
+ ```bash
57
+ pal cli skill link <name>
58
+ ```
59
+ This creates the per-skill discovery symlink in each installed agent's skills directory (Claude Code, Cursor, Copilot, Codex); opencode discovers it automatically via `~/.pal/skills/`.
60
+ 6. Validate before declaring done:
61
+ - **Trigger clarity** could a model decide *not* to invoke this from the description alone? If so, tighten it.
62
+ - **Step concreteness** every step has a verb and an object.
63
+ - **Output specification** the caller knows what they get back.
64
+ - **Scope discipline** — one skill, one job.
63
65
 
64
66
  ## Output format
65
67
 
66
68
  After scaffolding, return:
67
- - The path of the created `SKILL.md`.
69
+ - The path of the created `SKILL.md` (under `~/.pal/skills/<name>/`).
70
+ - The agents it was linked into (from the `pal cli skill link` output).
68
71
  - A 2-3 sentence summary of the trigger and workflow.
69
- - Any anti-pattern violations you caught and corrected during scaffolding (so the user learns the rule).
72
+ - How to invoke it (its `/<name>` slash command or trigger phrase).
@@ -13,7 +13,7 @@
13
13
  // node --experimental-strip-types ~/.pal/skills/playwright/tools/shot.ts <url> [opts]
14
14
 
15
15
  import { spawnSync } from "node:child_process";
16
- import { existsSync } from "node:fs";
16
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join, resolve } from "node:path";
19
19
  import { chooseTier, parseArgs, type ShotOptions } from "./shot-lib.ts";
@@ -27,13 +27,23 @@ function playwrightCliAvailable(): boolean {
27
27
  }
28
28
 
29
29
  function runViaCli(opts: ShotOptions, out: string): boolean {
30
- const open = spawnSync("playwright-cli", ["open", opts.url], { stdio: "inherit" });
31
- if (open.status !== 0) return false;
32
- const args = ["screenshot", `--filename=${out}`];
33
- if (opts.selector) args.push(opts.selector);
34
- const shot = spawnSync("playwright-cli", args, { stdio: "inherit" });
35
- spawnSync("playwright-cli", ["close"], { stdio: "ignore" });
36
- return shot.status === 0 && existsSync(out);
30
+ // playwright-cli writes a `.playwright-cli/` session dir (logs + page snapshots)
31
+ // into its cwd. Run it inside a throwaway temp dir so nothing lands in the user's
32
+ // repo, then remove it. `out` is already absolute, so the screenshot is unaffected.
33
+ // Cross-platform: os.tmpdir + fs.mkdtemp/rm — no shell, no platform-specific paths.
34
+ const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
35
+ const run = (args: string[], quiet = false) =>
36
+ spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
37
+ try {
38
+ if (run(["open", opts.url]).status !== 0) return false;
39
+ const args = ["screenshot", `--filename=${out}`];
40
+ if (opts.selector) args.push(opts.selector);
41
+ const shot = run(args);
42
+ run(["close"], true);
43
+ return shot.status === 0 && existsSync(out);
44
+ } finally {
45
+ rmSync(work, { recursive: true, force: true });
46
+ }
37
47
  }
38
48
 
39
49
  // "unavailable" = the engine itself can't run (package or Chromium missing) → MCP fallback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * status Show current PAL configuration
17
17
  * doctor Check prerequisites and system health
18
18
  * usage Summarize token usage and cost
19
+ * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
19
20
  */
20
21
 
21
22
  import { spawnSync } from "node:child_process";
@@ -192,6 +193,12 @@ async function runCli(command: string | undefined, args: string[]) {
192
193
  if (code !== 0) process.exit(code);
193
194
  break;
194
195
  }
196
+ case "skill": {
197
+ const { runSkill } = await import("./skill");
198
+ const code = await runSkill(args);
199
+ if (code !== 0) process.exit(code);
200
+ break;
201
+ }
195
202
  case "--help":
196
203
  case "-h":
197
204
  case "help":
@@ -234,6 +241,7 @@ function showHelp() {
234
241
  pal cli usage Summarize token usage and cost
235
242
  pal cli knowledge <sub> [args] Query & manage the knowledge store
236
243
  (search · graph · stats · hubs · find · show · add · ls)
244
+ pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
237
245
 
238
246
  Environment:
239
247
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * pal cli skill — manage personal skills under ~/.pal/skills/.
3
+ *
4
+ * pal cli skill link <name> Link an existing ~/.pal/skills/<name>/ into
5
+ * every installed agent so it is discoverable.
6
+ */
7
+
8
+ import { linkPersonalSkill, log } from "../targets/lib";
9
+
10
+ export async function runSkill(args: string[]): Promise<number> {
11
+ const [sub, name] = args;
12
+
13
+ if (sub === "link") {
14
+ if (!name) {
15
+ log.error("Usage: pal cli skill link <name>");
16
+ return 1;
17
+ }
18
+ try {
19
+ const linked = linkPersonalSkill(name);
20
+ if (linked.length === 0) {
21
+ log.warn(
22
+ `'${name}' linked to no per-skill agents (none installed). ` +
23
+ "It is still discoverable by opencode via ~/.pal/skills/."
24
+ );
25
+ } else {
26
+ log.success(
27
+ `Linked '${name}' into: ${linked.join(", ")} ` +
28
+ "(opencode: auto via ~/.pal/skills/)"
29
+ );
30
+ }
31
+ return 0;
32
+ } catch (e) {
33
+ log.error(e instanceof Error ? e.message : String(e));
34
+ return 1;
35
+ }
36
+ }
37
+
38
+ log.error("Usage: pal cli skill link <name>");
39
+ return 1;
40
+ }
@@ -506,6 +506,46 @@ export function copySkills(claudeSkillsDir: string): number {
506
506
  return count;
507
507
  }
508
508
 
509
+ /**
510
+ * Agent skills directories that need a per-skill discovery link.
511
+ *
512
+ * opencode is intentionally absent: it discovers the whole ~/.pal/skills/ tree
513
+ * via the ~/.agents/skills → ~/.pal/skills symlink, so any personal skill is
514
+ * picked up without a per-skill link.
515
+ */
516
+ function perSkillAgentDirs(): { agent: string; dir: string }[] {
517
+ return [
518
+ { agent: "claude", dir: resolve(platform.claudeDir(), "skills") },
519
+ { agent: "cursor", dir: resolve(platform.cursorDir(), "skills") },
520
+ { agent: "copilot", dir: resolve(platform.copilotDir(), "skills") },
521
+ { agent: "codex", dir: resolve(platform.codexDir(), "skills") },
522
+ ];
523
+ }
524
+
525
+ /**
526
+ * Link a personal skill that already lives at ~/.pal/skills/<name>/ into every
527
+ * installed agent's skills directory so the agent discovers it. Mirrors the
528
+ * per-skill discovery link copySkills() creates for shipped skills.
529
+ *
530
+ * An agent counts as installed when its skills directory already exists.
531
+ * Returns the agents a discovery link was created for (opencode excluded — it
532
+ * is covered by the whole-dir ~/.agents/skills link).
533
+ */
534
+ export function linkPersonalSkill(name: string): string[] {
535
+ const palLink = resolve(PAL_SKILLS_DIR, name);
536
+ if (!existsSync(resolve(palLink, "SKILL.md"))) {
537
+ throw new Error(`No skill found at ${palLink}/SKILL.md`);
538
+ }
539
+ const linkType = process.platform === "win32" ? "junction" : "dir";
540
+ const linked: string[] = [];
541
+ for (const { agent, dir } of perSkillAgentDirs()) {
542
+ if (!existsSync(dir)) continue; // agent not installed
543
+ ensureSymlink(resolve(dir, name), palLink, linkType);
544
+ linked.push(agent);
545
+ }
546
+ return linked;
547
+ }
548
+
509
549
  /** Create or update a symlink/junction, replacing any non-symlink entry. */
510
550
  function ensureSymlink(link: string, target: string, type: "dir" | "junction"): void {
511
551
  try {