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

@@ -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');
});
});