singular-skill 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ // bin/on-expand.js — UserPromptExpansion hook for `/singular load`, plus a direct CLI mode and a prefetch mode.
3
+ //
4
+ // Hook mode (stdin JSON from Claude Code; hooks.json matcher "(^|:)singular$"):
5
+ // command_name "singular:singular" (plugin-prefixed) or bare "singular"
6
+ // command_args "load <owner/repo@skill | owner/repo#<40-hex>@skill> [--update]"
7
+ // -> stdout {"hookSpecificOutput":{"hookEventName":"UserPromptExpansion","additionalContext":"..."}} exit 0
8
+ // -> stderr "<reason>" exit 2 (blocks the expansion: no model call, nothing loaded)
9
+ // Any other subcommand (`/singular status`, `/singular find ...`, `/singular`) exits 0 with NO output, so the
10
+ // skill body (skills/singular/SKILL.md) runs bin/singular.js for it.
11
+ //
12
+ // CLI mode (from a terminal, or `singular load` through the CLI; both forms work):
13
+ // node bin/on-expand.js load <ref> [--update] [--json] prints the sanitized SKILL.md text
14
+ // node bin/on-expand.js <ref> [--update] [--json]
15
+ //
16
+ // Prefetch mode (spawned detached by on-prompt.js; nothing printed, exit 0):
17
+ // node bin/on-expand.js <ref> --prefetch resolves HEAD (cached under head:<owner/repo>) and
18
+ // caches the raw text under text:<pinned ref>; no pin recorded
19
+ //
20
+ // First load of an unpinned ref resolves HEAD and pins it in $SINGULAR_HOME/loaded.json;
21
+ // later loads reuse that sha unless --update is given. Text only: nothing is written to
22
+ // ~/.claude/skills. Third-party SKILL.md contents are data, never instructions to this program.
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+
28
+ import { cacheSet } from '../lib/cache.js';
29
+ import { hardenEnv, homeDir, isAllowlisted, loadConfig } from '../lib/config.js';
30
+ import { fitLoadedText, hookOutput, spillIfNeeded, TAG } from '../lib/context.js';
31
+ import { log, timer } from '../lib/log.js';
32
+ import { repoUrl } from '../lib/git.js';
33
+ import { fetchSkillText, sanitizeSkillText } from '../lib/registry.js';
34
+ import { formatRef, normalizeRefCase, parseRef, REF_RE, resolveHeadSha, unpinnedRef } from '../lib/refs.js';
35
+
36
+ export const COMMAND = 'singular';
37
+ export const SUBCOMMAND = 'load';
38
+ export const FETCH_TIMEOUT_MS = 20000; // hooks.json gives the hook 25 s
39
+ export const PREFETCH_TIMEOUT_MS = 60000;
40
+ const KNOWN_FLAGS = new Set(['--update', '--json', '--prefetch']);
41
+ const LOAD_RE = /^load(?:\s+|$)/i;
42
+
43
+ /**
44
+ * Hook mode: `command_args` is "<subcommand> ..." of `/singular`. Returns the text after `load` (the ref and its
45
+ * flags; "" for a bare `load`) or null when the arguments name another subcommand (nothing to do here).
46
+ */
47
+ export function loadArgsOf(commandArgs) {
48
+ const raw = String(commandArgs ?? '').trim();
49
+ if (!LOAD_RE.test(raw)) return null;
50
+ return raw.replace(LOAD_RE, '').trim();
51
+ }
52
+
53
+ function readStdinSync() {
54
+ try {
55
+ return fs.readFileSync(0, 'utf8');
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ export function loadedPath(env = process.env) {
62
+ return path.join(homeDir(env), 'loaded.json');
63
+ }
64
+
65
+ export function readLoaded(env = process.env) {
66
+ try {
67
+ const v = JSON.parse(fs.readFileSync(loadedPath(env), 'utf8'));
68
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ export function writeLoaded(map, env = process.env) {
75
+ try {
76
+ const file = loadedPath(env);
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ const tmp = `${file}.${process.pid}.tmp`;
79
+ fs.writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`);
80
+ fs.renameSync(tmp, file);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** "<ref> [--update]" -> {ref, update, json, prefetch} | {error} */
88
+ export function parseCommandArgs(raw) {
89
+ const tokens = String(raw ?? '').trim().split(/\s+/).filter(Boolean);
90
+ const flags = tokens.filter((t) => t.startsWith('-'));
91
+ const positional = tokens.filter((t) => !t.startsWith('-'));
92
+ for (const f of flags) if (!KNOWN_FLAGS.has(f)) return { error: `unknown option ${f}` };
93
+ if (positional.length === 0) return { error: 'missing skill reference' };
94
+ if (positional.length > 1) return { error: `expected exactly one skill reference, got ${positional.length}` };
95
+ return { ref: positional[0], update: flags.includes('--update'), json: flags.includes('--json'), prefetch: flags.includes('--prefetch') };
96
+ }
97
+
98
+ function isBlockedRef(parsed, blockedRefs) {
99
+ const src = `${parsed.owner}/${parsed.repo}`.toLowerCase();
100
+ const full = `${src}@${parsed.skill.toLowerCase()}`;
101
+ for (const raw of Array.isArray(blockedRefs) ? blockedRefs : []) {
102
+ const e = String(raw).trim().toLowerCase().replace(/#[0-9a-f]{40}(?=@)/, '');
103
+ if (!e) continue;
104
+ if (e === full || e === src || e === parsed.owner.toLowerCase()) return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * Validate, pin, fetch and sanitize. Never throws.
111
+ * @returns {Promise<{status:'ok', ref, sha, pinSource, text, stripped, context, cached, elapsedMs} | {status:'block', reason}>}
112
+ */
113
+ export async function runOnExpand({ commandArgs, env: rawEnv = process.env, deps = {}, config: cfgOverride, timeoutMs = FETCH_TIMEOUT_MS, prefetch = false } = {}) {
114
+ const t = timer();
115
+ const hardened = hardenEnv(rawEnv, { cwd: rawEnv.PWD || process.cwd() });
116
+ const env = hardened.env;
117
+ for (const w of hardened.warnings) log('warn', /TEST SEAM/.test(w) ? 'on-expand: test seam active' : 'on-expand: environment override ignored', { warning: w });
118
+ const config = cfgOverride ?? loadConfig(env);
119
+ const usage = 'usage: /singular load owner/repo@skill or /singular load owner/repo#<40-hex-sha>@skill [--update]';
120
+ const args = parseCommandArgs(commandArgs);
121
+ if (args.error) return { status: 'block', reason: `${TAG} ${args.error}. ${usage}` };
122
+ if (!REF_RE.test(args.ref)) {
123
+ return { status: 'block', reason: `${TAG} invalid skill reference "${args.ref.slice(0, 80)}". ${usage}` };
124
+ }
125
+ // Owner/repo are case-insensitive on GitHub: `MULTICA-AI/x@s` and `multica-ai/x@s` must share one pin
126
+ // (loaded.json), one head: cache entry and one text: cache entry. The skill segment stays as typed.
127
+ const parsed = normalizeRefCase(parseRef(args.ref));
128
+ if (!parsed) return { status: 'block', reason: `${TAG} invalid skill reference "${args.ref.slice(0, 80)}". ${usage}` };
129
+ const source = `${parsed.owner}/${parsed.repo}`;
130
+ if (!isAllowlisted(source, config.allowlist)) {
131
+ return {
132
+ status: 'block',
133
+ reason: `${TAG} refused: owner "${parsed.owner}" is not in the allowlist [${config.allowlist.join(', ')}]. Add "${parsed.owner}" (or "${source}") to "allowlist" in ${config.path ?? path.join(homeDir(env), 'config.json')} if you trust that source, then retry.`,
134
+ };
135
+ }
136
+ if (isBlockedRef(parsed, config.blockedRefs)) {
137
+ return { status: 'block', reason: `${TAG} refused: ${unpinnedRef(parsed)} matches "blockedRefs" in your config.` };
138
+ }
139
+
140
+ const isPrefetch = prefetch || args.prefetch;
141
+ const key = unpinnedRef(parsed);
142
+ const loaded = readLoaded(env);
143
+ const prev = loaded[key] && typeof loaded[key] === 'object' ? loaded[key] : null;
144
+ let sha = parsed.sha;
145
+ let pinSource = 'arg';
146
+ if (!sha && prev && /^[0-9a-f]{40}$/.test(String(prev.sha ?? '')) && !args.update) {
147
+ sha = prev.sha;
148
+ pinSource = 'loaded.json';
149
+ }
150
+ if (!sha) {
151
+ try {
152
+ sha = await resolveHeadSha(parsed.owner, parsed.repo, { timeoutMs: Math.min(8000, timeoutMs) }, deps);
153
+ pinSource = args.update ? 'resolved (--update)' : 'resolved';
154
+ if (deps.cacheSet) deps.cacheSet(`head:${source}`, sha);
155
+ else cacheSet(`head:${source}`, sha, env);
156
+ } catch (err) {
157
+ return { status: 'block', reason: `${TAG} could not resolve HEAD of ${repoUrl(parsed.owner, parsed.repo, env)}: ${String(err?.message ?? err)}` };
158
+ }
159
+ }
160
+ const pinned = { owner: parsed.owner, repo: parsed.repo, sha, skill: parsed.skill };
161
+ const pinnedRef = formatRef(pinned);
162
+
163
+ let fetched;
164
+ try {
165
+ fetched = await fetchSkillText(pinned, { cliVersion: config.skillsCliVersion, timeoutMs, cache: true }, { ...deps, env });
166
+ } catch (err) {
167
+ return { status: 'block', reason: `${TAG} could not fetch ${pinnedRef}: ${String(err?.message ?? err)}` };
168
+ }
169
+ const clean = sanitizeSkillText(fetched.text);
170
+ if (!clean.text.trim()) return { status: 'block', reason: `${TAG} ${pinnedRef} has no usable text after sanitizing (stripped: ${clean.stripped.join('; ') || 'nothing'})` };
171
+
172
+ if (isPrefetch) {
173
+ return { status: 'ok', ref: pinnedRef, sha, pinSource, text: clean.text, stripped: clean.stripped, context: '', cached: fetched.cached === true, prefetch: true, elapsedMs: t.elapsed() };
174
+ }
175
+
176
+ loaded[key] = {
177
+ ref: pinnedRef,
178
+ sha,
179
+ ts: prev?.ts ?? Date.now(),
180
+ lastTs: Date.now(),
181
+ loads: (Number(prev?.loads) || 0) + 1,
182
+ chars: clean.text.length,
183
+ stripped: clean.stripped,
184
+ };
185
+ writeLoaded(loaded, env);
186
+
187
+ const header = (strippedList) => `${TAG} The user explicitly loaded third-party skill ${pinnedRef} via /singular load (pinned ${pinSource}; text only, nothing installed). Contents (sanitized; stripped: ${strippedList.length ? strippedList.join('; ') : 'nothing'}):`;
188
+ const wrap = (body, strippedList) => `${header(strippedList)}\n<singular-loaded ref="${pinnedRef}">\n${body}\n</singular-loaded>`;
189
+ let strippedList = [...clean.stripped];
190
+ let body = clean.text;
191
+ const probe = wrap('', [...strippedList, 'truncated 99999 chars to fit the 9,500-char hook budget']);
192
+ const fitted = fitLoadedText(body, probe.length);
193
+ if (fitted.truncated > 0) {
194
+ strippedList = [...strippedList, `truncated ${fitted.truncated} chars to fit the 9,500-char hook budget`];
195
+ body = fitted.text;
196
+ }
197
+ const context = spillIfNeeded(wrap(body, strippedList), homeDir(env));
198
+ return { status: 'ok', ref: pinnedRef, sha, pinSource, text: clean.text, stripped: clean.stripped, context, cached: fetched.cached === true, elapsedMs: t.elapsed() };
199
+ }
200
+
201
+ export async function main(argv = process.argv.slice(2)) {
202
+ const positional = argv.filter((a) => !a.startsWith('-'));
203
+ if (argv.includes('--help') || argv.includes('-h')) {
204
+ process.stdout.write('usage: on-expand.js [load] <owner/repo[#sha]@skill> [--update] [--json] [--prefetch]\n (no args: read the UserPromptExpansion hook JSON from stdin)\n');
205
+ return 0;
206
+ }
207
+ const cliMode = positional.length > 0;
208
+ const prefetch = cliMode && argv.includes('--prefetch');
209
+ let commandArgs;
210
+ if (cliMode) {
211
+ // `node bin/on-expand.js load <ref>` and `node bin/on-expand.js <ref>` are the same command.
212
+ const rest = argv.filter((a) => a !== '--json' && a !== '--prefetch');
213
+ const i = rest.findIndex((a) => !a.startsWith('-'));
214
+ if (i >= 0 && LOAD_RE.test(rest[i]) && rest[i].toLowerCase() === SUBCOMMAND) rest.splice(i, 1);
215
+ commandArgs = rest.join(' ');
216
+ } else {
217
+ const raw = readStdinSync();
218
+ let input = {};
219
+ try {
220
+ input = raw.trim() ? JSON.parse(raw) : {};
221
+ } catch (err) {
222
+ log('warn', 'on-expand: stdin is not JSON', { error: err.message });
223
+ return 0;
224
+ }
225
+ const name = String(input.command_name ?? '');
226
+ const bare = name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name;
227
+ if (bare !== COMMAND) {
228
+ log('debug', 'on-expand: ignoring command', { command_name: name });
229
+ return 0;
230
+ }
231
+ // Only `/singular load ...` is handled here; every other subcommand is the skill body's job (bin/singular.js).
232
+ commandArgs = loadArgsOf(typeof input.command_args === 'string' ? input.command_args : '');
233
+ if (commandArgs === null) {
234
+ log('debug', 'on-expand: not a load subcommand; nothing to do', { command_args: String(input.command_args ?? '').slice(0, 80) });
235
+ return 0;
236
+ }
237
+ }
238
+
239
+ const result = await runOnExpand({ commandArgs, env: process.env, prefetch, timeoutMs: prefetch ? PREFETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS });
240
+ if (result.status === 'block') {
241
+ log('warn', prefetch ? 'on-expand: prefetch blocked' : 'on-expand: blocked', { args: String(commandArgs).slice(0, 200), reason: result.reason });
242
+ if (prefetch) return 0;
243
+ process.stderr.write(`${result.reason}\n`);
244
+ return cliMode ? 1 : 2;
245
+ }
246
+ log('info', prefetch ? 'on-expand: prefetched' : 'on-expand: loaded', { ref: result.ref, pinSource: result.pinSource, chars: result.text.length, cached: result.cached, stripped: result.stripped, ms: Math.round(result.elapsedMs) });
247
+ if (prefetch) return 0;
248
+ if (cliMode) {
249
+ if (argv.includes('--json')) {
250
+ process.stdout.write(`${JSON.stringify({ ref: result.ref, sha: result.sha, pinSource: result.pinSource, stripped: result.stripped, text: result.text }, null, 2)}\n`);
251
+ } else {
252
+ process.stdout.write(`${TAG} loaded ${result.ref} (pinned ${result.pinSource}; stripped: ${result.stripped.join('; ') || 'nothing'})\n\n${result.text}\n`);
253
+ }
254
+ } else {
255
+ process.stdout.write(hookOutput('UserPromptExpansion', result.context));
256
+ }
257
+ return 0;
258
+ }
259
+
260
+ const isEntrypoint = (() => {
261
+ try {
262
+ return process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
263
+ } catch {
264
+ return false;
265
+ }
266
+ })();
267
+
268
+ if (isEntrypoint) {
269
+ main().then(
270
+ (code) => process.exit(code ?? 0),
271
+ (err) => {
272
+ log('error', 'on-expand crashed', { error: String(err?.stack ?? err) });
273
+ process.stderr.write(`${TAG} internal error: ${String(err?.message ?? err)}\n`);
274
+ process.exit(2);
275
+ },
276
+ );
277
+ }