@nicknisi/pi-claude-compat
Claude Code compatibility for pi: CLAUDE_PLUGIN_ROOT shimming and !`command` dynamic skill placeholders
Package details
Install @nicknisi/pi-claude-compat from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@nicknisi/pi-claude-compat- Package
@nicknisi/pi-claude-compat- Version
0.1.2- Published
- Aug 7, 2026
- Downloads
- 190/mo · 10/wk
- Author
- nicknisi
- License
- MIT
- Types
- extension
- Size
- 35.7 KB
- Dependencies
- 0 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./index.ts",
"./dynamic-skills.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@nicknisi/pi-claude-compat
Claude Code compatibility for pi — two independent shims that let Claude Code-origin skills run unmodified:
CLAUDE_PLUGIN_ROOTresolution (index.ts) — CC skills reference files and scripts via${CLAUDE_PLUGIN_ROOT}, which pi does not set. This shim discovers loaded skills' package roots and transparently rewrites the variable in tool calls to real paths.!`command`dynamic placeholders (dynamic-skills.ts) — CC-style dynamic-command placeholders inSKILL.mdfiles, executed at invocation time with output inlined before the model sees the prompt, guarded by a strict registered-skills allowlist.
Compatibility shim that makes Claude Code-origin skills work under pi. Claude Code sets ${CLAUDE_PLUGIN_ROOT} to the root of the plugin a skill belongs to, and CC skills (ideation, image-gen, etc.) reference files and scripts via that variable. Pi does not set it, so ported skills break when they try to read references or run scripts. This extension discovers the package roots of all loaded skills and transparently resolves ${CLAUDE_PLUGIN_ROOT} references — the model writes the variable as it always does, and the extension silently rewrites it to a real path.
What it adds
No slash commands, tools, keybindings, widgets, or entry types. It is entirely passive: two event hooks plus one environment variable.
- Event
session_start— callspi.getCommands(), filters to commands withsource === "skill", derives each skill's package root from itssourceInfo.path, and collects unique roots. - Event
tool_call— rewritesCLAUDE_PLUGIN_ROOTreferences in thebashtool'sinput.commandand thereadtool'sinput.pathbefore execution. - Environment variable
CLAUDE_PLUGIN_ROOT— set onprocess.envat session start to the first discovered package root, so plain shell expansions ($CLAUDE_PLUGIN_ROOT) work in the single-package case.
How it works
Root discovery
Skill paths follow the convention <package-root>/skills/<skill-name>/SKILL.md, so the package root is three levels up from the skill file (SKILL.md → <skill-name>/ → skills/ → <package-root>/). All unique roots are kept in an in-memory array, rebuilt on every session_start.
Rewriting
Two forms are handled:
- Single-package:
process.env.CLAUDE_PLUGIN_ROOTis set, so bash expansions of$CLAUDE_PLUGIN_ROOTresolve naturally. - Multi-package (or any case): the
tool_callinterceptor matches the pattern${CLAUDE_PLUGIN_ROOT}/path,$CLAUDE_PLUGIN_ROOT/path, and a bare${CLAUDE_PLUGIN_ROOT}(no trailing path). For a reference with a path, it tries each known root withexistsSync()and substitutes the first root where the file exists. If none match, it falls back to the first root. A bare reference is replaced with the first root directly.
Usage
No configuration. Once installed and loaded, ported CC skills that reference ${CLAUDE_PLUGIN_ROOT} work without modification:
# In a skill ported from Claude Code:
read ${CLAUDE_PLUGIN_ROOT}/references/schema.md
bash ${CLAUDE_PLUGIN_ROOT}/scripts/sync.sh
Both resolve to real paths under the skill's package root before the tool runs.
Configuration
None. No config files, no options, no user-facing environment variables. CLAUDE_PLUGIN_ROOT is written (not read) by the extension.
Dependencies
- Peer:
@earendil-works/pi-coding-agent— usesExtensionAPI.on("session_start"),ExtensionAPI.on("tool_call"), andpi.getCommands(). - Node builtins only:
node:fs(existsSync),node:path(dirname,join).
No workspace deps, no npm deps.
Caveats
- Pi internals: depends on the shape of
pi.getCommands()entries — specificallysource === "skill"andsourceInfo.path. If pi changes how skill commands are tagged or where their paths live, root discovery silently yields zero roots and the extension becomes a no-op. - Heuristic resolution: in the multi-package case, a
${CLAUDE_PLUGIN_ROOT}/pathreference resolves to the first root where the file exists. Two skills shipping a file at the same relative path can resolve to the wrong package. - Fallback is arbitrary: when no root contains the referenced file, the first discovered root is substituted — discovery order is
getCommands()order, not sorted. - Only
bashandreadare intercepted. References inside other tools' inputs (e.g.edit,write) are not rewritten. - String-level rewrite: the regex
/\$\{?CLAUDE_PLUGIN_ROOT\}?(?:\s*\/([^\s;|&"'()]+))?/goperates on raw text. Paths containing spaces, semicolons, quotes, or parentheses after the variable will be truncated or missed. - Mutation of tool input: the interceptor rewrites
event.inputin place. Behavior relies on pi applying tool-call input mutations from event handlers; a pi version that freezes inputs would break this. process.env.CLAUDE_PLUGIN_ROOTis a single value, so in multi-package sessions plain shell expansions of$CLAUDE_PLUGIN_ROOT(that the interceptor misses) resolve to the first package, which may be wrong.
Install
pi install /Users/nicknisi/Developer/pi-extensions/packages/claude-plugin-root
Part 2: !command`` dynamic skill placeholders
Claude Code-style !`command` placeholder execution inside pi SKILL.md files. When a skill's SKILL.md contains dynamic-command placeholders, the extension runs them as shell commands at invocation time and inlines the output before the model ever sees the prompt. This lets skills embed live context (git state, gh output, file listings, etc.) instead of static text.
What it adds
- Events hooked:
input,tool_result - Slash commands: none (it intercepts and rewrites pi's built-in
/skill:<name>expansion) - Tools / keybindings / widgets / overlays / message types: none
Two expansion paths
/skill:nameinvocations — theinputhandler matches^/skill:(\S+)(?:\s+([\s\S]*))?$, looks up the skill viapi.getCommands()(source === "skill"), reads itsSKILL.md, executes any placeholders, and returns{ action: "transform", text }replicating pi's built-in skill expansion (content+\n\nUser: <args>when args are present). If the file has no placeholders, it returns{ action: "continue" }and pi handles it normally.- Agent
readtool on aSKILL.md— thetool_resulthandler fires whentoolName === "read"and the input path ends withSKILL.md. Text blocks containing placeholders are rewritten with command output inlined, and the handler returns{ content: newContent }.
Security boundary
Commands are executed only for SKILL.md files pi has registered as skills in the current session (resolved path in pi.getCommands() where source === "skill"). Reading an arbitrary SKILL.md — e.g. from a freshly cloned untrusted repo — never executes anything; the extension posts a warning notification (Skipped !\cmd` expansion in unregistered …) and passes the result through unchanged. Without this guard, reading a hostile SKILL.md` is remote code execution.
Placeholder syntax
Mirrors Claude Code. Both forms are recognized by one regex:
Inline:
!`command`— recognized only when!starts a line or follows whitespace, soKEY=!`cmd`stays literal and does not execute.Fenced block: a fence opened with
```!for multi-line commands:```! gh pr view --json title,body ```
Execution semantics:
- All matched commands run in parallel (
Promise.alloverchild_process.exec). - Replacement is index-based in reverse order; command output is never re-scanned for nested placeholders.
cwdis the skill's base directory (sourceInfo.baseDir, falling back todirname(skillPath)).- Per-command limits:
timeout: 30_000ms,maxBuffer: 512 KiB, UTF-8. - If the command produces no stdout and no stderr and exits non-zero, the placeholder is replaced with
[!\` failed: <err.message>]whereis the first line of the command (suffixed with…for multi-line fenced blocks). If stdout or stderr is non-empty, that output is used (.trim()`ed) even on non-zero exit.
Usage
Write placeholders into a skill that pi discovers under a configured skill path:
---
name: pr-summary
description: Summarize changes in a pull request
---
- PR diff: !`gh pr diff`
- Changed files: !`gh pr diff --name-only`
```!
gh pr view --json title,body
```
Summarize this pull request.
Then invoke:
/skill:pr-summary 123
An `info` notification (`Expanding dynamic commands in pr-summary…`) is shown while commands run; the model receives the fully expanded prompt.
## Configuration
None. No config files, no options, no environment variables.
## Dependencies
- `@earendil-works/pi-coding-agent` (peer, `*`) — `ExtensionAPI` surface used:
- `pi.on("input", …)` / `pi.on("tool_result", …)` event hooks
- `pi.getCommands()` — enumerates registered commands; relies on `source === "skill"` and `sourceInfo.path` / `sourceInfo.baseDir` to locate and validate skill files
- `ctx.ui.notify()` for status/warning notifications; `ctx.cwd` for resolving relative read paths
- Node builtins only: `node:child_process` (`exec`), `node:fs`, `node:path`. No npm runtime dependencies.
## Caveats
- **Depends on pi internals.** The extension reaches into `pi.getCommands()` command metadata (`source`, `sourceInfo.path`, `sourceInfo.baseDir`) and reimplements pi's built-in skill expansion (`content + "\n\nUser: " + args`). If pi changes how skills are registered or expanded, the `/skill:` path can drift from core behavior.
- **`tool_result` shape.** Assumes `event.content` is an array of blocks with `type: "text"` and a `text` field, and that returning `{ content }` from a `tool_result` handler patches the result. Internal shapes; may change across pi versions.
- **Regex edge cases.** The inline form requires `!` at line start or after whitespace; a `` !`cmd` `` immediately following punctuation (e.g. `(!`cmd`)`) will not execute. Fenced blocks must occupy whole lines.
- **`exec` shell.** Commands run via `child_process.exec` (`/bin/sh -c` on POSIX). Only registered skills execute, but treat any registered skill's placeholders as trusted code with your user privileges.
- Path comparison uses `path.resolve` (symlinks are not canonicalized), so a skill read through a symlinked alias path will be treated as unregistered.
## Install
```bash
pi install /Users/nicknisi/Developer/pi-extensions/packages/dynamic-skills