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.
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +399 -2
- package/bin/on-expand.js +277 -0
- package/bin/on-prompt.js +672 -0
- package/bin/on-subagent.js +127 -0
- package/bin/singular-cli.js +52 -0
- package/bin/singular.js +114 -0
- package/config.example.json +23 -0
- package/docs/security.md +110 -0
- package/docs/spec.md +209 -0
- package/docs/testing.md +298 -0
- package/hooks/hooks.json +38 -0
- package/lib/cache.js +93 -0
- package/lib/cli-core.js +862 -0
- package/lib/commands/add.js +339 -0
- package/lib/commands/find.js +195 -0
- package/lib/commands/mode.js +93 -0
- package/lib/commands/status.js +128 -0
- package/lib/config.js +407 -0
- package/lib/context.js +184 -0
- package/lib/git.js +173 -0
- package/lib/inspect.js +183 -0
- package/lib/log.js +67 -0
- package/lib/refs.js +101 -0
- package/lib/registry.js +1152 -0
- package/lib/score.js +416 -0
- package/lib/skill-index.js +684 -0
- package/lib/stack.js +401 -0
- package/package.json +50 -4
- package/skills/singular/SKILL.md +24 -0
- package/skills/singular-find/SKILL.md +18 -0
package/lib/context.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// lib/context.js — compose the additionalContext text and the hook JSON envelope.
|
|
2
|
+
//
|
|
3
|
+
// composeContext({stack, localMatches, candidates, mode, budgetChars, loaded?, pinned?}) -> string
|
|
4
|
+
// fitLoadedText(text, reservedChars) -> {text, truncated} (budget a skill body against the spill threshold)
|
|
5
|
+
// spillIfNeeded(text, home) -> text | short note with the spill file path (when > 9,500 chars)
|
|
6
|
+
// hookOutput(eventName, additionalContext, {systemMessage?}) -> JSON string for stdout
|
|
7
|
+
//
|
|
8
|
+
// The context is factual and short. It names things; it never says "you must use".
|
|
9
|
+
// Format (sections omitted when empty; the first line carries the [singular] tag):
|
|
10
|
+
// [singular] Stack: <summary>
|
|
11
|
+
// Installed skills likely relevant: <name> (<why>), <name> (<why>)
|
|
12
|
+
// If one of the third-party candidates below fits the request, end your reply with exactly one line ...
|
|
13
|
+
// Third-party candidates (not loaded): <owner/repo#sha7@skill> (<installs> installs, audit <status>) — load with /singular load owner/repo@skill
|
|
14
|
+
// <notes: extra factual one-liners such as the singular command list>
|
|
15
|
+
//
|
|
16
|
+
// The relay instruction sits BEFORE the candidate list and names the exact line to add (measured
|
|
17
|
+
// 2026-10-07: the old trailing "tell the user ... how to load it" line was relayed in 0 of 6 runs with
|
|
18
|
+
// candidates); the hook additionally announces fresh candidates to the user through `systemMessage`
|
|
19
|
+
// (bin/on-prompt.js), so the user learns about /singular load even when the model stays silent.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
export const TAG = '[singular]';
|
|
25
|
+
export const SPILL_THRESHOLD = 9500;
|
|
26
|
+
export const HOOK_HARD_CAP = 10000;
|
|
27
|
+
export const TRUNCATION_MARKER = '\n[singular: truncated]';
|
|
28
|
+
export const RELAY_LINE = 'If one of the third-party candidates below fits the request, end your reply with exactly one line `Load with /singular load <ref>` naming the single best fit; omit that line if none fits.';
|
|
29
|
+
|
|
30
|
+
function fmtInstalls(n) {
|
|
31
|
+
const v = Number(n) || 0;
|
|
32
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(v >= 10_000_000 ? 0 : 1)}M`;
|
|
33
|
+
if (v >= 1000) return `${(v / 1000).toFixed(v >= 100_000 ? 0 : 1)}K`;
|
|
34
|
+
return String(v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function skillLabel(m) {
|
|
38
|
+
const s = m && m.skill ? m.skill : m;
|
|
39
|
+
if (!s || typeof s !== 'object') return String(s ?? '');
|
|
40
|
+
return s.invocableName || (s.pluginName ? `${s.pluginName}:${s.name}` : s.name) || '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function whyOf(m) {
|
|
44
|
+
if (!m || typeof m !== 'object') return '';
|
|
45
|
+
const matched = Array.isArray(m.matched) ? m.matched.filter(Boolean).slice(0, 4) : [];
|
|
46
|
+
if (matched.length) return `matches ${matched.join(', ')}`;
|
|
47
|
+
return typeof m.why === 'string' ? m.why : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Candidate line: owner/repo#sha7@skill (N installs, audit S) — load with /singular load owner/repo@skill
|
|
52
|
+
* The command is the short unpinned form (the first /singular load pins HEAD and records it); the
|
|
53
|
+
* `#sha7` suffix is display only, shown when a sha is already known from the cache.
|
|
54
|
+
*/
|
|
55
|
+
function candidateLine(c) {
|
|
56
|
+
const source = c.source ?? (c.owner && c.repo ? `${c.owner}/${c.repo}` : '');
|
|
57
|
+
const skill = c.skill ?? '';
|
|
58
|
+
if (!source || !skill) return '';
|
|
59
|
+
const sha = typeof c.sha === 'string' && /^[0-9a-f]{40}$/.test(c.sha) ? c.sha : null;
|
|
60
|
+
const display = `${source}${sha ? `#${sha.slice(0, 7)}` : ''}@${skill}`;
|
|
61
|
+
const audit = c.audit && typeof c.audit === 'object' ? c.audit.status : c.audit;
|
|
62
|
+
const auditText = audit ? `, audit ${audit}` : '';
|
|
63
|
+
return `${display} (${fmtInstalls(c.installs)} installs${auditText}) — load with /singular load ${source}@${skill}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fit a loaded skill body into what remains under SPILL_THRESHOLD once `reservedChars` (the rest of
|
|
68
|
+
* the context, header and wrapper) are accounted for. Never returns more than `room` chars.
|
|
69
|
+
*/
|
|
70
|
+
export function fitLoadedText(text, reservedChars = 0) {
|
|
71
|
+
const s = String(text ?? '');
|
|
72
|
+
const room = SPILL_THRESHOLD - Math.max(0, Math.floor(Number(reservedChars) || 0));
|
|
73
|
+
if (s.length <= room) return { text: s, truncated: 0 };
|
|
74
|
+
const keep = Math.max(0, room - TRUNCATION_MARKER.length);
|
|
75
|
+
return { text: `${s.slice(0, keep)}${TRUNCATION_MARKER}`, truncated: s.length - keep };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} p
|
|
80
|
+
* @param {{summary?: string, tags?: string[]}|string} [p.stack]
|
|
81
|
+
* @param {{skill: object, score: number, matched: string[]}[]} [p.localMatches]
|
|
82
|
+
* @param {object[]} [p.candidates] gated candidates: {source|owner+repo, skill, installs, sha?, audit?}
|
|
83
|
+
* @param {string} [p.mode]
|
|
84
|
+
* @param {number} [p.budgetChars] default 2000
|
|
85
|
+
* @param {{ref: string, text: string, stripped?: string[], reason?: string}} [p.loaded] auto-mode / pinned payload, appended last
|
|
86
|
+
* @param {string[]} [p.pinned] installed skills the user pinned in config (one hint line)
|
|
87
|
+
* @param {string[]} [p.notes] extra factual one-liners appended after the candidates (never dropped by the budget)
|
|
88
|
+
*/
|
|
89
|
+
export function composeContext({ stack, localMatches, candidates, mode = 'suggest', budgetChars = 2000, loaded, pinned, notes } = {}) {
|
|
90
|
+
const budget = Math.max(200, Math.floor(Number(budgetChars) || 2000));
|
|
91
|
+
const extra = (Array.isArray(notes) ? notes : []).map((n) => String(n ?? '').trim()).filter(Boolean);
|
|
92
|
+
const summary = typeof stack === 'string' ? stack : stack && typeof stack === 'object' ? String(stack.summary ?? '') : '';
|
|
93
|
+
const stackLine = summary.trim() ? `Stack: ${summary.trim()}` : '';
|
|
94
|
+
|
|
95
|
+
const locals = (Array.isArray(localMatches) ? localMatches : [])
|
|
96
|
+
.map((m) => {
|
|
97
|
+
const label = skillLabel(m);
|
|
98
|
+
if (!label) return '';
|
|
99
|
+
const why = whyOf(m);
|
|
100
|
+
return why ? `${label} (${why})` : label;
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
|
|
104
|
+
const pins = (Array.isArray(pinned) ? pinned : []).map((p) => String(p ?? '').trim()).filter(Boolean);
|
|
105
|
+
|
|
106
|
+
const cands = mode === 'off' || mode === 'local'
|
|
107
|
+
? []
|
|
108
|
+
: (Array.isArray(candidates) ? candidates : []).map(candidateLine).filter(Boolean);
|
|
109
|
+
|
|
110
|
+
const build = (nLocal, nCand) => {
|
|
111
|
+
const lines = [];
|
|
112
|
+
if (stackLine) lines.push(stackLine);
|
|
113
|
+
if (pins.length) lines.push(`Pinned installed skills (user-configured, consider invoking): ${pins.join(', ')}`);
|
|
114
|
+
if (nLocal > 0) lines.push(`Installed skills likely relevant: ${locals.slice(0, nLocal).join(', ')}`);
|
|
115
|
+
if (nCand > 0) lines.push(RELAY_LINE, `Third-party candidates (not loaded): ${cands.slice(0, nCand).join('; ')}`);
|
|
116
|
+
lines.push(...extra);
|
|
117
|
+
if (lines.length === 0) return '';
|
|
118
|
+
lines[0] = `${TAG} ${lines[0]}`;
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
let nLocal = locals.length;
|
|
123
|
+
let nCand = cands.length;
|
|
124
|
+
let text = build(nLocal, nCand);
|
|
125
|
+
// Shrink to budget: drop candidates from the end first, then local matches, then hard-cut.
|
|
126
|
+
while (text.length > budget && (nCand > 0 || nLocal > 0)) {
|
|
127
|
+
if (nCand > 0) nCand--;
|
|
128
|
+
else nLocal--;
|
|
129
|
+
text = build(nLocal, nCand);
|
|
130
|
+
}
|
|
131
|
+
if (text.length > budget) text = `${text.slice(0, budget - 1)}…`;
|
|
132
|
+
|
|
133
|
+
if (loaded && typeof loaded === 'object' && typeof loaded.text === 'string' && loaded.text.trim()) {
|
|
134
|
+
const strippedList = Array.isArray(loaded.stripped) ? [...loaded.stripped] : [];
|
|
135
|
+
const reason = typeof loaded.reason === 'string' && loaded.reason.trim() ? loaded.reason.trim() : 'auto mode, user-configured';
|
|
136
|
+
const wrap = (body, strippedText) => {
|
|
137
|
+
const header = `Loaded third-party skill ${loaded.ref} (${reason}; text only, nothing installed).${strippedText}`;
|
|
138
|
+
return `${text ? `${text}\n` : `${TAG} `}${header}\n<singular-loaded ref="${loaded.ref}">\n${body}\n</singular-loaded>`;
|
|
139
|
+
};
|
|
140
|
+
const strippedText = () => (strippedList.length ? ` Stripped: ${strippedList.join('; ')}.` : '');
|
|
141
|
+
let block = wrap(loaded.text, strippedText());
|
|
142
|
+
if (block.length > SPILL_THRESHOLD) {
|
|
143
|
+
// Budget the skill body against the threshold; the stack / routing lines are more valuable than its tail.
|
|
144
|
+
const probe = wrap('', ` Stripped: ${[...strippedList, `truncated 99999 chars to fit the 9,500-char hook budget`].join('; ')}.`);
|
|
145
|
+
const fitted = fitLoadedText(loaded.text, probe.length);
|
|
146
|
+
if (fitted.truncated > 0) strippedList.push(`truncated ${fitted.truncated} chars to fit the 9,500-char hook budget`);
|
|
147
|
+
block = wrap(fitted.text, strippedText());
|
|
148
|
+
}
|
|
149
|
+
return block;
|
|
150
|
+
}
|
|
151
|
+
return text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Last-resort guard: Claude Code only shows the model a 2 KB preview of hook output past 10,000
|
|
156
|
+
* chars, so anything still over SPILL_THRESHOLD (9,500) is written to $SINGULAR_HOME/spill-<ts>.md
|
|
157
|
+
* and replaced by a short note with the path. composeContext() budgets the loaded body first, so this
|
|
158
|
+
* fires only for oversized stack / routing text.
|
|
159
|
+
*/
|
|
160
|
+
export function spillIfNeeded(text, home) {
|
|
161
|
+
const s = String(text ?? '');
|
|
162
|
+
if (s.length <= SPILL_THRESHOLD) return s;
|
|
163
|
+
const dir = String(home || '').trim() || process.cwd();
|
|
164
|
+
const file = path.join(dir, `spill-${Date.now()}.md`);
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
167
|
+
fs.writeFileSync(file, s);
|
|
168
|
+
return `${TAG} Context was ${s.length} chars (over singular's ${SPILL_THRESHOLD}-char threshold; Claude Code previews only 2 KB past ${HOOK_HARD_CAP}) and was written to ${file}. Read that file if the loaded skill text is needed.`;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return `${s.slice(0, SPILL_THRESHOLD - 120)}\n${TAG} [truncated: ${s.length} chars; spill to ${file} failed: ${String(err?.message ?? err).slice(0, 60)}]`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** JSON envelope Claude Code expects on a hook's stdout. `systemMessage` (optional) is shown to the user. */
|
|
175
|
+
export function hookOutput(eventName, additionalContext, extra = {}) {
|
|
176
|
+
const out = {
|
|
177
|
+
hookSpecificOutput: {
|
|
178
|
+
hookEventName: String(eventName),
|
|
179
|
+
additionalContext: String(additionalContext ?? ''),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
if (extra && typeof extra.systemMessage === 'string' && extra.systemMessage.trim()) out.systemMessage = extra.systemMessage;
|
|
183
|
+
return JSON.stringify(out);
|
|
184
|
+
}
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// lib/git.js — the few git operations singular performs, shared by lib/refs.js, lib/registry.js and
|
|
2
|
+
// lib/commands/add.js (/singular add). Zero dependencies; every call is execFile with an argument array (no shell),
|
|
3
|
+
// GIT_TERMINAL_PROMPT=0, LC_ALL=C, an allowlisted child environment (childEnv), hard timeouts with SIGKILL.
|
|
4
|
+
//
|
|
5
|
+
// GITHUB_BASE 'https://github.com'
|
|
6
|
+
// gitBase(env) -> GITHUB_BASE, or the SINGULAR_GIT_BASE test seam (local path / file:// only)
|
|
7
|
+
// repoUrl(owner, repo, env) -> `${gitBase}/${owner}/${repo}`
|
|
8
|
+
// childEnv(env, extra) -> the minimal, fixed environment every git / npx child gets (allowlist, never the
|
|
9
|
+
// inherited env: a repository's .claude/settings.json env block reaches hook
|
|
10
|
+
// processes, and GIT_CONFIG_* / npm_config_* there would redirect clones and npx)
|
|
11
|
+
// describeGitFailure(what, r, ms) -> one-line error text; credential prompts become "repository not found or private"
|
|
12
|
+
// runExecFile(impl, file, args, opts) -> Promise<{error, code, signal, killed, stdout, stderr}> (never rejects)
|
|
13
|
+
// cloneAtSha({...}) -> fetch exactly one commit into an empty dir (init / remote add / fetch --depth 1 / checkout)
|
|
14
|
+
// findSkillDirs(root) -> every SKILL.md folder under root as {dir, rel, name}
|
|
15
|
+
//
|
|
16
|
+
// The owner/repo segments are validated by the callers (lib/refs.js grammar) before they reach an argv.
|
|
17
|
+
|
|
18
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { seamGitBase } from './config.js';
|
|
23
|
+
import { parseFrontmatter } from './skill-index.js';
|
|
24
|
+
|
|
25
|
+
export const GITHUB_BASE = 'https://github.com';
|
|
26
|
+
|
|
27
|
+
// The only variables a git or npx child inherits from singular's own environment. That environment is
|
|
28
|
+
// untrusted (docs/security.md section 4: a repository's committed `.claude/settings.json` `env` block is
|
|
29
|
+
// applied to every hook process), and git honours GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
|
|
30
|
+
// (`url.<elsewhere>.insteadOf=https://github.com/` would make a trusted ref fetch attacker-chosen text under an
|
|
31
|
+
// allowlisted owner), GIT_SSL_NO_VERIFY, GIT_SSH_COMMAND, GIT_PROXY_COMMAND, GIT_EXEC_PATH, GIT_DIR, XDG_CONFIG_HOME
|
|
32
|
+
// (a config file inside the repository), while npm honours npm_config_registry (`npx skills` would download and run
|
|
33
|
+
// a package from an attacker registry), npm_config_cache / npm_config_userconfig, NODE_OPTIONS, NODE_PATH,
|
|
34
|
+
// NODE_TLS_REJECT_UNAUTHORIZED and NODE_EXTRA_CA_CERTS / SSL_CERT_FILE (an attacker CA inside the repository).
|
|
35
|
+
// None of those are copied. PATH, HOME and the proxy variables are: a proxy cannot forge a verified TLS peer, and
|
|
36
|
+
// a corporate CA or registry mirror belongs in ~/.gitconfig (http.sslCAInfo) / ~/.npmrc (cafile, registry), which
|
|
37
|
+
// git and npm still read from HOME.
|
|
38
|
+
export const CHILD_ENV_ALLOWLIST = Object.freeze([
|
|
39
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'LANG',
|
|
40
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'all_proxy',
|
|
41
|
+
// Windows: git and node need these to start at all
|
|
42
|
+
'SYSTEMROOT', 'SystemRoot', 'SYSTEMDRIVE', 'SystemDrive', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA', 'PATHEXT', 'COMSPEC', 'ComSpec', 'TEMP', 'TMP',
|
|
43
|
+
]);
|
|
44
|
+
// What every child gets regardless of the caller: no credential prompts, stable English messages, no colour.
|
|
45
|
+
export const CHILD_ENV_FIXED = Object.freeze({ GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', NO_COLOR: '1' });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The environment for a git / npx child: the allowlisted keys of `env` (strings only), the fixed values, then
|
|
49
|
+
* `extra` (TMPDIR=<work dir>, DISABLE_TELEMETRY=1, ...). Everything else in `env` is dropped. Pure.
|
|
50
|
+
*/
|
|
51
|
+
export function childEnv(env = process.env, extra = {}) {
|
|
52
|
+
const out = {};
|
|
53
|
+
const src = env && typeof env === 'object' ? env : {};
|
|
54
|
+
for (const k of CHILD_ENV_ALLOWLIST) {
|
|
55
|
+
if (src[k] != null && String(src[k]) !== '') out[k] = String(src[k]);
|
|
56
|
+
}
|
|
57
|
+
return { ...out, ...CHILD_ENV_FIXED, ...extra };
|
|
58
|
+
}
|
|
59
|
+
// What git prints when GitHub answers a request for a missing or private repository with a credential
|
|
60
|
+
// prompt (which GIT_TERMINAL_PROMPT=0 turns into an error), or when it names the condition itself.
|
|
61
|
+
export const CREDENTIAL_PROMPT_RE = /could not read Username|Repository not found|Authentication failed|terminal prompts disabled|Permission denied \(publickey\)/i;
|
|
62
|
+
const MAX_SKILL_MD_SCAN = 2000;
|
|
63
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
64
|
+
|
|
65
|
+
export function gitBase(env = process.env) {
|
|
66
|
+
return seamGitBase(env) ?? GITHUB_BASE;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function repoUrl(owner, repo, env = process.env) {
|
|
70
|
+
return `${gitBase(env)}/${owner}/${repo}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Promise wrapper over an execFile-shaped function; closes the child's stdin; never rejects. */
|
|
74
|
+
export function runExecFile(execFileImpl, file, args, options) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let done = false;
|
|
77
|
+
const finish = (error, stdout, stderr) => {
|
|
78
|
+
if (done) return;
|
|
79
|
+
done = true;
|
|
80
|
+
resolve({
|
|
81
|
+
error: error ?? null,
|
|
82
|
+
code: error ? (typeof error.code === 'number' ? error.code : null) : 0,
|
|
83
|
+
signal: error?.signal ?? null,
|
|
84
|
+
killed: Boolean(error?.killed || error?.signal),
|
|
85
|
+
stdout: String(stdout ?? ''),
|
|
86
|
+
stderr: String(stderr ?? ''),
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
let child;
|
|
90
|
+
try {
|
|
91
|
+
child = execFileImpl(file, args, options, finish);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
finish(err, '', '');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
child?.stdin?.end?.();
|
|
98
|
+
} catch {
|
|
99
|
+
/* ignore */
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One-line description of a failed git run. A credential prompt (what GitHub does for a missing or
|
|
106
|
+
* private repository) is named as such, with the raw detail kept after it.
|
|
107
|
+
*/
|
|
108
|
+
export function describeGitFailure(what, r, timeoutMs) {
|
|
109
|
+
if (r.killed || r.signal) return `${what} timed out after ${timeoutMs}ms`;
|
|
110
|
+
const err = String(r.stderr ?? '').trim() || r.error?.message || '';
|
|
111
|
+
const detail = err.split('\n').filter(Boolean).slice(-2).join(' | ').slice(0, 300);
|
|
112
|
+
const code = r.code ?? r.error?.code ?? 'unknown';
|
|
113
|
+
const hint = CREDENTIAL_PROMPT_RE.test(err) ? 'repository not found or private (GitHub asked for credentials)' : '';
|
|
114
|
+
const tail = [hint, detail].filter(Boolean).join(': ');
|
|
115
|
+
return `${what} failed (exit ${code})${tail ? `: ${tail}` : ''}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fetch exactly one commit of <base>/<owner>/<repo> into `dest` (an existing, empty directory), the way
|
|
120
|
+
* the skills CLI's cloneAtSha does: git init, remote add, fetch --depth 1 <sha>, checkout FETCH_HEAD.
|
|
121
|
+
* Returns {ok: true, steps} or {ok: false, step, message}. Never throws.
|
|
122
|
+
*/
|
|
123
|
+
export async function cloneAtSha({ owner, repo, sha, dest, timeoutMs = 90000, execFileImpl = nodeExecFile, env = process.env, tmpdir } = {}) {
|
|
124
|
+
const url = repoUrl(owner, repo, env);
|
|
125
|
+
const gitEnv = childEnv(env, tmpdir ? { TMPDIR: tmpdir } : {});
|
|
126
|
+
const steps = [
|
|
127
|
+
['init', ['init', '-q']],
|
|
128
|
+
['remote add', ['remote', 'add', 'origin', url]],
|
|
129
|
+
['fetch', ['fetch', '--depth', '1', 'origin', sha]],
|
|
130
|
+
['checkout', ['-c', 'core.symlinks=true', 'checkout', '-q', 'FETCH_HEAD']],
|
|
131
|
+
];
|
|
132
|
+
for (const [label, args] of steps) {
|
|
133
|
+
const r = await runExecFile(execFileImpl, 'git', args, { cwd: dest, env: gitEnv, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 32 * 1024 * 1024, windowsHide: true });
|
|
134
|
+
if (r.killed) return { ok: false, step: label, message: `git ${label} timed out after ${timeoutMs}ms` };
|
|
135
|
+
if (r.error || r.code !== 0) return { ok: false, step: label, message: `${describeGitFailure(`git ${label}`, r, timeoutMs)} — could not fetch ${owner}/${repo} at ${sha}` };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, url, env: gitEnv };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every SKILL.md under `root` (depth-limited, skipping .git/node_modules), as {dir, rel, name} sorted by rel. */
|
|
141
|
+
export function findSkillDirs(root, maxDepth = 6) {
|
|
142
|
+
const out = [];
|
|
143
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
144
|
+
let scanned = 0;
|
|
145
|
+
while (stack.length && scanned < MAX_SKILL_MD_SCAN) {
|
|
146
|
+
const { dir, depth } = stack.pop();
|
|
147
|
+
let ents;
|
|
148
|
+
try {
|
|
149
|
+
ents = fs.readdirSync(dir, { withFileTypes: true });
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const ent of ents) {
|
|
154
|
+
scanned++;
|
|
155
|
+
const full = path.join(dir, ent.name);
|
|
156
|
+
if (ent.isDirectory()) {
|
|
157
|
+
if (!SKIP_DIRS.has(ent.name) && depth < maxDepth) stack.push({ dir: full, depth: depth + 1 });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ent.isFile() && ent.name === 'SKILL.md') {
|
|
161
|
+
let name = null;
|
|
162
|
+
try {
|
|
163
|
+
name = parseFrontmatter(fs.readFileSync(full, 'utf8')).name;
|
|
164
|
+
} catch {
|
|
165
|
+
name = null;
|
|
166
|
+
}
|
|
167
|
+
out.push({ dir, rel: path.relative(root, dir) || '.', name: name ? String(name).trim() : null });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
package/lib/inspect.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// lib/inspect.js — decide whether a downloaded skill folder is prose or a plugin in disguise.
|
|
2
|
+
//
|
|
3
|
+
// inspectSkillFolder(dir) -> {ok: boolean, problems: string[], files: number}
|
|
4
|
+
//
|
|
5
|
+
// A folder copied under ~/.claude/skills becomes a `<name>@skills-dir` plugin (hooks, MCP, LSP,
|
|
6
|
+
// bin/ on PATH, agents) with no trust dialog when it carries plugin components, and SKILL.md
|
|
7
|
+
// frontmatter itself can grant capability (allowed-tools, hooks, context/agent, shell, paths).
|
|
8
|
+
// This module only reads; it never modifies anything.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { parseFrontmatter } from './skill-index.js';
|
|
14
|
+
|
|
15
|
+
export const FORBIDDEN_DIRS = ['.claude-plugin', 'hooks', 'bin', 'monitors', 'agents', 'commands', 'scripts', '.claude'];
|
|
16
|
+
export const FORBIDDEN_FILES = ['plugin.json', '.mcp.json', '.lsp.json', 'settings.json', 'hooks.json', 'marketplace.json'];
|
|
17
|
+
export const FORBIDDEN_FRONTMATTER = ['hooks', 'allowed-tools', 'disallowed-tools', 'context', 'agent', 'shell', 'paths', 'model'];
|
|
18
|
+
const MAX_DEPTH = 8;
|
|
19
|
+
const MAX_ENTRIES = 5000;
|
|
20
|
+
|
|
21
|
+
const FENCE_BANG_RE = /^\s*(`{3,}|~{3,})\s*!/;
|
|
22
|
+
const AT_REF_RE = /^\s*(?:[-*+]\s+)?@[^\s@]/;
|
|
23
|
+
// Raw frontmatter scan (fail closed): Claude Code 2.1.282 honours quoted (`"allowed-tools":`) and
|
|
24
|
+
// uniformly indented (` hooks:`) keys that the minimal parser above does not see as top-level.
|
|
25
|
+
const RAW_CAPABILITY_KEY_RE = /^\s*["']?(hooks|allowed-tools|disallowed-tools|context|agent|shell|paths|model|user-invocable)["']?\s*:/i;
|
|
26
|
+
const RAW_QUOTED_KEY_RE = /^\s*["'][^"']*["']\s*:/;
|
|
27
|
+
const RAW_FLOW_RE = /^\s*[{[]/;
|
|
28
|
+
const RAW_KEY_RE = /^(\s*)[A-Za-z0-9_"'][^:]*:/;
|
|
29
|
+
const PLAIN_TOP_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]*\s*:(?:\s|$)/;
|
|
30
|
+
|
|
31
|
+
function rel(root, p) {
|
|
32
|
+
const r = path.relative(root, p);
|
|
33
|
+
return r || '.';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walk(root, problems, counters) {
|
|
37
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
38
|
+
while (stack.length) {
|
|
39
|
+
const { dir, depth } = stack.pop();
|
|
40
|
+
let ents;
|
|
41
|
+
try {
|
|
42
|
+
ents = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
problems.push(`unreadable directory ${rel(root, dir)}: ${err.code ?? err.message}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const ent of ents) {
|
|
48
|
+
if (++counters.entries > MAX_ENTRIES) {
|
|
49
|
+
problems.push(`more than ${MAX_ENTRIES} entries; inspection aborted`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const full = path.join(dir, ent.name);
|
|
53
|
+
const r = rel(root, full);
|
|
54
|
+
let lst;
|
|
55
|
+
try {
|
|
56
|
+
lst = fs.lstatSync(full);
|
|
57
|
+
} catch {
|
|
58
|
+
problems.push(`unreadable entry ${r}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (lst.isSymbolicLink()) {
|
|
62
|
+
let target = '?';
|
|
63
|
+
try {
|
|
64
|
+
target = fs.readlinkSync(full);
|
|
65
|
+
} catch {
|
|
66
|
+
/* ignore */
|
|
67
|
+
}
|
|
68
|
+
problems.push(`symlink ${r} -> ${target}`);
|
|
69
|
+
continue; // never follow
|
|
70
|
+
}
|
|
71
|
+
if (lst.isDirectory()) {
|
|
72
|
+
if (ent.name === '.git') continue; // not copied by the CLI; ignore
|
|
73
|
+
if (FORBIDDEN_DIRS.includes(ent.name)) problems.push(`plugin component directory ${r}/`);
|
|
74
|
+
if (depth + 1 <= MAX_DEPTH) stack.push({ dir: full, depth: depth + 1 });
|
|
75
|
+
else problems.push(`directory nesting deeper than ${MAX_DEPTH}: ${r}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!lst.isFile()) {
|
|
79
|
+
problems.push(`special file ${r}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
counters.files++;
|
|
83
|
+
if (FORBIDDEN_FILES.includes(ent.name)) problems.push(`plugin component file ${r}`);
|
|
84
|
+
if (lst.mode & 0o111) problems.push(`executable bit on ${r}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function inspectSkillMd(file, problems) {
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = fs.readFileSync(file, 'utf8');
|
|
93
|
+
} catch (err) {
|
|
94
|
+
problems.push(`SKILL.md unreadable: ${err.code ?? err.message}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const fm = parseFrontmatter(text);
|
|
98
|
+
for (const key of Object.keys(fm.fields)) {
|
|
99
|
+
const k = key.toLowerCase();
|
|
100
|
+
if (FORBIDDEN_FRONTMATTER.includes(k)) problems.push(`frontmatter key "${key}"`);
|
|
101
|
+
if (k === 'user-invocable' && /^(false|no|off)$/i.test(String(fm.fields[key]).trim())) problems.push('frontmatter user-invocable: false');
|
|
102
|
+
}
|
|
103
|
+
const lines = fm.body.split('\n');
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i];
|
|
106
|
+
if (line.includes('!`')) problems.push(`inline shell (!\`) in SKILL.md line ${i + 1}`);
|
|
107
|
+
if (FENCE_BANG_RE.test(line)) problems.push(`fenced shell block (\`\`\`!) in SKILL.md line ${i + 1}`);
|
|
108
|
+
if (AT_REF_RE.test(line)) problems.push(`@-file reference in SKILL.md line ${i + 1}: ${line.trim().slice(0, 60)}`);
|
|
109
|
+
}
|
|
110
|
+
// Frontmatter lines can carry shell forms too (e.g. description: !`cmd`).
|
|
111
|
+
const fmText = text.slice(0, Math.max(0, text.length - fm.body.length));
|
|
112
|
+
if (fm.hasFrontmatter && fmText.includes('!`')) problems.push('inline shell (!`) in SKILL.md frontmatter');
|
|
113
|
+
if (fm.hasFrontmatter) scanRawFrontmatter(fmText, problems);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Line-by-line scan of the raw frontmatter block. Rejects any capability key in any spelling
|
|
118
|
+
* (quoted, indented, mixed case), any quoted key at all, any flow mapping line (`{` / `[`), and a
|
|
119
|
+
* first key that is indented: whatever the minimal parser cannot fully interpret is refused.
|
|
120
|
+
*/
|
|
121
|
+
export function scanRawFrontmatter(fmText, problems) {
|
|
122
|
+
const lines = String(fmText ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
123
|
+
const inner = lines.slice(1); // drop the opening ---
|
|
124
|
+
let firstKeySeen = false;
|
|
125
|
+
for (let i = 0; i < inner.length; i++) {
|
|
126
|
+
const line = inner[i];
|
|
127
|
+
const t = line.trim();
|
|
128
|
+
if (t === '---' || t === '...') break;
|
|
129
|
+
if (!t || t.startsWith('#')) continue;
|
|
130
|
+
const cap = RAW_CAPABILITY_KEY_RE.exec(line);
|
|
131
|
+
// Plain top-level keys (`hooks:` at column 0, unquoted) are already reported from the parsed fields;
|
|
132
|
+
// the raw scan adds the spellings the parser does not see (quoted, indented, mixed case).
|
|
133
|
+
if (cap && !PLAIN_TOP_KEY_RE.test(line)) {
|
|
134
|
+
const key = cap[1].toLowerCase();
|
|
135
|
+
const value = line.slice(line.indexOf(':') + 1).trim();
|
|
136
|
+
if (key === 'user-invocable') {
|
|
137
|
+
if (/^(false|no|off)$/i.test(value)) problems.push(`frontmatter user-invocable: false (raw line ${i + 1})`);
|
|
138
|
+
} else {
|
|
139
|
+
problems.push(`frontmatter key "${key}" (raw line ${i + 1})`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (RAW_QUOTED_KEY_RE.test(line)) problems.push(`frontmatter quoted key (raw line ${i + 1}): ${t.slice(0, 40)}`);
|
|
143
|
+
if (RAW_FLOW_RE.test(line)) problems.push(`frontmatter flow mapping (raw line ${i + 1}): ${t.slice(0, 40)}`);
|
|
144
|
+
const km = RAW_KEY_RE.exec(line);
|
|
145
|
+
if (km && !firstKeySeen) {
|
|
146
|
+
firstKeySeen = true;
|
|
147
|
+
if (km[1].length > 0) problems.push(`frontmatter first key is indented (raw line ${i + 1}): ${t.slice(0, 40)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} dir the skill folder (must contain SKILL.md)
|
|
154
|
+
* @returns {Promise<{ok: boolean, problems: string[], files: number}>}
|
|
155
|
+
*/
|
|
156
|
+
export async function inspectSkillFolder(dir) {
|
|
157
|
+
const problems = [];
|
|
158
|
+
const counters = { entries: 0, files: 0 };
|
|
159
|
+
const root = path.resolve(String(dir ?? ''));
|
|
160
|
+
let st;
|
|
161
|
+
try {
|
|
162
|
+
st = fs.lstatSync(root);
|
|
163
|
+
} catch {
|
|
164
|
+
return { ok: false, problems: [`not found: ${root}`], files: 0 };
|
|
165
|
+
}
|
|
166
|
+
if (st.isSymbolicLink()) return { ok: false, problems: [`skill folder itself is a symlink: ${root}`], files: 0 };
|
|
167
|
+
if (!st.isDirectory()) return { ok: false, problems: [`not a directory: ${root}`], files: 0 };
|
|
168
|
+
|
|
169
|
+
walk(root, problems, counters);
|
|
170
|
+
|
|
171
|
+
const skillMd = path.join(root, 'SKILL.md');
|
|
172
|
+
let skillSt = null;
|
|
173
|
+
try {
|
|
174
|
+
skillSt = fs.lstatSync(skillMd);
|
|
175
|
+
} catch {
|
|
176
|
+
skillSt = null;
|
|
177
|
+
}
|
|
178
|
+
if (!skillSt || !skillSt.isFile()) problems.push('SKILL.md missing');
|
|
179
|
+
else inspectSkillMd(skillMd, problems);
|
|
180
|
+
|
|
181
|
+
const unique = [...new Set(problems)];
|
|
182
|
+
return { ok: unique.length === 0, problems: unique, files: counters.files };
|
|
183
|
+
}
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lib/log.js — JSON-lines logging for singular. Never writes to stdout
|
|
2
|
+
// (stdout of a hook is the hook's output) and never throws.
|
|
3
|
+
//
|
|
4
|
+
// log(level, msg, extra?) -> appends {ts, level, msg, ...extra} to $SINGULAR_HOME/singular.log
|
|
5
|
+
// timer() -> { elapsed(): ms }
|
|
6
|
+
// logPath(env?) -> absolute path of the log file
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { defaultHomeDir, redirectedHome } from './config.js';
|
|
12
|
+
|
|
13
|
+
export const LOG_FILE = 'singular.log';
|
|
14
|
+
const MAX_LOG_BYTES = 2 * 1024 * 1024; // rotate to singular.log.1 past this
|
|
15
|
+
|
|
16
|
+
/** Same directory rule as lib/config.js homeDir(): SINGULAR_HOME only when absolute, else ~/.claude/singular. */
|
|
17
|
+
export function logPath(env = process.env) {
|
|
18
|
+
return path.join(redirectedHome(env) ?? defaultHomeDir(env), LOG_FILE);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeExtra(extra) {
|
|
22
|
+
if (!extra || typeof extra !== 'object') return {};
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
25
|
+
if (k === 'ts' || k === 'level' || k === 'msg') continue;
|
|
26
|
+
if (v instanceof Error) out[k] = { message: v.message, name: v.name };
|
|
27
|
+
else out[k] = v;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Append one JSON line to the log. `level` is free-form ("debug" | "info" | "warn" | "error").
|
|
34
|
+
* Failures (unwritable dir, disk full, cyclic extra) are swallowed.
|
|
35
|
+
*/
|
|
36
|
+
export function log(level, msg, extra) {
|
|
37
|
+
try {
|
|
38
|
+
const file = logPath();
|
|
39
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
40
|
+
try {
|
|
41
|
+
const st = fs.statSync(file);
|
|
42
|
+
if (st.size > MAX_LOG_BYTES) fs.renameSync(file, `${file}.1`);
|
|
43
|
+
} catch {
|
|
44
|
+
/* no file yet */
|
|
45
|
+
}
|
|
46
|
+
const line = JSON.stringify({
|
|
47
|
+
ts: new Date().toISOString(),
|
|
48
|
+
pid: process.pid,
|
|
49
|
+
level: String(level ?? 'info'),
|
|
50
|
+
msg: String(msg ?? ''),
|
|
51
|
+
...safeExtra(extra),
|
|
52
|
+
});
|
|
53
|
+
fs.appendFileSync(file, `${line}\n`);
|
|
54
|
+
} catch {
|
|
55
|
+
/* never throw from logging */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Monotonic stopwatch. */
|
|
60
|
+
export function timer() {
|
|
61
|
+
const start = process.hrtime.bigint();
|
|
62
|
+
return {
|
|
63
|
+
elapsed() {
|
|
64
|
+
return Number(process.hrtime.bigint() - start) / 1e6;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|