@reddb-io/red-skills 3.18.12 → 3.19.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/.agents/plugins/marketplace.json +63 -0
- package/.claude-plugin/marketplace.json +32 -0
- package/.gemini-plugin/marketplace.json +63 -0
- package/README.md +18 -18
- package/dist/code-nav.bundle.min.mjs +1 -1
- package/dist/opencode-host.bundle.min.mjs +165 -0
- package/dist/redskilled-mcp.bundle.min.mjs +372 -912
- package/dist/redskilled.bundle.min.mjs +301 -280
- package/dist/release.bundle.min.mjs +259 -0
- package/dist/rsp-core.bundle.min.mjs +199 -199
- package/dist/rsp.bundle.min.mjs +1 -1
- package/package.json +15 -3
- package/scripts/build-gemini-extension.mjs +180 -0
- package/scripts/generate-codex-manifests.mjs +291 -0
- package/scripts/generate-gemini-manifests.mjs +259 -0
- package/scripts/generate-pi-manifests.mjs +133 -0
- package/scripts/install-hermes-skills.mjs +194 -0
- package/scripts/install-opencode.sh +556 -0
- package/scripts/install-pi.sh +303 -0
- package/scripts/lib/manifest-core.mjs +106 -0
- package/scripts/validate-gemini-extension.mjs +194 -0
- package/dist/brain.bundle.min.mjs +0 -331
- package/dist/dev.bundle.min.mjs +0 -1372
- package/dist/memory.bundle.min.mjs +0 -7030
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const REPO_URL = "https://github.com/reddb-io/red-skills";
|
|
9
|
+
const AUTHOR = {
|
|
10
|
+
name: "reddb.io",
|
|
11
|
+
url: "https://github.com/reddb-io",
|
|
12
|
+
};
|
|
13
|
+
const CAPABILITIES = ["Interactive", "Read", "Write", "Shell"];
|
|
14
|
+
const PREFERRED_SKILL_ROOT_ORDER = ["engineering", "knowledge", "productivity", "misc", "core"];
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const args = {
|
|
18
|
+
root: process.cwd(),
|
|
19
|
+
check: false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
23
|
+
const arg = argv[index];
|
|
24
|
+
if (arg === "--check") {
|
|
25
|
+
args.check = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (arg === "--root") {
|
|
29
|
+
const next = argv[index + 1];
|
|
30
|
+
if (!next) throw new Error("--root requires a path");
|
|
31
|
+
args.root = next;
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function geminiText(input) {
|
|
42
|
+
return String(input ?? "")
|
|
43
|
+
.replace(/[`\u2018\u2019]/g, "")
|
|
44
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
45
|
+
.replace(/[\u2013\u2014]/g, "-")
|
|
46
|
+
.replace(/\u2026/g, "...")
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function titleCaseName(name) {
|
|
52
|
+
return String(name)
|
|
53
|
+
.split(/[-_\s]+/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
56
|
+
.join(" ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function marketplaceDisplayName(name) {
|
|
60
|
+
if (name === "red-skills") return "RedSkills";
|
|
61
|
+
return titleCaseName(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function brandColorForPlugin(name) {
|
|
65
|
+
if (name === "brain") return "#2563EB";
|
|
66
|
+
return "#D92D20";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pluginKeywords(name) {
|
|
70
|
+
const keywords = ["gemini-cli", "skills", "agents"];
|
|
71
|
+
if (name === "dev") return [...keywords, "github-issues", "tdd"];
|
|
72
|
+
return [...keywords, name];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeSkillEntry(entry) {
|
|
76
|
+
return String(entry).replace(/\/+$/, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function deriveGeminiSkillRoots(skills) {
|
|
80
|
+
if (typeof skills === "string") {
|
|
81
|
+
return skills.endsWith("/") ? skills : `${skills}/`;
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(skills)) return undefined;
|
|
84
|
+
|
|
85
|
+
const roots = new Set();
|
|
86
|
+
for (const skill of skills) {
|
|
87
|
+
const normalized = normalizeSkillEntry(skill);
|
|
88
|
+
const match = normalized.match(/^\.\/skills\/([^/]+)/);
|
|
89
|
+
if (match) roots.add(match[1]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const orderedRoots = [...roots].sort((left, right) => {
|
|
93
|
+
const leftIndex = PREFERRED_SKILL_ROOT_ORDER.indexOf(left);
|
|
94
|
+
const rightIndex = PREFERRED_SKILL_ROOT_ORDER.indexOf(right);
|
|
95
|
+
if (leftIndex !== -1 || rightIndex !== -1) {
|
|
96
|
+
return (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex);
|
|
97
|
+
}
|
|
98
|
+
return left.localeCompare(right);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return orderedRoots.map((root) => `./skills/${root}/`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultPromptForPlugin(name, skills) {
|
|
105
|
+
const skillEntries = Array.isArray(skills) ? skills : [];
|
|
106
|
+
const skillNames = skillEntries
|
|
107
|
+
.map((entry) => normalizeSkillEntry(entry).split("/").at(-1))
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.slice(0, 4);
|
|
110
|
+
|
|
111
|
+
if (skillNames.length === 0) return [`Use RedSkills ${titleCaseName(name)} when this workspace needs it.`];
|
|
112
|
+
return skillNames.map((skill) => `Use $${skill} when this workspace needs it.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function maybeSet(target, key, value) {
|
|
116
|
+
if (value !== undefined) target[key] = value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildGeminiMarketplace(claudeMarketplace) {
|
|
120
|
+
return {
|
|
121
|
+
name: claudeMarketplace.name,
|
|
122
|
+
interface: {
|
|
123
|
+
displayName: marketplaceDisplayName(claudeMarketplace.name),
|
|
124
|
+
},
|
|
125
|
+
plugins: claudeMarketplace.plugins.map((plugin) => {
|
|
126
|
+
const geminiPlugin = {
|
|
127
|
+
name: plugin.name,
|
|
128
|
+
source: {
|
|
129
|
+
source: "local",
|
|
130
|
+
path: plugin.source,
|
|
131
|
+
},
|
|
132
|
+
policy: {
|
|
133
|
+
installation: plugin.name === "dev" ? "INSTALLED_BY_DEFAULT" : "AVAILABLE",
|
|
134
|
+
authentication: "ON_USE",
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
maybeSet(geminiPlugin, "dependencies", plugin.dependencies);
|
|
138
|
+
maybeSet(geminiPlugin, "description", geminiText(plugin.description));
|
|
139
|
+
geminiPlugin.category = "Developer Tools";
|
|
140
|
+
return geminiPlugin;
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildGeminiPluginManifest(claudePlugin) {
|
|
146
|
+
const description = geminiText(claudePlugin.description);
|
|
147
|
+
const manifest = {
|
|
148
|
+
name: claudePlugin.name,
|
|
149
|
+
version: claudePlugin.version,
|
|
150
|
+
description,
|
|
151
|
+
author: AUTHOR,
|
|
152
|
+
homepage: REPO_URL,
|
|
153
|
+
repository: REPO_URL,
|
|
154
|
+
license: "Apache-2.0",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
maybeSet(manifest, "dependencies", claudePlugin.dependencies);
|
|
158
|
+
manifest.keywords = pluginKeywords(claudePlugin.name);
|
|
159
|
+
maybeSet(manifest, "skills", deriveGeminiSkillRoots(claudePlugin.skills));
|
|
160
|
+
// Gemini CLI hooks are not Claude hook manifests with a renamed file. The
|
|
161
|
+
// native dev extension builder emits the supported hooks/hooks.json subset;
|
|
162
|
+
// this compatibility metadata must not advertise a fabricated sidecar.
|
|
163
|
+
maybeSet(manifest, "mcpServers", claudePlugin.mcpServers);
|
|
164
|
+
manifest.interface = {
|
|
165
|
+
displayName: `RedSkills ${titleCaseName(claudePlugin.name)}`,
|
|
166
|
+
shortDescription: description,
|
|
167
|
+
longDescription: description,
|
|
168
|
+
developerName: AUTHOR.name,
|
|
169
|
+
category: "Developer Tools",
|
|
170
|
+
capabilities: CAPABILITIES,
|
|
171
|
+
websiteURL: REPO_URL,
|
|
172
|
+
defaultPrompt: defaultPromptForPlugin(claudePlugin.name, claudePlugin.skills),
|
|
173
|
+
brandColor: brandColorForPlugin(claudePlugin.name),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return manifest;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function jsonBytes(value) {
|
|
180
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readJson(path) {
|
|
184
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writeGenerated(path, bytes, check, mismatches) {
|
|
188
|
+
if (!check) {
|
|
189
|
+
await mkdir(dirname(path), { recursive: true });
|
|
190
|
+
await writeFile(path, bytes);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let current = "";
|
|
195
|
+
try {
|
|
196
|
+
current = await readFile(path, "utf8");
|
|
197
|
+
} catch {
|
|
198
|
+
current = "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (current !== bytes) {
|
|
202
|
+
mismatches.push({ path, bytes });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function printDiffs(root, mismatches) {
|
|
207
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "red-skills-gemini-manifest-diff-"));
|
|
208
|
+
try {
|
|
209
|
+
for (const mismatch of mismatches) {
|
|
210
|
+
const rel = relative(root, mismatch.path);
|
|
211
|
+
const expected = join(tempRoot, rel);
|
|
212
|
+
await mkdir(dirname(expected), { recursive: true });
|
|
213
|
+
await writeFile(expected, mismatch.bytes);
|
|
214
|
+
const diff = spawnSync("git", ["diff", "--no-index", "--", mismatch.path, expected], {
|
|
215
|
+
encoding: "utf8",
|
|
216
|
+
});
|
|
217
|
+
const output = `${diff.stdout}${diff.stderr}`.trim();
|
|
218
|
+
if (output) console.error(output);
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function generateGeminiManifests({ root, check = false }) {
|
|
226
|
+
const mismatches = [];
|
|
227
|
+
const claudeMarketplacePath = join(root, ".claude-plugin/marketplace.json");
|
|
228
|
+
const geminiMarketplacePath = join(root, ".gemini-plugin/marketplace.json");
|
|
229
|
+
const claudeMarketplace = await readJson(claudeMarketplacePath);
|
|
230
|
+
await writeGenerated(geminiMarketplacePath, jsonBytes(buildGeminiMarketplace(claudeMarketplace)), check, mismatches);
|
|
231
|
+
|
|
232
|
+
for (const plugin of claudeMarketplace.plugins) {
|
|
233
|
+
const pluginRoot = join(root, plugin.source);
|
|
234
|
+
const claudePlugin = await readJson(join(pluginRoot, ".claude-plugin/plugin.json"));
|
|
235
|
+
await writeGenerated(
|
|
236
|
+
join(pluginRoot, ".gemini-plugin/plugin.json"),
|
|
237
|
+
jsonBytes(buildGeminiPluginManifest(claudePlugin)),
|
|
238
|
+
check,
|
|
239
|
+
mismatches,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (check && mismatches.length > 0) {
|
|
244
|
+
await printDiffs(root, mismatches);
|
|
245
|
+
throw new Error(`Gemini manifests are stale; run node scripts/generate-gemini-manifests.mjs (${mismatches.length} file(s))`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function main() {
|
|
250
|
+
const args = parseArgs(process.argv.slice(2));
|
|
251
|
+
await generateGeminiManifests(args);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
255
|
+
main().catch((error) => {
|
|
256
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates Pi-package manifests for every RedSkills plugin from the canonical
|
|
3
|
+
// Claude-side plugin tree, mirroring scripts/generate-codex-manifests.mjs.
|
|
4
|
+
//
|
|
5
|
+
// Pi packages are installed via `pi install <local-path>` and need a
|
|
6
|
+
// package.json with a `pi-package` keyword plus a `pi.skills` array pointing at
|
|
7
|
+
// the same skill buckets the Claude/Codex manifests expose. This script keeps
|
|
8
|
+
// the per-plugin package.json files under `plugins/<name>/package.json` in
|
|
9
|
+
// sync with the source-of-truth Claude manifests; run `pnpm pi:manifests` to
|
|
10
|
+
// regenerate, `pnpm pi:manifests:check` to fail on drift.
|
|
11
|
+
//
|
|
12
|
+
// The generated package.json intentionally lives alongside the existing
|
|
13
|
+
// .claude-plugin/plugin.json and .codex-plugin/plugin.json so a single source
|
|
14
|
+
// tree continues to serve every host without forking the plugin definitions.
|
|
15
|
+
// Skills exposed here are the same bucket paths the Codex manifest lists, so
|
|
16
|
+
// a `pi install ./plugins/dev` install gives the agent every published dev
|
|
17
|
+
// skill (engineering/knowledge/productivity/misc) without the in-progress
|
|
18
|
+
// drafts.
|
|
19
|
+
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import {
|
|
23
|
+
jsonBytes,
|
|
24
|
+
normalizeSkillEntry,
|
|
25
|
+
normalizeText,
|
|
26
|
+
parseArgs,
|
|
27
|
+
printDiffs,
|
|
28
|
+
readJson,
|
|
29
|
+
writeGenerated,
|
|
30
|
+
} from "./lib/manifest-core.mjs";
|
|
31
|
+
|
|
32
|
+
const REPO_URL = "https://github.com/reddb-io/red-skills";
|
|
33
|
+
const HOMEPAGE = "https://github.com/reddb-io/red-skills";
|
|
34
|
+
const LICENSE = "Apache-2.0";
|
|
35
|
+
const PREFERRED_SKILL_ROOT_ORDER = ["engineering", "knowledge", "productivity", "misc", "core"];
|
|
36
|
+
const SCOPED_NAMESPACE = "@reddb-io";
|
|
37
|
+
|
|
38
|
+
function deriveSkillRoots(skills) {
|
|
39
|
+
if (typeof skills === "string") {
|
|
40
|
+
return skills.startsWith("./skills/") || skills === "./skills/" ? skills : `./skills/${skills.replace(/^\.\//, "")}/`;
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(skills)) return [];
|
|
43
|
+
|
|
44
|
+
const roots = new Set();
|
|
45
|
+
for (const skill of skills) {
|
|
46
|
+
const normalized = normalizeSkillEntry(skill);
|
|
47
|
+
const match = normalized.match(/^\.\/skills\/([^/]+)/);
|
|
48
|
+
if (match) roots.add(match[1]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const orderedRoots = [...roots].sort((left, right) => {
|
|
52
|
+
const leftIndex = PREFERRED_SKILL_ROOT_ORDER.indexOf(left);
|
|
53
|
+
const rightIndex = PREFERRED_SKILL_ROOT_ORDER.indexOf(right);
|
|
54
|
+
if (leftIndex !== -1 || rightIndex !== -1) {
|
|
55
|
+
return (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex) -
|
|
56
|
+
(rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex);
|
|
57
|
+
}
|
|
58
|
+
return left.localeCompare(right);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The Pi package manifest sits at plugins/<name>/package.json, so paths are
|
|
62
|
+
// rooted there and must include the shared `./skills/` prefix the Claude and
|
|
63
|
+
// Codex manifests also use. Pi auto-discovers SKILL.md files recursively
|
|
64
|
+
// beneath each listed directory.
|
|
65
|
+
return orderedRoots.map((root) => `./skills/${root}/`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildPiPackage(claudePlugin) {
|
|
69
|
+
const description = normalizeText(claudePlugin.description);
|
|
70
|
+
const packageName = `${SCOPED_NAMESPACE}/red-skills-${claudePlugin.name}`;
|
|
71
|
+
const skillRoots = deriveSkillRoots(claudePlugin.skills);
|
|
72
|
+
if (skillRoots.length === 0) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${claudePlugin.name}: cannot derive skill buckets from Claude plugin manifest`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const packageJson = {
|
|
79
|
+
name: packageName,
|
|
80
|
+
version: claudePlugin.version,
|
|
81
|
+
private: true,
|
|
82
|
+
description,
|
|
83
|
+
license: LICENSE,
|
|
84
|
+
homepage: HOMEPAGE,
|
|
85
|
+
repository: {
|
|
86
|
+
type: "git",
|
|
87
|
+
url: `${REPO_URL}.git`,
|
|
88
|
+
},
|
|
89
|
+
keywords: ["pi-package", "reddb-io", "red-skills", ...(claudePlugin.dependencies ?? []).map((dep) => `dep:${dep}`)],
|
|
90
|
+
pi: {
|
|
91
|
+
skills: skillRoots,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return packageJson;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function generatePiManifests({ root, check = false }) {
|
|
99
|
+
const mismatches = [];
|
|
100
|
+
const claudeMarketplacePath = join(root, ".claude-plugin/marketplace.json");
|
|
101
|
+
const claudeMarketplace = await readJson(claudeMarketplacePath);
|
|
102
|
+
|
|
103
|
+
for (const plugin of claudeMarketplace.plugins) {
|
|
104
|
+
const pluginRoot = join(root, plugin.source);
|
|
105
|
+
const claudePlugin = await readJson(join(pluginRoot, ".claude-plugin/plugin.json"));
|
|
106
|
+
const packageJson = buildPiPackage(claudePlugin);
|
|
107
|
+
await writeGenerated(
|
|
108
|
+
join(pluginRoot, "package.json"),
|
|
109
|
+
jsonBytes(packageJson),
|
|
110
|
+
check,
|
|
111
|
+
mismatches,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (check && mismatches.length > 0) {
|
|
116
|
+
await printDiffs(root, mismatches, { tempLabel: "red-skills-pi-manifest-diff-" });
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Pi manifests are stale; run node scripts/generate-pi-manifests.mjs (${mismatches.length} file(s))`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main() {
|
|
124
|
+
const args = parseArgs(process.argv.slice(2));
|
|
125
|
+
await generatePiManifests(args);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
129
|
+
main().catch((error) => {
|
|
130
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const NAMESPACE = "redskills-dev";
|
|
8
|
+
const OWNED_STATE = "redskills-dev-owned.txt";
|
|
9
|
+
const OWNED_HEADER = "redskills-hermes-owned-v1";
|
|
10
|
+
const OWNED_ROOT = `skills/${NAMESPACE}`;
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const options = { action: "install", home: "", source: "" };
|
|
14
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
+
const argument = argv[index];
|
|
16
|
+
if (argument === "--install" || argument === "--verify" || argument === "--uninstall") {
|
|
17
|
+
options.action = argument.slice(2);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (argument === "--home" || argument === "--source") {
|
|
21
|
+
const value = argv[index + 1];
|
|
22
|
+
if (!value) throw new Error(`${argument} requires a path`);
|
|
23
|
+
options[argument.slice(2)] = value;
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`unknown argument: ${argument}`);
|
|
28
|
+
}
|
|
29
|
+
if (!options.home) throw new Error("--home requires a path");
|
|
30
|
+
if (options.action !== "uninstall" && !options.source) throw new Error("--source requires a path");
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inside(root, path) {
|
|
35
|
+
const rel = relative(root, path);
|
|
36
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(rel);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readDevSkills(sourceRoot) {
|
|
40
|
+
const pluginRoot = join(sourceRoot, "plugins", "dev");
|
|
41
|
+
const manifestPath = join(pluginRoot, ".claude-plugin", "plugin.json");
|
|
42
|
+
let manifest;
|
|
43
|
+
try {
|
|
44
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
if (manifest.name !== "dev") throw new Error(`${manifestPath}: name must be dev`);
|
|
49
|
+
if (!Array.isArray(manifest.skills) || manifest.skills.length === 0) {
|
|
50
|
+
throw new Error(`${manifestPath}: skills must be a non-empty array`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const names = new Set();
|
|
54
|
+
const skills = [];
|
|
55
|
+
for (const declared of manifest.skills) {
|
|
56
|
+
if (typeof declared !== "string" || !declared.startsWith("./skills/")) {
|
|
57
|
+
throw new Error(`${manifestPath}: invalid skill path ${JSON.stringify(declared)}`);
|
|
58
|
+
}
|
|
59
|
+
const source = resolve(pluginRoot, declared);
|
|
60
|
+
if (!inside(pluginRoot, source)) throw new Error(`${manifestPath}: skill escapes plugin root: ${declared}`);
|
|
61
|
+
const name = basename(source);
|
|
62
|
+
if (names.has(name)) throw new Error(`${manifestPath}: duplicate flattened Hermes skill name: ${name}`);
|
|
63
|
+
names.add(name);
|
|
64
|
+
const skillFile = await lstat(join(source, "SKILL.md")).catch(() => undefined);
|
|
65
|
+
if (!skillFile?.isFile()) throw new Error(`${manifestPath}: declared skill is missing SKILL.md: ${declared}`);
|
|
66
|
+
skills.push({ name, source });
|
|
67
|
+
}
|
|
68
|
+
return skills.sort((left, right) => left.name.localeCompare(right.name, "en"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function describeTree(root, prefix = "") {
|
|
72
|
+
const result = [];
|
|
73
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
74
|
+
const path = join(root, entry.name);
|
|
75
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
76
|
+
const metadata = await lstat(path);
|
|
77
|
+
if (metadata.isDirectory()) {
|
|
78
|
+
result.push([`${rel}/`, "directory"]);
|
|
79
|
+
result.push(...await describeTree(path, rel));
|
|
80
|
+
} else if (metadata.isSymbolicLink()) {
|
|
81
|
+
result.push([rel, `link:${await readlink(path)}`]);
|
|
82
|
+
} else if (metadata.isFile()) {
|
|
83
|
+
result.push([rel, `file:${metadata.mode & 0o777}:${await readFile(path, "base64")}`]);
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error(`unsupported skill entry type: ${path}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result.sort(([left], [right]) => left.localeCompare(right, "en"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function assertSkillCopy(source, target, name) {
|
|
92
|
+
const expected = await describeTree(source);
|
|
93
|
+
const actual = await describeTree(target).catch(() => undefined);
|
|
94
|
+
if (!actual || JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
95
|
+
throw new Error(`Hermes skill verification failed: ${name}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function capabilities(skillCount) {
|
|
100
|
+
return `# RedSkills dev capabilities for Hermes
|
|
101
|
+
|
|
102
|
+
Capability | Status | Detail
|
|
103
|
+
--- | --- | ---
|
|
104
|
+
Skills | healthy | ${skillCount} complete skill directories are available from the user-global Hermes skills tree.
|
|
105
|
+
Hooks | unsupported | Hermes has no RedSkills hook projection; no hook success is claimed.
|
|
106
|
+
MCP | unsupported | Hermes has no RedSkills MCP projection; no MCP success is claimed.
|
|
107
|
+
Agents | unsupported | Hermes has no RedSkills agent projection; no agent success is claimed.
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function reportReadiness() {
|
|
112
|
+
process.stdout.write("Hermes readiness: skills=healthy\n");
|
|
113
|
+
process.stdout.write("Hermes limitation: hooks=unsupported\n");
|
|
114
|
+
process.stdout.write("Hermes limitation: mcp=unsupported\n");
|
|
115
|
+
process.stdout.write("Hermes limitation: agents=unsupported\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function install(source, home) {
|
|
119
|
+
const sourceRoot = await realpath(resolve(source));
|
|
120
|
+
const skills = await readDevSkills(sourceRoot);
|
|
121
|
+
const target = join(home, OWNED_ROOT);
|
|
122
|
+
const staging = join(home, `.${NAMESPACE}.tmp-${process.pid}`);
|
|
123
|
+
await mkdir(home, { recursive: true });
|
|
124
|
+
await rm(staging, { recursive: true, force: true });
|
|
125
|
+
await mkdir(staging, { recursive: true });
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
for (const skill of skills) {
|
|
129
|
+
const destination = join(staging, skill.name);
|
|
130
|
+
await cp(skill.source, destination, { recursive: true, force: true, verbatimSymlinks: true });
|
|
131
|
+
await assertSkillCopy(skill.source, destination, skill.name);
|
|
132
|
+
}
|
|
133
|
+
await writeFile(join(staging, "CAPABILITIES.md"), capabilities(skills.length));
|
|
134
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
135
|
+
await rm(target, { recursive: true, force: true });
|
|
136
|
+
await rename(staging, target);
|
|
137
|
+
await writeFile(join(home, OWNED_STATE), `${OWNED_HEADER}\n${OWNED_ROOT}\n`);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
await rm(staging, { recursive: true, force: true });
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await verify(sourceRoot, home);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function verify(source, home) {
|
|
147
|
+
const skills = await readDevSkills(source);
|
|
148
|
+
const target = join(home, OWNED_ROOT);
|
|
149
|
+
const owned = await readFile(join(home, OWNED_STATE), "utf8");
|
|
150
|
+
if (owned !== `${OWNED_HEADER}\n${OWNED_ROOT}\n`) throw new Error("Hermes owned-state record is invalid");
|
|
151
|
+
for (const skill of skills) await assertSkillCopy(skill.source, join(target, skill.name), skill.name);
|
|
152
|
+
const capabilityReport = await readFile(join(target, "CAPABILITIES.md"), "utf8");
|
|
153
|
+
if (capabilityReport !== capabilities(skills.length)) throw new Error("Hermes capability report has drifted");
|
|
154
|
+
reportReadiness();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function uninstall(home) {
|
|
158
|
+
const statePath = join(home, OWNED_STATE);
|
|
159
|
+
let state;
|
|
160
|
+
try {
|
|
161
|
+
state = await readFile(statePath, "utf8");
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const lines = state.trimEnd().split("\n");
|
|
167
|
+
if (lines[0] !== OWNED_HEADER) throw new Error("refusing Hermes uninstall: owned-state header is invalid");
|
|
168
|
+
for (const owned of lines.slice(1)) {
|
|
169
|
+
if (owned !== OWNED_ROOT) throw new Error(`refusing Hermes uninstall: unexpected owned path ${JSON.stringify(owned)}`);
|
|
170
|
+
const target = resolve(home, owned);
|
|
171
|
+
if (!inside(resolve(home), target)) throw new Error("refusing Hermes uninstall: owned path escapes Hermes home");
|
|
172
|
+
await rm(target, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
await rm(statePath, { force: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function runHermesSkillsInstaller(options) {
|
|
178
|
+
const home = resolve(options.home);
|
|
179
|
+
if (options.action === "uninstall") return uninstall(home);
|
|
180
|
+
const source = await realpath(resolve(options.source));
|
|
181
|
+
if (options.action === "verify") return verify(source, home);
|
|
182
|
+
return install(source, home);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function main() {
|
|
186
|
+
await runHermesSkillsInstaller(parseArgs(process.argv.slice(2)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
190
|
+
main().catch((error) => {
|
|
191
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
});
|
|
194
|
+
}
|