@genrupt/cli 0.1.0 → 0.1.2
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 +75 -0
- package/dist/agent.js +149 -0
- package/dist/cli.js +160 -9
- package/dist/constants.js +1 -1
- package/dist/doctor.js +225 -0
- package/dist/localMcpServer.js +73 -8
- package/dist/skills.js +219 -0
- package/dist/telemetry.js +156 -0
- package/dist/updateCheck.js +77 -0
- package/dist/upload.js +206 -69
- package/dist/version.js +32 -0
- package/package.json +1 -1
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getValidConfig } from "./auth.js";
|
|
4
|
+
import { CLI_VERSION } from "./constants.js";
|
|
5
|
+
import { listRemoteMcpTools } from "./mcpClient.js";
|
|
6
|
+
import { GENRUPT_SKILL_NAMES, computeInstalledSkillDigests, defaultTargetForAgent, detectAgent, getInstalledRuntimeManifestPath, readInstalledRuntimeManifest, } from "./skills.js";
|
|
7
|
+
import { emitRuntimeTelemetry } from "./telemetry.js";
|
|
8
|
+
import { CLI_PACKAGE_NAME, fetchLatestCliVersion, isVersionNewer } from "./version.js";
|
|
9
|
+
function normalizeAgent(agent) {
|
|
10
|
+
const normalized = agent?.trim().toLowerCase();
|
|
11
|
+
if (!normalized)
|
|
12
|
+
return detectAgent();
|
|
13
|
+
if (normalized === "claude-code")
|
|
14
|
+
return "claude";
|
|
15
|
+
if (normalized === "claude" || normalized === "codex" || normalized === "cursor") {
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
throw new Error("--agent must be claude, codex, or cursor.");
|
|
19
|
+
}
|
|
20
|
+
function icon(status) {
|
|
21
|
+
if (status === "ok")
|
|
22
|
+
return "OK";
|
|
23
|
+
if (status === "warn")
|
|
24
|
+
return "WARN";
|
|
25
|
+
return "FAIL";
|
|
26
|
+
}
|
|
27
|
+
function buildSkillsCheck(params) {
|
|
28
|
+
const missingSkills = GENRUPT_SKILL_NAMES.filter((skillName) => !existsSync(path.join(params.targetRoot, skillName, "SKILL.md")));
|
|
29
|
+
if (missingSkills.length > 0) {
|
|
30
|
+
return {
|
|
31
|
+
label: "Skills",
|
|
32
|
+
status: "fail",
|
|
33
|
+
detail: `Missing ${missingSkills.length} skill(s) for ${params.agent}: ${missingSkills.join(", ")}`,
|
|
34
|
+
remediation: `Run: npx -y ${CLI_PACKAGE_NAME}@latest agent install --agent ${params.agent}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
label: "Skills",
|
|
39
|
+
status: "ok",
|
|
40
|
+
detail: `Installed for ${params.agent} at ${params.targetRoot}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function buildRuntimeManifestCheck(targetRoot) {
|
|
44
|
+
const manifest = await readInstalledRuntimeManifest(targetRoot);
|
|
45
|
+
if (!manifest) {
|
|
46
|
+
return {
|
|
47
|
+
label: "Runtime manifest",
|
|
48
|
+
status: "warn",
|
|
49
|
+
detail: `No installed runtime manifest found at ${getInstalledRuntimeManifestPath(targetRoot)}`,
|
|
50
|
+
remediation: `Run: npx -y ${CLI_PACKAGE_NAME}@latest agent install`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
label: "Runtime manifest",
|
|
55
|
+
status: "ok",
|
|
56
|
+
detail: `Skills ${manifest.version}, requires ${manifest.cliPackage} >= ${manifest.requiresCliVersion}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function buildSkillIntegrityCheck(targetRoot) {
|
|
60
|
+
const manifest = await readInstalledRuntimeManifest(targetRoot);
|
|
61
|
+
if (!manifest?.installedSkills?.length) {
|
|
62
|
+
return {
|
|
63
|
+
label: "Skill integrity",
|
|
64
|
+
status: "warn",
|
|
65
|
+
detail: "No installed skill digests recorded.",
|
|
66
|
+
remediation: `Run: npx -y ${CLI_PACKAGE_NAME}@latest agent install`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const currentDigests = await computeInstalledSkillDigests(targetRoot);
|
|
71
|
+
const expectedByName = new Map(manifest.installedSkills.map((skill) => [skill.name, skill.digest]));
|
|
72
|
+
const changed = currentDigests.filter((skill) => expectedByName.get(skill.name) !== skill.digest);
|
|
73
|
+
if (changed.length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
label: "Skill integrity",
|
|
76
|
+
status: "fail",
|
|
77
|
+
detail: `Changed skill file(s): ${changed.map((skill) => skill.name).join(", ")}`,
|
|
78
|
+
remediation: `Run: npx -y ${CLI_PACKAGE_NAME}@latest agent install`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
label: "Skill integrity",
|
|
83
|
+
status: "ok",
|
|
84
|
+
detail: `Verified ${currentDigests.length} installed skill digest(s).`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
return {
|
|
90
|
+
label: "Skill integrity",
|
|
91
|
+
status: "fail",
|
|
92
|
+
detail: message,
|
|
93
|
+
remediation: `Run: npx -y ${CLI_PACKAGE_NAME}@latest agent install`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function runDoctor(options = {}) {
|
|
98
|
+
const startedAt = Date.now();
|
|
99
|
+
const checks = [];
|
|
100
|
+
const agent = normalizeAgent(options.agent);
|
|
101
|
+
const targetRoot = options.target?.trim() || defaultTargetForAgent(agent);
|
|
102
|
+
let latestCliVersion;
|
|
103
|
+
let staleCliDetected = false;
|
|
104
|
+
checks.push({
|
|
105
|
+
label: "CLI",
|
|
106
|
+
status: "ok",
|
|
107
|
+
detail: `${CLI_PACKAGE_NAME} ${CLI_VERSION}`,
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
const latestVersion = await fetchLatestCliVersion();
|
|
111
|
+
latestCliVersion = latestVersion;
|
|
112
|
+
if (latestVersion && isVersionNewer(latestVersion, CLI_VERSION)) {
|
|
113
|
+
staleCliDetected = true;
|
|
114
|
+
checks.push({
|
|
115
|
+
label: "CLI update",
|
|
116
|
+
status: "warn",
|
|
117
|
+
detail: `Latest published CLI is ${latestVersion}; current is ${CLI_VERSION}`,
|
|
118
|
+
remediation: `Run: npm install -g ${CLI_PACKAGE_NAME}@latest`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
checks.push({
|
|
123
|
+
label: "CLI update",
|
|
124
|
+
status: "ok",
|
|
125
|
+
detail: latestVersion
|
|
126
|
+
? `No newer published CLI found (published: ${latestVersion}, current: ${CLI_VERSION}).`
|
|
127
|
+
: "No newer CLI found.",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
checks.push({
|
|
134
|
+
label: "CLI update",
|
|
135
|
+
status: "warn",
|
|
136
|
+
detail: `Could not check npm registry: ${message}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
let authenticated = false;
|
|
140
|
+
try {
|
|
141
|
+
const config = await getValidConfig();
|
|
142
|
+
authenticated = true;
|
|
143
|
+
checks.push({
|
|
144
|
+
label: "Auth",
|
|
145
|
+
status: "ok",
|
|
146
|
+
detail: `Authenticated for ${config.origin}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
checks.push({
|
|
151
|
+
label: "Auth",
|
|
152
|
+
status: "fail",
|
|
153
|
+
detail: "Not authenticated.",
|
|
154
|
+
remediation: "Run: genrupt auth login",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (authenticated) {
|
|
158
|
+
try {
|
|
159
|
+
const tools = await listRemoteMcpTools();
|
|
160
|
+
checks.push({
|
|
161
|
+
label: "Remote MCP",
|
|
162
|
+
status: "ok",
|
|
163
|
+
detail: `Listed ${tools.tools.length} tool(s).`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
checks.push({
|
|
169
|
+
label: "Remote MCP",
|
|
170
|
+
status: "fail",
|
|
171
|
+
detail: message,
|
|
172
|
+
remediation: "Run: genrupt auth login, then retry genrupt doctor",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
checks.push(buildSkillsCheck({ agent, targetRoot }));
|
|
177
|
+
checks.push(await buildRuntimeManifestCheck(targetRoot));
|
|
178
|
+
checks.push(await buildSkillIntegrityCheck(targetRoot));
|
|
179
|
+
const failed = checks.some((check) => check.status === "fail");
|
|
180
|
+
const warned = checks.some((check) => check.status === "warn");
|
|
181
|
+
if (options.json) {
|
|
182
|
+
console.log(JSON.stringify({ ok: !failed, warned, agent, targetRoot, checks }, null, 2));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
console.log();
|
|
186
|
+
console.log("Genrupt doctor");
|
|
187
|
+
console.log();
|
|
188
|
+
for (const check of checks) {
|
|
189
|
+
console.log(`[${icon(check.status)}] ${check.label}: ${check.detail}`);
|
|
190
|
+
if (check.remediation) {
|
|
191
|
+
console.log(` ${check.remediation}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (staleCliDetected && latestCliVersion) {
|
|
196
|
+
await emitRuntimeTelemetry({
|
|
197
|
+
eventType: "stale_cli_detected",
|
|
198
|
+
command: "doctor",
|
|
199
|
+
agent,
|
|
200
|
+
latestCliVersion,
|
|
201
|
+
durationMs: Date.now() - startedAt,
|
|
202
|
+
metadata: {
|
|
203
|
+
source: "doctor",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (failed) {
|
|
208
|
+
const failedChecks = checks.filter((check) => check.status === "fail");
|
|
209
|
+
const warnedChecks = checks.filter((check) => check.status === "warn");
|
|
210
|
+
await emitRuntimeTelemetry({
|
|
211
|
+
eventType: "doctor_failed",
|
|
212
|
+
command: "doctor",
|
|
213
|
+
agent,
|
|
214
|
+
latestCliVersion,
|
|
215
|
+
durationMs: Date.now() - startedAt,
|
|
216
|
+
metadata: {
|
|
217
|
+
failedChecks: failedChecks.map((check) => check.label),
|
|
218
|
+
failedCount: failedChecks.length,
|
|
219
|
+
warnedChecks: warnedChecks.map((check) => check.label),
|
|
220
|
+
warnedCount: warnedChecks.length,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
throw new Error("Genrupt doctor found failing checks.");
|
|
224
|
+
}
|
|
225
|
+
}
|
package/dist/localMcpServer.js
CHANGED
|
@@ -3,11 +3,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { CLI_VERSION } from "./constants.js";
|
|
5
5
|
import { listRemoteMcpTools, callRemoteMcpTool } from "./mcpClient.js";
|
|
6
|
-
import { uploadProductReferenceImages } from "./upload.js";
|
|
6
|
+
import { uploadProductReferenceImages, uploadReferenceMedia, } from "./upload.js";
|
|
7
|
+
import { classifyCliError, emitRuntimeTelemetry } from "./telemetry.js";
|
|
7
8
|
const LOCAL_UPLOAD_TOOL = {
|
|
8
9
|
name: "upload_product_reference_images",
|
|
9
10
|
title: "Upload local product reference images",
|
|
10
|
-
description: "Use when the user provides local image file paths from this computer and
|
|
11
|
+
description: "Use when the user provides local image file or folder paths from this computer and needs ProductReferenceAsset sourceAssetIds for create_product_reference_sheet. For custom video reference images, videos, or audio, prefer upload_reference_media. This local CLI MCP tool can read local files/folders, uploads supported images through Genrupt's presigned upload flow, completes registration, and returns sourceAssetIds plus referenceImageUrls. Folder paths include JPEG, PNG, and WebP files directly inside the folder. Supports JPEG, PNG, and WebP images up to 50 MB each.",
|
|
11
12
|
inputSchema: {
|
|
12
13
|
type: "object",
|
|
13
14
|
properties: {
|
|
@@ -15,7 +16,7 @@ const LOCAL_UPLOAD_TOOL = {
|
|
|
15
16
|
type: "array",
|
|
16
17
|
minItems: 1,
|
|
17
18
|
items: { type: "string", minLength: 1 },
|
|
18
|
-
description: "Absolute or working-directory-relative local image file paths.",
|
|
19
|
+
description: "Absolute or working-directory-relative local image file paths or folders containing images.",
|
|
19
20
|
},
|
|
20
21
|
asin: {
|
|
21
22
|
type: "string",
|
|
@@ -42,6 +43,35 @@ const LOCAL_UPLOAD_TOOL = {
|
|
|
42
43
|
additionalProperties: false,
|
|
43
44
|
},
|
|
44
45
|
};
|
|
46
|
+
const LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL = {
|
|
47
|
+
name: "upload_reference_media",
|
|
48
|
+
title: "Upload local reference media",
|
|
49
|
+
description: "Use when the user provides local image, video, audio, or folder paths from this computer for custom video reference inputs. This local CLI MCP tool can read local files/folders, upload supported media through Genrupt's presigned reference-asset flow, and return typed referenceImageUrls, referenceVideoUrls, and referenceAudioUrls for generate_video. Folder paths include supported files directly inside the folder. Supports JPEG, PNG, WebP, MP4, MOV, WebM, MP3, WAV, M4A, and OGG files up to 50 MB each. This does not create sourceAssetIds for Product Reference Sheets; use upload_product_reference_images for that.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
paths: {
|
|
54
|
+
type: "array",
|
|
55
|
+
minItems: 1,
|
|
56
|
+
items: { type: "string", minLength: 1 },
|
|
57
|
+
description: "Absolute or working-directory-relative local media file paths or folders containing supported media.",
|
|
58
|
+
},
|
|
59
|
+
mediaKind: {
|
|
60
|
+
type: "string",
|
|
61
|
+
enum: ["media", "image", "video", "audio"],
|
|
62
|
+
default: "media",
|
|
63
|
+
description: "Optional media filter. Use media to auto-detect supported image, video, and audio files.",
|
|
64
|
+
},
|
|
65
|
+
projectId: {
|
|
66
|
+
type: "string",
|
|
67
|
+
minLength: 1,
|
|
68
|
+
description: "Optional Genrupt project ID to scope uploaded reference media.",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
required: ["paths"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
45
75
|
function buildToolResult(payload) {
|
|
46
76
|
return {
|
|
47
77
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
@@ -75,11 +105,11 @@ function buildToolError(error) {
|
|
|
75
105
|
}
|
|
76
106
|
function normalizeStringArray(value) {
|
|
77
107
|
if (!Array.isArray(value)) {
|
|
78
|
-
throw new Error("paths must be an array of
|
|
108
|
+
throw new Error("paths must be an array of local file or folder paths.");
|
|
79
109
|
}
|
|
80
110
|
const paths = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
81
111
|
if (paths.length === 0) {
|
|
82
|
-
throw new Error("paths must include at least one
|
|
112
|
+
throw new Error("paths must include at least one local file or folder path.");
|
|
83
113
|
}
|
|
84
114
|
return paths;
|
|
85
115
|
}
|
|
@@ -97,6 +127,23 @@ async function handleLocalUploadTool(args) {
|
|
|
97
127
|
});
|
|
98
128
|
return buildToolResult(payload);
|
|
99
129
|
}
|
|
130
|
+
function normalizeMediaKind(value) {
|
|
131
|
+
if (value === undefined || value === null || value === "") {
|
|
132
|
+
return "media";
|
|
133
|
+
}
|
|
134
|
+
if (value === "media" || value === "image" || value === "video" || value === "audio") {
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
throw new Error("mediaKind must be media, image, video, or audio.");
|
|
138
|
+
}
|
|
139
|
+
async function handleLocalReferenceMediaUploadTool(args) {
|
|
140
|
+
const payload = await uploadReferenceMedia({
|
|
141
|
+
paths: normalizeStringArray(args.paths),
|
|
142
|
+
mode: normalizeMediaKind(args.mediaKind),
|
|
143
|
+
projectId: getOptionalString(args, "projectId"),
|
|
144
|
+
});
|
|
145
|
+
return buildToolResult(payload);
|
|
146
|
+
}
|
|
100
147
|
export async function serveLocalMcp() {
|
|
101
148
|
const server = new Server({
|
|
102
149
|
name: "genrupt-cli",
|
|
@@ -105,7 +152,7 @@ export async function serveLocalMcp() {
|
|
|
105
152
|
capabilities: {
|
|
106
153
|
tools: {},
|
|
107
154
|
},
|
|
108
|
-
instructions: "Genrupt local bridge. Use
|
|
155
|
+
instructions: "Genrupt local bridge. Use upload_reference_media for local image/video/audio reference paths in custom video workflows. Use upload_product_reference_images when product reference sheet workflows need sourceAssetIds. Then use Genrupt remote MCP tools for market analysis, listing images, A+ content, product reference sheets, and video workflows.",
|
|
109
156
|
});
|
|
110
157
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
111
158
|
try {
|
|
@@ -113,14 +160,16 @@ export async function serveLocalMcp() {
|
|
|
113
160
|
return {
|
|
114
161
|
tools: [
|
|
115
162
|
LOCAL_UPLOAD_TOOL,
|
|
116
|
-
|
|
163
|
+
LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL,
|
|
164
|
+
...remote.tools.filter((tool) => tool.name !== LOCAL_UPLOAD_TOOL.name &&
|
|
165
|
+
tool.name !== LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL.name),
|
|
117
166
|
],
|
|
118
167
|
};
|
|
119
168
|
}
|
|
120
169
|
catch (error) {
|
|
121
170
|
console.error(`[genrupt] Failed to list remote MCP tools: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
171
|
return {
|
|
123
|
-
tools: [LOCAL_UPLOAD_TOOL],
|
|
172
|
+
tools: [LOCAL_UPLOAD_TOOL, LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL],
|
|
124
173
|
};
|
|
125
174
|
}
|
|
126
175
|
});
|
|
@@ -132,9 +181,25 @@ export async function serveLocalMcp() {
|
|
|
132
181
|
if (request.params.name === LOCAL_UPLOAD_TOOL.name) {
|
|
133
182
|
return await handleLocalUploadTool(args);
|
|
134
183
|
}
|
|
184
|
+
if (request.params.name === LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL.name) {
|
|
185
|
+
return await handleLocalReferenceMediaUploadTool(args);
|
|
186
|
+
}
|
|
135
187
|
return await callRemoteMcpTool(request.params.name, args);
|
|
136
188
|
}
|
|
137
189
|
catch (error) {
|
|
190
|
+
if (request.params.name === LOCAL_UPLOAD_TOOL.name ||
|
|
191
|
+
request.params.name === LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL.name) {
|
|
192
|
+
await emitRuntimeTelemetry({
|
|
193
|
+
eventType: "upload_failed",
|
|
194
|
+
command: request.params.name,
|
|
195
|
+
errorCode: classifyCliError(error, "LOCAL_MCP_UPLOAD_FAILED"),
|
|
196
|
+
metadata: {
|
|
197
|
+
uploadType: request.params.name === LOCAL_REFERENCE_MEDIA_UPLOAD_TOOL.name
|
|
198
|
+
? getOptionalString(args, "mediaKind") ?? "media"
|
|
199
|
+
: "product_reference_images",
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
138
203
|
return buildToolError(error);
|
|
139
204
|
}
|
|
140
205
|
});
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { CLI_VERSION, DEFAULT_ORIGIN } from "./constants.js";
|
|
7
|
+
import { isVersionAtLeast } from "./version.js";
|
|
8
|
+
export const GENRUPT_SKILL_NAMES = [
|
|
9
|
+
"genrupt-listing-video",
|
|
10
|
+
"genrupt-custom-video",
|
|
11
|
+
"genrupt-listing-images",
|
|
12
|
+
"genrupt-aplus-content",
|
|
13
|
+
"genrupt-product-variations",
|
|
14
|
+
];
|
|
15
|
+
const SKILL_FILES = [
|
|
16
|
+
"SKILL.md",
|
|
17
|
+
"agents/openai.yaml",
|
|
18
|
+
"references/local-reference-assets.md",
|
|
19
|
+
"assets/icon.svg",
|
|
20
|
+
];
|
|
21
|
+
const CURSOR_PLUGIN_FILES = [
|
|
22
|
+
".cursor-plugin/plugin.json",
|
|
23
|
+
"VERSION",
|
|
24
|
+
"README.md",
|
|
25
|
+
"runtime.json",
|
|
26
|
+
];
|
|
27
|
+
function normalizeOrigin(origin) {
|
|
28
|
+
return (origin?.trim() || DEFAULT_ORIGIN).replace(/\/$/, "");
|
|
29
|
+
}
|
|
30
|
+
function normalizeAgent(agent) {
|
|
31
|
+
const normalized = agent?.trim().toLowerCase();
|
|
32
|
+
if (!normalized)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (normalized === "claude-code")
|
|
35
|
+
return "claude";
|
|
36
|
+
if (normalized === "claude" || normalized === "codex" || normalized === "cursor") {
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
throw new Error("--agent must be claude, codex, or cursor.");
|
|
40
|
+
}
|
|
41
|
+
export function detectAgent() {
|
|
42
|
+
const home = os.homedir();
|
|
43
|
+
if (process.env.CLAUDECODE || process.env.CLAUDE_CODE)
|
|
44
|
+
return "claude";
|
|
45
|
+
if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_SESSION_ID)
|
|
46
|
+
return "cursor";
|
|
47
|
+
if (process.env.CODEX_HOME || process.env.OPENAI_CODEX)
|
|
48
|
+
return "codex";
|
|
49
|
+
if (pathExistsSync(path.join(home, ".claude")))
|
|
50
|
+
return "claude";
|
|
51
|
+
if (pathExistsSync(path.join(home, ".codex")))
|
|
52
|
+
return "codex";
|
|
53
|
+
if (pathExistsSync(path.join(home, ".cursor")))
|
|
54
|
+
return "cursor";
|
|
55
|
+
return "claude";
|
|
56
|
+
}
|
|
57
|
+
function pathExistsSync(filePath) {
|
|
58
|
+
return existsSync(filePath);
|
|
59
|
+
}
|
|
60
|
+
export function defaultTargetForAgent(agent) {
|
|
61
|
+
const home = os.homedir();
|
|
62
|
+
if (agent === "claude")
|
|
63
|
+
return path.join(home, ".claude", "skills");
|
|
64
|
+
if (agent === "codex")
|
|
65
|
+
return path.join(home, ".codex", "skills");
|
|
66
|
+
return path.join(home, ".cursor", "plugins", "genrupt");
|
|
67
|
+
}
|
|
68
|
+
function targetRootForSkillInstall(params) {
|
|
69
|
+
return params.target?.trim() || defaultTargetForAgent(params.agent);
|
|
70
|
+
}
|
|
71
|
+
async function fetchText(url) {
|
|
72
|
+
const response = await fetch(url);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
75
|
+
}
|
|
76
|
+
return response.text();
|
|
77
|
+
}
|
|
78
|
+
async function downloadPublicFile(params) {
|
|
79
|
+
const relativePath = params.relativePath
|
|
80
|
+
.split("/")
|
|
81
|
+
.map((segment) => encodeURIComponent(segment))
|
|
82
|
+
.join("/");
|
|
83
|
+
return fetchText(`${params.origin}/downloads/genrupt-skills/${relativePath}`);
|
|
84
|
+
}
|
|
85
|
+
function parseRuntimeManifest(raw) {
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (parsed.name !== "genrupt-agent-runtime" ||
|
|
88
|
+
typeof parsed.version !== "string" ||
|
|
89
|
+
typeof parsed.requiresCliVersion !== "string" ||
|
|
90
|
+
typeof parsed.cliPackage !== "string" ||
|
|
91
|
+
typeof parsed.cliBinary !== "string" ||
|
|
92
|
+
!Array.isArray(parsed.skills)) {
|
|
93
|
+
throw new Error("Invalid Genrupt runtime manifest.");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
name: parsed.name,
|
|
97
|
+
version: parsed.version,
|
|
98
|
+
requiresCliVersion: parsed.requiresCliVersion,
|
|
99
|
+
cliPackage: parsed.cliPackage,
|
|
100
|
+
cliBinary: parsed.cliBinary,
|
|
101
|
+
skills: parsed.skills.filter((skill) => typeof skill === "string"),
|
|
102
|
+
installedSkills: Array.isArray(parsed.installedSkills)
|
|
103
|
+
? parsed.installedSkills.flatMap((skill) => {
|
|
104
|
+
if (skill &&
|
|
105
|
+
typeof skill === "object" &&
|
|
106
|
+
"name" in skill &&
|
|
107
|
+
"digest" in skill &&
|
|
108
|
+
typeof skill.name === "string" &&
|
|
109
|
+
typeof skill.digest === "string") {
|
|
110
|
+
return [{ name: skill.name, digest: skill.digest }];
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
})
|
|
114
|
+
: undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export async function downloadRuntimeManifest(origin) {
|
|
118
|
+
return parseRuntimeManifest(await downloadPublicFile({
|
|
119
|
+
origin,
|
|
120
|
+
relativePath: "runtime.json",
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
export function getInstalledRuntimeManifestPath(targetRoot) {
|
|
124
|
+
return path.join(targetRoot, ".genrupt-runtime.json");
|
|
125
|
+
}
|
|
126
|
+
export async function readInstalledRuntimeManifest(targetRoot) {
|
|
127
|
+
try {
|
|
128
|
+
const raw = await readFile(getInstalledRuntimeManifestPath(targetRoot), "utf8");
|
|
129
|
+
return parseRuntimeManifest(raw.replace(/^\uFEFF/, ""));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code === "ENOENT") {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function computeInstalledSkillDigest(params) {
|
|
139
|
+
const hash = crypto.createHash("sha256");
|
|
140
|
+
for (const file of SKILL_FILES) {
|
|
141
|
+
const filePath = path.join(params.targetRoot, params.skillName, ...file.split("/"));
|
|
142
|
+
hash.update(file);
|
|
143
|
+
hash.update(await readFile(filePath));
|
|
144
|
+
}
|
|
145
|
+
return `sha256:${hash.digest("hex")}`;
|
|
146
|
+
}
|
|
147
|
+
export async function computeInstalledSkillDigests(targetRoot) {
|
|
148
|
+
return Promise.all(GENRUPT_SKILL_NAMES.map(async (skillName) => ({
|
|
149
|
+
name: skillName,
|
|
150
|
+
digest: await computeInstalledSkillDigest({ targetRoot, skillName }),
|
|
151
|
+
})));
|
|
152
|
+
}
|
|
153
|
+
async function writeInstalledRuntimeManifest(params) {
|
|
154
|
+
const installedSkills = await computeInstalledSkillDigests(params.targetRoot);
|
|
155
|
+
await writeFile(getInstalledRuntimeManifestPath(params.targetRoot), `${JSON.stringify({
|
|
156
|
+
...params.runtime,
|
|
157
|
+
installedSkills,
|
|
158
|
+
agent: params.agent,
|
|
159
|
+
origin: params.origin,
|
|
160
|
+
installedCliVersion: CLI_VERSION,
|
|
161
|
+
installedAt: new Date().toISOString(),
|
|
162
|
+
}, null, 2)}\n`, "utf8");
|
|
163
|
+
}
|
|
164
|
+
function assertRuntimeSupported(runtime) {
|
|
165
|
+
if (isVersionAtLeast(CLI_VERSION, runtime.requiresCliVersion)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`These Genrupt skills require ${runtime.cliPackage} >= ${runtime.requiresCliVersion}; installed CLI is ${CLI_VERSION}.\n` +
|
|
169
|
+
`Update with: npm install -g ${runtime.cliPackage}@latest\n` +
|
|
170
|
+
`Or run once with: npx -y ${runtime.cliPackage}@latest agent install`);
|
|
171
|
+
}
|
|
172
|
+
async function writeDownloadedFile(params) {
|
|
173
|
+
const contents = await downloadPublicFile({
|
|
174
|
+
origin: params.origin,
|
|
175
|
+
relativePath: params.relativePath,
|
|
176
|
+
});
|
|
177
|
+
await mkdir(path.dirname(params.targetPath), { recursive: true });
|
|
178
|
+
await writeFile(params.targetPath, contents, "utf8");
|
|
179
|
+
}
|
|
180
|
+
async function installSkillFolder(params) {
|
|
181
|
+
const skillTarget = path.join(params.targetRoot, params.skillName);
|
|
182
|
+
await rm(skillTarget, { recursive: true, force: true });
|
|
183
|
+
for (const file of SKILL_FILES) {
|
|
184
|
+
await writeDownloadedFile({
|
|
185
|
+
origin: params.origin,
|
|
186
|
+
relativePath: `${params.skillName}/${file}`,
|
|
187
|
+
targetPath: path.join(skillTarget, ...file.split("/")),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function installCursorPluginShell(params) {
|
|
192
|
+
for (const file of CURSOR_PLUGIN_FILES) {
|
|
193
|
+
await writeDownloadedFile({
|
|
194
|
+
origin: params.origin,
|
|
195
|
+
relativePath: file,
|
|
196
|
+
targetPath: path.join(params.targetRoot, ...file.split("/")),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export async function installSkills(options = {}) {
|
|
201
|
+
const agent = normalizeAgent(options.agent) ?? detectAgent();
|
|
202
|
+
const targetRoot = targetRootForSkillInstall({ agent, target: options.target });
|
|
203
|
+
const origin = normalizeOrigin(options.origin);
|
|
204
|
+
const runtime = await downloadRuntimeManifest(origin);
|
|
205
|
+
if (!options.skipRuntimeCheck) {
|
|
206
|
+
assertRuntimeSupported(runtime);
|
|
207
|
+
}
|
|
208
|
+
await mkdir(targetRoot, { recursive: true });
|
|
209
|
+
if (agent === "cursor") {
|
|
210
|
+
await installCursorPluginShell({ origin, targetRoot });
|
|
211
|
+
}
|
|
212
|
+
for (const skillName of GENRUPT_SKILL_NAMES) {
|
|
213
|
+
await installSkillFolder({ origin, targetRoot, skillName });
|
|
214
|
+
}
|
|
215
|
+
await writeInstalledRuntimeManifest({ targetRoot, agent, origin, runtime });
|
|
216
|
+
console.log(`Installed ${GENRUPT_SKILL_NAMES.length} Genrupt skills for ${agent}.`);
|
|
217
|
+
console.log(`Runtime: ${runtime.version} (requires ${runtime.cliPackage} >= ${runtime.requiresCliVersion})`);
|
|
218
|
+
console.log(targetRoot);
|
|
219
|
+
}
|