portable-agent-layer 0.74.0 → 0.75.1

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
@@ -97,6 +97,7 @@ pal cli status # check your setup
97
97
  | `pal cli subagent link <name>` | Install a personal `~/.pal/agents/<name>.md` (merged multi-platform definition) into every installed agent, split per platform |
98
98
  | `pal cli subagent doctor <name>` | Evaluate a subagent definition against the authoring best practices (name/file match, per-platform blocks, model/tools/permission shape, shipped-name collision) |
99
99
  | `pal cli subagent list` | List the user-authored subagents in `~/.pal/agents/` |
100
+ | `pal cli version` | Print the installed PAL version. `-v` and `--version` do the same |
100
101
 
101
102
  ### Target flags
102
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.74.0",
3
+ "version": "0.75.1",
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
@@ -23,6 +23,7 @@
23
23
  * subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
24
24
  * subagent doctor <name> Evaluate a subagent against the authoring best practices
25
25
  * debug [on|off] Enable / disable verbose hook debug logging
26
+ * version | -v Print the installed PAL version
26
27
  */
27
28
 
28
29
  import { spawnSync } from "node:child_process";
@@ -280,6 +281,11 @@ async function runCli(command: string | undefined, args: string[]) {
280
281
  case "debug":
281
282
  cliDebug(args);
282
283
  break;
284
+ case "version":
285
+ case "-v":
286
+ case "--version":
287
+ showVersion();
288
+ break;
283
289
  case "--help":
284
290
  case "-h":
285
291
  case "help":
@@ -351,6 +357,7 @@ function showHelp() {
351
357
  pal cli subagent list List the user-authored subagents in ~/.pal/agents/
352
358
  pal cli subagent author-model Print the flagship model that authors subagents for the active agent
353
359
  pal cli debug [on|off] Enable/disable verbose hook debug logging (persisted)
360
+ pal cli version | -v Print the installed PAL version
354
361
 
355
362
  Environment:
356
363
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
@@ -1617,21 +1624,29 @@ function cliDebug(args: string[]) {
1617
1624
  }
1618
1625
  }
1619
1626
 
1620
- async function status() {
1621
- const home = palHome();
1622
- const pkg = palPkg();
1623
-
1624
- let pkgJson: { version: string };
1627
+ function packageVersion(): string {
1625
1628
  try {
1626
- pkgJson = JSON.parse(readFileSync(resolve(pkg, "package.json"), "utf-8")) as {
1629
+ const pkgJson = JSON.parse(
1630
+ readFileSync(resolve(palPkg(), "package.json"), "utf-8")
1631
+ ) as {
1627
1632
  version: string;
1628
1633
  };
1634
+ return pkgJson.version;
1629
1635
  } catch (e) {
1630
1636
  throw new Error(`Failed to read package.json: ${e}`);
1631
1637
  }
1638
+ }
1639
+
1640
+ function showVersion() {
1641
+ console.log(packageVersion());
1642
+ }
1643
+
1644
+ async function status() {
1645
+ const home = palHome();
1646
+ const pkg = palPkg();
1632
1647
 
1633
1648
  console.log("");
1634
- log.info(`Version: ${pkgJson.version}`);
1649
+ log.info(`Version: ${packageVersion()}`);
1635
1650
  log.info(`Package: ${pkg}`);
1636
1651
  log.info(`Home: ${home}`);
1637
1652
  console.log("");
@@ -9,9 +9,13 @@
9
9
  * Detection reads PAL_AGENT first (set in-process by
10
10
  * `src/targets/opencode/plugin.ts`), then the host's own environment, and only
11
11
  * then the `--agent=` flag the install templates in `assets/templates/*` put
12
- * on the hook command line. See declaredAgent for why that order.
12
+ * on the hook command line. See declaredAgent for why that order. When nothing
13
+ * declares an agent at all, getActiveAgent falls back to the agent CLIs found
14
+ * on PATH rather than assuming claude.
13
15
  */
14
16
 
17
+ import { findBinaryOnPath } from "./which";
18
+
15
19
  export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode" | "vscode";
16
20
 
17
21
  const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
@@ -109,9 +113,36 @@ export function declaredAgent(): AgentType | undefined {
109
113
  return agentFromRuntimeEnv() ?? flag;
110
114
  }
111
115
 
112
- /** Which agent's conventions to follow. Assumes "claude" when undeclared. */
116
+ /**
117
+ * The CLI each agent spawns for inference, in the order inference.ts routes
118
+ * them. vscode is absent because it has no CLI of its own — it runs Claude's.
119
+ */
120
+ const AGENT_BINARIES: ReadonlyArray<readonly [AgentType, string]> = [
121
+ ["claude", "claude"],
122
+ ["codex", "codex"],
123
+ ["opencode", "opencode"],
124
+ ["copilot", "copilot"],
125
+ ["cursor", "cursor-agent"],
126
+ ];
127
+
128
+ /**
129
+ * Which agent this machine actually has, for the case where nothing declared
130
+ * one — a plain terminal running `pal cli`, a cron child, a detached spawn.
131
+ * Assuming claude there names an agent that may not be installed, which routes
132
+ * inference to a binary that isn't on PATH and reports a host that isn't there.
133
+ */
134
+ function agentFromInstalledBinary(): AgentType | undefined {
135
+ return AGENT_BINARIES.find(([, binary]) => findBinaryOnPath(binary) !== null)?.[0];
136
+ }
137
+
138
+ /**
139
+ * Which agent's conventions to follow.
140
+ *
141
+ * A declaration always wins; claude remains the last resort so a machine with
142
+ * no agent CLI at all behaves as it always has.
143
+ */
113
144
  export function getActiveAgent(): AgentType {
114
- return declaredAgent() ?? "claude";
145
+ return declaredAgent() ?? agentFromInstalledBinary() ?? "claude";
115
146
  }
116
147
 
117
148
  export const isClaude = () => getActiveAgent() === "claude";
@@ -18,15 +18,26 @@
18
18
  */
19
19
 
20
20
  import { spawn } from "node:child_process";
21
+ import { getActiveAgent } from "./agent";
21
22
  import { logDebug, logError } from "./log";
22
23
 
24
+ /**
25
+ * A hook run by Cursor, Copilot or Codex knows its agent only from the
26
+ * `--agent=` flag its own config put on the command line, and argv does not
27
+ * cross a spawn. Re-declaring it here is what stops the child from taking the
28
+ * "claude" default and spawning the wrong CLI for its inference.
29
+ */
30
+ function withAgentDeclaration(args: string[]): string[] {
31
+ return [...args, `--agent=${getActiveAgent()}`];
32
+ }
33
+
23
34
  export function spawnDetachedInference(
24
35
  scriptPath: string,
25
36
  args: string[],
26
37
  scope: string
27
38
  ): void {
28
39
  try {
29
- const child = spawn("bun", [scriptPath, ...args], {
40
+ const child = spawn("bun", [scriptPath, ...withAgentDeclaration(args)], {
30
41
  detached: true,
31
42
  stdio: "ignore",
32
43
  env: { ...process.env, CLAUDECODE: undefined },
@@ -24,6 +24,14 @@ export const WATCHED_PATHS = [
24
24
  "assets/agents",
25
25
  ];
26
26
 
27
+ /**
28
+ * A flag spelling of a command the README documents under its canonical name,
29
+ * or the `cli` prefix the dispatcher consumes before a command is ever named.
30
+ */
31
+ function isAliasOrInternalRoute(cmd: string): boolean {
32
+ return ["--help", "-h", "help", "-v", "--version", "cli"].includes(cmd);
33
+ }
34
+
27
35
  /** Extract CLI command names from the switch statement in index.ts */
28
36
  function extractCliCommands(): string[] {
29
37
  const pkg = palPkg();
@@ -36,8 +44,7 @@ function extractCliCommands(): string[] {
36
44
 
37
45
  for (const match of matches) {
38
46
  const cmd = match[1];
39
- // Skip help aliases and internal routing
40
- if (["--help", "-h", "help", "cli"].includes(cmd)) continue;
47
+ if (isAliasOrInternalRoute(cmd)) continue;
41
48
  commands.push(cmd);
42
49
  }
43
50
 
@@ -9,9 +9,26 @@
9
9
  * names — passing the full `.cmd`/`.exe` path bypasses that fragility.
10
10
  */
11
11
 
12
- import { accessSync, constants, existsSync } from "node:fs";
12
+ import { accessSync, constants, statSync } from "node:fs";
13
13
  import { delimiter, resolve as resolvePath } from "node:path";
14
14
 
15
+ /**
16
+ * A directory carries the execute bit as "traversable", so X_OK alone accepts
17
+ * a folder that happens to share a CLI's name. statSync follows symlinks, so a
18
+ * linked binary still resolves. Windows has no executable bit — being a file
19
+ * under a PATHEXT extension is all it can offer.
20
+ */
21
+ function isExecutableFile(candidate: string): boolean {
22
+ try {
23
+ if (!statSync(candidate).isFile()) return false;
24
+ if (process.platform === "win32") return true;
25
+ accessSync(candidate, constants.X_OK);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
15
32
  /** Resolve a binary on PATH to its full absolute path, or null if absent. */
16
33
  export function findBinaryOnPath(name: string): string | null {
17
34
  const PATH = process.env.PATH;
@@ -24,17 +41,7 @@ export function findBinaryOnPath(name: string): string | null {
24
41
  if (!dir) continue;
25
42
  for (const ext of exts) {
26
43
  const candidate = resolvePath(dir, name + ext);
27
- try {
28
- if (process.platform === "win32") {
29
- // Windows has no executable bit — existence in PATHEXT is enough.
30
- if (existsSync(candidate)) return candidate;
31
- } else {
32
- accessSync(candidate, constants.X_OK);
33
- return candidate;
34
- }
35
- } catch {
36
- /* not here — try next */
37
- }
44
+ if (isExecutableFile(candidate)) return candidate;
38
45
  }
39
46
  }
40
47
  return null;