@oh-my-tool/cli 0.2.0 → 0.3.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/README.md +7 -2
- package/assets/skills/oh-my-tool/SKILL.md +11 -3
- package/bin/ohmytool.cjs +0 -0
- package/package.json +12 -10
- package/src/cli/commands/describe.ts +23 -22
- package/src/cli/commands/extension.ts +24 -24
- package/src/cli/commands/index.ts +7 -6
- package/src/cli/commands/integrate.ts +64 -64
- package/src/cli/commands/mcp.ts +86 -0
- package/src/cli/commands/run.ts +8 -7
- package/src/cli/commands/search.ts +14 -13
- package/src/cli/commands/secret.ts +68 -68
- package/src/cli/context.ts +25 -2
- package/src/cli/index.ts +296 -272
- package/src/cli/parseArgs.ts +62 -44
- package/src/config/config.ts +155 -63
- package/src/core/executor.ts +89 -89
- package/src/core/registry.ts +31 -31
- package/src/core/result.ts +14 -14
- package/src/extension/discovery.ts +61 -61
- package/src/extension/install.ts +23 -23
- package/src/extension/loader.ts +32 -32
- package/src/extension/manifest.ts +114 -114
- package/src/integration/adapters.ts +98 -98
- package/src/integration/index.ts +4 -4
- package/src/integration/manager.ts +375 -375
- package/src/integration/skill.ts +84 -84
- package/src/integration/types.ts +55 -55
- package/src/policy/policy.ts +136 -136
- package/src/runtime/errors.ts +7 -2
- package/src/runtime/executor.ts +6 -1
- package/src/runtime/provider.ts +1 -0
- package/src/runtime/providers/mcp/normalize.ts +36 -0
- package/src/runtime/providers/mcp/oauth-callback.ts +91 -0
- package/src/runtime/providers/mcp/oauth-provider.ts +348 -0
- package/src/runtime/providers/mcp/oauth-store.ts +106 -0
- package/src/runtime/providers/mcp/provider.ts +99 -0
- package/src/runtime/providers/mcp/safe-errors.ts +63 -0
- package/src/runtime/providers/mcp/session.ts +117 -0
- package/src/runtime/providers/mcp/transport.ts +140 -0
- package/src/runtime/result.ts +1 -1
- package/src/runtime/runtime.ts +38 -12
- package/src/runtime/schema.ts +14 -4
- package/src/search/search.ts +78 -78
- package/src/secrets/secrets.ts +45 -45
- package/src/version.ts +1 -1
|
@@ -1,375 +1,375 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
lstatSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
readlinkSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
realpathSync,
|
|
8
|
-
renameSync,
|
|
9
|
-
symlinkSync,
|
|
10
|
-
unlinkSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "node:fs";
|
|
13
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
-
import { homedir } from "node:os";
|
|
15
|
-
import { bundledSkillPath, stageCanonicalSkill } from "./skill";
|
|
16
|
-
import { detectAgents, type FindCommand } from "./adapters";
|
|
17
|
-
import type {
|
|
18
|
-
AgentDetection,
|
|
19
|
-
AgentId,
|
|
20
|
-
IntegrationResult,
|
|
21
|
-
IntegrationState,
|
|
22
|
-
ManagedAgentState,
|
|
23
|
-
} from "./types";
|
|
24
|
-
|
|
25
|
-
export class IntegrationConflictError extends Error {
|
|
26
|
-
constructor(target: string) {
|
|
27
|
-
super(`Integration target already exists and is not managed by OMT: ${target}`);
|
|
28
|
-
this.name = "IntegrationConflictError";
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface IntegrationManagerOptions {
|
|
33
|
-
omtHome: string;
|
|
34
|
-
userHome?: string;
|
|
35
|
-
skillSource?: string;
|
|
36
|
-
skillVersion: string;
|
|
37
|
-
findCommand?: FindCommand;
|
|
38
|
-
platform?: NodeJS.Platform;
|
|
39
|
-
now?: () => Date;
|
|
40
|
-
linkDirectory?: (source: string, target: string, platform: NodeJS.Platform) => void;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface InstallOptions {
|
|
44
|
-
force?: boolean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface LinkOperation {
|
|
48
|
-
target: string;
|
|
49
|
-
previous?: string;
|
|
50
|
-
created: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function emptyState(): IntegrationState {
|
|
54
|
-
return { schemaVersion: 1, skills: {} };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function safeRealpath(path: string): string | undefined {
|
|
58
|
-
try {
|
|
59
|
-
return realpathSync(path);
|
|
60
|
-
} catch {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isLink(path: string): boolean {
|
|
66
|
-
try {
|
|
67
|
-
return lstatSync(path).isSymbolicLink();
|
|
68
|
-
} catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function pathPresent(path: string): boolean {
|
|
74
|
-
try {
|
|
75
|
-
lstatSync(path);
|
|
76
|
-
return true;
|
|
77
|
-
} catch {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function isManagedLink(path: string, canonical: string): boolean {
|
|
83
|
-
if (!isLink(path)) return false;
|
|
84
|
-
const actual = safeRealpath(path);
|
|
85
|
-
const expected = safeRealpath(canonical);
|
|
86
|
-
if (actual && expected) return actual === expected;
|
|
87
|
-
try {
|
|
88
|
-
const linked = readlinkSync(path);
|
|
89
|
-
return resolve(dirname(path), linked) === resolve(canonical);
|
|
90
|
-
} catch {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function samePath(left: string, right: string, platform: NodeJS.Platform): boolean {
|
|
96
|
-
const a = resolve(left);
|
|
97
|
-
const b = resolve(right);
|
|
98
|
-
return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function assertSafeAgentTarget(
|
|
102
|
-
agent: AgentDetection,
|
|
103
|
-
userHome: string,
|
|
104
|
-
platform: NodeJS.Platform = process.platform,
|
|
105
|
-
): void {
|
|
106
|
-
const agentsSkills = join(userHome, ".agents", "skills");
|
|
107
|
-
const allowedParents: Record<AgentId, string[]> = {
|
|
108
|
-
codex: [agentsSkills],
|
|
109
|
-
omp: [join(userHome, ".omp", "agent", "skills")],
|
|
110
|
-
qoder: [join(userHome, ".qoder", "skills"), join(userHome, ".qoder-cn", "skills")],
|
|
111
|
-
pi: [agentsSkills],
|
|
112
|
-
cursor: [agentsSkills],
|
|
113
|
-
claude: [join(userHome, ".claude", "skills")],
|
|
114
|
-
};
|
|
115
|
-
const target = resolve(agent.target);
|
|
116
|
-
const parentAllowed = allowedParents[agent.id].some((parent) => samePath(dirname(target), parent, platform));
|
|
117
|
-
if (basename(target) !== "oh-my-tool" || !parentAllowed) {
|
|
118
|
-
throw new Error(`Unsafe agent skill target: ${agent.target}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function unlinkOnly(path: string): void {
|
|
123
|
-
if (!pathPresent(path)) return;
|
|
124
|
-
if (!isLink(path)) throw new Error(`Refusing to delete non-link path: ${path}`);
|
|
125
|
-
unlinkSync(path);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function assertSafeManagedState(
|
|
129
|
-
agent: AgentDetection,
|
|
130
|
-
recorded: ManagedAgentState,
|
|
131
|
-
omtHome: string,
|
|
132
|
-
platform: NodeJS.Platform,
|
|
133
|
-
): void {
|
|
134
|
-
if (!samePath(recorded.target, agent.target, platform)) {
|
|
135
|
-
throw new Error(`Unsafe managed target in state: ${recorded.target}`);
|
|
136
|
-
}
|
|
137
|
-
const canonicalRoot = resolve(omtHome, "integrations", "skills", "oh-my-tool");
|
|
138
|
-
if (!samePath(dirname(recorded.canonical), canonicalRoot, platform)) {
|
|
139
|
-
throw new Error(`Unsafe managed canonical path: ${recorded.canonical}`);
|
|
140
|
-
}
|
|
141
|
-
if (recorded.backup) {
|
|
142
|
-
const backup = resolve(recorded.backup);
|
|
143
|
-
const expectedPrefix = `${resolve(agent.target)}.backup-`;
|
|
144
|
-
const prefixMatches = platform === "win32"
|
|
145
|
-
? backup.toLowerCase().startsWith(expectedPrefix.toLowerCase())
|
|
146
|
-
: backup.startsWith(expectedPrefix);
|
|
147
|
-
if (!prefixMatches || !samePath(dirname(backup), dirname(agent.target), platform)) {
|
|
148
|
-
throw new Error(`Unsafe managed backup path: ${recorded.backup}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function createIntegrationManager(options: IntegrationManagerOptions) {
|
|
154
|
-
const userHome = options.userHome ?? homedir();
|
|
155
|
-
const source = options.skillSource ?? bundledSkillPath();
|
|
156
|
-
const statePath = join(options.omtHome, "integrations", "state.json");
|
|
157
|
-
const platform = options.platform ?? process.platform;
|
|
158
|
-
const now = options.now ?? (() => new Date());
|
|
159
|
-
|
|
160
|
-
function loadState(): IntegrationState {
|
|
161
|
-
if (!existsSync(statePath)) return emptyState();
|
|
162
|
-
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as IntegrationState;
|
|
163
|
-
return parsed.schemaVersion === 1 ? parsed : emptyState();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function saveState(state: IntegrationState): void {
|
|
167
|
-
mkdirSync(dirname(statePath), { recursive: true });
|
|
168
|
-
const staging = `${statePath}.tmp-${process.pid}`;
|
|
169
|
-
writeFileSync(staging, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
170
|
-
renameSync(staging, statePath);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function detections(): Promise<AgentDetection[]> {
|
|
174
|
-
const found = await detectAgents({ userHome, findCommand: options.findCommand });
|
|
175
|
-
for (const agent of found) assertSafeAgentTarget(agent, userHome, platform);
|
|
176
|
-
return found;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function selected(ids?: AgentId[]): Promise<AgentDetection[]> {
|
|
180
|
-
const found = await detections();
|
|
181
|
-
if (!ids?.length) return found;
|
|
182
|
-
const byId = new Map(found.map((agent) => [agent.id, agent]));
|
|
183
|
-
return ids.map((id) => {
|
|
184
|
-
const agent = byId.get(id);
|
|
185
|
-
if (!agent) throw new Error(`Agent is not detected: ${id}`);
|
|
186
|
-
return agent;
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function createManagedLink(canonical: string, target: string): void {
|
|
191
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
192
|
-
const staging = `${target}.omt-tmp-${process.pid}-${Date.now()}`;
|
|
193
|
-
if (pathPresent(staging)) throw new Error(`Refusing to replace existing staging path: ${staging}`);
|
|
194
|
-
try {
|
|
195
|
-
if (options.linkDirectory) options.linkDirectory(canonical, staging, platform);
|
|
196
|
-
else symlinkSync(canonical, staging, platform === "win32" ? "junction" : "dir");
|
|
197
|
-
renameSync(staging, target);
|
|
198
|
-
} catch (error) {
|
|
199
|
-
if (isLink(staging)) unlinkSync(staging);
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function install(ids?: AgentId[], installOptions: InstallOptions = {}): Promise<IntegrationResult[]> {
|
|
205
|
-
const agents = await selected(ids);
|
|
206
|
-
if (!agents.length) return [];
|
|
207
|
-
const canonical = stageCanonicalSkill(options.omtHome, source, options.skillVersion);
|
|
208
|
-
const state = loadState();
|
|
209
|
-
const skillState = state.skills["oh-my-tool"] ?? {
|
|
210
|
-
version: options.skillVersion,
|
|
211
|
-
digest: canonical.digest,
|
|
212
|
-
agents: {},
|
|
213
|
-
};
|
|
214
|
-
const operations: LinkOperation[] = [];
|
|
215
|
-
const results: IntegrationResult[] = [];
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
for (const agent of agents) {
|
|
219
|
-
const recorded = skillState.agents[agent.id];
|
|
220
|
-
if (recorded) assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
221
|
-
const targetRealpath = safeRealpath(agent.target);
|
|
222
|
-
const canonicalRealpath = realpathSync(canonical.path);
|
|
223
|
-
if (targetRealpath === canonicalRealpath && isLink(agent.target)) {
|
|
224
|
-
skillState.agents[agent.id] = managedState(agent.target, canonical.path, canonical.digest);
|
|
225
|
-
results.push(result(agent, "current"));
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
let previous: string | undefined;
|
|
230
|
-
let backup: string | undefined;
|
|
231
|
-
if (pathPresent(agent.target)) {
|
|
232
|
-
const isRecordedLink = recorded && isManagedLink(agent.target, recorded.canonical);
|
|
233
|
-
if (!isRecordedLink && !installOptions.force) throw new IntegrationConflictError(agent.target);
|
|
234
|
-
backup = installOptions.force && !isRecordedLink
|
|
235
|
-
? `${agent.target}.backup-${timestamp(now())}`
|
|
236
|
-
: `${agent.target}.omt-old-${process.pid}-${Date.now()}`;
|
|
237
|
-
renameSync(agent.target, backup);
|
|
238
|
-
previous = backup;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const operation: LinkOperation = { target: agent.target, previous, created: false };
|
|
242
|
-
operations.push(operation);
|
|
243
|
-
createManagedLink(canonical.path, agent.target);
|
|
244
|
-
operation.created = true;
|
|
245
|
-
const managed = managedState(agent.target, canonical.path, canonical.digest);
|
|
246
|
-
if (installOptions.force && previous && !previous.includes(".omt-old-")) managed.backup = previous;
|
|
247
|
-
skillState.agents[agent.id] = managed;
|
|
248
|
-
results.push(result(agent, "installed"));
|
|
249
|
-
}
|
|
250
|
-
skillState.version = options.skillVersion;
|
|
251
|
-
skillState.digest = canonical.digest;
|
|
252
|
-
state.skills["oh-my-tool"] = skillState;
|
|
253
|
-
saveState(state);
|
|
254
|
-
for (const operation of operations) {
|
|
255
|
-
if (operation.previous?.includes(".omt-old-") && pathPresent(operation.previous)) {
|
|
256
|
-
unlinkOnly(operation.previous);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return results;
|
|
260
|
-
} catch (error) {
|
|
261
|
-
for (const operation of operations.reverse()) {
|
|
262
|
-
if (operation.created) unlinkOnly(operation.target);
|
|
263
|
-
if (operation.previous && pathPresent(operation.previous)) renameSync(operation.previous, operation.target);
|
|
264
|
-
}
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function status(): Promise<IntegrationResult[]> {
|
|
270
|
-
const agents = await detections();
|
|
271
|
-
const state = loadState();
|
|
272
|
-
const records = state.skills["oh-my-tool"]?.agents ?? {};
|
|
273
|
-
return agents.map((agent) => {
|
|
274
|
-
const recorded = records[agent.id];
|
|
275
|
-
if (!recorded) {
|
|
276
|
-
if (!pathPresent(agent.target)) {
|
|
277
|
-
return result(agent, "not-installed", "not installed; run `omt integrate` to install");
|
|
278
|
-
}
|
|
279
|
-
return result(agent, "conflict", "target exists but is not managed by OMT");
|
|
280
|
-
}
|
|
281
|
-
try {
|
|
282
|
-
assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
283
|
-
} catch {
|
|
284
|
-
return result(agent, "conflict", "recorded state is unsafe; run `omt integrate repair`");
|
|
285
|
-
}
|
|
286
|
-
if (!pathPresent(agent.target) || !safeRealpath(agent.target)) {
|
|
287
|
-
return result(agent, "broken", "link is missing; run `omt integrate repair`");
|
|
288
|
-
}
|
|
289
|
-
if (!isManagedLink(agent.target, recorded.canonical)) {
|
|
290
|
-
return result(agent, "conflict", "target was replaced by unmanaged content");
|
|
291
|
-
}
|
|
292
|
-
if (recorded.version === options.skillVersion) {
|
|
293
|
-
return result(agent, "current");
|
|
294
|
-
}
|
|
295
|
-
return result(agent, "update-available", `new version ${options.skillVersion} available; run \`omt integrate\``);
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
async function repair(ids?: AgentId[]): Promise<IntegrationResult[]> {
|
|
300
|
-
const state = loadState();
|
|
301
|
-
const records = state.skills["oh-my-tool"]?.agents ?? {};
|
|
302
|
-
for (const agent of await selected(ids)) {
|
|
303
|
-
const recorded = records[agent.id];
|
|
304
|
-
if (recorded) assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
305
|
-
if (pathPresent(agent.target) && (!recorded || !isManagedLink(agent.target, recorded.canonical))) {
|
|
306
|
-
throw new IntegrationConflictError(agent.target);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
const installed = await install(ids);
|
|
310
|
-
return installed.map((item) => ({ ...item, status: item.status === "installed" ? "repaired" : item.status }));
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async function uninstall(ids?: AgentId[]): Promise<IntegrationResult[]> {
|
|
314
|
-
const agents = await selected(ids);
|
|
315
|
-
const state = loadState();
|
|
316
|
-
const skillState = state.skills["oh-my-tool"];
|
|
317
|
-
const results: IntegrationResult[] = [];
|
|
318
|
-
for (const agent of agents) {
|
|
319
|
-
const recorded = skillState?.agents[agent.id];
|
|
320
|
-
if (!recorded) {
|
|
321
|
-
results.push(result(agent, "not-installed"));
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
325
|
-
const remaining = Object.entries(skillState!.agents).filter(
|
|
326
|
-
([id, other]) => id !== agent.id && other && samePath(other.target, agent.target, platform),
|
|
327
|
-
);
|
|
328
|
-
if (remaining.length) {
|
|
329
|
-
if (recorded.backup) remaining[0][1]!.backup = recorded.backup;
|
|
330
|
-
} else {
|
|
331
|
-
if (pathPresent(agent.target)) {
|
|
332
|
-
if (!isManagedLink(agent.target, recorded.canonical)) {
|
|
333
|
-
throw new IntegrationConflictError(agent.target);
|
|
334
|
-
}
|
|
335
|
-
unlinkOnly(agent.target);
|
|
336
|
-
}
|
|
337
|
-
if (recorded.backup && existsSync(recorded.backup)) renameSync(recorded.backup, agent.target);
|
|
338
|
-
}
|
|
339
|
-
delete skillState!.agents[agent.id];
|
|
340
|
-
results.push(result(agent, "uninstalled"));
|
|
341
|
-
}
|
|
342
|
-
saveState(state);
|
|
343
|
-
return results;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return { detect: detections, install, status, repair, uninstall };
|
|
347
|
-
|
|
348
|
-
function managedState(target: string, canonical: string, digest: string): ManagedAgentState {
|
|
349
|
-
return {
|
|
350
|
-
target,
|
|
351
|
-
canonical,
|
|
352
|
-
version: options.skillVersion,
|
|
353
|
-
digest,
|
|
354
|
-
mode: platform === "win32" ? "junction" : "symlink",
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function result(
|
|
360
|
-
agent: AgentDetection,
|
|
361
|
-
status: IntegrationResult["status"],
|
|
362
|
-
detail?: string,
|
|
363
|
-
): IntegrationResult {
|
|
364
|
-
return {
|
|
365
|
-
agent: agent.id,
|
|
366
|
-
displayName: agent.variant ?? agent.displayName,
|
|
367
|
-
target: agent.target,
|
|
368
|
-
status,
|
|
369
|
-
detail,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function timestamp(date: Date): string {
|
|
374
|
-
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
375
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { bundledSkillPath, stageCanonicalSkill } from "./skill";
|
|
16
|
+
import { detectAgents, type FindCommand } from "./adapters";
|
|
17
|
+
import type {
|
|
18
|
+
AgentDetection,
|
|
19
|
+
AgentId,
|
|
20
|
+
IntegrationResult,
|
|
21
|
+
IntegrationState,
|
|
22
|
+
ManagedAgentState,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
export class IntegrationConflictError extends Error {
|
|
26
|
+
constructor(target: string) {
|
|
27
|
+
super(`Integration target already exists and is not managed by OMT: ${target}`);
|
|
28
|
+
this.name = "IntegrationConflictError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IntegrationManagerOptions {
|
|
33
|
+
omtHome: string;
|
|
34
|
+
userHome?: string;
|
|
35
|
+
skillSource?: string;
|
|
36
|
+
skillVersion: string;
|
|
37
|
+
findCommand?: FindCommand;
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
now?: () => Date;
|
|
40
|
+
linkDirectory?: (source: string, target: string, platform: NodeJS.Platform) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InstallOptions {
|
|
44
|
+
force?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface LinkOperation {
|
|
48
|
+
target: string;
|
|
49
|
+
previous?: string;
|
|
50
|
+
created: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function emptyState(): IntegrationState {
|
|
54
|
+
return { schemaVersion: 1, skills: {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeRealpath(path: string): string | undefined {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(path);
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLink(path: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
return lstatSync(path).isSymbolicLink();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pathPresent(path: string): boolean {
|
|
74
|
+
try {
|
|
75
|
+
lstatSync(path);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isManagedLink(path: string, canonical: string): boolean {
|
|
83
|
+
if (!isLink(path)) return false;
|
|
84
|
+
const actual = safeRealpath(path);
|
|
85
|
+
const expected = safeRealpath(canonical);
|
|
86
|
+
if (actual && expected) return actual === expected;
|
|
87
|
+
try {
|
|
88
|
+
const linked = readlinkSync(path);
|
|
89
|
+
return resolve(dirname(path), linked) === resolve(canonical);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function samePath(left: string, right: string, platform: NodeJS.Platform): boolean {
|
|
96
|
+
const a = resolve(left);
|
|
97
|
+
const b = resolve(right);
|
|
98
|
+
return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function assertSafeAgentTarget(
|
|
102
|
+
agent: AgentDetection,
|
|
103
|
+
userHome: string,
|
|
104
|
+
platform: NodeJS.Platform = process.platform,
|
|
105
|
+
): void {
|
|
106
|
+
const agentsSkills = join(userHome, ".agents", "skills");
|
|
107
|
+
const allowedParents: Record<AgentId, string[]> = {
|
|
108
|
+
codex: [agentsSkills],
|
|
109
|
+
omp: [join(userHome, ".omp", "agent", "skills")],
|
|
110
|
+
qoder: [join(userHome, ".qoder", "skills"), join(userHome, ".qoder-cn", "skills")],
|
|
111
|
+
pi: [agentsSkills],
|
|
112
|
+
cursor: [agentsSkills],
|
|
113
|
+
claude: [join(userHome, ".claude", "skills")],
|
|
114
|
+
};
|
|
115
|
+
const target = resolve(agent.target);
|
|
116
|
+
const parentAllowed = allowedParents[agent.id].some((parent) => samePath(dirname(target), parent, platform));
|
|
117
|
+
if (basename(target) !== "oh-my-tool" || !parentAllowed) {
|
|
118
|
+
throw new Error(`Unsafe agent skill target: ${agent.target}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function unlinkOnly(path: string): void {
|
|
123
|
+
if (!pathPresent(path)) return;
|
|
124
|
+
if (!isLink(path)) throw new Error(`Refusing to delete non-link path: ${path}`);
|
|
125
|
+
unlinkSync(path);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertSafeManagedState(
|
|
129
|
+
agent: AgentDetection,
|
|
130
|
+
recorded: ManagedAgentState,
|
|
131
|
+
omtHome: string,
|
|
132
|
+
platform: NodeJS.Platform,
|
|
133
|
+
): void {
|
|
134
|
+
if (!samePath(recorded.target, agent.target, platform)) {
|
|
135
|
+
throw new Error(`Unsafe managed target in state: ${recorded.target}`);
|
|
136
|
+
}
|
|
137
|
+
const canonicalRoot = resolve(omtHome, "integrations", "skills", "oh-my-tool");
|
|
138
|
+
if (!samePath(dirname(recorded.canonical), canonicalRoot, platform)) {
|
|
139
|
+
throw new Error(`Unsafe managed canonical path: ${recorded.canonical}`);
|
|
140
|
+
}
|
|
141
|
+
if (recorded.backup) {
|
|
142
|
+
const backup = resolve(recorded.backup);
|
|
143
|
+
const expectedPrefix = `${resolve(agent.target)}.backup-`;
|
|
144
|
+
const prefixMatches = platform === "win32"
|
|
145
|
+
? backup.toLowerCase().startsWith(expectedPrefix.toLowerCase())
|
|
146
|
+
: backup.startsWith(expectedPrefix);
|
|
147
|
+
if (!prefixMatches || !samePath(dirname(backup), dirname(agent.target), platform)) {
|
|
148
|
+
throw new Error(`Unsafe managed backup path: ${recorded.backup}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createIntegrationManager(options: IntegrationManagerOptions) {
|
|
154
|
+
const userHome = options.userHome ?? homedir();
|
|
155
|
+
const source = options.skillSource ?? bundledSkillPath();
|
|
156
|
+
const statePath = join(options.omtHome, "integrations", "state.json");
|
|
157
|
+
const platform = options.platform ?? process.platform;
|
|
158
|
+
const now = options.now ?? (() => new Date());
|
|
159
|
+
|
|
160
|
+
function loadState(): IntegrationState {
|
|
161
|
+
if (!existsSync(statePath)) return emptyState();
|
|
162
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as IntegrationState;
|
|
163
|
+
return parsed.schemaVersion === 1 ? parsed : emptyState();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function saveState(state: IntegrationState): void {
|
|
167
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
168
|
+
const staging = `${statePath}.tmp-${process.pid}`;
|
|
169
|
+
writeFileSync(staging, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
170
|
+
renameSync(staging, statePath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function detections(): Promise<AgentDetection[]> {
|
|
174
|
+
const found = await detectAgents({ userHome, findCommand: options.findCommand });
|
|
175
|
+
for (const agent of found) assertSafeAgentTarget(agent, userHome, platform);
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function selected(ids?: AgentId[]): Promise<AgentDetection[]> {
|
|
180
|
+
const found = await detections();
|
|
181
|
+
if (!ids?.length) return found;
|
|
182
|
+
const byId = new Map(found.map((agent) => [agent.id, agent]));
|
|
183
|
+
return ids.map((id) => {
|
|
184
|
+
const agent = byId.get(id);
|
|
185
|
+
if (!agent) throw new Error(`Agent is not detected: ${id}`);
|
|
186
|
+
return agent;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function createManagedLink(canonical: string, target: string): void {
|
|
191
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
192
|
+
const staging = `${target}.omt-tmp-${process.pid}-${Date.now()}`;
|
|
193
|
+
if (pathPresent(staging)) throw new Error(`Refusing to replace existing staging path: ${staging}`);
|
|
194
|
+
try {
|
|
195
|
+
if (options.linkDirectory) options.linkDirectory(canonical, staging, platform);
|
|
196
|
+
else symlinkSync(canonical, staging, platform === "win32" ? "junction" : "dir");
|
|
197
|
+
renameSync(staging, target);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isLink(staging)) unlinkSync(staging);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function install(ids?: AgentId[], installOptions: InstallOptions = {}): Promise<IntegrationResult[]> {
|
|
205
|
+
const agents = await selected(ids);
|
|
206
|
+
if (!agents.length) return [];
|
|
207
|
+
const canonical = stageCanonicalSkill(options.omtHome, source, options.skillVersion);
|
|
208
|
+
const state = loadState();
|
|
209
|
+
const skillState = state.skills["oh-my-tool"] ?? {
|
|
210
|
+
version: options.skillVersion,
|
|
211
|
+
digest: canonical.digest,
|
|
212
|
+
agents: {},
|
|
213
|
+
};
|
|
214
|
+
const operations: LinkOperation[] = [];
|
|
215
|
+
const results: IntegrationResult[] = [];
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
for (const agent of agents) {
|
|
219
|
+
const recorded = skillState.agents[agent.id];
|
|
220
|
+
if (recorded) assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
221
|
+
const targetRealpath = safeRealpath(agent.target);
|
|
222
|
+
const canonicalRealpath = realpathSync(canonical.path);
|
|
223
|
+
if (targetRealpath === canonicalRealpath && isLink(agent.target)) {
|
|
224
|
+
skillState.agents[agent.id] = managedState(agent.target, canonical.path, canonical.digest);
|
|
225
|
+
results.push(result(agent, "current"));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let previous: string | undefined;
|
|
230
|
+
let backup: string | undefined;
|
|
231
|
+
if (pathPresent(agent.target)) {
|
|
232
|
+
const isRecordedLink = recorded && isManagedLink(agent.target, recorded.canonical);
|
|
233
|
+
if (!isRecordedLink && !installOptions.force) throw new IntegrationConflictError(agent.target);
|
|
234
|
+
backup = installOptions.force && !isRecordedLink
|
|
235
|
+
? `${agent.target}.backup-${timestamp(now())}`
|
|
236
|
+
: `${agent.target}.omt-old-${process.pid}-${Date.now()}`;
|
|
237
|
+
renameSync(agent.target, backup);
|
|
238
|
+
previous = backup;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const operation: LinkOperation = { target: agent.target, previous, created: false };
|
|
242
|
+
operations.push(operation);
|
|
243
|
+
createManagedLink(canonical.path, agent.target);
|
|
244
|
+
operation.created = true;
|
|
245
|
+
const managed = managedState(agent.target, canonical.path, canonical.digest);
|
|
246
|
+
if (installOptions.force && previous && !previous.includes(".omt-old-")) managed.backup = previous;
|
|
247
|
+
skillState.agents[agent.id] = managed;
|
|
248
|
+
results.push(result(agent, "installed"));
|
|
249
|
+
}
|
|
250
|
+
skillState.version = options.skillVersion;
|
|
251
|
+
skillState.digest = canonical.digest;
|
|
252
|
+
state.skills["oh-my-tool"] = skillState;
|
|
253
|
+
saveState(state);
|
|
254
|
+
for (const operation of operations) {
|
|
255
|
+
if (operation.previous?.includes(".omt-old-") && pathPresent(operation.previous)) {
|
|
256
|
+
unlinkOnly(operation.previous);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return results;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
for (const operation of operations.reverse()) {
|
|
262
|
+
if (operation.created) unlinkOnly(operation.target);
|
|
263
|
+
if (operation.previous && pathPresent(operation.previous)) renameSync(operation.previous, operation.target);
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function status(): Promise<IntegrationResult[]> {
|
|
270
|
+
const agents = await detections();
|
|
271
|
+
const state = loadState();
|
|
272
|
+
const records = state.skills["oh-my-tool"]?.agents ?? {};
|
|
273
|
+
return agents.map((agent) => {
|
|
274
|
+
const recorded = records[agent.id];
|
|
275
|
+
if (!recorded) {
|
|
276
|
+
if (!pathPresent(agent.target)) {
|
|
277
|
+
return result(agent, "not-installed", "not installed; run `omt integrate` to install");
|
|
278
|
+
}
|
|
279
|
+
return result(agent, "conflict", "target exists but is not managed by OMT");
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
283
|
+
} catch {
|
|
284
|
+
return result(agent, "conflict", "recorded state is unsafe; run `omt integrate repair`");
|
|
285
|
+
}
|
|
286
|
+
if (!pathPresent(agent.target) || !safeRealpath(agent.target)) {
|
|
287
|
+
return result(agent, "broken", "link is missing; run `omt integrate repair`");
|
|
288
|
+
}
|
|
289
|
+
if (!isManagedLink(agent.target, recorded.canonical)) {
|
|
290
|
+
return result(agent, "conflict", "target was replaced by unmanaged content");
|
|
291
|
+
}
|
|
292
|
+
if (recorded.version === options.skillVersion) {
|
|
293
|
+
return result(agent, "current");
|
|
294
|
+
}
|
|
295
|
+
return result(agent, "update-available", `new version ${options.skillVersion} available; run \`omt integrate\``);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function repair(ids?: AgentId[]): Promise<IntegrationResult[]> {
|
|
300
|
+
const state = loadState();
|
|
301
|
+
const records = state.skills["oh-my-tool"]?.agents ?? {};
|
|
302
|
+
for (const agent of await selected(ids)) {
|
|
303
|
+
const recorded = records[agent.id];
|
|
304
|
+
if (recorded) assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
305
|
+
if (pathPresent(agent.target) && (!recorded || !isManagedLink(agent.target, recorded.canonical))) {
|
|
306
|
+
throw new IntegrationConflictError(agent.target);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const installed = await install(ids);
|
|
310
|
+
return installed.map((item) => ({ ...item, status: item.status === "installed" ? "repaired" : item.status }));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function uninstall(ids?: AgentId[]): Promise<IntegrationResult[]> {
|
|
314
|
+
const agents = await selected(ids);
|
|
315
|
+
const state = loadState();
|
|
316
|
+
const skillState = state.skills["oh-my-tool"];
|
|
317
|
+
const results: IntegrationResult[] = [];
|
|
318
|
+
for (const agent of agents) {
|
|
319
|
+
const recorded = skillState?.agents[agent.id];
|
|
320
|
+
if (!recorded) {
|
|
321
|
+
results.push(result(agent, "not-installed"));
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
325
|
+
const remaining = Object.entries(skillState!.agents).filter(
|
|
326
|
+
([id, other]) => id !== agent.id && other && samePath(other.target, agent.target, platform),
|
|
327
|
+
);
|
|
328
|
+
if (remaining.length) {
|
|
329
|
+
if (recorded.backup) remaining[0][1]!.backup = recorded.backup;
|
|
330
|
+
} else {
|
|
331
|
+
if (pathPresent(agent.target)) {
|
|
332
|
+
if (!isManagedLink(agent.target, recorded.canonical)) {
|
|
333
|
+
throw new IntegrationConflictError(agent.target);
|
|
334
|
+
}
|
|
335
|
+
unlinkOnly(agent.target);
|
|
336
|
+
}
|
|
337
|
+
if (recorded.backup && existsSync(recorded.backup)) renameSync(recorded.backup, agent.target);
|
|
338
|
+
}
|
|
339
|
+
delete skillState!.agents[agent.id];
|
|
340
|
+
results.push(result(agent, "uninstalled"));
|
|
341
|
+
}
|
|
342
|
+
saveState(state);
|
|
343
|
+
return results;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { detect: detections, install, status, repair, uninstall };
|
|
347
|
+
|
|
348
|
+
function managedState(target: string, canonical: string, digest: string): ManagedAgentState {
|
|
349
|
+
return {
|
|
350
|
+
target,
|
|
351
|
+
canonical,
|
|
352
|
+
version: options.skillVersion,
|
|
353
|
+
digest,
|
|
354
|
+
mode: platform === "win32" ? "junction" : "symlink",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function result(
|
|
360
|
+
agent: AgentDetection,
|
|
361
|
+
status: IntegrationResult["status"],
|
|
362
|
+
detail?: string,
|
|
363
|
+
): IntegrationResult {
|
|
364
|
+
return {
|
|
365
|
+
agent: agent.id,
|
|
366
|
+
displayName: agent.variant ?? agent.displayName,
|
|
367
|
+
target: agent.target,
|
|
368
|
+
status,
|
|
369
|
+
detail,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function timestamp(date: Date): string {
|
|
374
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
375
|
+
}
|