Skip to content

Commit 57316b1

Browse files
LynricsyWineFoxDev
andcommitted
feat(cli): 🛠️ add OMP setup and doctor support
Co-authored-by: Wine Fox <fox@ling.plus>
1 parent 3f4a69d commit 57316b1

21 files changed

Lines changed: 1570 additions & 97 deletions

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
{
22
"name": "@cortexkit/magic-context",
33
"version": "0.33.0",
4-
"description": "Unified CLI for Magic Context — setup, doctor, and migration across OpenCode and Pi",
4+
"description": "Unified CLI for Magic Context — setup, doctor, and migration across OpenCode, Pi, and OMP",
55
"keywords": [
66
"opencode",
77
"opencode-plugin",
88
"pi",
99
"pi-extension",
10+
"omp",
11+
"oh-my-pi",
1012
"magic-context",
1113
"cli",
1214
"setup",

packages/cli/src/adapters/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { OmpAdapter } from "./omp";
12
import { OpenCodeAdapter } from "./opencode";
23
import { PiAdapter } from "./pi";
34
import type { HarnessAdapter, HarnessKind } from "./types";
45

56
export type { HarnessAdapter, HarnessKind } from "./types";
6-
export { OpenCodeAdapter, PiAdapter };
7+
export { OmpAdapter, OpenCodeAdapter, PiAdapter };
78

8-
const ALL: HarnessAdapter[] = [new OpenCodeAdapter(), new PiAdapter()];
9+
const ALL: HarnessAdapter[] = [new OpenCodeAdapter(), new PiAdapter(), new OmpAdapter()];
910

1011
/** Look up an adapter by kind. Throws on unknown kind. */
1112
export function getAdapter(kind: HarnessKind): HarnessAdapter {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { OmpAdapter } from "./omp";
6+
7+
const original = {
8+
HOME: process.env.HOME,
9+
PATH: process.env.PATH,
10+
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
11+
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
12+
};
13+
const roots: string[] = [];
14+
15+
afterEach(() => {
16+
for (const [key, value] of Object.entries(original)) {
17+
if (value === undefined) delete process.env[key];
18+
else process.env[key] = value;
19+
}
20+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
21+
});
22+
23+
describe("OmpAdapter", () => {
24+
it("detects an enabled Magic Context plugin from omp plugin list", () => {
25+
const root = mkdtempSync(join(tmpdir(), "mc-omp-adapter-"));
26+
roots.push(root);
27+
const bin = join(root, "bin");
28+
mkdirSync(bin, { recursive: true });
29+
const omp = join(bin, "omp");
30+
writeFileSync(
31+
omp,
32+
`#!/bin/sh
33+
if [ "$1 $2 $3" = "plugin list --json" ]; then
34+
printf '%s' '{"npm":[{"name":"@cortexkit/pi-magic-context","version":"0.33.0","enabled":true}],"marketplace":[]}'
35+
fi
36+
`,
37+
{ mode: 0o755 },
38+
);
39+
process.env.PATH = bin;
40+
process.env.HOME = root;
41+
delete process.env.XDG_DATA_HOME;
42+
43+
const adapter = new OmpAdapter();
44+
expect(adapter.isInstalled()).toBe(true);
45+
expect(adapter.hasPluginEntry()).toBe(true);
46+
expect(adapter.getInstalledPluginVersion()).toBe("0.33.0");
47+
});
48+
});

packages/cli/src/adapters/omp.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import {
4+
detectOmpBinary,
5+
listOmpPlugins,
6+
OMP_PLUGIN_PACKAGE,
7+
runOmpCommand,
8+
} from "../lib/omp-helpers";
9+
import {
10+
dirSizeBytes,
11+
getMagicContextLogPath,
12+
getOmpAgentDir,
13+
getOmpPluginsDir,
14+
getOmpPluginsLockPath,
15+
getOmpUserConfigPath,
16+
} from "../lib/paths";
17+
import type {
18+
HarnessAdapter,
19+
HarnessConfigPaths,
20+
PluginCacheInfo,
21+
PluginEntryResult,
22+
} from "./types";
23+
24+
export class OmpAdapter implements HarnessAdapter {
25+
readonly kind = "omp" as const;
26+
readonly displayName = "Oh My Pi (OMP)";
27+
readonly pluginPackageName = OMP_PLUGIN_PACKAGE;
28+
29+
isInstalled(): boolean {
30+
return detectOmpBinary() !== null;
31+
}
32+
33+
hasPluginEntry(): boolean {
34+
const omp = detectOmpBinary();
35+
if (!omp) return false;
36+
return (
37+
listOmpPlugins(omp.path)?.some(
38+
(plugin) => plugin.name === OMP_PLUGIN_PACKAGE && plugin.enabled,
39+
) ?? false
40+
);
41+
}
42+
43+
getConfigPaths(): HarnessConfigPaths {
44+
return {
45+
configDir: getOmpAgentDir(),
46+
pluginConfigPath: getOmpPluginsLockPath(),
47+
magicContextConfigPath: getOmpUserConfigPath(),
48+
secondaryConfigPath: null,
49+
};
50+
}
51+
52+
async ensurePluginEntry(): Promise<PluginEntryResult> {
53+
const configPath = getOmpPluginsLockPath();
54+
const omp = detectOmpBinary();
55+
if (!omp) return this.errorResult(configPath, "OMP binary not found");
56+
const plugins = listOmpPlugins(omp.path);
57+
if (plugins === null) {
58+
return this.errorResult(configPath, "`omp plugin list --json` failed");
59+
}
60+
const installed = plugins.find((plugin) => plugin.name === OMP_PLUGIN_PACKAGE);
61+
if (installed?.enabled) {
62+
return {
63+
ok: true,
64+
action: "already_present",
65+
message: `${OMP_PLUGIN_PACKAGE} is already enabled in OMP.`,
66+
configPath,
67+
};
68+
}
69+
const originalRuntimeEnabled = this.readRuntimeEnabled(configPath);
70+
71+
const args = installed
72+
? ["plugin", "enable", OMP_PLUGIN_PACKAGE]
73+
: ["plugin", "install", OMP_PLUGIN_PACKAGE];
74+
const result = runOmpCommand(omp.path, args, 120_000);
75+
if (!result.ok) {
76+
return this.errorResult(
77+
configPath,
78+
result.stderr || result.stdout || `omp ${args.join(" ")} failed`,
79+
);
80+
}
81+
const enabledAfter = listOmpPlugins(omp.path)?.some(
82+
(plugin) => plugin.name === OMP_PLUGIN_PACKAGE && plugin.enabled,
83+
);
84+
if (!enabledAfter) {
85+
// A project override can keep the plugin disabled even when the
86+
// global install/enable command exits 0. New installs are removed.
87+
// Existing installs restore the exact lockfile enable state; never
88+
// infer global state from the project-effective plugin list.
89+
if (!installed) {
90+
runOmpCommand(omp.path, ["plugin", "uninstall", OMP_PLUGIN_PACKAGE], 120_000);
91+
} else if (originalRuntimeEnabled !== undefined) {
92+
runOmpCommand(
93+
omp.path,
94+
["plugin", originalRuntimeEnabled ? "enable" : "disable", OMP_PLUGIN_PACKAGE],
95+
120_000,
96+
);
97+
}
98+
return this.errorResult(
99+
configPath,
100+
`${OMP_PLUGIN_PACKAGE} is still disabled in the current project after \`omp ${args.join(" ")}\``,
101+
);
102+
}
103+
return {
104+
ok: true,
105+
action: installed ? "updated" : "added",
106+
message: installed
107+
? `Enabled ${OMP_PLUGIN_PACKAGE} in OMP.`
108+
: `Installed ${OMP_PLUGIN_PACKAGE} in OMP.`,
109+
configPath,
110+
};
111+
}
112+
113+
async removePluginEntry(): Promise<PluginEntryResult> {
114+
const configPath = getOmpPluginsLockPath();
115+
const omp = detectOmpBinary();
116+
if (!omp) return this.errorResult(configPath, "OMP binary not found");
117+
const plugins = listOmpPlugins(omp.path);
118+
if (plugins === null) {
119+
return this.errorResult(configPath, "`omp plugin list --json` failed");
120+
}
121+
const installed = plugins.some((plugin) => plugin.name === OMP_PLUGIN_PACKAGE);
122+
if (!installed) {
123+
return {
124+
ok: true,
125+
action: "already_present",
126+
message: `${OMP_PLUGIN_PACKAGE} is not installed in OMP.`,
127+
configPath,
128+
};
129+
}
130+
const result = runOmpCommand(
131+
omp.path,
132+
["plugin", "uninstall", OMP_PLUGIN_PACKAGE],
133+
120_000,
134+
);
135+
if (!result.ok) {
136+
return this.errorResult(
137+
configPath,
138+
result.stderr || result.stdout || "OMP plugin uninstall failed",
139+
);
140+
}
141+
return {
142+
ok: true,
143+
action: "updated",
144+
message: `Uninstalled ${OMP_PLUGIN_PACKAGE} from OMP.`,
145+
configPath,
146+
};
147+
}
148+
149+
getInstallHint(): string {
150+
return "Install OMP: https://omp.sh (npm: @oh-my-pi/pi-coding-agent)";
151+
}
152+
153+
getPluginCacheInfo(): PluginCacheInfo {
154+
const path = join(getOmpPluginsDir(), "cache");
155+
return { path, exists: existsSync(path), sizeBytes: dirSizeBytes(path) };
156+
}
157+
158+
getLogPath(): string {
159+
// OMP executes the Pi-compatible runtime, which intentionally keeps the
160+
// existing `pi` DB/log discriminator for cross-host session semantics.
161+
return getMagicContextLogPath("pi");
162+
}
163+
164+
getInstalledPluginVersion(): string | null {
165+
const omp = detectOmpBinary();
166+
if (!omp) return null;
167+
return (
168+
listOmpPlugins(omp.path)?.find((plugin) => plugin.name === OMP_PLUGIN_PACKAGE)
169+
?.version ?? null
170+
);
171+
}
172+
173+
private readRuntimeEnabled(configPath: string): boolean | undefined {
174+
try {
175+
const lock = JSON.parse(readFileSync(configPath, "utf-8")) as {
176+
plugins?: Record<string, { enabled?: unknown }>;
177+
};
178+
const enabled = lock.plugins?.[OMP_PLUGIN_PACKAGE]?.enabled;
179+
return typeof enabled === "boolean" ? enabled : undefined;
180+
} catch {
181+
return undefined;
182+
}
183+
}
184+
185+
private errorResult(configPath: string, message: string): PluginEntryResult {
186+
return {
187+
ok: false,
188+
action: "error",
189+
message: `Failed to configure OMP: ${message}`,
190+
configPath,
191+
};
192+
}
193+
}

packages/cli/src/adapters/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* HarnessAdapter — abstracts what the unified Magic Context CLI needs to know
3-
* about a specific agent harness (OpenCode, Pi).
3+
* about a specific agent harness (OpenCode, Pi, or Oh My Pi).
44
*
55
* Each adapter covers:
66
* 1. *Detection* — is the harness installed? is the plugin registered with it?
@@ -12,7 +12,7 @@
1212
* structures; async work lives in the command layer.
1313
*/
1414

15-
export type HarnessKind = "opencode" | "pi";
15+
export type HarnessKind = "opencode" | "pi" | "omp";
1616

1717
export interface HarnessConfigPaths {
1818
/** Primary config dir (e.g. `~/.config/opencode`, `~/.pi/agent`). */
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts";
6+
import { runDoctor } from "./doctor-omp";
7+
8+
class MockPrompts implements PromptIO {
9+
readonly messages: string[] = [];
10+
readonly log = {
11+
info: (message: string) => this.messages.push(`info:${message}`),
12+
success: (message: string) => this.messages.push(`success:${message}`),
13+
warn: (message: string) => this.messages.push(`warn:${message}`),
14+
error: (message: string) => this.messages.push(`error:${message}`),
15+
message: (message: string) => this.messages.push(`message:${message}`),
16+
step: (message: string) => this.messages.push(`step:${message}`),
17+
};
18+
intro(message: string): void {
19+
this.messages.push(`intro:${message}`);
20+
}
21+
outro(): void {}
22+
note(): void {}
23+
spinner(): PromptSpinner {
24+
return { start: () => {}, stop: () => {}, message: () => {} };
25+
}
26+
async confirm(): Promise<boolean> {
27+
return false;
28+
}
29+
async text(): Promise<string> {
30+
return "test";
31+
}
32+
async selectOne(_message: string, options: SelectOption[]): Promise<string> {
33+
return options[0]?.value ?? "";
34+
}
35+
async selectMany(_message: string, options: SelectOption[]): Promise<string[]> {
36+
return options.map((option) => option.value);
37+
}
38+
async selectAutocomplete(_message: string, options: SelectOption[]): Promise<string> {
39+
return options[0]?.value ?? "";
40+
}
41+
}
42+
43+
const roots: string[] = [];
44+
const original = {
45+
HOME: process.env.HOME,
46+
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
47+
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
48+
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
49+
};
50+
51+
afterEach(() => {
52+
for (const [key, value] of Object.entries(original)) {
53+
if (value === undefined) delete process.env[key];
54+
else process.env[key] = value;
55+
}
56+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
57+
});
58+
59+
describe("OMP doctor", () => {
60+
it("accepts a healthy OMP installation", async () => {
61+
const root = mkdtempSync(join(tmpdir(), "mc-omp-doctor-"));
62+
roots.push(root);
63+
const agentDir = join(root, ".omp", "agent");
64+
const pluginDir = join(root, "plugin");
65+
const configDir = join(root, ".config", "cortexkit");
66+
mkdirSync(agentDir, { recursive: true });
67+
mkdirSync(pluginDir, { recursive: true });
68+
mkdirSync(configDir, { recursive: true });
69+
writeFileSync(
70+
join(pluginDir, "package.json"),
71+
JSON.stringify({ omp: { extensions: ["./dist/index.js"] } }),
72+
);
73+
writeFileSync(join(configDir, "magic-context.jsonc"), "{}\n");
74+
process.env.HOME = root;
75+
process.env.PI_CODING_AGENT_DIR = agentDir;
76+
process.env.XDG_CONFIG_HOME = join(root, ".config");
77+
delete process.env.XDG_DATA_HOME;
78+
const prompts = new MockPrompts();
79+
80+
const code = await runDoctor({
81+
cwd: root,
82+
prompts,
83+
deps: {
84+
detectOmpBinary: () => ({ path: "/fake/omp", source: "path" }),
85+
getOmpVersion: () => "17.1.7",
86+
listOmpPlugins: () => [
87+
{
88+
name: "@cortexkit/pi-magic-context",
89+
version: "0.33.0",
90+
enabled: true,
91+
path: pluginDir,
92+
},
93+
],
94+
getOmpSetting: ((_path: string, key: string) =>
95+
key === "compaction.enabled" ? false : "off") as never,
96+
runOmpCommand: () => ({ ok: true, stdout: agentDir, stderr: "" }),
97+
},
98+
});
99+
100+
expect(code).toBe(0);
101+
expect(prompts.messages.join("\n")).toContain("OMP 17.1.7 detected");
102+
expect(prompts.messages.join("\n")).toContain("FAIL 0");
103+
});
104+
});

0 commit comments

Comments
 (0)