@oh-my-tool/cli 0.2.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/LICENSE +21 -0
- package/README.md +13 -0
- package/assets/skills/oh-my-tool/SKILL.md +77 -0
- package/bin/ohmytool.cjs +15 -0
- package/bin/ohmytool.ts +5 -0
- package/package.json +34 -0
- package/src/cli/commands/describe.ts +24 -0
- package/src/cli/commands/extension.ts +24 -0
- package/src/cli/commands/index.ts +7 -0
- package/src/cli/commands/integrate.ts +64 -0
- package/src/cli/commands/run.ts +39 -0
- package/src/cli/commands/search.ts +16 -0
- package/src/cli/commands/secret.ts +72 -0
- package/src/cli/context.ts +44 -0
- package/src/cli/index.ts +296 -0
- package/src/cli/parseArgs.ts +44 -0
- package/src/config/config.ts +63 -0
- package/src/core/executor.ts +99 -0
- package/src/core/registry.ts +33 -0
- package/src/core/result.ts +14 -0
- package/src/core/schema.ts +2 -0
- package/src/extension/discovery.ts +62 -0
- package/src/extension/install.ts +24 -0
- package/src/extension/loader.ts +32 -0
- package/src/extension/manifest.ts +115 -0
- package/src/integration/adapters.ts +98 -0
- package/src/integration/index.ts +4 -0
- package/src/integration/manager.ts +375 -0
- package/src/integration/skill.ts +84 -0
- package/src/integration/types.ts +55 -0
- package/src/migration.ts +44 -0
- package/src/paths.ts +41 -0
- package/src/policy/policy.ts +139 -0
- package/src/runtime/errors.ts +8 -0
- package/src/runtime/executor.ts +66 -0
- package/src/runtime/provider-registry.ts +23 -0
- package/src/runtime/provider.ts +29 -0
- package/src/runtime/providers/native/discovery.ts +2 -0
- package/src/runtime/providers/native/install.ts +2 -0
- package/src/runtime/providers/native/loader.ts +1 -0
- package/src/runtime/providers/native/manifest.ts +6 -0
- package/src/runtime/providers/native/provider.ts +57 -0
- package/src/runtime/result.ts +12 -0
- package/src/runtime/runtime.ts +71 -0
- package/src/runtime/schema.ts +49 -0
- package/src/runtime/tool-registry.ts +54 -0
- package/src/search/search.ts +78 -0
- package/src/secrets/secrets.ts +45 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { OMT_API_VERSION, type ExtensionManifest } from "@oh-my-tool/sdk";
|
|
2
|
+
|
|
3
|
+
export class ManifestError extends Error {}
|
|
4
|
+
|
|
5
|
+
export function parseManifest(raw: string): ExtensionManifest {
|
|
6
|
+
let data: unknown;
|
|
7
|
+
try {
|
|
8
|
+
data = JSON.parse(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
throw new ManifestError("manifest is not valid JSON");
|
|
11
|
+
}
|
|
12
|
+
const obj = data as Partial<ExtensionManifest>;
|
|
13
|
+
if (!obj.id || typeof obj.id !== "string") {
|
|
14
|
+
throw new ManifestError("manifest must contain a string 'id'");
|
|
15
|
+
}
|
|
16
|
+
if (!Array.isArray(obj.tools)) {
|
|
17
|
+
throw new ManifestError("manifest must contain a 'tools' array");
|
|
18
|
+
}
|
|
19
|
+
return obj as ExtensionManifest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateManifest(manifest: ExtensionManifest): void {
|
|
23
|
+
if (!manifest.name || typeof manifest.name !== "string") {
|
|
24
|
+
throw new ManifestError("manifest must contain a string 'name'");
|
|
25
|
+
}
|
|
26
|
+
if (!manifest.version || typeof manifest.version !== "string") {
|
|
27
|
+
throw new ManifestError("manifest must contain a string 'version'");
|
|
28
|
+
}
|
|
29
|
+
if (!manifest.sdkVersion || typeof manifest.sdkVersion !== "string") {
|
|
30
|
+
throw new ManifestError("manifest must contain a string 'sdkVersion'");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
for (const tool of manifest.tools) {
|
|
35
|
+
if (!tool.name || typeof tool.name !== "string") {
|
|
36
|
+
throw new ManifestError("each tool must have a string 'name'");
|
|
37
|
+
}
|
|
38
|
+
if (seen.has(tool.name)) {
|
|
39
|
+
throw new ManifestError(`duplicate tool name '${tool.name}'`);
|
|
40
|
+
}
|
|
41
|
+
seen.add(tool.name);
|
|
42
|
+
|
|
43
|
+
const prefix = `${manifest.id}.`;
|
|
44
|
+
if (!tool.name.startsWith(prefix)) {
|
|
45
|
+
throw new ManifestError(
|
|
46
|
+
`tool '${tool.name}' must be prefixed by extension id '${manifest.id}.'`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FULL_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
53
|
+
const PARTIAL_VERSION_RE = /^(\d+|x|X|\*)(\.(\d+|x|X|\*))?(\.(\d+|x|X|\*))?$/;
|
|
54
|
+
|
|
55
|
+
function isValidVersionToken(token: string): boolean {
|
|
56
|
+
return FULL_VERSION_RE.test(token) || PARTIAL_VERSION_RE.test(token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isValidSemverRange(range: string): boolean {
|
|
60
|
+
const trimmed = range.trim();
|
|
61
|
+
if (!trimmed) return false;
|
|
62
|
+
for (const orPart of trimmed.split("||")) {
|
|
63
|
+
const pieces = orPart.trim().split(/\s+/).filter(Boolean);
|
|
64
|
+
if (pieces.length === 0) return false;
|
|
65
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
66
|
+
const piece = pieces[i];
|
|
67
|
+
if (piece === "-") {
|
|
68
|
+
if (i === 0 || i === pieces.length - 1) return false;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const m = piece.match(/^(>=|<=|>|<|=|\^|~)?(.*)$/);
|
|
72
|
+
if (!m || m[2] === "" || !isValidVersionToken(m[2])) return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function checkSdkCompatibility(requiredRange: string): void {
|
|
79
|
+
if (typeof requiredRange !== "string" || requiredRange.trim() === "") {
|
|
80
|
+
throw new ManifestError("extension must declare a non-empty 'sdkVersion'");
|
|
81
|
+
}
|
|
82
|
+
if (!isValidSemverRange(requiredRange)) {
|
|
83
|
+
throw new ManifestError(
|
|
84
|
+
`invalid sdkVersion '${requiredRange}': not a valid semver range`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (!Bun.semver.satisfies(OMT_API_VERSION, requiredRange)) {
|
|
88
|
+
throw new ManifestError(
|
|
89
|
+
`extension requires sdk '${requiredRange}' but core provides '${OMT_API_VERSION}'`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validateHandlers(
|
|
95
|
+
manifest: ExtensionManifest,
|
|
96
|
+
handlerNames: string[],
|
|
97
|
+
): void {
|
|
98
|
+
const handlerSet = new Set(handlerNames);
|
|
99
|
+
const manifestSet = new Set(manifest.tools.map((t) => t.name));
|
|
100
|
+
|
|
101
|
+
for (const name of manifest.tools.map((t) => t.name)) {
|
|
102
|
+
if (!handlerSet.has(name)) {
|
|
103
|
+
throw new ManifestError(
|
|
104
|
+
`manifest declares tool '${name}' but runtime has no handler`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const name of handlerNames) {
|
|
109
|
+
if (!manifestSet.has(name)) {
|
|
110
|
+
throw new ManifestError(
|
|
111
|
+
`runtime registers tool '${name}' not declared in manifest`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, delimiter, extname, join } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import type { AgentDetection, AgentId } from "./types";
|
|
5
|
+
|
|
6
|
+
export type FindCommand = (candidates: string[]) => Promise<string | undefined> | string | undefined;
|
|
7
|
+
|
|
8
|
+
export interface DetectionOptions {
|
|
9
|
+
userHome?: string;
|
|
10
|
+
findCommand?: FindCommand;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AdapterDefinition {
|
|
14
|
+
id: AgentId;
|
|
15
|
+
displayName: string;
|
|
16
|
+
commands: string[];
|
|
17
|
+
target(userHome: string, command: string): string;
|
|
18
|
+
variant?(command: string): string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const definitions: AdapterDefinition[] = [
|
|
22
|
+
{
|
|
23
|
+
id: "codex",
|
|
24
|
+
displayName: "Codex",
|
|
25
|
+
commands: ["codex"],
|
|
26
|
+
target: (home) => join(home, ".agents", "skills", "oh-my-tool"),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "omp",
|
|
30
|
+
displayName: "OMP",
|
|
31
|
+
commands: ["omp"],
|
|
32
|
+
target: (home) => join(home, ".omp", "agent", "skills", "oh-my-tool"),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "qoder",
|
|
36
|
+
displayName: "Qoder",
|
|
37
|
+
commands: ["qoderclicn", "qodercli", "qoder"],
|
|
38
|
+
target: (home, command) =>
|
|
39
|
+
commandName(command) === "qoderclicn"
|
|
40
|
+
? join(home, ".qoder-cn", "skills", "oh-my-tool")
|
|
41
|
+
: join(home, ".qoder", "skills", "oh-my-tool"),
|
|
42
|
+
variant: (command) => (commandName(command) === "qoderclicn" ? "Qoder CLI CN" : undefined),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "pi",
|
|
46
|
+
displayName: "Pi",
|
|
47
|
+
commands: ["pi"],
|
|
48
|
+
target: (home) => join(home, ".agents", "skills", "oh-my-tool"),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "cursor",
|
|
52
|
+
displayName: "Cursor",
|
|
53
|
+
commands: ["cursor"],
|
|
54
|
+
target: (home) => join(home, ".agents", "skills", "oh-my-tool"),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "claude",
|
|
58
|
+
displayName: "Claude Code",
|
|
59
|
+
commands: ["claude"],
|
|
60
|
+
target: (home) => join(home, ".claude", "skills", "oh-my-tool"),
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function commandName(command: string): string {
|
|
65
|
+
return basename(command, extname(command)).toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function findCommandOnPath(candidates: string[]): string | undefined {
|
|
69
|
+
const pathEntries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
70
|
+
const suffixes = process.platform === "win32" ? [".exe", ".cmd", ".bat", ".ps1", ""] : [""];
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
for (const directory of pathEntries) {
|
|
73
|
+
for (const suffix of suffixes) {
|
|
74
|
+
const path = join(directory, candidate + suffix);
|
|
75
|
+
if (existsSync(path)) return path;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function detectAgents(options: DetectionOptions = {}): Promise<AgentDetection[]> {
|
|
83
|
+
const userHome = options.userHome ?? homedir();
|
|
84
|
+
const findCommand = options.findCommand ?? findCommandOnPath;
|
|
85
|
+
const detected: AgentDetection[] = [];
|
|
86
|
+
for (const definition of definitions) {
|
|
87
|
+
const command = await findCommand(definition.commands);
|
|
88
|
+
if (!command) continue;
|
|
89
|
+
detected.push({
|
|
90
|
+
id: definition.id,
|
|
91
|
+
displayName: definition.displayName,
|
|
92
|
+
command,
|
|
93
|
+
target: definition.target(userHome, command),
|
|
94
|
+
variant: definition.variant?.(command),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return detected;
|
|
98
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
export interface SkillMetadata {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function bundledSkillPath(): string {
|
|
20
|
+
return fileURLToPath(new URL("../../assets/skills/oh-my-tool", import.meta.url));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateSkill(directory: string): SkillMetadata {
|
|
24
|
+
const skillFile = join(directory, "SKILL.md");
|
|
25
|
+
if (!existsSync(skillFile)) throw new Error(`Skill is missing SKILL.md: ${directory}`);
|
|
26
|
+
const content = readFileSync(skillFile, "utf8");
|
|
27
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
28
|
+
if (!frontmatter) throw new Error("Skill SKILL.md is missing YAML frontmatter");
|
|
29
|
+
const name = frontmatter[1].match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
30
|
+
const description = frontmatter[1].match(/^description:\s*(.+?)\s*$/m)?.[1];
|
|
31
|
+
if (!name || !description) throw new Error("Skill frontmatter requires name and description");
|
|
32
|
+
if (name !== "oh-my-tool") throw new Error(`Unexpected bundled skill name: ${name}`);
|
|
33
|
+
return { name, description };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function filesUnder(directory: string): string[] {
|
|
37
|
+
const files: string[] = [];
|
|
38
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
39
|
+
const path = join(directory, entry.name);
|
|
40
|
+
if (entry.isDirectory()) files.push(...filesUnder(path));
|
|
41
|
+
else if (entry.isFile()) files.push(path);
|
|
42
|
+
}
|
|
43
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function skillDigest(directory: string): string {
|
|
47
|
+
const hash = createHash("sha256");
|
|
48
|
+
for (const file of filesUnder(directory)) {
|
|
49
|
+
hash.update(relative(directory, file).replaceAll("\\", "/"));
|
|
50
|
+
hash.update("\0");
|
|
51
|
+
hash.update(readFileSync(file));
|
|
52
|
+
hash.update("\0");
|
|
53
|
+
}
|
|
54
|
+
return `sha256:${hash.digest("hex")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stageCanonicalSkill(
|
|
58
|
+
omtHome: string,
|
|
59
|
+
source: string,
|
|
60
|
+
version: string,
|
|
61
|
+
): { path: string; digest: string } {
|
|
62
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
63
|
+
throw new Error(`Invalid skill version: ${version}`);
|
|
64
|
+
}
|
|
65
|
+
validateSkill(source);
|
|
66
|
+
const digest = skillDigest(source);
|
|
67
|
+
const canonicalRoot = resolve(omtHome, "integrations", "skills", "oh-my-tool");
|
|
68
|
+
const target = resolve(canonicalRoot, version);
|
|
69
|
+
if (dirname(target) !== canonicalRoot) throw new Error(`Invalid skill version path: ${version}`);
|
|
70
|
+
if (existsSync(target)) {
|
|
71
|
+
validateSkill(target);
|
|
72
|
+
if (skillDigest(target) !== digest) {
|
|
73
|
+
throw new Error(`Immutable skill version ${version} already exists with different content`);
|
|
74
|
+
}
|
|
75
|
+
return { path: target, digest };
|
|
76
|
+
}
|
|
77
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
78
|
+
const staging = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
79
|
+
if (existsSync(staging)) throw new Error(`Skill staging path already exists: ${staging}`);
|
|
80
|
+
cpSync(source, staging, { recursive: true, errorOnExist: true });
|
|
81
|
+
validateSkill(staging);
|
|
82
|
+
if (statSync(staging).isDirectory()) renameSync(staging, target);
|
|
83
|
+
return { path: target, digest };
|
|
84
|
+
}
|