@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.
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/install-pi.sh — install the RedSkills Pi packages into Pi's user or
3
+ # project settings.
4
+ #
5
+ # Two install surfaces, picked automatically:
6
+ #
7
+ # 1. npm-distributed (default; ADR 0110): the public user path.
8
+ # `pi install npm:@reddb-io/red-skills-<plugin>` once per published plugin
9
+ # (dev/memory/brain/internal). Auto-updates via `pi update --all`.
10
+ # No source checkout required.
11
+ #
12
+ # 2. local-path (--source-dir <path>; ADR 0110 dev path): for in-repo
13
+ # development and offline use. Calls `pi install <path>` per plugin.
14
+ # Auto-updates only via `git pull` inside <path>.
15
+ #
16
+ # The two surfaces share the same manifest at
17
+ # ~/.pi/agent/redskills-install-manifest.json (or
18
+ # <target>/.pi/redskills-install-manifest.json for --project), so a single
19
+ # `--uninstall` cleanly tears down whichever surface was used.
20
+ #
21
+ # Usage:
22
+ # scripts/install-pi.sh [--user] [--project TARGET_DIR] [--source-dir PATH]
23
+ # scripts/install-pi.sh --uninstall [--user] [--project TARGET_DIR]
24
+ # scripts/install-pi.sh --dry-run
25
+ #
26
+ # Environment:
27
+ # RED_SKILLS_PI_VERSION — pin the npm-installed version (default: latest).
28
+ # Ignored when --source-dir is set.
29
+ #
30
+ # Exit codes: 0 success; 1 `pi` not installed or `pi install` failed;
31
+ # 2 usage error.
32
+
33
+ set -euo pipefail
34
+
35
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
36
+ ACTION="install"
37
+ SCOPE="user"
38
+ TARGET_DIR=""
39
+ DRY_RUN="false"
40
+ SOURCE_DIR=""
41
+ NPM_SCOPE="@reddb-io"
42
+ INSTALLED_PLUGINS=(dev memory brain internal)
43
+ PIN_VERSION="${RED_SKILLS_PI_VERSION:-}"
44
+
45
+ usage() {
46
+ cat <<'EOF'
47
+ Usage: scripts/install-pi.sh [--user | --project TARGET_DIR] [--source-dir PATH]
48
+ [--uninstall] [--dry-run]
49
+
50
+ Options:
51
+ --user install into ~/.pi/agent/settings.json (default)
52
+ --project TARGET_DIR install into <TARGET_DIR>/.pi/settings.json
53
+ --source-dir PATH use the local checkout at PATH instead of npm
54
+ (dev/offline path; ADR 0110)
55
+ --uninstall remove packages this script previously installed
56
+ --dry-run print actions without invoking `pi` or writing files
57
+ -h, --help show this help
58
+
59
+ Examples:
60
+ scripts/install-pi.sh # user-scoped, latest npm
61
+ scripts/install-pi.sh --project /path/to/repo # project-scoped, npm
62
+ scripts/install-pi.sh --source-dir /path/to/checkout # dev/offline install
63
+ scripts/install-pi.sh --uninstall # user-scoped uninstall
64
+ scripts/install-pi.sh --project . --dry-run # inspect project install
65
+ EOF
66
+ }
67
+
68
+ while [ $# -gt 0 ]; do
69
+ case "$1" in
70
+ --user)
71
+ SCOPE="user"
72
+ shift
73
+ ;;
74
+ --project)
75
+ [ $# -ge 2 ] || { echo "error: --project requires a path" >&2; usage; exit 2; }
76
+ SCOPE="project"
77
+ TARGET_DIR="$2"
78
+ shift 2
79
+ ;;
80
+ --source-dir)
81
+ [ $# -ge 2 ] || { echo "error: --source-dir requires a path" >&2; usage; exit 2; }
82
+ SOURCE_DIR="$2"
83
+ shift 2
84
+ ;;
85
+ --uninstall)
86
+ ACTION="uninstall"
87
+ shift
88
+ ;;
89
+ --dry-run)
90
+ DRY_RUN="true"
91
+ shift
92
+ ;;
93
+ -h|--help)
94
+ usage
95
+ exit 0
96
+ ;;
97
+ *)
98
+ echo "error: unknown argument: $1" >&2
99
+ usage
100
+ exit 2
101
+ ;;
102
+ esac
103
+ done
104
+
105
+ if [ "$SCOPE" = "project" ] && [ -z "$TARGET_DIR" ]; then
106
+ echo "error: --project requires a path" >&2
107
+ usage
108
+ exit 2
109
+ fi
110
+
111
+ if [ "$SCOPE" = "project" ]; then
112
+ TARGET_DIR="$(cd "$TARGET_DIR" && pwd)"
113
+ fi
114
+
115
+ if [ -n "$SOURCE_DIR" ]; then
116
+ if [ ! -d "$SOURCE_DIR" ]; then
117
+ echo "error: --source-dir path does not exist: $SOURCE_DIR" >&2
118
+ exit 2
119
+ fi
120
+ SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
121
+ fi
122
+
123
+ SETTINGS_FILE="$([ "$SCOPE" = "user" ] && printf '%s' "$HOME/.pi/agent/settings.json" || printf '%s' "$TARGET_DIR/.pi/settings.json")"
124
+ MANIFEST_FILE="$([ "$SCOPE" = "user" ] && printf '%s' "$HOME/.pi/agent/redskills-install-manifest.json" || printf '%s' "$TARGET_DIR/.pi/redskills-install-manifest.json")"
125
+
126
+ log() { printf 'install-pi: %s\n' "$*"; }
127
+ die() { printf 'install-pi: %s\n' "$*" >&2; exit 1; }
128
+ warn() { printf 'install-pi: warn: %s\n' "$*" >&2; }
129
+
130
+ require_cmd() {
131
+ command -v "$1" >/dev/null 2>&1 || die "$1 is required"
132
+ }
133
+
134
+ if [ "$DRY_RUN" != "true" ]; then
135
+ require_cmd pi
136
+ require_cmd jq
137
+ fi
138
+
139
+ # Decide the install source for one plugin: emit the pi install argument and
140
+ # the manifest entry to record. npm:<spec> for the npm surface, a filesystem
141
+ # path for the local-source surface.
142
+ package_spec() {
143
+ local plugin_name="$1"
144
+ if [ -n "$SOURCE_DIR" ]; then
145
+ printf '%s/plugins/%s' "$SOURCE_DIR" "$plugin_name"
146
+ return 0
147
+ fi
148
+ local pin=""
149
+ if [ -n "$PIN_VERSION" ]; then
150
+ pin="@$PIN_VERSION"
151
+ fi
152
+ printf 'npm:%s/red-skills-%s%s' "$NPM_SCOPE" "$plugin_name" "$pin"
153
+ }
154
+
155
+ assert_settings_file_present() {
156
+ [ -f "$SETTINGS_FILE" ] \
157
+ || die "Pi settings file is missing: $SETTINGS_FILE. Run \`pi\` once interactively to create it."
158
+ }
159
+
160
+ read_manifest() {
161
+ [ -f "$MANIFEST_FILE" ] || return 0
162
+ jq -r '.plugins[]? | "\(.name)\t\(.spec)"' "$MANIFEST_FILE"
163
+ }
164
+
165
+ write_manifest() {
166
+ local plugin_name="$1"
167
+ local spec="$2"
168
+ local source_kind="$3" # "npm" | "local"
169
+ local tmp
170
+ tmp="$(mktemp)"
171
+ if [ -f "$MANIFEST_FILE" ]; then
172
+ jq --arg name "$plugin_name" --arg spec "$spec" --arg kind "$source_kind" \
173
+ '.plugins |= map(select(.name != $name)) + [{name: $name, spec: $spec, source: $kind}]' \
174
+ "$MANIFEST_FILE" > "$tmp"
175
+ else
176
+ mkdir -p "$(dirname "$MANIFEST_FILE")"
177
+ jq -n --arg name "$plugin_name" --arg spec "$spec" --arg kind "$source_kind" \
178
+ '{version: 2, scope: "'"$SCOPE"'", settings_file: "'"$SETTINGS_FILE"'", plugins: [{name: $name, spec: $spec, source: $kind}]}' \
179
+ > "$tmp"
180
+ fi
181
+ mv "$tmp" "$MANIFEST_FILE"
182
+ }
183
+
184
+ remove_from_manifest() {
185
+ local plugin_name="$1"
186
+ [ -f "$MANIFEST_FILE" ] || return 0
187
+ local tmp
188
+ tmp="$(mktemp)"
189
+ jq --arg name "$plugin_name" '.plugins |= map(select(.name != $name))' \
190
+ "$MANIFEST_FILE" > "$tmp"
191
+ mv "$tmp" "$MANIFEST_FILE"
192
+ }
193
+
194
+ invoke_pi_install() {
195
+ local plugin_name="$1"
196
+ local spec="$2"
197
+ local source_kind="$3"
198
+ if [ "$DRY_RUN" = "true" ]; then
199
+ log "(dry-run) would run: pi install $spec"
200
+ log "(dry-run) manifest entry: $plugin_name -> $spec ($SETTINGS_FILE)"
201
+ return 0
202
+ fi
203
+ log "installing $plugin_name via \`pi install $spec\`"
204
+ if [ "$SCOPE" = "project" ]; then
205
+ ( cd "$TARGET_DIR" && pi install -l "$spec" )
206
+ else
207
+ pi install "$spec"
208
+ fi
209
+ write_manifest "$plugin_name" "$spec" "$source_kind"
210
+ }
211
+
212
+ invoke_pi_remove() {
213
+ local plugin_name="$1"
214
+ local spec="$2"
215
+ if [ "$DRY_RUN" = "true" ]; then
216
+ log "(dry-run) would run: pi remove $spec"
217
+ log "(dry-run) manifest entry: $plugin_name -> $spec"
218
+ return 0
219
+ fi
220
+ log "removing $plugin_name via \`pi remove $spec\`"
221
+ pi remove "$spec" || warn "pi remove reported an error for $plugin_name (continuing)"
222
+ remove_from_manifest "$plugin_name"
223
+ }
224
+
225
+ regenerate_manifests() {
226
+ # The local-source surface needs the plugins/<name>/package.json manifests
227
+ # to exist (they are the package the path-based install resolves through).
228
+ # The npm surface does not — npm carries the staged trees directly. We
229
+ # only regenerate when local-source is in use; CI/npm releases run the
230
+ # generator via `pnpm pi:manifests` separately.
231
+ if [ -z "$SOURCE_DIR" ]; then
232
+ return 0
233
+ fi
234
+ if [ "$DRY_RUN" = "true" ]; then
235
+ log "(dry-run) would run scripts/generate-pi-manifests.mjs"
236
+ return 0
237
+ fi
238
+ if [ ! -f "$SOURCE_DIR/scripts/generate-pi-manifests.mjs" ]; then
239
+ die "scripts/generate-pi-manifests.mjs is missing from $SOURCE_DIR"
240
+ fi
241
+ node "$SOURCE_DIR/scripts/generate-pi-manifests.mjs" --root "$SOURCE_DIR" \
242
+ || die "pi manifest generation failed"
243
+ }
244
+
245
+ run_install() {
246
+ if [ "$DRY_RUN" != "true" ]; then
247
+ assert_settings_file_present
248
+ fi
249
+ regenerate_manifests
250
+ local source_kind="npm"
251
+ if [ -n "$SOURCE_DIR" ]; then
252
+ source_kind="local"
253
+ fi
254
+ for plugin in "${INSTALLED_PLUGINS[@]}"; do
255
+ if [ "$source_kind" = "local" ] && [ ! -d "$SOURCE_DIR/plugins/$plugin" ]; then
256
+ warn "skipping $plugin: source dir $SOURCE_DIR/plugins/$plugin not found"
257
+ continue
258
+ fi
259
+ spec="$(package_spec "$plugin")"
260
+ invoke_pi_install "$plugin" "$spec" "$source_kind"
261
+ done
262
+ if [ "$DRY_RUN" = "true" ]; then
263
+ log "(dry-run) would record manifest at $MANIFEST_FILE"
264
+ else
265
+ log "wrote install manifest $MANIFEST_FILE"
266
+ fi
267
+ log "restart any open pi sessions so the new skills reload"
268
+ }
269
+
270
+ run_uninstall() {
271
+ if [ "$DRY_RUN" != "true" ]; then
272
+ assert_settings_file_present
273
+ fi
274
+ if [ ! -f "$MANIFEST_FILE" ]; then
275
+ warn "no manifest at $MANIFEST_FILE; nothing to remove"
276
+ return 0
277
+ fi
278
+ local seen_plugin
279
+ seen_plugin=""
280
+ while IFS=$'\t' read -r plugin_name spec; do
281
+ [ -n "$plugin_name" ] || continue
282
+ invoke_pi_remove "$plugin_name" "$spec"
283
+ seen_plugin="$seen_plugin $plugin_name"
284
+ done < <(read_manifest)
285
+ if [ "$DRY_RUN" = "true" ]; then
286
+ log "(dry-run) would retain manifest at $MANIFEST_FILE"
287
+ else
288
+ # Remove the manifest file once empty so a fresh install starts clean.
289
+ local remaining
290
+ remaining="$(jq -r '.plugins | length' "$MANIFEST_FILE" 2>/dev/null || echo 0)"
291
+ if [ "$remaining" = "0" ]; then
292
+ rm -f "$MANIFEST_FILE"
293
+ log "removed empty manifest $MANIFEST_FILE"
294
+ fi
295
+ fi
296
+ log "restart any open pi sessions so the removed skills unload"
297
+ }
298
+
299
+ case "$ACTION" in
300
+ install) run_install ;;
301
+ uninstall) run_uninstall ;;
302
+ *) die "internal: unknown ACTION $ACTION" ;;
303
+ esac
@@ -0,0 +1,106 @@
1
+ // Shared helpers for generate-codex-manifests.mjs, generate-pi-manifests.mjs,
2
+ // and build-pi-packages.mjs. Single source of truth — import from here instead
3
+ // of duplicating.
4
+
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join, relative } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ export function parseArgs(argv) {
11
+ const args = { root: process.cwd(), check: false };
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ const arg = argv[index];
14
+ if (arg === "--check") {
15
+ args.check = true;
16
+ continue;
17
+ }
18
+ if (arg === "--root") {
19
+ const next = argv[index + 1];
20
+ if (!next) throw new Error("--root requires a path");
21
+ args.root = next;
22
+ index += 1;
23
+ continue;
24
+ }
25
+ throw new Error(`unknown argument: ${arg}`);
26
+ }
27
+ return args;
28
+ }
29
+
30
+ export function titleCaseName(name) {
31
+ return String(name)
32
+ .split(/[-_\s]+/)
33
+ .filter(Boolean)
34
+ .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
35
+ .join(" ");
36
+ }
37
+
38
+ export function normalizeSkillEntry(entry) {
39
+ return String(entry).replace(/\/+$/, "");
40
+ }
41
+
42
+ // Sanitize unicode smart quotes, dashes, and ellipses so generated JSON
43
+ // serialises cleanly in any terminal and across manifest formats.
44
+ export function normalizeText(input) {
45
+ return String(input ?? "")
46
+ .replace(/[`‘’]/g, "")
47
+ .replace(/[“”]/g, '"')
48
+ .replace(/[–—]/g, "-")
49
+ .replace(/…/g, "...")
50
+ .replace(/\s+/g, " ")
51
+ .trim();
52
+ }
53
+
54
+ export function jsonBytes(value) {
55
+ return `${JSON.stringify(value, null, 2)}\n`;
56
+ }
57
+
58
+ export async function readJson(path) {
59
+ return JSON.parse(await readFile(path, "utf8"));
60
+ }
61
+
62
+ export async function writeGenerated(path, bytes, check, mismatches) {
63
+ if (!check) {
64
+ await mkdir(dirname(path), { recursive: true });
65
+ await writeFile(path, bytes);
66
+ return;
67
+ }
68
+
69
+ let current = "";
70
+ try {
71
+ current = await readFile(path, "utf8");
72
+ } catch {
73
+ current = "";
74
+ }
75
+
76
+ if (current !== bytes) {
77
+ mismatches.push({ path, bytes });
78
+ }
79
+ }
80
+
81
+ // Print git diffs for mismatched generated files. `tempLabel` customises the
82
+ // temp-dir prefix so error output identifies which generator produced it.
83
+ // Mismatches with a `note` string array are printed as plain text (used by
84
+ // build-pi-packages for directory-tree diffs that have no single file to diff).
85
+ export async function printDiffs(root, mismatches, { tempLabel = "red-skills-diff-" } = {}) {
86
+ const tempRoot = await mkdtemp(join(tmpdir(), tempLabel));
87
+ try {
88
+ for (const mismatch of mismatches) {
89
+ if (mismatch.note) {
90
+ console.error(`# ${relative(root, mismatch.path)}\n${mismatch.note.join("\n")}`);
91
+ continue;
92
+ }
93
+ const rel = relative(root, mismatch.path);
94
+ const expected = join(tempRoot, rel);
95
+ await mkdir(dirname(expected), { recursive: true });
96
+ await writeFile(expected, mismatch.bytes);
97
+ const diff = spawnSync("git", ["diff", "--no-index", "--", mismatch.path, expected], {
98
+ encoding: "utf8",
99
+ });
100
+ const output = `${diff.stdout}${diff.stderr}`.trim();
101
+ if (output) console.error(output);
102
+ }
103
+ } finally {
104
+ await rm(tempRoot, { recursive: true, force: true });
105
+ }
106
+ }
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const NATIVE_FIELDS = new Set(["name", "version", "description", "mcpServers"]);
8
+ const HOOK_FIELDS = new Set(["hooks"]);
9
+
10
+ function parseArgs(argv) {
11
+ let extension = "";
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ if (argv[index] !== "--extension") throw new Error(`unknown argument: ${argv[index]}`);
14
+ extension = argv[index + 1] ?? "";
15
+ if (!extension) throw new Error("--extension requires a path");
16
+ index += 1;
17
+ }
18
+ if (!extension) throw new Error("--extension requires a path");
19
+ return resolve(extension);
20
+ }
21
+
22
+ async function readJson(path) {
23
+ try {
24
+ return JSON.parse(await readFile(path, "utf8"));
25
+ } catch (error) {
26
+ throw new Error(`${path}: ${error instanceof Error ? error.message : String(error)}`);
27
+ }
28
+ }
29
+
30
+ async function fileMetadata(path, label) {
31
+ try {
32
+ const metadata = await stat(path);
33
+ if (!metadata.isFile()) throw new Error(`${label} is not a file: ${path}`);
34
+ return metadata;
35
+ } catch (error) {
36
+ if (error instanceof Error && error.message.includes("is not a file")) throw error;
37
+ throw new Error(`${label} not found: ${path}`);
38
+ }
39
+ }
40
+
41
+ async function directoryEntries(path, label) {
42
+ try {
43
+ return await readdir(path, { withFileTypes: true });
44
+ } catch {
45
+ throw new Error(`${label} not found: ${path}`);
46
+ }
47
+ }
48
+
49
+ function assertObject(value, label) {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
51
+ throw new Error(`${label} must be an object`);
52
+ }
53
+ }
54
+
55
+ function assertOnlyKeys(object, allowed, label) {
56
+ const invalid = Object.keys(object).filter((key) => !allowed.has(key));
57
+ if (invalid.length > 0) throw new Error(`${label} contains unsupported field(s): ${invalid.join(", ")}`);
58
+ }
59
+
60
+ function resolveExtensionReference(extensionRoot, value, owner) {
61
+ const prefix = "${extensionPath}${/}";
62
+ if (typeof value !== "string" || !value.startsWith(prefix)) {
63
+ throw new Error(`${owner} must use a ${prefix} path`);
64
+ }
65
+ const tail = value.slice(prefix.length).replaceAll("${/}", "/");
66
+ if (!tail || tail.includes("${") || tail.includes("\0")) {
67
+ throw new Error(`${owner} has an invalid extension path: ${value}`);
68
+ }
69
+ const target = resolve(extensionRoot, tail);
70
+ const rel = relative(extensionRoot, target);
71
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
72
+ throw new Error(`${owner} escapes the extension root: ${value}`);
73
+ }
74
+ return { target, relative: rel };
75
+ }
76
+
77
+ async function validateLegacy(extensionRoot) {
78
+ const manifestPath = join(extensionRoot, ".gemini-plugin", "plugin.json");
79
+ const manifest = await readJson(manifestPath);
80
+ for (const field of ["hooks", "mcpServers"]) {
81
+ const value = manifest[field];
82
+ if (typeof value !== "string" || !value.startsWith("./")) continue;
83
+ const target = resolve(extensionRoot, value.slice(2));
84
+ try {
85
+ await fileMetadata(target, `legacy Gemini ${field} path ${value}`);
86
+ } catch {
87
+ throw new Error(`${value.slice(2)}: legacy Gemini ${field} path is dangling`);
88
+ }
89
+ }
90
+ throw new Error(`${manifestPath}: native gemini-extension.json not found`);
91
+ }
92
+
93
+ async function validateNativeManifest(extensionRoot) {
94
+ const path = join(extensionRoot, "gemini-extension.json");
95
+ let manifest;
96
+ try {
97
+ manifest = await readJson(path);
98
+ } catch (error) {
99
+ try {
100
+ await fileMetadata(join(extensionRoot, ".gemini-plugin", "plugin.json"), "legacy manifest");
101
+ return validateLegacy(extensionRoot);
102
+ } catch (legacyError) {
103
+ if (legacyError instanceof Error && legacyError.message.includes("legacy Gemini")) throw legacyError;
104
+ throw error;
105
+ }
106
+ }
107
+ assertObject(manifest, "gemini-extension.json");
108
+ assertOnlyKeys(manifest, NATIVE_FIELDS, "gemini-extension.json");
109
+ if (manifest.name !== "dev") throw new Error("gemini-extension.json name must be dev");
110
+ for (const field of ["version", "description"]) {
111
+ if (typeof manifest[field] !== "string" || !manifest[field]) {
112
+ throw new Error(`gemini-extension.json ${field} must be a non-empty string`);
113
+ }
114
+ }
115
+ assertObject(manifest.mcpServers, "gemini-extension.json mcpServers");
116
+ const serverNames = Object.keys(manifest.mcpServers).sort();
117
+ if (serverNames.join(",") !== "navigator,redskilled,rsp") {
118
+ throw new Error("gemini-extension.json must declare exactly navigator, redskilled, and rsp MCP servers");
119
+ }
120
+ for (const name of serverNames) {
121
+ const server = manifest.mcpServers[name];
122
+ assertObject(server, `MCP server ${name}`);
123
+ if (server.command !== "node") throw new Error(`MCP server ${name} command must be node`);
124
+ if (!Array.isArray(server.args) || server.args.length === 0) {
125
+ throw new Error(`MCP server ${name} args must be a non-empty array`);
126
+ }
127
+ if (server.cwd !== "${extensionPath}") {
128
+ throw new Error(`MCP server ${name} cwd must be \${extensionPath}`);
129
+ }
130
+ const reference = resolveExtensionReference(extensionRoot, server.args[0], `MCP server ${name} entrypoint`);
131
+ await fileMetadata(reference.target, `MCP server ${name} entrypoint ${reference.relative}`);
132
+ }
133
+ }
134
+
135
+ async function validateHooks(extensionRoot) {
136
+ const path = join(extensionRoot, "hooks", "hooks.json");
137
+ const manifest = await readJson(path);
138
+ assertObject(manifest, "hooks/hooks.json");
139
+ assertOnlyKeys(manifest, HOOK_FIELDS, "hooks/hooks.json");
140
+ assertObject(manifest.hooks, "hooks/hooks.json hooks");
141
+ if (Object.keys(manifest.hooks).join(",") !== "BeforeTool") {
142
+ throw new Error("hooks/hooks.json must contain only the Gemini BeforeTool event");
143
+ }
144
+ const groups = manifest.hooks.BeforeTool;
145
+ if (!Array.isArray(groups) || groups.length !== 1) {
146
+ throw new Error("hooks/hooks.json BeforeTool must contain one hook group");
147
+ }
148
+ const group = groups[0];
149
+ assertObject(group, "Gemini BeforeTool hook group");
150
+ if (group.matcher !== "run_shell_command") {
151
+ throw new Error("Gemini BeforeTool matcher must be run_shell_command");
152
+ }
153
+ if (!Array.isArray(group.hooks) || group.hooks.length !== 1) {
154
+ throw new Error("Gemini BeforeTool group must contain one command hook");
155
+ }
156
+ const hook = group.hooks[0];
157
+ assertObject(hook, "Gemini BeforeTool command hook");
158
+ if (hook.type !== "command") throw new Error("Gemini BeforeTool hook type must be command");
159
+ const reference = resolveExtensionReference(extensionRoot, hook.command, "Gemini BeforeTool command");
160
+ const metadata = await fileMetadata(reference.target, `Gemini hook ${reference.relative}`);
161
+ if ((metadata.mode & 0o111) === 0) {
162
+ throw new Error(`Gemini hook ${reference.relative} is not executable`);
163
+ }
164
+ }
165
+
166
+ async function validateSkills(extensionRoot) {
167
+ const skillsRoot = join(extensionRoot, "skills");
168
+ const entries = await directoryEntries(skillsRoot, "Gemini skills directory");
169
+ const skills = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
170
+ if (skills.length === 0) throw new Error("skills: Gemini extension contains no skills");
171
+ for (const skill of skills) {
172
+ const relativePath = `skills/${skill.name}/SKILL.md`;
173
+ await fileMetadata(join(skillsRoot, skill.name, "SKILL.md"), `Gemini skill ${relativePath}`);
174
+ }
175
+ }
176
+
177
+ export async function validateGeminiExtension(extensionRoot) {
178
+ await validateNativeManifest(extensionRoot);
179
+ await validateHooks(extensionRoot);
180
+ await validateSkills(extensionRoot);
181
+ }
182
+
183
+ async function main() {
184
+ const extensionRoot = parseArgs(process.argv.slice(2));
185
+ await validateGeminiExtension(extensionRoot);
186
+ console.log("Gemini dev extension metadata ok");
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
+ }