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:
83
src/cli/tests/commands/config-pi.test.ts
Normal file
83
src/cli/tests/commands/config-pi.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
92
src/cli/tests/utils/pi-settings.test.ts
Normal file
92
src/cli/tests/utils/pi-settings.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user