Compare commits

..

3 Commits

Author SHA1 Message Date
2b87cfdbf1 Merge pull request 'fix(pi-ext): stop importing @earendil-works/pi-ai at runtime' (#105) from fix/pi-ext-module-resolution into main
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Failing after 13m0s
CI/CD / typecheck (push) Failing after 13m49s
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-09 23:15:30 +00:00
Michal
be7fabd467 fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m10s
CI/CD / smoke (pull_request) Failing after 10m42s
CI/CD / build (pull_request) Failing after 13m32s
CI/CD / publish (pull_request) Has been skipped
The extension failed to load outright on older pi installs:

  Failed to load extension ".../mcpctl-pi.ts":
  Cannot find module '@earendil-works/pi-ai'

pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.

The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.

Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.

Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
2026-08-10 00:15:04 +01:00
e4e2e063f1 Merge 'fix(health): a passing tools/list is live, not healthy' into main
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m9s
CI/CD / build (push) Successful in 2m25s
CI/CD / smoke (push) Failing after 3m22s
CI/CD / publish (push) Has been skipped
2026-08-09 23:01:01 +00:00
4 changed files with 83 additions and 6 deletions

View File

@@ -101,9 +101,34 @@ src/pi-ext/
mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps)
``` ```
The extension imports only from pi-bundled packages The extension imports only from pi-bundled packages, so it loads standalone.
(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it
loads standalone. ### `typebox` is the only bare runtime import
pi does not let an extension resolve modules the ordinary way: it hands jiti a
hard-coded alias table built from its *own* dependencies, and that table is not
the same across pi distributions. The newer `@earendil-works/pi-coding-agent`
aliases both the `@earendil-works/*` and the legacy `@mariozechner/*` names;
older `@mariozechner/pi-coding-agent` installs (0.73.x and earlier) alias only
the `@mariozechner/*` ones. Neither resolves the other's namespace.
So an import of anything outside the intersection kills the *whole* extension on
someone else's pi — every tool, the `/mcpctl` command, the status line — with:
```
Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai'
```
which is exactly what `import { StringEnum } from "@earendil-works/pi-ai"` did.
`typebox` is aliased by every published pi, so it is the only bare specifier
allowed at runtime. Everything else must be a `node:` builtin, a relative path,
an `import type` (erased before jiti resolves anything), or inlined — pi-ai's
`StringEnum` is now a six-line local `stringEnum`. The
`tests/config/pi-extension-embed.test.ts` guard fails the build on a reintroduced
runtime import.
If a user does hit this error, check `type -a pi`: two installs on `$PATH` is the
usual cause, and the extension has to load under whichever one wins.
## Typechecking ## Typechecking

File diff suppressed because one or more lines are too long

View File

@@ -29,6 +29,34 @@ describe('embedded pi extension', () => {
expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js'); expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js');
}); });
/**
* pi resolves an extension's bare specifiers through a hard-coded alias table
* in its own loader, and that table is not the same across pi distributions:
* `@earendil-works/*` exists only in the newer packages, `@mariozechner/*`
* installs alias only the old names, and neither resolves the other. An
* import of a package outside the intersection makes the whole extension fail
* to load with `Cannot find module` — every tool gone, on someone else's pi.
*
* `typebox` is aliased by every published pi, so it is the only safe bare
* runtime import. Type-only imports are erased before jiti resolves anything,
* so they may name whatever they like.
*/
it('imports nothing at runtime that some pi build cannot resolve', () => {
// `import x from "s"` / `import {..} from "s"` (but not `import type`),
// plus the side-effect form `import "s"`.
const runtimeImport =
/^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm;
const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/;
for (const name of PI_EXTENSION_FILENAMES) {
const src = PI_EXTENSION_FILES[name] ?? '';
for (const match of src.matchAll(runtimeImport)) {
const specifier = match[1] ?? match[2] ?? '';
expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed);
}
}
});
it('carries the fixes the pi API requires', () => { it('carries the fixes the pi API requires', () => {
const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? ''; const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? '';
// ctx.ui.select takes string[] and returns the chosen string. // ctx.ui.select takes string[] and returns the chosen string.

View File

@@ -20,9 +20,16 @@
* or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"] * or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"]
* *
* Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude. * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.
*
* RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers
* through a fixed alias table in its own loader, and that table differs between
* pi distributions — `@earendil-works/*` exists only in the newer packages,
* while `@mariozechner/*` installs alias only the old names. `typebox` is the
* one specifier every published pi aliases, so it is the ONLY runtime import
* allowed here. Anything else must be `import type` (erased before jiti runs)
* or inlined — see `stringEnum` below.
*/ */
import { Type, type TSchema } from "typebox"; import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { import {
McpHttpSession, McpHttpSession,
@@ -110,6 +117,23 @@ async function listProjects(mcplocalUrl: string, token?: string): Promise<string
} }
// ── JSON Schema → TypeBox ──────────────────────────────────────────────────── // ── JSON Schema → TypeBox ────────────────────────────────────────────────────
/**
* `{ type: "string", enum: [...] }` rather than a union of literals: Google's
* API (and other providers that reject anyOf/const) only accept the flat form.
*
* Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in
* `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the
* whole extension then failed to load. See the import note at the top.
*/
function stringEnum(values: string[], description?: string): TSchema {
return Type.Unsafe<string>({
type: "string",
enum: values,
...(description ? { description } : {}),
});
}
function convertSchema(inputSchema: unknown): TSchema { function convertSchema(inputSchema: unknown): TSchema {
if (!inputSchema || typeof inputSchema !== "object") { if (!inputSchema || typeof inputSchema !== "object") {
return Type.Object({}); return Type.Object({});
@@ -147,7 +171,7 @@ function convertProp(raw: unknown): TSchema {
const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined; const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;
if (enumVals && enumVals.every((v) => typeof v === "string")) { if (enumVals && enumVals.every((v) => typeof v === "string")) {
return StringEnum(enumVals as string[]); return stringEnum(enumVals as string[], desc);
} }
if (enumVals && enumVals.every((v) => typeof v === "number")) { if (enumVals && enumVals.every((v) => typeof v === "number")) {
const literals = enumVals.map((v) => Type.Literal(v)); const literals = enumVals.map((v) => Type.Literal(v));