feat(pi): add native pi integration — extension, config pi, skills --agent pi

- 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.
This commit is contained in:
Michal
2026-08-08 16:23:02 +01:00
parent 2c8419eddb
commit 28f1a411fd
9 changed files with 1222 additions and 3 deletions

View File

@@ -11,6 +11,17 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js';
import { writeProjectMarker } from '../utils/project-marker.js';
import { installManagedSessionHook } from '../utils/sessionhook.js';
import {
installExtensionFiles,
registerWithPi,
piSkillsDir,
piSettingsPath,
piExtensionDir,
piExtensionMainPath,
piStatePath,
withPiAgentDir,
writePiProjectState,
} from '../utils/pi-settings.js';
import { runSkillsSync } from './skills.js';
interface McpConfig {
@@ -196,6 +207,98 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
registerClaudeCommand('claude', false);
registerClaudeCommand('claude-generate', true); // backward compat
// ── pi: wire the mcpctl pi extension (no MCP, no Claude) ──
config
.command('pi')
.description('Install the pi extension + sync skills (native, no MCP client, no Claude)')
.option('-p, --project <name>', 'Project name to make active')
.option('--extension-dir <path>', 'Path to the src/pi-ext sources to install (default: this checkout)')
.option('--skip-skills', 'Skip the initial skills sync')
.option('--settings <path>', 'pi settings.json path (default: ~/.pi/agent/settings.json)')
.option('--pi-dir <path>', 'Override the pi agent home (default: ~/.pi/agent)')
.action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => {
if (!opts.project) {
log('Error: --project is required for mcpctl config pi');
process.exitCode = 1;
return;
}
// Resolve target paths, honouring an optional --pi-dir override.
const piHome = opts.piDir ? withPiAgentDir(opts.piDir) : undefined;
const settingsPath = opts.settings ?? piHome?.settingsPath() ?? piSettingsPath();
const extDest = piHome?.extensionDir() ?? piExtensionDir();
const skillsInstall = piHome?.skillsDir() ?? piSkillsDir();
// Isolate the persisted active-project state under the pi home when a
// custom --pi-dir is used (also keeps tests off the real ~/.mcpctl).
const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath();
// 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/
let srcDir: string = opts.extensionDir!;
if (!srcDir) {
// best-effort: locate this checkout's src/pi-ext from the running
// script (dist compile) or from cwd (tsx/dev).
const candidates = [
resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', 'src', 'pi-ext'),
resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', '..', 'src', 'pi-ext'),
resolve(process.cwd(), 'src', 'pi-ext'),
];
const { existsSync } = await import('node:fs');
srcDir = candidates.find((c) => existsSync(join(c, 'mcpctl-pi.ts'))) ?? candidates[0]!;
}
try {
const written = await installExtensionFiles(srcDir, extDest);
log(`Installed extension files:`);
for (const w of written) log(` ${w}`);
} catch (err: unknown) {
log(`Error: could not install extension from '${srcDir}': ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
return;
}
// 2. Register with pi settings (extensions + skills dir).
try {
const extMain = piHome?.extensionMainPath() ?? piExtensionMainPath();
const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extMain, skillsInstall);
log(`Updated ${settingsPath}`);
log(` extensions: ${addedExtensions.length ? addedExtensions.join(', ') : 'already registered'}`);
log(` skills: ${addedSkills.length ? addedSkills.join(', ') : 'already registered'}`);
} catch (err: unknown) {
log(`Warning: could not update pi settings: ${err instanceof Error ? err.message : String(err)}`);
}
// 3. Persist the active project (survives across sessions/cwd).
try {
const p = await writePiProjectState(opts.project, statePath);
log(`Wrote active project to ${p}`);
} catch (err: unknown) {
log(`Warning: could not write pi state: ${err instanceof Error ? err.message : String(err)}`);
}
// 4. Optional initial skills sync into pi's skills dir.
if (!opts.skipSkills && skillsClient) {
try {
const result = await runSkillsSync(
{ project: opts.project, installRoot: skillsInstall },
{ client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
);
const total = result.installed.length + result.updated.length + result.removed.length;
if (total > 0) {
log(`Skills synced to ${skillsInstall} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`);
} else {
log('Skills: no changes (already up to date)');
}
} catch (err: unknown) {
log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
}
} else if (!opts.skipSkills) {
log('Warning: skipping initial skills sync (no API client provided)');
}
log('');
log('Next: restart pi (or start a new session) — the mcpctl tools for the active');
log(`project will be registered automatically. Use /mcpctl to switch projects.`);
});
config.addCommand(createConfigSetupCommand({ configDeps }));
if (apiDeps) {

View File

@@ -435,30 +435,52 @@ export interface SkillsCommandDeps {
log: (...args: unknown[]) => void;
}
/**
* Map an agent target to its on-disk skills install root.
* "claude" → ~/.claude/skills
* "prime-agent" → ~/.prime/agent/skills
* "pi" → ~/.pi/agent/skills
* Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree.
*/
export function agentInstallRoot(agent: string | undefined): string {
switch (agent) {
case 'prime-agent':
return join(homedir(), '.prime', 'agent', 'skills');
case 'pi':
return join(homedir(), '.pi', 'agent', 'skills');
case 'claude':
default:
return join(homedir(), '.claude', 'skills');
}
}
export function createSkillsCommand(deps: SkillsCommandDeps): Command {
const { client, log } = deps;
const warn = (...args: unknown[]): void => {
console.error(...(args as Parameters<typeof console.error>));
};
const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd');
const cmd = new Command('skills').description('Manage Agent-skill bundles synced from mcpd');
cmd.command('sync')
.description('Sync skills from mcpd onto disk under ~/.claude/skills/')
.description('Sync skills from mcpd onto disk')
.option('-p, --project <name>', 'Project to sync (overrides .mcpctl-project marker)')
.option('--agent <name>', 'Sync target install root: claude (default), prime-agent, or pi', 'claude')
.option('--dry-run', 'Print what would change without writing anything')
.option('--force', 'Overwrite locally-modified skills')
.option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)')
.option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)')
.option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)')
.option('--keep-orphans', 'Do not remove skills that are no longer in the server set')
.action(async (opts: {
project?: string;
agent?: string;
dryRun?: boolean;
force?: boolean;
quiet?: boolean;
skipPostinstall?: boolean;
keepOrphans?: boolean;
}) => {
const installRoot = agentInstallRoot(opts.agent);
const result = await runSkillsSync(
{
...(opts.project !== undefined ? { project: opts.project } : {}),
@@ -467,6 +489,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
installRoot,
},
{ client, log, warn },
);

View File

@@ -0,0 +1,147 @@
/**
* Wiring helpers for the pi extension.
*
* `mcpctl config pi --project X`:
* 1. copies `src/pi-ext/` (extension + http client) into
* `~/.pi/agent/extensions/mcpctl/`,
* 2. adds the extension path and the pi skills dir to
* `~/.pi/agent/settings.json` (`extensions`, `skills` arrays),
* 3. writes `~/.mcpctl/pi-state.json` so the active project survives across
* sessions regardless of the shell's cwd.
*
* Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`.
*/
import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises';
import { dirname, join, basename, resolve } from 'node:path';
import { homedir } from 'node:os';
export function piAgentDir(): string {
return join(homedir(), '.pi', 'agent');
}
/** Override the pi agent home (for tests / custom installs). */
export function withPiAgentDir(base: string): {
settingsPath: () => string;
extensionDir: () => string;
skillsDir: () => string;
extensionMainPath: () => string;
} {
return {
settingsPath: () => join(base, 'settings.json'),
extensionDir: () => join(base, 'extensions', 'mcpctl'),
skillsDir: () => join(base, 'skills'),
extensionMainPath: () => join(base, 'extensions', 'mcpctl', 'mcpctl-pi.ts'),
};
}
export function piSettingsPath(): string {
return join(piAgentDir(), 'settings.json');
}
export function piExtensionDir(): string {
return join(piAgentDir(), 'extensions', 'mcpctl');
}
export function piSkillsDir(): string {
return join(piAgentDir(), 'skills');
}
export function piExtensionMainPath(): string {
return join(piExtensionDir(), 'mcpctl-pi.ts');
}
export function piStatePath(): string {
return join(homedir(), '.mcpctl', 'pi-state.json');
}
/**
* Copy the bundled extension sources (relative to this source tree's
* `src/pi-ext/`) into the pi extensions dir. The extension imports its http
* client via a sibling `./mcp-http.js`, so both files must land together.
*/
export async function installExtensionFiles(
srcPiExtDir: string,
dest: string = piExtensionDir(),
): Promise<string[]> {
await mkdir(dest, { recursive: true });
const files = [
['mcpctl-pi.ts', 'mcpctl-pi.ts'],
['mcp-http.ts', 'mcp-http.ts'],
] as const;
const written: string[] = [];
for (const [from, to] of files) {
const src = resolve(srcPiExtDir, from);
const dst = join(dest, to);
await copyFile(src, dst);
written.push(dst);
}
return written;
}
interface PiSettings {
extensions?: string[];
skills?: string[];
[k: string]: unknown;
}
async function readSettings(path: string): Promise<PiSettings> {
try {
const raw = await readFile(path, 'utf-8');
if (raw.trim().length === 0) return {};
return JSON.parse(raw) as PiSettings;
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code === 'ENOENT') return {};
// settings.json may contain line comments; strip them defensively.
const stripped = (await readFile(path, 'utf-8')).replace(/^\s*\/\/.*$/gm, '');
return JSON.parse(stripped) as PiSettings;
}
}
async function writeSettings(path: string, settings: PiSettings): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp.${String(process.pid)}`;
await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
await rename(tmp, path);
}
/**
* Merge our extension path + skills dir into `~/.pi/agent/settings.json`.
* Idempotent — re-running doesn't duplicate entries.
* Returns the paths that were added.
*/
export async function registerWithPi(
settingsPath = piSettingsPath(),
extensionPath = piExtensionMainPath(),
skillsDir = piSkillsDir(),
): Promise<{ addedExtensions: string[]; addedSkills: string[] }> {
const settings = await readSettings(settingsPath);
if (!Array.isArray(settings.extensions)) settings.extensions = [];
if (!Array.isArray(settings.skills)) settings.skills = [];
const addedExtensions: string[] = [];
if (!settings.extensions.includes(extensionPath)) {
settings.extensions.push(extensionPath);
addedExtensions.push(extensionPath);
}
const addedSkills: string[] = [];
if (!settings.skills.includes(skillsDir)) {
settings.skills.push(skillsDir);
addedSkills.push(skillsDir);
}
await writeSettings(settingsPath, settings);
return { addedExtensions, addedSkills };
}
/** Write the persisted active-project state. */
export async function writePiProjectState(project: string, path: string = piStatePath()): Promise<string> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify({ project }, null, 2) + '\n', 'utf-8');
return path;
}
/** Debug helper: the on-disk file name of an extension path. */
export function _base(path: string): string {
return basename(path);
}

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(visibleSkills: unknown[] = []): ApiClient {
return {
get: vi.fn(async (path: string) => {
if (path.endsWith('/skills/visible')) return visibleSkills;
if (path.startsWith('/api/v1/skills/')) return {
id: 's1', name: 'demo', description: 'd', semver: '1.0.0',
content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null,
};
return {};
}),
post: vi.fn(async () => ({ token: 't', user: { email: 'x' } })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('config pi', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' '));
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-pi-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('installs the extension + registers with a custom pi dir (skips skills)', async () => {
// Stage a src/pi-ext source tree to install from.
const srcPiExt = join(tmpDir, 'src-pi-ext');
mkdirSync(srcPiExt, { recursive: true });
writeFileSync(join(srcPiExt, 'mcpctl-pi.ts'), '// a\n');
writeFileSync(join(srcPiExt, 'mcp-http.ts'), '// b\n');
const piDir = join(tmpDir, 'pi-agent');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(
['pi', '--project', 'docmost', '--extension-dir', srcPiExt, '--pi-dir', piDir, '--skip-skills'],
{ from: 'user' },
);
// Extension files landed in the pi dir.
expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'))).toBe(true);
expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcp-http.ts'))).toBe(true);
// settings.json updated.
const settings = JSON.parse(readFileSync(join(piDir, 'settings.json'), 'utf-8'));
expect(settings.extensions).toContain(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'));
expect(settings.skills).toContain(join(piDir, 'skills'));
// Active project persisted under the pi dir (fully isolated).
const state = JSON.parse(readFileSync(join(piDir, 'pi-state.json'), 'utf-8'));
expect(state.project).toBe('docmost');
// Output mentions the write.
expect(output.join('\n')).toContain('Wrote active project');
});
it('requires a project', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['pi', '--pi-dir', join(tmpDir, 'x'), '--skip-skills'], { from: 'user' });
expect(output.join('\n')).toContain('--project is required');
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { agentInstallRoot } from '../../src/commands/skills.js';
import {
registerWithPi,
installExtensionFiles,
} from '../../src/utils/pi-settings.js';
describe('skills agentInstallRoot', () => {
it('maps claude (default) to ~/.claude/skills', () => {
expect(agentInstallRoot(undefined)).toBe(join(homedir(), '.claude', 'skills'));
expect(agentInstallRoot('claude')).toBe(join(homedir(), '.claude', 'skills'));
});
it('maps pi to ~/.pi/agent/skills', () => {
expect(agentInstallRoot('pi')).toBe(join(homedir(), '.pi', 'agent', 'skills'));
});
it('maps prime-agent to ~/.prime/agent/skills', () => {
expect(agentInstallRoot('prime-agent')).toBe(join(homedir(), '.prime', 'agent', 'skills'));
});
});
describe('pi-settings util', () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mcpctl-pi-settings-'));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('registers extension + skills dir into an empty settings file', async () => {
const settingsPath = join(tmp, 'settings.json');
const extPath = join(tmp, 'ext', 'mcpctl-pi.ts');
const skillsDir = join(tmp, 'skills');
const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extPath, skillsDir);
expect(addedExtensions).toEqual([extPath]);
expect(addedSkills).toEqual([skillsDir]);
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.extensions).toEqual([extPath]);
expect(written.skills).toEqual([skillsDir]);
});
it('is idempotent — running twice does not duplicate entries', async () => {
const settingsPath = join(tmp, 'settings.json');
const extPath = join(tmp, 'ext', 'mcpctl-pi.ts');
const skillsDir = join(tmp, 'skills');
await registerWithPi(settingsPath, extPath, skillsDir);
const second = await registerWithPi(settingsPath, extPath, skillsDir);
expect(second.addedExtensions).toEqual([]);
expect(second.addedSkills).toEqual([]);
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.extensions).toEqual([extPath]);
expect(written.skills).toEqual([skillsDir]);
});
it('preserves existing settings keys', async () => {
const settingsPath = join(tmp, 'settings.json');
mkdirSync(tmp, { recursive: true });
writeFileSync(settingsPath, JSON.stringify({ theme: 'dark', packages: ['git:foo/bar'] }, null, 2));
await registerWithPi(settingsPath, '/abs/ext.ts', '/abs/skills');
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.theme).toBe('dark');
expect(written.packages).toEqual(['git:foo/bar']);
expect(written.extensions).toEqual(['/abs/ext.ts']);
expect(written.skills).toEqual(['/abs/skills']);
});
it('copies the extension files into the pi extension dir', async () => {
const srcDir = join(tmp, 'src-pi-ext');
const destDir = join(tmp, 'dest-ext');
mkdirSync(srcDir, { recursive: true });
writeFileSync(join(srcDir, 'mcpctl-pi.ts'), '// extension\n');
writeFileSync(join(srcDir, 'mcp-http.ts'), '// client\n');
const written = await installExtensionFiles(srcDir, destDir);
expect(written).toHaveLength(2);
expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n');
expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n');
});
});