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,339 @@
1
+ // lib/commands/add.js — `singular add`: persistent, pinned install of one allowlisted skill with inspection BEFORE
2
+ // anything lands in ~/.claude/skills.
3
+ //
4
+ // node bin/singular.js add <owner/repo[#sha]@skill> [--yes] [--json] (bin/singular.js dispatches here)
5
+ //
6
+ // 1. ref must match REF_RE, owner must be allowlisted, ref must not be blocked
7
+ // 2. sha: from the ref, else `git ls-remote ... HEAD` (pin)
8
+ // 3. refuses if ~/.claude/skills/<skill> already exists (never overwrites)
9
+ // 4. pinned fetch into a fresh temp dir, exactly what the skills CLI's cloneAtSha does:
10
+ // git init && git remote add origin https://github.com/<owner>/<repo> && git fetch --depth 1 origin <sha> && git checkout FETCH_HEAD
11
+ // (execFile, args array, GIT_TERMINAL_PROMPT=0, no shell)
12
+ // 5. locate the skill folder: a SKILL.md whose directory name OR frontmatter `name` equals <skill> exactly
13
+ // (the skills CLI matches case-insensitively; we do not, so `@DOCX` cannot install `docx`)
14
+ // 6. `git ls-tree -r <sha> -- <skill path>`: any 120000 (symlink), 100755 (exec bit) or 160000 (submodule)
15
+ // entry rejects the skill before it is copied — the CLI's `fs.cp({dereference: true})` would otherwise
16
+ // turn a symlink to a host file into a regular file that no later inspection could see
17
+ // 7. inspectSkillFolder(<checkout>/<skill path>) (plugin components, capability frontmatter, shell forms)
18
+ // 8. only then: fs.cpSync(checkout -> ~/.claude/skills/<skill>) with dereference: false, errorOnExist,
19
+ // a snapshot diff of ~/.claude/skills (exactly one new entry: the target) and a post-copy re-inspection;
20
+ // any failure removes exactly the folder we created (guarded three ways; other entries that appeared
21
+ // meanwhile belong to someone else and are only reported) and prints REJECTED
22
+ // 9. otherwise INSTALLED <ref> sha <sha> + audit JSON + "Run /reload-skills or start a new session"
23
+
24
+ import { execFile as nodeExecFile } from 'node:child_process';
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import readline from 'node:readline';
29
+
30
+ import { hardenEnv, homeDir, isAllowlisted, loadConfig, seamSkillsDir, tmpdirOf } from '../config.js';
31
+ import { TAG } from '../context.js';
32
+ import { childEnv, cloneAtSha, findSkillDirs, repoUrl, runExecFile } from '../git.js';
33
+ import { inspectSkillFolder } from '../inspect.js';
34
+ import { auditSkill } from '../registry.js';
35
+ import { log, timer } from '../log.js';
36
+ import { formatRef, parseRef, REF_RE, resolveHeadSha, unpinnedRef } from '../refs.js';
37
+
38
+ export const ADD_TIMEOUT_MS = 90000;
39
+ export const USAGE = 'usage: singular add <owner/repo[#sha]@skill> [--yes] [--json]';
40
+ export { findSkillDirs };
41
+
42
+ /** ~/.claude/skills, or the SINGULAR_SKILLS_DIR test seam (absolute, outside the project; docs/testing.md). */
43
+ export function skillsDir(env = process.env) {
44
+ return seamSkillsDir(env) ?? path.join(env.HOME || os.homedir(), '.claude', 'skills');
45
+ }
46
+
47
+ const run = runExecFile;
48
+
49
+ /** The CLI prints an ANSI box before the JSON; find the last parseable JSON value in stdout. (Kept for tooling.) */
50
+ export function parseCliJson(stdout) {
51
+ const s = String(stdout ?? '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '');
52
+ const lines = s.split('\n');
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const t = lines[i].trim();
55
+ if (t !== '[' && t !== '{' && !t.startsWith('[{') && !t.startsWith('{"')) continue;
56
+ const chunk = lines.slice(i).join('\n').trim();
57
+ for (let end = chunk.length; end > 0; end--) {
58
+ const c = chunk[end - 1];
59
+ if (c !== ']' && c !== '}') continue;
60
+ try {
61
+ return JSON.parse(chunk.slice(0, end));
62
+ } catch {
63
+ /* keep shrinking */
64
+ }
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function isBlockedRef(parsed, blockedRefs) {
71
+ const src = `${parsed.owner}/${parsed.repo}`.toLowerCase();
72
+ const full = `${src}@${parsed.skill.toLowerCase()}`;
73
+ for (const raw of Array.isArray(blockedRefs) ? blockedRefs : []) {
74
+ const e = String(raw).trim().toLowerCase().replace(/#[0-9a-f]{40}(?=@)/, '');
75
+ if (e && (e === full || e === src || e === parsed.owner.toLowerCase())) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function recordInstall(env, entry) {
81
+ const file = path.join(homeDir(env), 'installed.json');
82
+ let map = {};
83
+ try {
84
+ map = JSON.parse(fs.readFileSync(file, 'utf8'));
85
+ if (!map || typeof map !== 'object') map = {};
86
+ } catch {
87
+ map = {};
88
+ }
89
+ map[entry.key] = entry;
90
+ try {
91
+ fs.writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`);
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ }
96
+
97
+ /** Parse `git ls-tree -r` output; return entries whose mode is not a plain file (100644). */
98
+ export function unsafeTreeEntries(lsTreeOut) {
99
+ const bad = [];
100
+ for (const line of String(lsTreeOut ?? '').split('\n')) {
101
+ if (!line.trim()) continue;
102
+ const m = /^(\d{6})\s+(\S+)\s+([0-9a-f]{40})\t(.*)$/.exec(line);
103
+ if (!m) {
104
+ bad.push({ mode: '?', path: line.slice(0, 120), why: 'unparseable ls-tree line' });
105
+ continue;
106
+ }
107
+ const [, mode, type, , p] = m;
108
+ if (mode === '120000') bad.push({ mode, path: p, why: 'symlink' });
109
+ else if (mode === '100755') bad.push({ mode, path: p, why: 'executable bit' });
110
+ else if (mode === '160000' || type === 'commit') bad.push({ mode, path: p, why: 'git submodule' });
111
+ else if (mode !== '100644' || type !== 'blob') bad.push({ mode, path: p, why: `unexpected mode/type ${mode} ${type}` });
112
+ }
113
+ return bad;
114
+ }
115
+
116
+ /**
117
+ * @returns {Promise<{status:'installed'|'rejected'|'error', ref?, sha?, path?, problems?, audit?, message, files?}>}
118
+ */
119
+ export async function runAdd({ ref, env: rawEnv = process.env, deps = {}, config: cfgOverride, timeoutMs = ADD_TIMEOUT_MS } = {}) {
120
+ const t = timer();
121
+ const hardened = hardenEnv(rawEnv, { cwd: rawEnv.PWD || process.cwd() });
122
+ const env = hardened.env;
123
+ for (const w of hardened.warnings) log('warn', /TEST SEAM/.test(w) ? 'add: test seam active' : 'add: environment override ignored', { warning: w });
124
+ const config = cfgOverride ?? loadConfig(env);
125
+ const execFileImpl = deps.execFileImpl ?? nodeExecFile;
126
+ const gitDeps = { ...deps, env };
127
+ const raw = String(ref ?? '').trim();
128
+ if (!REF_RE.test(raw)) return { status: 'error', message: `invalid ref "${raw.slice(0, 80)}": expected owner/repo@skill or owner/repo#<40-hex-sha>@skill` };
129
+ const parsed = parseRef(raw);
130
+ if (!parsed) return { status: 'error', message: `invalid ref "${raw.slice(0, 80)}": segments must start with a letter or digit` };
131
+ const source = `${parsed.owner}/${parsed.repo}`;
132
+ if (!isAllowlisted(source, config.allowlist)) {
133
+ return { status: 'error', message: `REJECTED: owner "${parsed.owner}" is not allowlisted [${config.allowlist.join(', ')}]. Add it to "allowlist" in ${config.path ?? 'config.json'} if you trust it.` };
134
+ }
135
+ if (isBlockedRef(parsed, config.blockedRefs)) return { status: 'error', message: `REJECTED: ${unpinnedRef(parsed)} matches blockedRefs` };
136
+
137
+ // --- the target must not exist: checked BEFORE any network round trip (a rerun used to pay a ls-remote first)
138
+ const root = skillsDir(env);
139
+ const target = path.join(root, parsed.skill);
140
+ if (path.dirname(target) !== root || path.basename(target) !== parsed.skill) {
141
+ return { status: 'error', message: `refusing unexpected target path ${target}` };
142
+ }
143
+ let pre = null;
144
+ try {
145
+ pre = fs.lstatSync(target);
146
+ } catch {
147
+ pre = null;
148
+ }
149
+ if (pre) return { status: 'error', message: `REFUSED: ${target} already exists (${pre.isSymbolicLink() ? 'symlink' : pre.isDirectory() ? 'directory' : 'file'}). Remove it yourself first; singular never overwrites.` };
150
+
151
+ let sha = parsed.sha;
152
+ if (!sha) {
153
+ try {
154
+ sha = await resolveHeadSha(parsed.owner, parsed.repo, { timeoutMs: 8000 }, gitDeps);
155
+ } catch (err) {
156
+ return { status: 'error', message: `could not resolve HEAD of ${repoUrl(parsed.owner, parsed.repo, env)}: ${String(err?.message ?? err)}` };
157
+ }
158
+ }
159
+ const pinned = formatRef({ ...parsed, sha });
160
+ try {
161
+ fs.mkdirSync(root, { recursive: true });
162
+ } catch (err) {
163
+ return { status: 'error', message: `cannot create ${root}: ${err.message}` };
164
+ }
165
+
166
+ // --- pinned fetch into a temp dir (same steps as the skills CLI's cloneAtSha; lib/git.js)
167
+ const base = path.resolve(deps.tmpdir ?? tmpdirOf(env));
168
+ fs.mkdirSync(base, { recursive: true });
169
+ const work = fs.mkdtempSync(path.join(base, 'singular-add-'));
170
+ const checkout = path.join(work, 'repo');
171
+ fs.mkdirSync(checkout);
172
+ const gitEnv = childEnv(env, { TMPDIR: work });
173
+ const git = (args, extra = {}) => run(execFileImpl, 'git', args, { cwd: checkout, env: gitEnv, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 32 * 1024 * 1024, windowsHide: true, ...extra });
174
+ const cleanup = () => {
175
+ if (work.startsWith(base + path.sep)) fs.rmSync(work, { recursive: true, force: true });
176
+ };
177
+ const fail = (message) => {
178
+ cleanup();
179
+ return { status: 'error', message };
180
+ };
181
+ try {
182
+ const cloned = await cloneAtSha({ owner: parsed.owner, repo: parsed.repo, sha, dest: checkout, timeoutMs, execFileImpl, env, tmpdir: work });
183
+ if (!cloned.ok) return fail(cloned.message);
184
+
185
+ // --- locate the skill folder (exact match on directory name or frontmatter name)
186
+ const all = findSkillDirs(checkout);
187
+ const matches = all.filter((s) => path.basename(s.dir) === parsed.skill || s.name === parsed.skill);
188
+ if (matches.length === 0) {
189
+ const label = (s) => {
190
+ const dir = path.basename(s.dir);
191
+ return s.name && s.name !== dir ? `${dir} (name: ${s.name})` : dir;
192
+ };
193
+ const names = [...new Set(all.map(label))].slice(0, 30);
194
+ return fail(`no skill named "${parsed.skill}" in ${source} at ${sha.slice(0, 7)} (exact, case-sensitive). Found: ${names.join(', ') || 'none'}`);
195
+ }
196
+ if (matches.length > 1) return fail(`ambiguous: ${matches.length} folders match "${parsed.skill}" in ${source}: ${matches.map((m) => m.rel).join(', ')}`);
197
+ const found = matches[0];
198
+ const relPath = found.rel === '.' ? '' : found.rel.split(path.sep).join('/');
199
+
200
+ // --- tree modes from git itself: symlinks / exec bits / submodules never reach the copy step
201
+ const problems = [];
202
+ const ls = await git(['ls-tree', '-r', 'FETCH_HEAD', '--', relPath || '.']);
203
+ if (ls.error || ls.code !== 0) return fail(`git ls-tree failed (exit ${ls.code ?? 'unknown'}): ${ls.stderr.trim().slice(0, 300)}`);
204
+ for (const e of unsafeTreeEntries(ls.stdout)) problems.push(`${e.why} in repository tree: ${e.path} (mode ${e.mode})`);
205
+
206
+ // --- folder / frontmatter / shell-form inspection on the checkout (before anything is copied)
207
+ const inspection = await inspectSkillFolder(found.dir);
208
+ problems.push(...inspection.problems);
209
+ if (problems.length > 0) {
210
+ const unique = [...new Set(problems)];
211
+ log('warn', 'add rejected before copy', { ref: pinned, problems: unique });
212
+ cleanup();
213
+ return {
214
+ status: 'rejected',
215
+ ref: pinned,
216
+ sha,
217
+ path: target,
218
+ problems: unique,
219
+ removed: false,
220
+ copied: false,
221
+ message: `REJECTED: ${pinned}\n ${unique.join('\n ')}\nNothing was written to ${root}.`,
222
+ };
223
+ }
224
+
225
+ // --- copy (no dereference; refuse to overwrite), verify exactly one new entry appeared, re-inspect
226
+ let before;
227
+ try {
228
+ before = new Set(fs.readdirSync(root));
229
+ } catch (err) {
230
+ return fail(`cannot read ${root}: ${err.message}`);
231
+ }
232
+ let preCopy = null;
233
+ try {
234
+ preCopy = fs.lstatSync(target);
235
+ } catch {
236
+ preCopy = null;
237
+ }
238
+ if (preCopy) return fail(`REFUSED: ${target} appeared while fetching; not touching it.`);
239
+ try {
240
+ fs.cpSync(found.dir, target, { recursive: true, dereference: false, errorOnExist: true, force: false, verbatimSymlinks: true, filter: (src) => path.basename(src) !== '.git' });
241
+ } catch (err) {
242
+ const removed = removeCreated(target, root, parsed.skill, before);
243
+ return fail(`copy into ${target} failed: ${err.message}${removed ? ' (partial copy removed)' : ''}`);
244
+ }
245
+ const after = new Set(fs.readdirSync(root));
246
+ const created = [...after].filter((n) => !before.has(n));
247
+ const post = await inspectSkillFolder(target);
248
+ let postSt = null;
249
+ try {
250
+ postSt = fs.lstatSync(target);
251
+ } catch {
252
+ postSt = null;
253
+ }
254
+ const postProblems = [...post.problems];
255
+ if (!postSt || postSt.isSymbolicLink() || !postSt.isDirectory()) postProblems.unshift(`${target} is not a plain directory after the copy`);
256
+ if (created.length !== 1 || created[0] !== parsed.skill) postProblems.unshift(`expected exactly one new entry (${parsed.skill}) in ${root}, got: ${created.join(', ') || 'none'}`);
257
+ if (postProblems.length > 0) {
258
+ // Only the folder this run wrote is removed. fs.cpSync writes into `target` and nowhere else, so any other
259
+ // entry that appeared meanwhile belongs to a concurrent writer (another install tool, a sync) and is left
260
+ // alone; it is named in the problems so the user can look at it.
261
+ const removed = removeCreated(target, root, parsed.skill, before);
262
+ const others = created.filter((n) => n !== parsed.skill);
263
+ if (others.length) postProblems.push(`other entries appeared in ${root} during the copy and were left untouched (not created by singular): ${others.join(', ')}`);
264
+ log('warn', 'add rejected after copy', { ref: pinned, problems: postProblems, removed });
265
+ cleanup();
266
+ return {
267
+ status: 'rejected',
268
+ ref: pinned,
269
+ sha,
270
+ path: target,
271
+ problems: postProblems,
272
+ removed,
273
+ copied: true,
274
+ message: `REJECTED: ${pinned}\n ${postProblems.join('\n ')}\n${removed ? `Removed ${target}.` : `WARNING: ${target} was NOT removed; inspect it manually.`}`,
275
+ };
276
+ }
277
+
278
+ cleanup();
279
+ const audit = await auditSkill(source, parsed.skill, { timeoutMs: 5000 }, gitDeps);
280
+ recordInstall(env, { key: unpinnedRef(parsed), ref: pinned, sha, path: target, ts: Date.now(), audit: audit.status, files: post.files, sourcePath: relPath || '.' });
281
+ log('info', 'add installed', { ref: pinned, path: target, audit: audit.status, ms: Math.round(t.elapsed()) });
282
+ const auditJson = JSON.stringify({ status: audit.status, providers: audit.providers }, null, 2);
283
+ return {
284
+ status: 'installed',
285
+ ref: pinned,
286
+ sha,
287
+ path: target,
288
+ files: post.files,
289
+ audit,
290
+ message: `INSTALLED ${pinned} sha ${sha}\n path: ${target} (${post.files} files, inspected before and after the copy: clean)\n source: ${source} ${relPath || '.'}\n audit: ${auditJson.replace(/\n/g, '\n ')}\nRun /reload-skills or start a new session to use it.`,
291
+ };
292
+ } catch (err) {
293
+ cleanup();
294
+ return { status: 'error', message: `add failed: ${String(err?.message ?? err)}` };
295
+ }
296
+ }
297
+
298
+ /** Remove exactly the folder this run created: it must sit directly under root, be named after the skill, and not have existed before. */
299
+ function removeCreated(target, root, skill, before) {
300
+ if (path.dirname(target) !== root || path.basename(target) !== skill || !target.startsWith(root + path.sep) || before.has(skill)) return false;
301
+ try {
302
+ fs.rmSync(target, { recursive: true, force: true });
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
309
+ async function confirm(question) {
310
+ if (!process.stdin.isTTY) return false;
311
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
312
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
313
+ rl.close();
314
+ return /^y(es)?$/i.test(String(answer).trim());
315
+ }
316
+
317
+ /** `singular add <ref> [--yes] [--json]`: returns the exit code (0 only when INSTALLED). */
318
+ export async function main(argv = process.argv.slice(2)) {
319
+ const json = argv.includes('--json');
320
+ const yes = argv.includes('--yes') || argv.includes('-y');
321
+ const positional = argv.filter((a) => !a.startsWith('-'));
322
+ if (argv.includes('--help') || argv.includes('-h') || positional.length !== 1) {
323
+ process.stderr.write(`${USAGE}\n`);
324
+ return positional.length === 1 ? 0 : 1;
325
+ }
326
+ const ref = positional[0];
327
+ if (!yes) {
328
+ const parsed = REF_RE.test(ref) ? parseRef(ref) : null;
329
+ const ok = await confirm(`${TAG} install ${ref} into ${skillsDir()}${parsed ? `/${parsed.skill}` : ''} (pinned, inspected before it is copied)? [y/N] `);
330
+ if (!ok) {
331
+ process.stderr.write(`${TAG} not installed (pass --yes to skip this prompt; non-interactive runs require it)\n`);
332
+ return 1;
333
+ }
334
+ }
335
+ const result = await runAdd({ ref, env: process.env });
336
+ if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
337
+ else process.stdout.write(`${result.message}\n`);
338
+ return result.status === 'installed' ? 0 : 1;
339
+ }
@@ -0,0 +1,195 @@
1
+ // lib/commands/find.js — `singular find`: search skills.sh for a query, apply the same gates as the hook, print a table.
2
+ //
3
+ // node bin/singular.js find "<query>" [--limit N] [--all] [--json] [--cwd <dir>] (bin/singular.js dispatches here)
4
+ //
5
+ // Prints installed skills that already match (local index), then gated third-party candidates
6
+ // ref installs audit allowlisted?
7
+ // and the rejected count grouped by reason (--all lists every rejected entry). Never installs.
8
+ //
9
+ // This is the ONE path that sends prompt-derived text to skills.sh: the user typed the query on purpose,
10
+ // so the joined keywords go out as `q=` (one request) in addition to the cached owner catalogs.
11
+
12
+ import path from 'node:path';
13
+
14
+ import { hardenEnv, loadConfig } from '../config.js';
15
+ import { TAG } from '../context.js';
16
+ import { log } from '../log.js';
17
+ import { discover, rankCandidates } from '../registry.js';
18
+ import { isWeakMatch, scoreSkills } from '../score.js';
19
+ import { buildIndex } from '../skill-index.js';
20
+ import { detectStack } from '../stack.js';
21
+
22
+ export const FIND_TIMEOUT_MS = 15000;
23
+ export const USAGE = 'usage: singular find "<query>" [--limit N] [--all] [--json] [--cwd <dir>]';
24
+ const OWNER_REJECTION_RE = /^owner (\S+) not allowlisted$/;
25
+
26
+ function fmtInstalls(n) {
27
+ const v = Number(n) || 0;
28
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
29
+ if (v >= 1000) return `${(v / 1000).toFixed(v >= 100_000 ? 0 : 1)}K`;
30
+ return String(v);
31
+ }
32
+
33
+ function pad(s, n) {
34
+ const t = String(s ?? '');
35
+ return t.length >= n ? t : t + ' '.repeat(n - t.length);
36
+ }
37
+
38
+ export function parseArgs(argv) {
39
+ const out = { query: [], limit: 10, all: false, json: false, cwd: null, help: false };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a === '--limit') out.limit = Math.max(1, Math.min(50, Number(argv[++i]) || 10));
43
+ else if (a === '--all') out.all = true;
44
+ else if (a === '--json') out.json = true;
45
+ else if (a === '--cwd') out.cwd = argv[++i] ?? null;
46
+ else if (a === '--help' || a === '-h') out.help = true;
47
+ else out.query.push(a);
48
+ }
49
+ out.query = out.query.join(' ').trim();
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Rank the rejected entries by the same relevance score the accepted ones get (installs alone put
55
+ * `frontend-design (already installed)` on top of every query) and name the best allowlist-only
56
+ * rejection explicitly, so the model can quote it instead of inferring an owner from an unrelated row.
57
+ */
58
+ export function rankRejected(rejected, keywords, { prompt, stackTags } = {}) {
59
+ const list = Array.isArray(rejected) ? rejected : [];
60
+ const ranked = rankCandidates(list.map((r) => r.cand), keywords, { prompt, stackTags });
61
+ const byRef = new Map(ranked.map((c) => [`${c.source}@${c.skill}`.toLowerCase(), c]));
62
+ const out = list.map((r) => {
63
+ const ref = `${r.cand.source}@${r.cand.skill}`;
64
+ const c = byRef.get(ref.toLowerCase());
65
+ return { ref, installs: r.cand.installs, reason: r.reason, matches: c?.matches ?? 0, matched: c?.matched ?? [], rank: c?.rank ?? 0 };
66
+ });
67
+ out.sort((a, b) => Number(b.matches > 0) - Number(a.matches > 0) || b.rank - a.rank || b.installs - a.installs || a.ref.localeCompare(b.ref));
68
+ const best = out.find((x) => x.matches > 0 && OWNER_REJECTION_RE.test(x.reason));
69
+ const bestRejected = best ? { ref: best.ref, installs: best.installs, owner: OWNER_REJECTION_RE.exec(best.reason)[1], matched: best.matched } : null;
70
+ return { rejected: out, bestRejected };
71
+ }
72
+
73
+ /** @returns {Promise<{query, keywords, local, accepted, rejected, bestRejected, errors, cached, elapsedMs}>} */
74
+ export async function runFind({ query, limit = 10, cwd, env: rawEnv = process.env, deps = {}, config: cfgOverride, timeoutMs = FIND_TIMEOUT_MS } = {}) {
75
+ const dir = path.resolve(cwd || process.cwd());
76
+ const hardened = hardenEnv(rawEnv, { cwd: dir });
77
+ const env = hardened.env;
78
+ for (const w of hardened.warnings) log('warn', /TEST SEAM/.test(w) ? 'find: test seam active' : 'find: environment override ignored', { warning: w });
79
+ const config = cfgOverride ?? loadConfig(env);
80
+ const indexOpts = { cwd: dir, home: env.HOME };
81
+ if (env.CLAUDE_CONFIG_DIR) indexOpts.configDir = env.CLAUDE_CONFIG_DIR;
82
+ const [index, stack] = await Promise.all([buildIndex(indexOpts).catch(() => []), detectStack(dir).catch(() => ({ summary: '', tags: [] }))]);
83
+ const local = scoreSkills(query, index, { max: 8, minScore: 1.5 }).filter((m) => !isWeakMatch(m.matched)).slice(0, 5).map((m) => ({
84
+ name: m.skill.invocableName || m.skill.name,
85
+ scope: m.skill.scope,
86
+ score: m.score,
87
+ matched: m.matched,
88
+ description: String(m.skill.description ?? '').slice(0, 100),
89
+ }));
90
+ const installedNames = [...new Set(index.flatMap((s) => [s.name, s.frontmatterName].filter(Boolean).map((n) => String(n).toLowerCase())))];
91
+ const d = await discover(query, {
92
+ config: { ...config, maxCandidates: limit, remoteTimeoutMs: Math.max(Number(config.remoteTimeoutMs) || 2500, 6000) },
93
+ stackTags: stack.tags,
94
+ timeoutMs,
95
+ deps: { ...deps, env },
96
+ installedNames,
97
+ search: true,
98
+ });
99
+ const { rejected, bestRejected } = rankRejected(d.rejected, d.keywords, { prompt: query, stackTags: stack.tags });
100
+ return {
101
+ query,
102
+ keywords: d.keywords,
103
+ intent: d.intent,
104
+ stack: stack.summary,
105
+ configPath: config.path ?? null,
106
+ local,
107
+ accepted: d.candidates.map((c) => ({
108
+ ref: c.ref,
109
+ source: c.source,
110
+ skill: c.skill,
111
+ installs: c.installs,
112
+ audit: c.audit?.status ?? 'unknown',
113
+ allowlisted: true,
114
+ url: c.url,
115
+ matches: c.matches,
116
+ matched: c.matched,
117
+ rank: c.rank,
118
+ })),
119
+ rejected,
120
+ bestRejected,
121
+ queries: d.queries,
122
+ errors: d.errors,
123
+ cached: d.cached,
124
+ auditSkipped: d.auditSkipped,
125
+ cooldownUntil: d.cooldownUntil ?? null,
126
+ elapsedMs: d.elapsedMs,
127
+ };
128
+ }
129
+
130
+ function hhmm(ts) {
131
+ const d = new Date(ts);
132
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
133
+ }
134
+
135
+ export function formatReport(r, { all = false } = {}) {
136
+ const lines = [];
137
+ lines.push(`${TAG} query: "${r.query}" keywords: ${r.keywords.join(', ') || '(none)'}${r.stack ? ` stack: ${r.stack}` : ''}`);
138
+ if (r.local.length) {
139
+ lines.push('', 'Installed locally (already available, use the Skill tool / slash command):');
140
+ for (const l of r.local) lines.push(` ${pad(l.name, 44)} ${pad(l.scope, 8)} score ${l.score} ${l.description}`);
141
+ } else {
142
+ lines.push('', 'Installed locally: no match.');
143
+ }
144
+ lines.push('', 'Third-party candidates (gated: allowlist, install floor, audit; NOT installed):');
145
+ const requests = Array.isArray(r.queries) ? r.queries : [];
146
+ const allFailed = requests.length > 0 && requests.every((q) => q.skipped && !q.cached);
147
+ const rateLimited = r.cooldownUntil && Number(r.cooldownUntil) > Date.now();
148
+ if (r.accepted.length === 0 && (allFailed || rateLimited)) {
149
+ lines.push(` Third-party search failed (${rateLimited ? `skills.sh rate-limited until ${hhmm(r.cooldownUntil)}` : 'timeout / network error'}); nothing could be evaluated.`);
150
+ } else if (r.accepted.length === 0) lines.push(' (none passed the gates)');
151
+ else {
152
+ lines.push(` ${pad('ref', 64)} ${pad('installs', 9)} ${pad('audit', 8)} allowlisted?`);
153
+ for (const c of r.accepted) lines.push(` ${pad(c.ref, 64)} ${pad(fmtInstalls(c.installs), 9)} ${pad(c.audit, 8)} yes`);
154
+ lines.push('', ' Load ephemerally (text only): /singular load <ref>', ' Install persistently (pinned): /singular add <ref>');
155
+ }
156
+ const groups = new Map();
157
+ for (const x of r.rejected) {
158
+ const key = x.reason
159
+ .replace(/\d[\d,]*/g, 'N')
160
+ .replace(/kept .*$/, 'kept ...')
161
+ .replace(/fork of .*$/, 'fork of ...')
162
+ .replace(/owner \S+ not allowlisted/, 'owner not allowlisted')
163
+ .replace(/\(.*\)$/, '(...)');
164
+ groups.set(key, (groups.get(key) ?? 0) + 1);
165
+ }
166
+ if ((allFailed || rateLimited) && r.rejected.length === 0) lines.push('', `Rejected: n/a (${rateLimited ? `rate-limited until ${hhmm(r.cooldownUntil)}` : 'search failed'})`);
167
+ else {
168
+ lines.push('', `Rejected: ${r.rejected.length}${allFailed || rateLimited ? ' (from cached catalogs only)' : ''}`);
169
+ for (const [reason, n] of [...groups.entries()].sort((a, b) => b[1] - a[1])) lines.push(` ${n} x ${reason}`);
170
+ }
171
+ if (r.bestRejected) {
172
+ lines.push(` Best allowlist-only rejection: ${r.bestRejected.ref} (${fmtInstalls(r.bestRejected.installs)} installs, matched ${r.bestRejected.matched.join(', ') || '-'}) - add "${r.bestRejected.owner}" to "allowlist" in ${r.configPath ?? '~/.claude/singular/config.json'} to consider it`);
173
+ }
174
+ const show = all ? r.rejected : r.rejected.slice(0, 8);
175
+ if (show.length) {
176
+ lines.push(all ? ' all rejected entries (by relevance, then installs):' : ' top rejected by relevance (use --all for the full list):');
177
+ for (const x of show) lines.push(` ${pad(x.ref, 64)} ${pad(fmtInstalls(x.installs), 9)} ${x.reason}${x.matches > 0 ? ` [matched ${x.matched.join(', ')}]` : ''}`);
178
+ }
179
+ if (r.errors.length) lines.push('', `Errors: ${r.errors.join(' | ')}`);
180
+ lines.push('', `(${r.queries.length} requests, cached: ${r.cached}${r.auditSkipped ? ', audits skipped (deadline)' : ''}${rateLimited ? `, skills.sh rate-limited until ${hhmm(r.cooldownUntil)}` : ''}, ${Math.round(r.elapsedMs)} ms)`);
181
+ return lines.join('\n');
182
+ }
183
+
184
+ /** `singular find ...`: returns the exit code (1 without a query, 0 otherwise). */
185
+ export async function main(argv = process.argv.slice(2)) {
186
+ const args = parseArgs(argv);
187
+ if (args.help || !args.query) {
188
+ process.stderr.write(`${USAGE}\n`);
189
+ return args.help ? 0 : 1;
190
+ }
191
+ const r = await runFind({ query: args.query, limit: args.limit, cwd: args.cwd, env: process.env });
192
+ log('info', 'find', { query: args.query, accepted: r.accepted.length, rejected: r.rejected.length, ms: Math.round(r.elapsedMs) });
193
+ process.stdout.write(args.json ? `${JSON.stringify(r, null, 2)}\n` : `${formatReport(r, { all: args.all })}\n`);
194
+ return 0;
195
+ }
@@ -0,0 +1,93 @@
1
+ // lib/commands/mode.js — `singular mode`: show or switch singular's mode by rewriting ONLY the `mode` key of config.json.
2
+ //
3
+ // node bin/singular.js mode prints the current mode and where it comes from
4
+ // node bin/singular.js mode <off|local|suggest|auto> [--json] (bin/singular.js dispatches here)
5
+ //
6
+ // Writes to the default ~/.claude/singular/config.json (or $SINGULAR_HOME/config.json when that is
7
+ // an absolute path). `auto` is only honoured by the hooks when it lives in the default file, so this
8
+ // command refuses to write `auto` into a redirected home. Every other key in the file is left untouched
9
+ // (unknown keys, comments are not supported by JSON anyway).
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { configPath, DEFAULTS, defaultHomeDir, hardenEnv, loadConfig, MODES, redirectedHome } from '../config.js';
15
+ import { TAG } from '../context.js';
16
+ import { log } from '../log.js';
17
+
18
+ export const USAGE = `usage: singular mode [${MODES.join('|')}] [--json]`;
19
+ export const AUTO_WARNING = `${TAG} mode "auto": third-party skill text from allowlisted skills.sh sources will be injected into your prompts WITHOUT confirmation (text only, pinned, sanitized, still untrusted prose). Cold prompts add up to ~5 s and up to 9,000 chars of third-party text per prompt; see README "What auto actually costs". Run /singular mode suggest to go back.`;
20
+
21
+ /**
22
+ * @returns {{status:'ok'|'error', mode, previous?, path, message}}
23
+ */
24
+ export function setMode(mode, env = process.env) {
25
+ const m = String(mode ?? '').trim().toLowerCase();
26
+ const file = configPath(env);
27
+ if (!MODES.includes(m)) return { status: 'error', mode: null, path: file, message: `${TAG} unknown mode "${mode}"; expected one of ${MODES.join(' | ')}` };
28
+ if (m === 'auto' && redirectedHome(env)) {
29
+ return { status: 'error', mode: null, path: file, message: `${TAG} refusing to write mode "auto" into ${file}: auto is honoured only in the default ${defaultHomeDir(env)}/config.json (unset SINGULAR_HOME first)` };
30
+ }
31
+ let raw = {};
32
+ let previous = DEFAULTS.mode;
33
+ try {
34
+ const text = fs.readFileSync(file, 'utf8');
35
+ try {
36
+ const parsed = JSON.parse(text);
37
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) raw = parsed;
38
+ else return { status: 'error', mode: null, path: file, message: `${TAG} ${file} is not a JSON object; fix it by hand first` };
39
+ } catch (err) {
40
+ return { status: 'error', mode: null, path: file, message: `${TAG} ${file} is not valid JSON (${err.message}); fix it by hand first` };
41
+ }
42
+ } catch (err) {
43
+ if (err && err.code !== 'ENOENT') return { status: 'error', mode: null, path: file, message: `${TAG} ${file} is unreadable (${err.code ?? err.message}); fix it by hand first` };
44
+ raw = { ...DEFAULTS };
45
+ }
46
+ if (typeof raw.mode === 'string') previous = raw.mode;
47
+ raw.mode = m;
48
+ try {
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const tmp = `${file}.${process.pid}.tmp`;
51
+ fs.writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`);
52
+ fs.renameSync(tmp, file);
53
+ } catch (err) {
54
+ return { status: 'error', mode: null, path: file, message: `${TAG} could not write ${file}: ${err.message}` };
55
+ }
56
+ log('info', 'mode', { mode: m, previous, path: file });
57
+ const note = m === 'auto' ? `\n${AUTO_WARNING}` : '';
58
+ return { status: 'ok', mode: m, previous, path: file, message: `${TAG} mode: ${previous} -> ${m} (${file}). Takes effect on the next prompt; no restart needed.${note}` };
59
+ }
60
+
61
+ /** @param {string[]} [envWarnings] hardenEnv warnings (ignored overrides, active test seams), printed as "! ..." lines like `singular status` does */
62
+ export function describeMode(env = process.env, envWarnings = []) {
63
+ const config = loadConfig(env);
64
+ const lines = [`${TAG} mode: ${config.mode} (config file: ${config.path})`];
65
+ if (config.fileMode && config.fileMode !== config.mode) lines.push(` file says "${config.fileMode}"; effective "${config.mode}" (${config.warnings.filter((w) => /mode/i.test(w)).join('; ') || 'environment override'})`);
66
+ for (const w of [...envWarnings, ...config.warnings]) lines.push(` ! ${w}`);
67
+ lines.push(` usage: /singular mode <${MODES.join('|')}>`);
68
+ return lines.join('\n');
69
+ }
70
+
71
+ /** `singular mode [...]`: returns the exit code. */
72
+ export async function main(argv = process.argv.slice(2)) {
73
+ const json = argv.includes('--json');
74
+ const positional = argv.filter((a) => !a.startsWith('-'));
75
+ if (argv.includes('--help') || argv.includes('-h') || positional.length > 1) {
76
+ process.stderr.write(`${USAGE}\n`);
77
+ return positional.length > 1 ? 1 : 0;
78
+ }
79
+ // A relative SINGULAR_HOME (or one inside the project) is ignored: say so instead of silently
80
+ // reading or writing the real ~/.claude/singular/config.json.
81
+ const hardened = hardenEnv(process.env, { cwd: process.cwd() });
82
+ const env = hardened.env;
83
+ for (const w of hardened.warnings) log('warn', 'mode: environment note', { warning: w });
84
+ if (positional.length === 0) {
85
+ process.stdout.write(`${describeMode(env, hardened.warnings)}\n`);
86
+ return 0;
87
+ }
88
+ const r = setMode(positional[0], env);
89
+ r.warnings = hardened.warnings;
90
+ if (json) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
91
+ else process.stdout.write(`${r.message}\n${hardened.warnings.map((w) => ` ! ${w}\n`).join('')}`);
92
+ return r.status === 'ok' ? 0 : 1;
93
+ }