Skip to content

Commit 6ef55fb

Browse files
authored
Fix Pi compatibility gaps (module installer, config, drift detector) (#84)
1 parent 12a6321 commit 6ef55fb

4 files changed

Lines changed: 151 additions & 101 deletions

File tree

β€Ž.opencode/modules/install.mjsβ€Ž

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,30 @@ import { parseModulefile, parseFlowList } from '../../tools/lib/parse-modulefile
88

99
const __dirname = dirname(fileURLToPath(import.meta.url));
1010
const ROOT = resolve(__dirname, '..', '..');
11-
const MODULES_DIR = join(ROOT, '.opencode', 'modules');
12-
const INSTALLED_PATH = join(MODULES_DIR, 'installed.json');
11+
12+
// Harness target: 'opencode' (default) or 'pi' (--pi flag)
13+
// Controls where module files are installed and where installed.json lives.
14+
let HARNESS = 'opencode';
15+
let MODULES_DIR = join(ROOT, '.opencode', 'modules');
16+
let INSTALLED_PATH = join(MODULES_DIR, 'installed.json');
17+
let HARNESS_TARGET = '.opencode';
1318
const OPENCODE_JSON = join(ROOT, 'opencode.json');
1419

20+
function setHarness(mode) {
21+
HARNESS = mode;
22+
if (mode === 'pi') {
23+
MODULES_DIR = join(ROOT, '.agents', 'modules');
24+
HARNESS_TARGET = '.agents';
25+
} else {
26+
MODULES_DIR = join(ROOT, '.opencode', 'modules');
27+
HARNESS_TARGET = '.opencode';
28+
}
29+
INSTALLED_PATH = join(MODULES_DIR, 'installed.json');
30+
}
31+
1532
// ── Logging ────────────────────────────────────────────────────────────────
1633

17-
const LOG_PREFIXES = { ADD: 'ADD', DEL: 'DEL', SKIP: 'SKIP', KEEP: 'KEEP', WARN: 'WARN', ERR: 'ERR', OK: 'OK' };
34+
const LOG_PREFIXES = { ADD: 'ADD', DEL: 'DEL', SKIP: 'SKIP', KEEP: 'KEEP', WARN: 'WARN', ERR: 'ERR', OK: 'OK', SAME: 'SAME', UPDATE: 'UPDATE' };
1835

1936
function log(prefix, msg) {
2037
const p = (LOG_PREFIXES[prefix] || prefix).padEnd(5);
@@ -163,7 +180,7 @@ function cmdAdd(args) {
163180
continue;
164181
}
165182

166-
walkAndCopy(entryPath, join(ROOT, '.opencode', entry), addedFiles, force);
183+
walkAndCopy(entryPath, join(ROOT, HARNESS_TARGET, entry), addedFiles, force);
167184
}
168185

169186
// Handle MCP fragments
@@ -178,6 +195,13 @@ function cmdAdd(args) {
178195
log('WARN', `MCP fragment '${mcpFile}' is not valid JSON β€” skipped`);
179196
continue;
180197
}
198+
// MCP fragments are only merged into opencode.json (OpenCode harness).
199+
// Pi harness manages MCP separately β€” skip with a warning.
200+
if (HARNESS === 'pi') {
201+
log('WARN', `MCP fragment '${mcpFile}' β€” Pi harness does not merge MCP via installer (skipped)`);
202+
summary.mcpSkipped++;
203+
continue;
204+
}
181205
const ocfg = readJSON(OPENCODE_JSON) || {};
182206
if (!ocfg.mcp) ocfg.mcp = {};
183207
let merged = false;
@@ -245,14 +269,14 @@ function walkAndCopy(srcDir, destDir, fileList, force = false) {
245269
copyFileSync(srcPath, destPath);
246270
log('UPDATE', `${toForward(relative(MODULES_DIR, srcPath))} β†’ ${toForward(relative(ROOT, destPath))}`);
247271
}
248-
fileList.push(toForward(relative(join(ROOT, '.opencode'), destPath)));
272+
fileList.push(toForward(relative(join(ROOT, HARNESS_TARGET), destPath)));
249273
} else {
250274
log('SKIP', `${toForward(relative(MODULES_DIR, srcPath))} β†’ ${toForward(relative(ROOT, destPath))}`);
251275
}
252276
} else {
253277
copyFileSync(srcPath, destPath);
254278
log('ADD', `${toForward(relative(MODULES_DIR, srcPath))} β†’ ${toForward(relative(ROOT, destPath))}`);
255-
fileList.push(toForward(relative(join(ROOT, '.opencode'), destPath)));
279+
fileList.push(toForward(relative(join(ROOT, HARNESS_TARGET), destPath)));
256280
}
257281
}
258282
}
@@ -304,7 +328,7 @@ function cmdRemove(name) {
304328

305329
// Remove tracked files
306330
for (const f of files) {
307-
const installedPath = join(ROOT, '.opencode', f);
331+
const installedPath = join(ROOT, HARNESS_TARGET, f);
308332
const sourcePath = join(MODULES_DIR, name, f);
309333

310334
if (!existsSync(installedPath)) {
@@ -332,8 +356,8 @@ function cmdRemove(name) {
332356
// Clean up empty parent directories
333357
const parentDirs = new Set();
334358
for (const f of files) {
335-
let dir = dirname(join(ROOT, '.opencode', f));
336-
while (dir.startsWith(join(ROOT, '.opencode'))) {
359+
let dir = dirname(join(ROOT, HARNESS_TARGET, f));
360+
while (dir.startsWith(join(ROOT, HARNESS_TARGET))) {
337361
parentDirs.add(dir);
338362
dir = dirname(dir);
339363
}
@@ -349,8 +373,8 @@ function cmdRemove(name) {
349373
} catch { /* skip */ }
350374
}
351375

352-
// Remove MCP servers from opencode.json
353-
if (mcpServers.length > 0) {
376+
// Remove MCP servers from opencode.json (OpenCode-only)
377+
if (mcpServers.length > 0 && HARNESS !== 'pi') {
354378
const ocfg = readJSON(OPENCODE_JSON);
355379
if (ocfg && ocfg.mcp) {
356380
for (const serverName of mcpServers) {
@@ -386,7 +410,7 @@ function runValidation() {
386410
return;
387411
}
388412
log('OK', 'Running post-install validation...');
389-
const result = spawnSync('node', [validatePath], { cwd: ROOT, stdio: 'inherit', shell: true });
413+
const result = spawnSync('node', [validatePath], { cwd: ROOT, stdio: 'inherit' });
390414
if (result.status !== 0) {
391415
log('WARN', `Validation exited with code ${result.status} β€” fix issues if needed`);
392416
} else {
@@ -398,12 +422,24 @@ function runValidation() {
398422

399423
function main() {
400424
const args = process.argv.slice(2);
425+
426+
// Parse --pi flag (can appear anywhere in args)
427+
const piIndex = args.indexOf('--pi');
428+
if (piIndex !== -1) {
429+
setHarness('pi');
430+
args.splice(piIndex, 1);
431+
}
432+
401433
if (args.length === 0) {
434+
const scriptPath = HARNESS === 'pi' ? '.agents/modules/install.mjs' : '.opencode/modules/install.mjs';
402435
console.log('Usage:');
403-
console.log(' node .opencode/modules/install.mjs list');
404-
console.log(' node .opencode/modules/install.mjs info <name>');
405-
console.log(' node .opencode/modules/install.mjs add <name...>');
406-
console.log(' node .opencode/modules/install.mjs remove <name>');
436+
console.log(` node ${scriptPath} list [--pi]`);
437+
console.log(` node ${scriptPath} info <name> [--pi]`);
438+
console.log(` node ${scriptPath} add <name...> [--pi]`);
439+
console.log(` node ${scriptPath} remove <name> [--pi]`);
440+
console.log('');
441+
console.log('Options:');
442+
console.log(' --pi Install to .agents/ instead of .opencode/ (Pi harness)');
407443
process.exit(0);
408444
}
409445

β€Ž.pi/extensions/ocgs-drift-detector/index.tsβ€Ž

Lines changed: 95 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,93 +4,107 @@ import path from "node:path";
44

55
const AGENTS_DIR = path.resolve(process.cwd(), ".agents");
66

7-
// Required sections per file type
7+
// Required frontmatter fields per file type
88
const REQUIRED_SECTIONS: Record<string, string[]> = {
9-
agents: ["name", "description"],
10-
skills: ["description", "when_to_use", "procedure"],
11-
commands: ["description", "handler"],
9+
agents: ["description"],
10+
skills: ["name", "description"],
11+
commands: ["name", "description", "skill"],
1212
};
1313

14+
function parseFrontmatter(content: string): Record<string, unknown> {
15+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
16+
if (!match) return {};
17+
18+
const frontmatter: Record<string, unknown> = {};
19+
const lines = match[1].split(/\r?\n/);
20+
for (const line of lines) {
21+
const colonIdx = line.indexOf(':');
22+
if (colonIdx === -1) continue;
23+
const key = line.slice(0, colonIdx).trim();
24+
const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
25+
if (key) frontmatter[key] = value;
26+
}
27+
return frontmatter;
28+
}
29+
1430
async function checkFileForDrift(filePath: string): Promise<string[]> {
15-
const relPath = path.relative(AGENTS_DIR, filePath);
16-
const type = relPath.split(path.sep)[0]; // 'agents', 'skills', 'commands'
17-
const requirements = REQUIRED_SECTIONS[type];
18-
if (!requirements) return [];
19-
20-
const content = fs.readFileSync(filePath, "utf-8");
21-
const issues: string[] = [];
22-
23-
for (const section of requirements) {
24-
if (
25-
!content.toLowerCase().includes(`**${section}**`) &&
26-
!content.toLowerCase().includes(`## ${section}`) &&
27-
!content.toLowerCase().includes(`# ${section}`)
28-
) {
29-
issues.push(`missing required section: ${section}`);
30-
}
31-
}
32-
33-
return issues;
31+
const relPath = path.relative(AGENTS_DIR, filePath);
32+
const type = relPath.split(path.sep)[0]; // 'agents', 'skills', 'commands'
33+
const requirements = REQUIRED_SECTIONS[type];
34+
if (!requirements) return [];
35+
36+
const content = fs.readFileSync(filePath, "utf-8");
37+
const frontmatter = parseFrontmatter(content);
38+
const issues: string[] = [];
39+
40+
for (const field of requirements) {
41+
if (!frontmatter[field]) {
42+
issues.push(`missing required frontmatter field: ${field}`);
43+
}
44+
}
45+
46+
return issues;
3447
}
3548

3649
let driftCount = 0;
3750

38-
export default function (pi: ExtensionAPI) {
39-
// Startup scan
40-
pi.on("resources_discover", async (event, _ctx) => {
41-
if (event.reason !== "startup") return;
42-
driftCount = 0;
43-
44-
function scanDir(dir: string) {
45-
if (!fs.existsSync(dir)) return;
46-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47-
const fullPath = path.join(dir, entry.name);
48-
if (entry.isDirectory()) scanDir(fullPath);
49-
else if (entry.name.endsWith(".md")) {
50-
const issues = checkFileForDrift(fullPath);
51-
if (issues.length > 0) driftCount++;
52-
}
53-
}
54-
}
55-
56-
scanDir(path.join(AGENTS_DIR, "agents"));
57-
scanDir(path.join(AGENTS_DIR, "skills"));
58-
59-
if (driftCount > 0 && _ctx.hasUI) {
60-
_ctx.ui.setStatus("ocgs-drift", `drift: ${driftCount} files`);
61-
}
62-
});
63-
64-
// Post-write drift check
65-
pi.on("tool_result", async (event, _ctx) => {
66-
const toolName = event.toolName;
67-
const input = event.input as Record<string, unknown>;
68-
69-
if (toolName === "write" || toolName === "edit") {
70-
const filePath = input.path as string;
71-
if (filePath && filePath.startsWith(".agents")) {
72-
const issues = await checkFileForDrift(filePath);
73-
if (issues.length > 0) {
74-
driftCount++;
75-
if (_ctx.hasUI) {
76-
_ctx.ui.setStatus("ocgs-drift", `drift: ${driftCount} files`);
77-
}
78-
79-
// Append drift warning to the tool result (Pi-only enhancement)
80-
return {
81-
content: [
82-
...(Array.isArray(event.content)
83-
? event.content
84-
: [{ type: "text" as const, text: String(event.content) }]),
85-
{
86-
type: "text" as const,
87-
text: `\n\n⚠️ OCGS drift detected in ${filePath}: ${issues.join("; ")}`,
88-
},
89-
],
90-
details: { ...event.details, drift: issues },
91-
};
92-
}
93-
}
94-
}
95-
});
51+
export default function(pi: ExtensionAPI) {
52+
// Startup scan
53+
pi.on("resources_discover", async (event, _ctx) => {
54+
if (event.reason !== "startup") return;
55+
driftCount = 0;
56+
57+
function scanDir(dir: string) {
58+
if (!fs.existsSync(dir)) return;
59+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60+
const fullPath = path.join(dir, entry.name);
61+
if (entry.isDirectory()) scanDir(fullPath);
62+
else if (entry.name.endsWith(".md")) {
63+
const issues = checkFileForDrift(fullPath);
64+
if (issues.length > 0) driftCount++;
65+
}
66+
}
67+
}
68+
69+
scanDir(path.join(AGENTS_DIR, "agents"));
70+
scanDir(path.join(AGENTS_DIR, "skills"));
71+
scanDir(path.join(AGENTS_DIR, "commands"));
72+
73+
if (driftCount > 0 && _ctx.hasUI) {
74+
_ctx.ui.setStatus("ocgs-drift", `drift: ${driftCount} files`);
75+
}
76+
});
77+
78+
pi.on("tool_result", async (event, _ctx) => {
79+
const toolName = event.toolName;
80+
const input = event.input as Record<string, unknown>;
81+
82+
if (toolName === "write" || toolName === "edit") {
83+
if (!input || typeof input !== "object") return;
84+
const filePath = input.path as string;
85+
if (filePath && filePath.startsWith(".agents")) {
86+
const issues = await checkFileForDrift(filePath);
87+
if (issues.length > 0) {
88+
driftCount++;
89+
if (_ctx.hasUI) {
90+
_ctx.ui.setStatus("ocgs-drift", `drift: ${driftCount} files`);
91+
}
92+
93+
// Append drift warning to the tool result (Pi-only enhancement)
94+
return {
95+
content: [
96+
...(Array.isArray(event.content)
97+
? event.content
98+
: [{ type: "text" as const, text: String(event.content) }]),
99+
{
100+
type: "text" as const,
101+
text: `\n\n⚠️ OCGS drift detected in ${filePath}: ${issues.join("; ")}`,
102+
},
103+
],
104+
details: { ...event.details, drift: issues },
105+
};
106+
}
107+
}
108+
}
109+
});
96110
}

β€ŽAGENTS.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Each agent owns a specific domain, enforcing separation of concerns and quality.
1919
/
2020
β”œβ”€β”€ AGENTS.md # Project configuration
2121
β”œβ”€β”€ opencode.json # OpenCode config (permissions, plugins)
22-
β”œβ”€β”€ pi.json # Pi settings (generated)
22+
β”œβ”€β”€ .pi/ # Pi settings and extensions
2323
β”œβ”€β”€ .agents/ # Canonical content (harness-agnostic)
2424
β”‚ β”œβ”€β”€ agents/ # 51 agent definitions
2525
β”‚ β”œβ”€β”€ skills/ # 77 skill workflows

β€ŽREADME.mdβ€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ and runs on [OpenCode](https://opencode.ai) and [Pi](https://github.com/earendil
7676
| ⌨️ **Commands** | β€” | 54 commands (`.agents/commands/`) | β†’ prompt templates | βœ… New |
7777
| πŸ”— **Plugins** | 12 bash hooks (`.claude/hooks/`) | 3 TS plugins (`.opencode/plugins/`) | 7 Pi extensions (`.pi/extensions/`) | βœ… **183 tests** |
7878
| πŸ“ **Rules** | 11 rule files (`.claude/rules/`) | 11 rule files (`.agents/rules/`) | 11 rule files (`.agents/rules/`) | βœ… |
79-
| βš™οΈ **Config** | `CLAUDE.md` + `.claude/settings.json` | `AGENTS.md` + `opencode.json` | `AGENTS.md` + `pi.json` | βœ… |
79+
| βš™οΈ **Config** | `CLAUDE.md` + `.claude/settings.json` | `AGENTS.md` + `opencode.json` | `AGENTS.md` + `.pi/settings.json` | βœ… |
8080

8181
---
8282

@@ -182,7 +182,7 @@ on startup. See [docs/pi-extensions.md](docs/pi-extensions.md) for details.
182182
| `.claude/hooks/*.sh` β†’ | `.opencode/plugins/ccgs-hooks.ts` | `.pi/extensions/` |
183183
| `.claude/rules/*.md` β†’ | `.agents/rules/*.md` | `.agents/rules/*.md` |
184184
| `CLAUDE.md` β†’ | `AGENTS.md` | `AGENTS.md` |
185-
| `.claude/settings.json` β†’ | `opencode.json` | `pi.json` |
185+
| `.claude/settings.json` β†’ | `opencode.json` | `.pi/settings.json` |
186186

187187
---
188188

@@ -265,7 +265,7 @@ node utils/assign-models.js --config my-models.json
265265
/
266266
β”œβ”€β”€ AGENTS.md πŸ“‹ Project configuration
267267
β”œβ”€β”€ opencode.json βš™οΈ OpenCode config (permissions, plugins)
268-
β”œβ”€β”€ pi.json βš™οΈ Pi config (MCP, settings)
268+
β”œβ”€β”€ .pi/ βš™οΈ Pi config (extensions, settings)
269269
β”œβ”€β”€ .agents/ πŸ“¦ Canonical content (harness-agnostic)
270270
β”‚ β”œβ”€β”€ agents/ πŸ€– 51 agent definitions
271271
β”‚ β”œβ”€β”€ skills/ πŸ› οΈ 77 skill workflows

0 commit comments

Comments
Β (0)