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.
- package/package.json +15 -0
- package/package.json.bak +16 -0
- package/src/hooks/pre-tool-use.ts +19 -0
- package/src/hooks/user-prompt-submit.test.ts +222 -0
- package/src/hooks/user-prompt-submit.ts +262 -0
- package/src/index.ts +49 -0
- package/src/install.test.ts +589 -0
- package/src/install.ts +595 -0
- package/src/plugin.test.ts +150 -0
- package/src/rollback.ts +329 -0
- package/tsconfig.json +10 -0
package/src/install.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-scoped installer for Claude Code personal profiles.
|
|
3
|
+
*
|
|
4
|
+
* Copies the repo-neutral plugins/claude-offrouter package into the selected
|
|
5
|
+
* profile directory under skills/offrouter, patches hooks to set
|
|
6
|
+
* OFFROUTER_PROFILE for the selected profile only, and records rollback
|
|
7
|
+
* metadata inside the target profile (.offrouter/claude-install-state.json).
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
access,
|
|
11
|
+
copyFile,
|
|
12
|
+
mkdir,
|
|
13
|
+
readFile,
|
|
14
|
+
readdir,
|
|
15
|
+
stat,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from "node:fs/promises";
|
|
18
|
+
import { constants } from "node:fs";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { homedir as osHomedir } from "node:os";
|
|
23
|
+
import {
|
|
24
|
+
ROUTE_PROTOCOL_VERSION,
|
|
25
|
+
routeRequest,
|
|
26
|
+
type OffRouterConfig,
|
|
27
|
+
type PolicyDenialCode,
|
|
28
|
+
type RouteRequest,
|
|
29
|
+
} from "offrouter-core";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
export const CLAUDE_INSTALL_STATE_REL =
|
|
33
|
+
".offrouter/claude-install-state.json" as const;
|
|
34
|
+
|
|
35
|
+
export const INSTALLED_PLUGIN_REL = "skills/offrouter" as const;
|
|
36
|
+
|
|
37
|
+
const InstallFileStateSchema = z.object({
|
|
38
|
+
previous: z.string().nullable(),
|
|
39
|
+
hash: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const ClaudeInstallStateSchema = z.object({
|
|
43
|
+
version: z.literal(1),
|
|
44
|
+
profile: z.string().min(1),
|
|
45
|
+
harness: z.literal("claude"),
|
|
46
|
+
installedAt: z.string().min(1),
|
|
47
|
+
pluginRootRelative: z.string().min(1),
|
|
48
|
+
files: z.record(z.string().min(1), InstallFileStateSchema),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type ClaudeInstallState = z.infer<typeof ClaudeInstallStateSchema>;
|
|
52
|
+
|
|
53
|
+
export type InstallChangeKind = "create" | "update" | "unchanged" | "remove";
|
|
54
|
+
|
|
55
|
+
export interface InstallFileChange {
|
|
56
|
+
path: string;
|
|
57
|
+
relativePath: string;
|
|
58
|
+
kind: InstallChangeKind;
|
|
59
|
+
/** Diff-like summary line for human output. */
|
|
60
|
+
summary: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ClaudeInstallOptions {
|
|
64
|
+
profile: string;
|
|
65
|
+
config: OffRouterConfig;
|
|
66
|
+
env?: NodeJS.ProcessEnv;
|
|
67
|
+
homedir?: () => string;
|
|
68
|
+
dryRun?: boolean;
|
|
69
|
+
/** Override source plugin directory (tests). Defaults to repo plugins/claude-offrouter. */
|
|
70
|
+
pluginSourceDir?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ClaudeInstallResult {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
dryRun: boolean;
|
|
76
|
+
profile: string;
|
|
77
|
+
profileDir: string;
|
|
78
|
+
changes: InstallFileChange[];
|
|
79
|
+
message: string;
|
|
80
|
+
errorCode?: PolicyDenialCode | "install_error";
|
|
81
|
+
statePath?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ResolveClaudeProfileDirOptions {
|
|
85
|
+
env?: NodeJS.ProcessEnv;
|
|
86
|
+
homedir?: () => string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const HOOKS_REL = "hooks/hooks.json";
|
|
90
|
+
const SAFE_PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
91
|
+
|
|
92
|
+
export function isSafeProfileId(profile: string): boolean {
|
|
93
|
+
return SAFE_PROFILE_ID_RE.test(profile);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function envValue(
|
|
97
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
98
|
+
key: string,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
const v = env?.[key]?.trim();
|
|
101
|
+
return v || undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the on-disk Claude profile directory for a OffRouter profile id.
|
|
106
|
+
*
|
|
107
|
+
* - When OFFROUTER_CLAUDE_PROFILES_DIR is set: $DIR/<profile>
|
|
108
|
+
* - profile id `claude` -> ~/.claude (the only path that may touch global Claude)
|
|
109
|
+
* - otherwise -> ~/.<profile> (e.g. claude-personal -> ~/.claude-personal)
|
|
110
|
+
*/
|
|
111
|
+
export function resolveClaudeProfileDir(
|
|
112
|
+
profile: string,
|
|
113
|
+
options: ResolveClaudeProfileDirOptions = {},
|
|
114
|
+
): string {
|
|
115
|
+
if (!isSafeProfileId(profile)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Unsafe profile id "${profile}". Use letters, numbers, dots, underscores, and dashes only.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const env = options.env ?? process.env;
|
|
121
|
+
const home = (options.homedir ?? osHomedir)();
|
|
122
|
+
const profilesRoot = envValue(env, "OFFROUTER_CLAUDE_PROFILES_DIR");
|
|
123
|
+
if (profilesRoot) {
|
|
124
|
+
return resolve(join(profilesRoot, profile));
|
|
125
|
+
}
|
|
126
|
+
if (profile === "claude") {
|
|
127
|
+
return resolve(join(home, ".claude"));
|
|
128
|
+
}
|
|
129
|
+
return resolve(join(home, `.${profile}`));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Personal Claude profiles only: id starts with "claude" or is exactly "claude",
|
|
134
|
+
* not a work profile by default pattern.
|
|
135
|
+
*/
|
|
136
|
+
export function isClaudeHarnessProfile(profile: string): boolean {
|
|
137
|
+
return profile === "claude" || profile.startsWith("claude-");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isWorkLikeProfile(profile: string): boolean {
|
|
141
|
+
return profile.endsWith("-work") || profile.includes("-work-");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Allowlisted personal Claude profiles eligible for install --all-personal.
|
|
146
|
+
*/
|
|
147
|
+
export function listAllowlistedPersonalClaudeProfiles(
|
|
148
|
+
config: OffRouterConfig,
|
|
149
|
+
): string[] {
|
|
150
|
+
return [...config.policy.allowlistedProfiles]
|
|
151
|
+
.filter(
|
|
152
|
+
(id) =>
|
|
153
|
+
isSafeProfileId(id) &&
|
|
154
|
+
isClaudeHarnessProfile(id) &&
|
|
155
|
+
id !== "claude" &&
|
|
156
|
+
!isWorkLikeProfile(id) &&
|
|
157
|
+
!(config.policy.deniedProfilePatterns ?? []).some((p) =>
|
|
158
|
+
matchGlob(p, id),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
.sort((a, b) => a.localeCompare(b));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function matchGlob(pattern: string, value: string): boolean {
|
|
165
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
166
|
+
const re = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
|
|
167
|
+
return re.test(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
171
|
+
try {
|
|
172
|
+
await access(path, constants.F_OK);
|
|
173
|
+
return true;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
180
|
+
try {
|
|
181
|
+
return (await stat(path)).isDirectory();
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function listFilesRecursive(root: string): Promise<string[]> {
|
|
188
|
+
const out: string[] = [];
|
|
189
|
+
async function walk(dir: string): Promise<void> {
|
|
190
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
const full = join(dir, entry.name);
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
await walk(full);
|
|
195
|
+
} else if (entry.isFile()) {
|
|
196
|
+
out.push(full);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
await walk(root);
|
|
201
|
+
return out.sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function toPosixRel(from: string, to: string): string {
|
|
205
|
+
return relative(from, to).split(sep).join("/");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sha256(content: string): string {
|
|
209
|
+
return createHash("sha256").update(content).digest("hex");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Default plugin source: monorepo plugins/claude-offrouter relative to this package.
|
|
214
|
+
*/
|
|
215
|
+
export function defaultPluginSourceDir(): string {
|
|
216
|
+
// packages/adapter-claude/src -> ../../../plugins/claude-offrouter
|
|
217
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
218
|
+
return resolve(here, "../../../plugins/claude-offrouter");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function tinyPolicyProbeRequest(profile: string): RouteRequest {
|
|
222
|
+
return {
|
|
223
|
+
requestId: "install-policy-probe",
|
|
224
|
+
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
225
|
+
harness: { kind: "claude", profile },
|
|
226
|
+
task: {
|
|
227
|
+
promptPreview: "install policy probe",
|
|
228
|
+
promptDigest: "sha256:install-policy-probe",
|
|
229
|
+
kind: "other",
|
|
230
|
+
risk: "low",
|
|
231
|
+
},
|
|
232
|
+
workspace: { cwd: "/tmp/offrouter-install-probe", trusted: true },
|
|
233
|
+
constraints: {
|
|
234
|
+
subscriptionFirst: true,
|
|
235
|
+
allowApiKeyFallback: false,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Use routeRequest with empty candidates so work/unallowlisted profile denials
|
|
242
|
+
* reuse the same core policy codes users see from route explain / hooks.
|
|
243
|
+
*/
|
|
244
|
+
export function evaluateInstallProfilePolicy(
|
|
245
|
+
profile: string,
|
|
246
|
+
config: OffRouterConfig,
|
|
247
|
+
): { allowed: boolean; errorCode?: PolicyDenialCode; message?: string } {
|
|
248
|
+
const decision = routeRequest(
|
|
249
|
+
tinyPolicyProbeRequest(profile),
|
|
250
|
+
[],
|
|
251
|
+
config.policy,
|
|
252
|
+
);
|
|
253
|
+
const denials = decision.policyDenials ?? [];
|
|
254
|
+
const hard = denials.find(
|
|
255
|
+
(d) =>
|
|
256
|
+
d.code === "work_profile_denied" ||
|
|
257
|
+
d.code === "profile_not_allowlisted" ||
|
|
258
|
+
d.code === "privacy_label_denied",
|
|
259
|
+
);
|
|
260
|
+
if (hard) {
|
|
261
|
+
return {
|
|
262
|
+
allowed: false,
|
|
263
|
+
errorCode: hard.code,
|
|
264
|
+
message: hard.message,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// Empty candidates also sets needsConfiguration + may include
|
|
268
|
+
// profile_not_allowlisted even for personal unallowlisted. When allowlisted,
|
|
269
|
+
// only needsConfiguration remains; that is fine for install.
|
|
270
|
+
const profileDenial = denials.find((d) => d.target === "profile");
|
|
271
|
+
if (profileDenial) {
|
|
272
|
+
return {
|
|
273
|
+
allowed: false,
|
|
274
|
+
errorCode: profileDenial.code,
|
|
275
|
+
message: profileDenial.message,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (!config.policy.allowlistedProfiles.includes(profile)) {
|
|
279
|
+
return {
|
|
280
|
+
allowed: false,
|
|
281
|
+
errorCode: "profile_not_allowlisted",
|
|
282
|
+
message: `Profile ${profile} is not allowlisted. Add it explicitly before installing or routing.`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return { allowed: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function patchHooksCommand(sourceJson: string, profile: string): string {
|
|
289
|
+
const parsed = JSON.parse(sourceJson) as {
|
|
290
|
+
description?: string;
|
|
291
|
+
hooks?: {
|
|
292
|
+
UserPromptSubmit?: Array<{
|
|
293
|
+
hooks?: Array<{
|
|
294
|
+
type?: string;
|
|
295
|
+
command?: string;
|
|
296
|
+
timeout?: number;
|
|
297
|
+
}>;
|
|
298
|
+
}>;
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const entries = parsed.hooks?.UserPromptSubmit ?? [];
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
for (const hook of entry.hooks ?? []) {
|
|
305
|
+
if (hook.type === "command" && typeof hook.command === "string") {
|
|
306
|
+
// Ensure profile is set for this install; do not inject workspace trust.
|
|
307
|
+
const base = hook.command
|
|
308
|
+
.replace(/\bOFFROUTER_PROFILE=\S+\s*/g, "")
|
|
309
|
+
.replace(/\bOFFROUTER_WORKSPACE_TRUSTED=\S+\s*/g, "")
|
|
310
|
+
.trim();
|
|
311
|
+
// Prefer env assignment before the offrouter binary for shell hooks.
|
|
312
|
+
if (!base.includes("offrouter hooks claude user-prompt-submit")) {
|
|
313
|
+
hook.command = `OFFROUTER_PROFILE=${profile} offrouter hooks claude user-prompt-submit`;
|
|
314
|
+
} else {
|
|
315
|
+
hook.command = `OFFROUTER_PROFILE=${profile} ${base}`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function desiredPluginContents(
|
|
325
|
+
pluginSourceDir: string,
|
|
326
|
+
profile: string,
|
|
327
|
+
): Promise<Map<string, string>> {
|
|
328
|
+
const files = await listFilesRecursive(pluginSourceDir);
|
|
329
|
+
const map = new Map<string, string>();
|
|
330
|
+
for (const full of files) {
|
|
331
|
+
const rel = toPosixRel(pluginSourceDir, full);
|
|
332
|
+
let content = await readFile(full, "utf8");
|
|
333
|
+
if (rel === HOOKS_REL) {
|
|
334
|
+
content = patchHooksCommand(content, profile);
|
|
335
|
+
}
|
|
336
|
+
map.set(rel, content);
|
|
337
|
+
}
|
|
338
|
+
return map;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function changeSummary(kind: InstallChangeKind, relativePath: string): string {
|
|
342
|
+
switch (kind) {
|
|
343
|
+
case "create":
|
|
344
|
+
return `+ ${relativePath}`;
|
|
345
|
+
case "update":
|
|
346
|
+
return `~ ${relativePath}`;
|
|
347
|
+
case "unchanged":
|
|
348
|
+
return `= ${relativePath}`;
|
|
349
|
+
case "remove":
|
|
350
|
+
return `- ${relativePath}`;
|
|
351
|
+
default:
|
|
352
|
+
return `? ${relativePath}`;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Install the OffRouter Claude plugin into a single profile directory.
|
|
358
|
+
*/
|
|
359
|
+
export async function installClaudeProfile(
|
|
360
|
+
options: ClaudeInstallOptions,
|
|
361
|
+
): Promise<ClaudeInstallResult> {
|
|
362
|
+
const {
|
|
363
|
+
profile,
|
|
364
|
+
config,
|
|
365
|
+
dryRun = false,
|
|
366
|
+
env = process.env,
|
|
367
|
+
homedir = osHomedir,
|
|
368
|
+
} = options;
|
|
369
|
+
if (!isSafeProfileId(profile)) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
dryRun,
|
|
373
|
+
profile,
|
|
374
|
+
profileDir: "",
|
|
375
|
+
changes: [],
|
|
376
|
+
message:
|
|
377
|
+
"Unsafe profile id. Use letters, numbers, dots, underscores, and dashes only.",
|
|
378
|
+
errorCode: "install_error",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const profileDir = resolveClaudeProfileDir(profile, { env, homedir });
|
|
382
|
+
const statePath = join(profileDir, CLAUDE_INSTALL_STATE_REL);
|
|
383
|
+
const pluginTarget = join(profileDir, INSTALLED_PLUGIN_REL);
|
|
384
|
+
|
|
385
|
+
if (!isClaudeHarnessProfile(profile)) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
dryRun,
|
|
389
|
+
profile,
|
|
390
|
+
profileDir,
|
|
391
|
+
changes: [],
|
|
392
|
+
message:
|
|
393
|
+
"Claude install supports only profile ids named claude or starting with claude-.",
|
|
394
|
+
errorCode: "install_error",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Work-like profile ids (e.g. claude-work, claude-work-staging) are never
|
|
399
|
+
// installable, even when allowlisted. This mirrors the filter used by
|
|
400
|
+
// listAllowlistedPersonalClaudeProfiles so explicit and --all-personal
|
|
401
|
+
// installs apply the same personal-only boundary.
|
|
402
|
+
if (isWorkLikeProfile(profile)) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
dryRun,
|
|
406
|
+
profile,
|
|
407
|
+
profileDir,
|
|
408
|
+
changes: [],
|
|
409
|
+
message: `Profile ${profile} looks like a work profile and is never installable, even if allowlisted. Use a personal profile such as claude-personal.`,
|
|
410
|
+
errorCode: "work_profile_denied",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const policy = evaluateInstallProfilePolicy(profile, config);
|
|
415
|
+
if (!policy.allowed) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
dryRun,
|
|
419
|
+
profile,
|
|
420
|
+
profileDir,
|
|
421
|
+
changes: [],
|
|
422
|
+
message: policy.message ?? `Profile ${profile} is not installable.`,
|
|
423
|
+
errorCode: policy.errorCode ?? "install_error",
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const pluginSourceDir = options.pluginSourceDir ?? defaultPluginSourceDir();
|
|
428
|
+
if (!(await isDirectory(pluginSourceDir))) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
dryRun,
|
|
432
|
+
profile,
|
|
433
|
+
profileDir,
|
|
434
|
+
changes: [],
|
|
435
|
+
message: `Plugin source not found: ${pluginSourceDir}`,
|
|
436
|
+
errorCode: "install_error",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const desired = await desiredPluginContents(pluginSourceDir, profile);
|
|
441
|
+
|
|
442
|
+
// Preserve original previous contents across re-installs (idempotent rollback).
|
|
443
|
+
let priorState: ClaudeInstallState | null = null;
|
|
444
|
+
if (await pathExists(statePath)) {
|
|
445
|
+
try {
|
|
446
|
+
const raw = await readFile(statePath, "utf8");
|
|
447
|
+
priorState = ClaudeInstallStateSchema.parse(JSON.parse(raw));
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
dryRun,
|
|
453
|
+
profile,
|
|
454
|
+
profileDir,
|
|
455
|
+
changes: [],
|
|
456
|
+
message: `Invalid install state at ${statePath}: ${detail}`,
|
|
457
|
+
errorCode: "install_error",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const changes: InstallFileChange[] = [];
|
|
463
|
+
const nextFiles: ClaudeInstallState["files"] = {};
|
|
464
|
+
|
|
465
|
+
for (const [rel, content] of desired) {
|
|
466
|
+
const abs = join(pluginTarget, rel);
|
|
467
|
+
const relativePath = `${INSTALLED_PLUGIN_REL}/${rel}`;
|
|
468
|
+
let previous: string | null = null;
|
|
469
|
+
let current: string | null = null;
|
|
470
|
+
if (await pathExists(abs)) {
|
|
471
|
+
current = await readFile(abs, "utf8");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (priorState?.files[relativePath]) {
|
|
475
|
+
previous = priorState.files[relativePath]!.previous;
|
|
476
|
+
} else {
|
|
477
|
+
previous = current;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
let kind: InstallChangeKind;
|
|
481
|
+
if (current === null) {
|
|
482
|
+
kind = "create";
|
|
483
|
+
} else if (current === content) {
|
|
484
|
+
kind = "unchanged";
|
|
485
|
+
} else {
|
|
486
|
+
kind = "update";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
changes.push({
|
|
490
|
+
path: abs,
|
|
491
|
+
relativePath,
|
|
492
|
+
kind,
|
|
493
|
+
summary: changeSummary(kind, relativePath),
|
|
494
|
+
});
|
|
495
|
+
nextFiles[relativePath] = {
|
|
496
|
+
previous,
|
|
497
|
+
hash: sha256(content),
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
if (!dryRun && kind !== "unchanged") {
|
|
501
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
502
|
+
await writeFile(abs, content, "utf8");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Always plan state metadata write
|
|
507
|
+
const state: ClaudeInstallState = {
|
|
508
|
+
version: 1,
|
|
509
|
+
profile,
|
|
510
|
+
harness: "claude",
|
|
511
|
+
installedAt: priorState?.installedAt ?? new Date().toISOString(),
|
|
512
|
+
pluginRootRelative: INSTALLED_PLUGIN_REL,
|
|
513
|
+
files: nextFiles,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const stateJson = `${JSON.stringify(state, null, 2)}\n`;
|
|
517
|
+
let stateKind: InstallChangeKind = "create";
|
|
518
|
+
if (await pathExists(statePath)) {
|
|
519
|
+
const existing = await readFile(statePath, "utf8");
|
|
520
|
+
stateKind = existing === stateJson ? "unchanged" : "update";
|
|
521
|
+
}
|
|
522
|
+
changes.push({
|
|
523
|
+
path: statePath,
|
|
524
|
+
relativePath: CLAUDE_INSTALL_STATE_REL,
|
|
525
|
+
kind: stateKind,
|
|
526
|
+
summary: changeSummary(stateKind, CLAUDE_INSTALL_STATE_REL),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
if (!dryRun && stateKind !== "unchanged") {
|
|
530
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
531
|
+
await writeFile(statePath, stateJson, "utf8");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const wrote = changes.filter(
|
|
535
|
+
(c) => c.kind === "create" || c.kind === "update",
|
|
536
|
+
);
|
|
537
|
+
const message = dryRun
|
|
538
|
+
? `Dry run: would apply ${wrote.length} file change(s) for profile ${profile}.`
|
|
539
|
+
: `Installed OffRouter Claude plugin for profile ${profile} (${wrote.length} file change(s)).`;
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
dryRun,
|
|
544
|
+
profile,
|
|
545
|
+
profileDir,
|
|
546
|
+
changes,
|
|
547
|
+
message,
|
|
548
|
+
statePath,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Human-readable diff-like text for an install/uninstall result matrix.
|
|
554
|
+
*/
|
|
555
|
+
export function formatInstallDiffText(
|
|
556
|
+
results: ClaudeInstallResult[],
|
|
557
|
+
headline?: string,
|
|
558
|
+
): string {
|
|
559
|
+
const lines: string[] = [];
|
|
560
|
+
if (headline) {
|
|
561
|
+
lines.push(headline);
|
|
562
|
+
}
|
|
563
|
+
for (const result of results) {
|
|
564
|
+
lines.push(``);
|
|
565
|
+
lines.push(
|
|
566
|
+
`## ${result.profile} (${result.ok ? (result.dryRun ? "dry-run" : "applied") : "error"})`,
|
|
567
|
+
);
|
|
568
|
+
lines.push(`profileDir: ${result.profileDir}`);
|
|
569
|
+
if (!result.ok) {
|
|
570
|
+
lines.push(`error: ${result.message}`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
for (const change of result.changes) {
|
|
574
|
+
if (change.kind === "unchanged") continue;
|
|
575
|
+
lines.push(change.summary);
|
|
576
|
+
}
|
|
577
|
+
const unchanged = result.changes.filter(
|
|
578
|
+
(c) => c.kind === "unchanged",
|
|
579
|
+
).length;
|
|
580
|
+
if (unchanged > 0) {
|
|
581
|
+
lines.push(`= ${unchanged} unchanged`);
|
|
582
|
+
}
|
|
583
|
+
lines.push(result.message);
|
|
584
|
+
}
|
|
585
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Copy helper retained for future link-mode installs. */
|
|
589
|
+
export async function copyPluginFile(
|
|
590
|
+
source: string,
|
|
591
|
+
dest: string,
|
|
592
|
+
): Promise<void> {
|
|
593
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
594
|
+
await copyFile(source, dest);
|
|
595
|
+
}
|