feat(pi): native pi integration — extension, config pi, skills sync --agent pi #96

Open
michal wants to merge 0 commits from feat/pi-extension into main
Owner

Native pi integration — no MCP client, no Claude

Adds mcpctl config pi, a self-contained pi extension, and skills sync --agent pi.

pi has no MCP client, so the extension talks JSON-RPC directly to mcplocal's
/projects/<name>/mcp endpoint and registers each project tool as a native pi tool.
mcplocal's gate state is bound to mcp-session-id, so the extension holds one
persistent session per project and re-lists tools after every call — that way
calling begin_session self-ungates the rest with no push channel.

Skills land in ~/.pi/agent/skills/ with their own sync state
(~/.mcpctl/skills-state-pi.json). Nothing touches ~/.claude/.

Review fixes (3rd commit)

src/pi-ext/ was covered by no tsconfig and no eslint config. It ships as source
— embedded into the CLI, then written into ~/.pi/agent/extensions/ — so the CLI
build never compiled it and nothing checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface inert:

  • /mcpctl did nothing. ctx.ui.select takes string[] and returns the chosen
    string; it was called with {value,label} objects, so the menu rendered five
    [object Object] rows and no branch ever matched.
  • "Sync skills" wrote to ~/.claude/skills — in an integration whose stated goal
    is not depending on ~/.claude — because it omitted --agent pi. It also ran
    execSync with stdio: "inherit" (raw output over pi's TUI) and interpolated the
    project name into a shell string. Now execFile with captured output.
  • Headless branch returned a string from a Promise<void> handler (silently dropped).
  • content[].type typed string instead of the "text" literal; callTool asserted
    Promise<unknown>.
  • Tool-name collisions silently shadowed: the second tool was never registered yet
    still reported active, so its calls were forwarded to the first tool.
  • registerWithPi rewrote settings.json on no-op runs, destroying // comments
    (parsing strips them).

Guards, so this can't recur

  • src/pi-ext/tsconfig.json checks against the real published pi types (dev
    dependency, not a shim — a shim drifting from the API is the exact failure guarded
    against). Wired into pnpm typecheck.
  • eslint now covers src/pi-ext/*.ts like every other source file.
  • A test fails when the embedded copy is stale — editing the sources without
    regenerating silently shipped old code.

Also: the branch had added config pi without regenerating shell completions (the
committed-completions test was failing), and the doc advertised mcpctl pi sync-skills,
which does not exist.

Verification

572 tests pass, typecheck clean, RPM/DEB built. Smoke-tested config pi --pi-dir
against an isolated pi home: pre-existing settings preserved, installed extension is
the fixed source, re-run is idempotent.

Notes for the reviewer

  • The dev dependency adds ~114M to node_modules (existing tree is 528M). Dropping it
    means going back to shipping an untypechecked user-facing surface.
  • The project picker lists all projects in a flat select (356 here) — usable API,
    unusable UX. Worth filtering before anyone leans on it.
  • Not done: wiring an mcpctl_pat_ token through to the extension. Only needed against
    an authenticated mcplocal serve; the local daemon does not authenticate
    /projects/*. Documented rather than built.
## Native pi integration — no MCP client, no Claude Adds `mcpctl config pi`, a self-contained pi extension, and `skills sync --agent pi`. pi has no MCP client, so the extension talks JSON-RPC **directly** to mcplocal's `/projects/<name>/mcp` endpoint and registers each project tool as a native pi tool. mcplocal's gate state is bound to `mcp-session-id`, so the extension holds one persistent session per project and re-lists tools after every call — that way calling `begin_session` self-ungates the rest with no push channel. Skills land in `~/.pi/agent/skills/` with their own sync state (`~/.mcpctl/skills-state-pi.json`). Nothing touches `~/.claude/`. ### Review fixes (3rd commit) `src/pi-ext/` was covered by no tsconfig and no eslint config. It ships as *source* — embedded into the CLI, then written into `~/.pi/agent/extensions/` — so the CLI build never compiled it and nothing checked it against pi's API. Pointing `tsc` at the published `@earendil-works/pi-coding-agent` types found the command surface inert: - **`/mcpctl` did nothing.** `ctx.ui.select` takes `string[]` and returns the chosen string; it was called with `{value,label}` objects, so the menu rendered five `[object Object]` rows and no branch ever matched. - **"Sync skills" wrote to `~/.claude/skills`** — in an integration whose stated goal is not depending on `~/.claude` — because it omitted `--agent pi`. It also ran `execSync` with `stdio: "inherit"` (raw output over pi's TUI) and interpolated the project name into a shell string. Now `execFile` with captured output. - Headless branch returned a string from a `Promise<void>` handler (silently dropped). - `content[].type` typed `string` instead of the `"text"` literal; `callTool` asserted `Promise<unknown>`. - Tool-name collisions silently shadowed: the second tool was never registered yet still reported active, so its calls were forwarded to the first tool. - `registerWithPi` rewrote settings.json on no-op runs, destroying `//` comments (parsing strips them). ### Guards, so this can't recur - `src/pi-ext/tsconfig.json` checks against the **real published** pi types (dev dependency, not a shim — a shim drifting from the API is the exact failure guarded against). Wired into `pnpm typecheck`. - eslint now covers `src/pi-ext/*.ts` like every other source file. - A test fails when the embedded copy is stale — editing the sources without regenerating silently shipped old code. Also: the branch had added `config pi` without regenerating shell completions (the committed-completions test was failing), and the doc advertised `mcpctl pi sync-skills`, which does not exist. ### Verification 572 tests pass, typecheck clean, RPM/DEB built. Smoke-tested `config pi --pi-dir` against an isolated pi home: pre-existing settings preserved, installed extension is the fixed source, re-run is idempotent. ### Notes for the reviewer - The dev dependency adds ~114M to `node_modules` (existing tree is 528M). Dropping it means going back to shipping an untypechecked user-facing surface. - The project picker lists **all** projects in a flat `select` (356 here) — usable API, unusable UX. Worth filtering before anyone leans on it. - Not done: wiring an `mcpctl_pat_` token through to the extension. Only needed against an authenticated `mcplocal serve`; the local daemon does not authenticate `/projects/*`. Documented rather than built.
michal added 4 commits 2026-08-08 16:38:20 +00:00
- src/pi-ext/: self-contained pi extension (mcpctl-pi.ts + vendored
  mcp-http client) that talks JSON-RPC directly to mcplocal and registers
  project MCP tools as native pi tools. No MCP client, no ~/.claude.
- Persistent per-project session so gated projects ungate on begin_session.
- /mcpctl command: status, switch project (GUI), refresh tools, sync skills.
- mcpctl config pi: installs extension, wires pi settings, persists active
  project, syncs skills into ~/.pi/agent/skills.
- skills sync: add --agent pi (target install root).
- docs + tests.
# Conflicts:
#	README.md
#	src/cli/src/commands/config.ts
#	src/cli/src/commands/skills.ts
- add scripts/generate-pi-extension.ts to embed src/pi-ext sources as
  string constants (mirrors prime-agent's embedded switcher pattern).
- config pi now writes the embedded sources by default, so it works from
  an /usr/bin install with no source tree (--extension-dir overrides for
  dev). Verified installed binary writes files byte-identical to source.
- add installEmbeddedExtension test.
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m49s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m21s
CI/CD / publish (pull_request) Has been skipped
739e679d1a
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.

Fixes:

- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
  chosen string; it was called with `{value,label}` objects, so the menu
  rendered five `[object Object]` rows and `choice === "status"` never
  matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
  `Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
  integration whose stated purpose is to not depend on ~/.claude — and said
  so in its own success message. It also ran execSync with `stdio: "inherit"`,
  painting raw output over pi's TUI, and interpolated the project name into a
  shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
  the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
  `docs-search`). The colliding tool was silently never registered but still
  reported active, so its calls were forwarded to the first tool. Names are
  now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
  parsing strips `//` comments, a no-op run destroyed them.

Guards, so this class of bug can't return:

- `src/pi-ext/tsconfig.json` checks the extension against the real published
  pi types (dev dependency, not a shim — a shim drifting from the published
  API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
  editing the sources without regenerating silently shipped old code.

Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
michal added 1 commit 2026-08-08 16:51:39 +00:00
feat(pi): filter the project picker instead of scrolling hundreds of rows
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 3m35s
CI/CD / build (pull_request) Successful in 2m7s
CI/CD / publish (pull_request) Has been skipped
24a3b8cc0a
pi's selector is a plain arrow-key list: `ExtensionUIDialogOptions` has no
search field and `ExtensionSelectorComponent` ignores typed characters, so
filtering has to happen before the list is handed over. With 356 projects —
most of them `smoke-proj-none-*` leftovers — arrowing to the one you want is
hopeless.

Above 20 projects the picker now asks for a filter first. Terms are
space-separated and all must match as case-insensitive substrings, so
`home auto` finds `homeautomation`. Blank shows everything, Esc cancels.

The active project sorts first (most likely pick), then alphabetical. A
result set over 50 is capped, and the title says what was dropped — a
silently truncated list reads as "that's all of them".

The ordering and matching are extracted into an exported `filterProjects` so
they are unit-tested rather than eyeballed through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
michal added 1 commit 2026-08-08 17:05:36 +00:00
fix(pi): correct the model's tool context after a project switch
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m13s
CI/CD / lint (pull_request) Successful in 2m30s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / build (pull_request) Successful in 2m28s
CI/CD / smoke (pull_request) Failing after 2m59s
CI/CD / publish (pull_request) Has been skipped
47809a8942
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.

Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".

Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.

The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.

`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
michal added 1 commit 2026-08-08 17:16:25 +00:00
fix(skills): write the frontmatter every Agent Skills host requires
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m15s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m17s
CI/CD / smoke (pull_request) Failing after 3m8s
CI/CD / publish (pull_request) Has been skipped
8b006263de
mcpd keeps `name` and `description` as columns, and a skill's `content` is
often just the body — `propose-learnings` starts straight at its `#` heading.
The sync wrote `full.content` verbatim, so the SKILL.md landed with no YAML
frontmatter and the host rejected it. Observed live:

  [Skill warning]
    ~/.prime/agent/skills/propose-learnings/SKILL.md
      description is required

Claude Code and pi require the same keys, so this affected every target; it
only surfaced now because prime-agent prints the warning at startup.

`ensureSkillFrontmatter` synthesises the block from the columns when it is
absent, and fills in only a missing `name`/`description` when the author
already supplied a header — an existing complete header is never rewritten.
Values are emitted as JSON strings (valid YAML double-quoted scalars) because
descriptions routinely contain `:` and `#`, which break a bare scalar.

Also: `--force` now re-fetches skills whose server content is unchanged.
Without it a skill already on disk could never be repaired by a client-side
fix — the content hash still matched, so it was skipped forever, which is
exactly what happened on the first attempt to repair the file above.

Three existing assertions compared SKILL.md to the raw server content; they
now assert the body survives rather than exact bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
michal added 2 commits 2026-08-08 17:34:16 +00:00
fix(pi): cap the project picker at 20 rows so it does not fill the screen
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m16s
CI/CD / lint (pull_request) Successful in 2m41s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / build (pull_request) Successful in 2m12s
CI/CD / smoke (pull_request) Failing after 3m46s
CI/CD / publish (pull_request) Has been skipped
bc83d36a68
pi's ExtensionSelectorComponent.updateList() renders every option with no
windowing, so 50 rows scrolled the transcript away. prime-agent's selector
windows to ~20 itself and shows a true (20/356) counter; matching that height
makes both hosts behave the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
michal added 1 commit 2026-08-08 17:39:00 +00:00
Merge main into feat/pi-extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m8s
CI/CD / typecheck (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / build (pull_request) Successful in 2m27s
CI/CD / smoke (pull_request) Failing after 3m30s
CI/CD / publish (pull_request) Has been skipped
e638f81456
michal added 1 commit 2026-08-08 18:59:27 +00:00
Merge main into feat/pi-extension
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m11s
CI/CD / lint (pull_request) Successful in 2m28s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 4m45s
CI/CD / publish (pull_request) Has been skipped
3fa41e4d46
michal added 2 commits 2026-08-08 19:16:49 +00:00
341 of 356 projects on the shared mcpd were smoke-test leftovers, plus 11
never-expiring `smoke-*` mcptokens sitting on the real `mcpctl-development`
and `sre` projects. Enough to make the project picker unusable.

Two causes, both silent:

- `delete project X --force` — `--force` is valid on `create project` but NOT
  on `delete`, so every cleanup call exited non-zero and deleted nothing.
  project-llm-ref's afterAll looked correct and had never worked, which is why
  there were 84 each of smoke-proj-{ok,orphan,none}-*.
- mcptoken.smoke had no afterAll at all; its cleanup was an `it()` at the end
  of the file, so it was skipped whenever an earlier assertion failed.

Cleanup now lives in `afterAll` (runs on failure too) and is no longer gated
on the health probe: the project is created through mcpd, which can be up when
the gateway probe is not, and deleting a project that was never created is a
no-op.

Adds `pnpm smoke:clean` for the case afterAll cannot cover — a killed process
(CI timeout, Ctrl-C, OOM). Dry-run by default, `--yes` to apply. Only touches
`smoke-*` names and protects the shared `smoke-data` / `smoke-aws-docs`
fixture, which five suites depend on and which must survive a concurrent run.

Verified end to end: created a throwaway smoke project, confirmed the dry run
lists without deleting, then `--yes` removed it and left all 15 real projects
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
Merge 'fix(smoke): actually clean up smoke-test resources' into feat/pi-extension
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 4m37s
CI/CD / publish (pull_request) Has been skipped
a8a1045824
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 4m37s
CI/CD / publish (pull_request) Has been skipped
This branch is already included in the target branch. There is nothing to merge.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/pi-extension:feat/pi-extension
git checkout feat/pi-extension
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: michal/mcpctl#96