@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,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
|
+
}
|