offrouter-adapter-claude 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { readFile, access } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ const repoRoot = resolve(fileURLToPath(new URL("../../../", import.meta.url)));
8
+ const pluginRoot = join(repoRoot, "plugins", "claude-offrouter");
9
+
10
+ async function exists(path: string): Promise<boolean> {
11
+ try {
12
+ await access(path, constants.F_OK);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ async function readText(path: string): Promise<string> {
20
+ return readFile(path, "utf8");
21
+ }
22
+
23
+ describe("Claude OffRouter plugin package", () => {
24
+ it("includes required plugin root files", async () => {
25
+ for (const rel of [
26
+ ".claude-plugin/plugin.json",
27
+ "skills/delegate/SKILL.md",
28
+ "skills/status/SKILL.md",
29
+ "hooks/hooks.json",
30
+ ".mcp.json",
31
+ ]) {
32
+ expect(await exists(join(pluginRoot, rel)), rel).toBe(true);
33
+ }
34
+ });
35
+
36
+ it("declares a strict-validation-friendly plugin manifest", async () => {
37
+ const raw = await readText(
38
+ join(pluginRoot, ".claude-plugin", "plugin.json"),
39
+ );
40
+ const manifest = JSON.parse(raw) as Record<string, unknown>;
41
+
42
+ expect(manifest.name).toBe("offrouter");
43
+ expect(typeof manifest.description).toBe("string");
44
+ expect(String(manifest.description).length).toBeGreaterThan(10);
45
+ if ("displayName" in manifest) {
46
+ expect(manifest.displayName).toBe("OffRouter");
47
+ }
48
+ if ("defaultEnabled" in manifest) {
49
+ expect(manifest.defaultEnabled).toBe(false);
50
+ }
51
+ expect(raw.toLowerCase()).not.toMatch(
52
+ /claude[-_ ]?router|codex[-_ ]?companion|openai[-_ ]?codex-plugin/,
53
+ );
54
+ });
55
+
56
+ it("skills document /offrouter:delegate and /offrouter:status via MCP with the V1 honesty boundary", async () => {
57
+ const delegate = await readText(
58
+ join(pluginRoot, "skills", "delegate", "SKILL.md"),
59
+ );
60
+ const status = await readText(
61
+ join(pluginRoot, "skills", "status", "SKILL.md"),
62
+ );
63
+
64
+ expect(delegate).toMatch(/offrouter:delegate|\/offrouter:delegate/);
65
+ expect(status).toMatch(/offrouter:status|\/offrouter:status/);
66
+
67
+ for (const skill of [delegate, status]) {
68
+ expect(skill.toLowerCase()).toContain("mcp");
69
+ expect(skill).toMatch(/primary model is unchanged/i);
70
+ expect(skill).toMatch(/routes delegated work/i);
71
+ // Reject affirmative takeover claims; allow the honesty boundary itself.
72
+ expect(skill.toLowerCase()).not.toMatch(
73
+ /\b(will|can|does|this plugin)\s+replace(s|ing)?\s+(claude|the primary)\b/i,
74
+ );
75
+ expect(skill.toLowerCase()).not.toMatch(
76
+ /\b(will|can|does|this plugin)\s+intercept(s|ing)?\s+(primary|claude)\b/i,
77
+ );
78
+ expect(skill.toLowerCase()).not.toMatch(
79
+ /claude[-_ ]?router|codex[-_ ]?companion/,
80
+ );
81
+ }
82
+ });
83
+
84
+ it("hooks UserPromptSubmit to the built offrouter CLI bridge without hardcoded profile names", async () => {
85
+ const raw = await readText(join(pluginRoot, "hooks", "hooks.json"));
86
+ const hooks = JSON.parse(raw) as {
87
+ hooks?: {
88
+ UserPromptSubmit?: Array<{
89
+ hooks?: Array<{ type?: string; command?: string }>;
90
+ }>;
91
+ };
92
+ };
93
+
94
+ const commands =
95
+ hooks.hooks?.UserPromptSubmit?.flatMap((entry) =>
96
+ (entry.hooks ?? [])
97
+ .filter((hook) => hook.type === "command")
98
+ .map((hook) => hook.command ?? ""),
99
+ ) ?? [];
100
+
101
+ expect(commands.length).toBeGreaterThan(0);
102
+
103
+ const joined = commands.join("\n");
104
+ expect(joined).toContain("offrouter hooks claude user-prompt-submit");
105
+ // Built CLI on PATH, not a repo-relative source or dist path that breaks
106
+ // after Claude caches the plugin.
107
+ expect(joined).not.toMatch(/packages\/cli\/src\//);
108
+ expect(joined).not.toMatch(/packages\/cli\/dist\//);
109
+ expect(joined).not.toContain("CLAUDE_PLUGIN_ROOT");
110
+ // Generic package must not hardcode profile selection.
111
+ expect(joined).not.toMatch(/claude-personal|claude-work/);
112
+ expect(raw.toLowerCase()).not.toMatch(
113
+ /claude[-_ ]?router|codex[-_ ]?companion/,
114
+ );
115
+ });
116
+
117
+ it("configures the exact offrouter MCP stdio server", async () => {
118
+ const raw = await readText(join(pluginRoot, ".mcp.json"));
119
+ const mcp = JSON.parse(raw) as {
120
+ mcpServers?: Record<
121
+ string,
122
+ { command?: string; args?: string[]; type?: string }
123
+ >;
124
+ };
125
+
126
+ expect(mcp.mcpServers?.offrouter).toEqual({
127
+ command: "offrouter",
128
+ args: ["mcp", "serve"],
129
+ });
130
+ expect(raw.toLowerCase()).not.toMatch(
131
+ /claude[-_ ]?router|codex[-_ ]?companion/,
132
+ );
133
+ });
134
+
135
+ it("contains no raw external-project references across plugin files", async () => {
136
+ const files = [
137
+ ".claude-plugin/plugin.json",
138
+ "skills/delegate/SKILL.md",
139
+ "skills/status/SKILL.md",
140
+ "hooks/hooks.json",
141
+ ".mcp.json",
142
+ ];
143
+ for (const rel of files) {
144
+ const text = (await readText(join(pluginRoot, rel))).toLowerCase();
145
+ expect(text, rel).not.toMatch(
146
+ /claude[-_ ]?router|codex[-_ ]?companion|openai[-_ ]?codex-plugin|claude[-_ ]?mem/,
147
+ );
148
+ }
149
+ });
150
+ });
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Rollback for profile-scoped Claude install state.
3
+ * Restores overwritten plugin files and removes files created by OffRouter.
4
+ */
5
+ import {
6
+ access,
7
+ mkdir,
8
+ readFile,
9
+ rmdir,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { constants } from "node:fs";
14
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { homedir as osHomedir } from "node:os";
16
+ import {
17
+ CLAUDE_INSTALL_STATE_REL,
18
+ ClaudeInstallStateSchema,
19
+ INSTALLED_PLUGIN_REL,
20
+ resolveClaudeProfileDir,
21
+ type ClaudeInstallState,
22
+ type InstallChangeKind,
23
+ type InstallFileChange,
24
+ } from "./install.js";
25
+
26
+ export interface ClaudeRollbackOptions {
27
+ profile: string;
28
+ env?: NodeJS.ProcessEnv;
29
+ homedir?: () => string;
30
+ dryRun?: boolean;
31
+ /** Optional absolute override of the profile directory (tests). */
32
+ profileDir?: string;
33
+ }
34
+
35
+ export interface ClaudeRollbackResult {
36
+ ok: boolean;
37
+ dryRun: boolean;
38
+ profile: string;
39
+ profileDir: string;
40
+ changes: InstallFileChange[];
41
+ message: string;
42
+ errorCode?: "no_install_state" | "invalid_install_state" | "rollback_error";
43
+ }
44
+
45
+ async function pathExists(path: string): Promise<boolean> {
46
+ try {
47
+ await access(path, constants.F_OK);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function changeSummary(kind: InstallChangeKind, relativePath: string): string {
55
+ switch (kind) {
56
+ case "create":
57
+ return `+ ${relativePath}`;
58
+ case "update":
59
+ return `~ ${relativePath}`;
60
+ case "unchanged":
61
+ return `= ${relativePath}`;
62
+ case "remove":
63
+ return `- ${relativePath}`;
64
+ default:
65
+ return `? ${relativePath}`;
66
+ }
67
+ }
68
+
69
+ async function loadState(statePath: string): Promise<
70
+ | { ok: true; state: ClaudeInstallState }
71
+ | {
72
+ ok: false;
73
+ errorCode: "no_install_state" | "invalid_install_state";
74
+ message: string;
75
+ }
76
+ > {
77
+ if (!(await pathExists(statePath))) {
78
+ return {
79
+ ok: false,
80
+ errorCode: "no_install_state",
81
+ message: `No install state at ${statePath}. Nothing to uninstall.`,
82
+ };
83
+ }
84
+ try {
85
+ const raw = await readFile(statePath, "utf8");
86
+ const state = ClaudeInstallStateSchema.parse(JSON.parse(raw));
87
+ return { ok: true, state };
88
+ } catch (err) {
89
+ const detail = err instanceof Error ? err.message : String(err);
90
+ return {
91
+ ok: false,
92
+ errorCode: "invalid_install_state",
93
+ message: `Invalid install state at ${statePath}: ${detail}`,
94
+ };
95
+ }
96
+ }
97
+
98
+ function isWindowsAbsolutePath(path: string): boolean {
99
+ return /^[A-Za-z]:[\\/]/.test(path);
100
+ }
101
+
102
+ function isInsideOrSame(root: string, candidate: string): boolean {
103
+ const rel = relative(root, candidate);
104
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
105
+ }
106
+
107
+ function resolveProfileRelativePath(
108
+ profileDir: string,
109
+ relativePath: string,
110
+ ): string | null {
111
+ if (
112
+ relativePath.trim() === "" ||
113
+ relativePath.includes("\0") ||
114
+ isAbsolute(relativePath) ||
115
+ isWindowsAbsolutePath(relativePath)
116
+ ) {
117
+ return null;
118
+ }
119
+
120
+ const parts = relativePath.split(/[\\/]+/);
121
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
122
+ return null;
123
+ }
124
+
125
+ const root = resolve(profileDir);
126
+ const candidate = resolve(root, ...parts);
127
+ if (!isInsideOrSame(root, candidate)) {
128
+ return null;
129
+ }
130
+ return candidate;
131
+ }
132
+
133
+ function validateRollbackState(
134
+ profileDir: string,
135
+ state: ClaudeInstallState,
136
+ ):
137
+ | { ok: true; filePaths: Map<string, string>; pluginRoot: string }
138
+ | { ok: false; message: string } {
139
+ if (state.pluginRootRelative !== INSTALLED_PLUGIN_REL) {
140
+ return {
141
+ ok: false,
142
+ message: `Invalid plugin root in install state: ${state.pluginRootRelative}`,
143
+ };
144
+ }
145
+
146
+ const pluginRoot = resolveProfileRelativePath(
147
+ profileDir,
148
+ state.pluginRootRelative,
149
+ );
150
+ if (!pluginRoot) {
151
+ return {
152
+ ok: false,
153
+ message: `Invalid plugin root in install state: ${state.pluginRootRelative}`,
154
+ };
155
+ }
156
+
157
+ const filePaths = new Map<string, string>();
158
+ const pluginPrefix = `${INSTALLED_PLUGIN_REL}/`;
159
+ for (const relativePath of Object.keys(state.files)) {
160
+ if (!relativePath.startsWith(pluginPrefix)) {
161
+ return {
162
+ ok: false,
163
+ message: `Install state path is outside plugin root: ${relativePath}`,
164
+ };
165
+ }
166
+ const abs = resolveProfileRelativePath(profileDir, relativePath);
167
+ if (!abs) {
168
+ return {
169
+ ok: false,
170
+ message: `Invalid install state path: ${relativePath}`,
171
+ };
172
+ }
173
+ filePaths.set(relativePath, abs);
174
+ }
175
+
176
+ return { ok: true, filePaths, pluginRoot };
177
+ }
178
+
179
+ function collectParentDirsUntil(
180
+ filePath: string,
181
+ stopDir: string,
182
+ out: Set<string>,
183
+ ): void {
184
+ const stop = resolve(stopDir);
185
+ let dir = dirname(filePath);
186
+ while (isInsideOrSame(stop, dir)) {
187
+ out.add(dir);
188
+ if (dir === stop) break;
189
+ const parent = dirname(dir);
190
+ if (parent === dir) break;
191
+ dir = parent;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Restore files tracked by claude-install-state.json for a profile.
197
+ */
198
+ export async function rollbackClaudeProfile(
199
+ options: ClaudeRollbackOptions,
200
+ ): Promise<ClaudeRollbackResult> {
201
+ const {
202
+ profile,
203
+ dryRun = false,
204
+ env = process.env,
205
+ homedir = osHomedir,
206
+ } = options;
207
+ const profileDir =
208
+ options.profileDir ?? resolveClaudeProfileDir(profile, { env, homedir });
209
+ const statePath = join(profileDir, CLAUDE_INSTALL_STATE_REL);
210
+
211
+ const loaded = await loadState(statePath);
212
+ if (!loaded.ok) {
213
+ return {
214
+ ok: false,
215
+ dryRun,
216
+ profile,
217
+ profileDir,
218
+ changes: [],
219
+ message: loaded.message,
220
+ errorCode: loaded.errorCode,
221
+ };
222
+ }
223
+
224
+ const { state } = loaded;
225
+ const validated = validateRollbackState(profileDir, state);
226
+ if (!validated.ok) {
227
+ return {
228
+ ok: false,
229
+ dryRun,
230
+ profile,
231
+ profileDir,
232
+ changes: [],
233
+ message: validated.message,
234
+ errorCode: "invalid_install_state",
235
+ };
236
+ }
237
+
238
+ const changes: InstallFileChange[] = [];
239
+ const emptyDirsToRemove = new Set<string>();
240
+
241
+ // Restore/remove plugin files in reverse path length so nested files go first.
242
+ const entries = Object.entries(state.files).sort(
243
+ (a, b) => b[0].length - a[0].length,
244
+ );
245
+
246
+ for (const [relativePath, fileState] of entries) {
247
+ const abs = validated.filePaths.get(relativePath)!;
248
+ const previous = fileState.previous;
249
+
250
+ if (previous === null) {
251
+ // File was created by install; remove it on rollback.
252
+ const existed = await pathExists(abs);
253
+ const kind: InstallChangeKind = existed ? "remove" : "unchanged";
254
+ changes.push({
255
+ path: abs,
256
+ relativePath,
257
+ kind,
258
+ summary: changeSummary(kind, relativePath),
259
+ });
260
+ if (!dryRun && existed) {
261
+ collectParentDirsUntil(abs, validated.pluginRoot, emptyDirsToRemove);
262
+ await unlink(abs);
263
+ }
264
+ continue;
265
+ }
266
+
267
+ let current: string | null = null;
268
+ if (await pathExists(abs)) {
269
+ current = await readFile(abs, "utf8");
270
+ }
271
+ let kind: InstallChangeKind;
272
+ if (current === previous) {
273
+ kind = "unchanged";
274
+ } else if (current === null) {
275
+ kind = "create"; // restore missing prior content
276
+ } else {
277
+ kind = "update";
278
+ }
279
+ changes.push({
280
+ path: abs,
281
+ relativePath,
282
+ kind,
283
+ summary: changeSummary(kind, relativePath),
284
+ });
285
+ if (!dryRun && kind !== "unchanged") {
286
+ await mkdir(dirname(abs), { recursive: true });
287
+ await writeFile(abs, previous, "utf8");
288
+ }
289
+ }
290
+
291
+ // Drop install state last
292
+ const stateChangeKind: InstallChangeKind = "remove";
293
+ changes.push({
294
+ path: statePath,
295
+ relativePath: CLAUDE_INSTALL_STATE_REL,
296
+ kind: stateChangeKind,
297
+ summary: changeSummary(stateChangeKind, CLAUDE_INSTALL_STATE_REL),
298
+ });
299
+ if (!dryRun) {
300
+ await unlink(statePath);
301
+ for (const dir of [...emptyDirsToRemove].sort(
302
+ (a, b) => b.length - a.length,
303
+ )) {
304
+ try {
305
+ await rmdir(dir);
306
+ } catch {
307
+ // ignore non-empty or already-removed directories
308
+ }
309
+ }
310
+ // Best-effort remove only the empty install-state directory.
311
+ try {
312
+ await rmdir(join(profileDir, ".offrouter"));
313
+ } catch {
314
+ // ignore
315
+ }
316
+ }
317
+
318
+ const applied = changes.filter((c) => c.kind !== "unchanged");
319
+ return {
320
+ ok: true,
321
+ dryRun,
322
+ profile,
323
+ profileDir,
324
+ changes,
325
+ message: dryRun
326
+ ? `Dry run: would roll back ${applied.length} change(s) for profile ${profile}.`
327
+ : `Rolled back OffRouter Claude install for profile ${profile} (${applied.length} change(s)).`,
328
+ };
329
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["src/**/*.test.ts"],
9
+ "references": [{ "path": "../core" }]
10
+ }