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/cli-core.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
// lib/cli-core.js — the `singular` command-line tool (bin/singular-cli.js, npm package singular-skill).
|
|
2
|
+
//
|
|
3
|
+
// singular install [--dir <path>] [--dry-run] [--json] copy the plugin payload to ~/.claude/singular/plugin and
|
|
4
|
+
// register it with Claude Code (marketplace + plugin)
|
|
5
|
+
// singular update same as install (re-copy, marketplace update, plugin update)
|
|
6
|
+
// singular uninstall [--purge] unregister and remove the payload (--purge: config, cache, log, pins too)
|
|
7
|
+
// singular status [--json] | doctor versions, payload, marketplace, plugin, config, log tail (+ hints)
|
|
8
|
+
// singular find|add|mode|load ... proxies to bin/singular.js (load: bin/on-expand.js) for terminal use
|
|
9
|
+
//
|
|
10
|
+
// Zero dependencies. Every `claude ...` call goes through execFile with an argument array (never a shell string) and
|
|
11
|
+
// inherits the caller's environment unchanged, so CLAUDE_CONFIG_DIR / HOME redirect Claude Code's state exactly as
|
|
12
|
+
// they do for the user. Every side effect (subprocesses, the file system, versions, output) comes from an injectable
|
|
13
|
+
// `deps` object: test/unit-cli.test.js drives the whole flow with a fake `claude`; test/cli-install.test.js runs the
|
|
14
|
+
// packed tarball against the real CLI in an isolated CLAUDE_CONFIG_DIR + HOME.
|
|
15
|
+
//
|
|
16
|
+
// Why a copied payload: Claude Code loads a directory marketplace plugin IN PLACE from the registered folder. A
|
|
17
|
+
// global npm install moves with every Node / nvm upgrade, so the registered path is the stable copy under
|
|
18
|
+
// ~/.claude/singular/plugin (replaced atomically: plugin.tmp -> plugin, previous copy kept as plugin.prev).
|
|
19
|
+
|
|
20
|
+
import { execFile as nodeExecFile, spawn as nodeSpawn } from 'node:child_process';
|
|
21
|
+
import nodeFs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { configPath, DEFAULTS, loadConfig } from './config.js';
|
|
27
|
+
import { logPath } from './log.js';
|
|
28
|
+
|
|
29
|
+
export const PLUGIN_NAME = 'singular';
|
|
30
|
+
export const MARKETPLACE = 'singular';
|
|
31
|
+
export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE}`;
|
|
32
|
+
export const MIN_NODE_MAJOR = 20;
|
|
33
|
+
export const MIN_CLAUDE_VERSION = '2.1.282';
|
|
34
|
+
/** What `singular install` copies (in this order) from the package directory; missing optional entries are skipped. */
|
|
35
|
+
export const PAYLOAD_ENTRIES = Object.freeze(['.claude-plugin', 'hooks', 'skills', 'bin', 'lib', 'package.json', 'LICENSE', 'README.md', 'config.example.json']);
|
|
36
|
+
export const REQUIRED_ENTRIES = Object.freeze(['.claude-plugin', 'hooks', 'skills', 'bin', 'lib', 'package.json']);
|
|
37
|
+
export const DONE_LINE = 'Installed. Open a new Claude Code session (or run /reload-plugins) and type /singular';
|
|
38
|
+
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
export const COMMANDS = Object.freeze(['install', 'update', 'uninstall', 'status', 'doctor', 'find', 'add', 'mode', 'load']);
|
|
40
|
+
export const PROXY_COMMANDS = Object.freeze(['find', 'add', 'mode', 'load']);
|
|
41
|
+
const INSTALL_STEPS = 5;
|
|
42
|
+
const UNINSTALL_STEPS = 3;
|
|
43
|
+
const CLAUDE_TIMEOUT_MS = 120000;
|
|
44
|
+
|
|
45
|
+
export const HELP = `singular — install and manage the Singular plugin for Claude Code
|
|
46
|
+
|
|
47
|
+
Usage: singular <command> [options]
|
|
48
|
+
|
|
49
|
+
Setup
|
|
50
|
+
install [--dir <path>] [--dry-run] [--json] copy the plugin payload to ~/.claude/singular/plugin and register it
|
|
51
|
+
with Claude Code (claude plugin marketplace add + claude plugin install)
|
|
52
|
+
update same as install: re-copy the payload, refresh the marketplace, update the plugin
|
|
53
|
+
uninstall [--purge] unregister the plugin, remove the payload; --purge also removes
|
|
54
|
+
~/.claude/singular (config, cache, log, pins)
|
|
55
|
+
status [--json] node/claude versions, payload, marketplace, plugin, config, last log lines
|
|
56
|
+
doctor status plus actionable hints; exit 1 when something needs fixing
|
|
57
|
+
|
|
58
|
+
Skills (the same commands as /singular inside Claude Code)
|
|
59
|
+
find <query> gated third-party candidates from skills.sh (never installs)
|
|
60
|
+
add <owner/repo[#sha]@skill> [--yes] pinned persistent install into ~/.claude/skills with folder inspection
|
|
61
|
+
mode [off|local|suggest|auto] show or set the mode in ~/.claude/singular/config.json
|
|
62
|
+
load <owner/repo[#sha]@skill> [--update] print a skill's sanitized text (what /singular load injects)
|
|
63
|
+
|
|
64
|
+
Options
|
|
65
|
+
--dir <path> payload directory (default ~/.claude/singular/plugin)
|
|
66
|
+
--dry-run install: print the plan and change nothing
|
|
67
|
+
--json install, uninstall, status, doctor: machine-readable output
|
|
68
|
+
-h, --help this text
|
|
69
|
+
-v, --version print the CLI version
|
|
70
|
+
|
|
71
|
+
Environment: CLAUDE_CONFIG_DIR is passed through to claude unchanged (an isolated config dir works);
|
|
72
|
+
HOME decides where ~/.claude/singular lives. No other variable is read.
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// deps, errors, small helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export class CliError extends Error {
|
|
80
|
+
constructor(message, { retry = null, step = null, command = null, exitCode = null } = {}) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = 'CliError';
|
|
83
|
+
this.retry = retry;
|
|
84
|
+
this.step = step;
|
|
85
|
+
this.command = command;
|
|
86
|
+
this.exitCode = exitCode;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function defaultDeps(overrides = {}) {
|
|
91
|
+
return {
|
|
92
|
+
env: process.env,
|
|
93
|
+
fs: nodeFs,
|
|
94
|
+
execFileImpl: nodeExecFile,
|
|
95
|
+
spawnImpl: nodeSpawn,
|
|
96
|
+
execPath: process.execPath,
|
|
97
|
+
nodeVersion: process.versions.node,
|
|
98
|
+
packageRoot: PACKAGE_ROOT,
|
|
99
|
+
homedir: () => os.homedir(),
|
|
100
|
+
stdout: (s) => process.stdout.write(s),
|
|
101
|
+
stderr: (s) => process.stderr.write(s),
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
107
|
+
const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
|
|
109
|
+
function cleanLines(text) {
|
|
110
|
+
return String(text ?? '')
|
|
111
|
+
.replace(ANSI_RE, '')
|
|
112
|
+
.split(/\r?\n/)
|
|
113
|
+
.map((l) => l.replace(/^[^\n]*?(?:…|\.\.\.)\s*(?=[✔✘])/u, '').trim()) // drop "Adding marketplace…✔" / "Installing plugin...✔" progress prefixes
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function firstLine(text) {
|
|
118
|
+
return cleanLines(text)[0] ?? '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function readJson(fs, file) {
|
|
122
|
+
try {
|
|
123
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
124
|
+
return v && typeof v === 'object' ? v : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** JSON.parse, tolerating progress text before the first `[`/`{` and after the last `]`/`}`. */
|
|
131
|
+
export function parseJsonLoose(text) {
|
|
132
|
+
const s = String(text ?? '').replace(ANSI_RE, '').trim();
|
|
133
|
+
if (!s) return null;
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(s);
|
|
136
|
+
} catch {
|
|
137
|
+
/* fall through */
|
|
138
|
+
}
|
|
139
|
+
const starts = [s.indexOf('['), s.indexOf('{')].filter((i) => i >= 0);
|
|
140
|
+
if (!starts.length) return null;
|
|
141
|
+
const start = Math.min(...starts);
|
|
142
|
+
const end = Math.max(s.lastIndexOf(']'), s.lastIndexOf('}'));
|
|
143
|
+
if (end <= start) return null;
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(s.slice(start, end + 1));
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function tailLines(fs, file, n) {
|
|
152
|
+
try {
|
|
153
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-n);
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function canon(fs, p) {
|
|
160
|
+
const abs = path.resolve(p);
|
|
161
|
+
try {
|
|
162
|
+
return typeof fs.realpathSync?.native === 'function' ? fs.realpathSync.native(abs) : fs.realpathSync(abs);
|
|
163
|
+
} catch {
|
|
164
|
+
return abs;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Same directory after symlink resolution (/tmp vs /private/tmp on macOS). */
|
|
169
|
+
export function samePath(fs, a, b) {
|
|
170
|
+
if (!a || !b) return false;
|
|
171
|
+
return canon(fs, a) === canon(fs, b);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isInsideOrEqual(child, parent) {
|
|
175
|
+
const c = path.resolve(child);
|
|
176
|
+
const p = path.resolve(parent);
|
|
177
|
+
return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// subprocesses
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* execFile with an argument array; never a shell. Resolves (never rejects) with
|
|
186
|
+
* {code, signal, stdout, stderr, notFound, timedOut, error, command}.
|
|
187
|
+
*/
|
|
188
|
+
export function runExec(deps, file, args, { timeoutMs = CLAUDE_TIMEOUT_MS, cwd } = {}) {
|
|
189
|
+
const command = `${file} ${args.join(' ')}`.trim();
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
const finish = (error, stdout, stderr) => {
|
|
192
|
+
const out = String(stdout ?? '');
|
|
193
|
+
const errText = String(stderr ?? '');
|
|
194
|
+
if (!error) return resolve({ code: 0, signal: null, stdout: out, stderr: errText, notFound: false, timedOut: false, error: null, command });
|
|
195
|
+
const notFound = error.code === 'ENOENT';
|
|
196
|
+
const timedOut = error.killed === true || error.signal === 'SIGTERM';
|
|
197
|
+
const code = typeof error.code === 'number' ? error.code : null;
|
|
198
|
+
return resolve({ code, signal: error.signal ?? null, stdout: out, stderr: errText || (notFound ? `${file}: not found on PATH` : error.message), notFound, timedOut, error, command });
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
deps.execFileImpl(file, args, { env: deps.env, cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true, encoding: 'utf8' }, finish);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
finish(err, '', '');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function claude(deps, args, opts) {
|
|
209
|
+
const r = await runExec(deps, 'claude', args, opts);
|
|
210
|
+
return { ...r, command: `claude ${args.join(' ')}`, ok: r.code === 0 && !r.notFound };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// versions
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export function parseVersion(text) {
|
|
218
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ''));
|
|
219
|
+
if (!m) return null;
|
|
220
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), text: `${m[1]}.${m[2]}.${m[3]}` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** <0, 0, >0 like a comparator; NaN when either side does not parse. */
|
|
224
|
+
export function compareVersions(a, b) {
|
|
225
|
+
const pa = typeof a === 'string' ? parseVersion(a) : a;
|
|
226
|
+
const pb = typeof b === 'string' ? parseVersion(b) : b;
|
|
227
|
+
if (!pa || !pb) return NaN;
|
|
228
|
+
return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Node >= 20 (from deps.nodeVersion) and `claude --version` >= 2.1.282 on PATH.
|
|
233
|
+
* @returns {Promise<{node:{version,ok}, claude:{found,version,ok,raw}, problems:string[]}>}
|
|
234
|
+
*/
|
|
235
|
+
export async function checkEnvironment(deps) {
|
|
236
|
+
const nodeV = parseVersion(deps.nodeVersion);
|
|
237
|
+
const result = {
|
|
238
|
+
node: { version: String(deps.nodeVersion), ok: !!nodeV && nodeV.major >= MIN_NODE_MAJOR },
|
|
239
|
+
claude: { found: false, version: null, ok: false, raw: '' },
|
|
240
|
+
problems: [],
|
|
241
|
+
};
|
|
242
|
+
if (!result.node.ok) result.problems.push(`Node >= ${MIN_NODE_MAJOR} is required (found ${deps.nodeVersion}). Install a newer Node (https://nodejs.org) and re-run.`);
|
|
243
|
+
const r = await runExec(deps, 'claude', ['--version'], { timeoutMs: 30000 });
|
|
244
|
+
if (r.notFound) {
|
|
245
|
+
result.problems.push('claude is not on PATH. Install Claude Code first (https://docs.claude.com/en/docs/claude-code/setup), open a new terminal, then re-run.');
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
result.claude.found = true;
|
|
249
|
+
result.claude.raw = (r.stdout || r.stderr).replace(ANSI_RE, '').trim();
|
|
250
|
+
const v = parseVersion(r.stdout) ?? parseVersion(r.stderr);
|
|
251
|
+
if (!v) {
|
|
252
|
+
result.problems.push(r.code === 0 ? `could not parse \`claude --version\` output: "${firstLine(result.claude.raw) || '(empty)'}"` : `\`claude --version\` failed (exit ${r.code ?? (r.timedOut ? 'timeout' : 'unknown')}): ${firstLine(r.stderr || r.stdout) || 'no output'}`);
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
result.claude.version = v.text;
|
|
256
|
+
result.claude.ok = compareVersions(v, MIN_CLAUDE_VERSION) >= 0;
|
|
257
|
+
if (!result.claude.ok) result.problems.push(`Claude Code >= ${MIN_CLAUDE_VERSION} is required (found ${v.text}). Run \`claude update\` (or reinstall Claude Code) and re-run.`);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// paths and the payload copy
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/** ~/.claude/singular (HOME-based, like lib/config.js defaultHomeDir) and the payload dir (--dir overrides it). */
|
|
266
|
+
export function statePaths(deps, { dir = null } = {}) {
|
|
267
|
+
const home = path.resolve(deps.env.HOME || deps.homedir());
|
|
268
|
+
const stateDir = path.join(home, '.claude', PLUGIN_NAME);
|
|
269
|
+
const payloadDir = dir ? path.resolve(dir) : path.join(stateDir, 'plugin');
|
|
270
|
+
return { home, stateDir, payloadDir, tmpDir: `${payloadDir}.tmp`, prevDir: `${payloadDir}.prev` };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function payloadManifest(fs, payloadDir) {
|
|
274
|
+
return readJson(fs, path.join(payloadDir, '.claude-plugin', 'plugin.json'));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Refuse payload directories that could never be right: a root, the home or an ancestor of it, the package itself, a foreign non-empty folder. */
|
|
278
|
+
export function guardPayloadDir(deps, paths) {
|
|
279
|
+
const { payloadDir, home } = paths;
|
|
280
|
+
if (payloadDir === path.parse(payloadDir).root) throw new CliError(`refusing to use the filesystem root ${payloadDir} as the payload directory`);
|
|
281
|
+
if (isInsideOrEqual(home, payloadDir)) throw new CliError(`refusing to use ${payloadDir} as the payload directory: it is your home directory or contains it`);
|
|
282
|
+
if (isInsideOrEqual(payloadDir, deps.packageRoot) || isInsideOrEqual(deps.packageRoot, payloadDir)) {
|
|
283
|
+
throw new CliError(`refusing to copy the payload onto itself: ${payloadDir} is inside, equal to, or contains the package directory ${deps.packageRoot}`);
|
|
284
|
+
}
|
|
285
|
+
if (deps.fs.existsSync(payloadDir)) {
|
|
286
|
+
let entries = [];
|
|
287
|
+
try {
|
|
288
|
+
entries = deps.fs.readdirSync(payloadDir);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
throw new CliError(`cannot read ${payloadDir}: ${err.message}`);
|
|
291
|
+
}
|
|
292
|
+
const manifest = payloadManifest(deps.fs, payloadDir);
|
|
293
|
+
if (entries.length && (!manifest || manifest.name !== PLUGIN_NAME)) {
|
|
294
|
+
throw new CliError(`refusing to replace ${payloadDir}: it is not a singular payload (no .claude-plugin/plugin.json with name "${PLUGIN_NAME}"). Pass another --dir or move that folder away.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Validate the package directory and copy PAYLOAD_ENTRIES into paths.payloadDir atomically:
|
|
301
|
+
* copy into <payload>.tmp, move the current payload to <payload>.prev (kept for rollback), rename .tmp into place;
|
|
302
|
+
* a failed final rename restores .prev. With dryRun only the plan is returned and nothing is written.
|
|
303
|
+
*/
|
|
304
|
+
export function copyPayload(deps, paths, { dryRun = false } = {}) {
|
|
305
|
+
const { fs, packageRoot } = deps;
|
|
306
|
+
const missing = REQUIRED_ENTRIES.filter((e) => !fs.existsSync(path.join(packageRoot, e)));
|
|
307
|
+
if (missing.length) throw new CliError(`the package at ${packageRoot} is incomplete: missing ${missing.join(', ')}. Reinstall singular-skill (npm install -g singular-skill) and re-run.`);
|
|
308
|
+
const manifest = payloadManifest(fs, packageRoot);
|
|
309
|
+
if (!manifest || manifest.name !== PLUGIN_NAME) throw new CliError(`${path.join(packageRoot, '.claude-plugin', 'plugin.json')} is missing or does not name the "${PLUGIN_NAME}" plugin; reinstall singular-skill`);
|
|
310
|
+
const marketplace = readJson(fs, path.join(packageRoot, '.claude-plugin', 'marketplace.json'));
|
|
311
|
+
if (!marketplace || marketplace.name !== MARKETPLACE) throw new CliError(`${path.join(packageRoot, '.claude-plugin', 'marketplace.json')} is missing or does not name the "${MARKETPLACE}" marketplace; reinstall singular-skill`);
|
|
312
|
+
const entries = PAYLOAD_ENTRIES.filter((e) => fs.existsSync(path.join(packageRoot, e)));
|
|
313
|
+
const skipped = PAYLOAD_ENTRIES.filter((e) => !entries.includes(e));
|
|
314
|
+
const previous = payloadManifest(fs, paths.payloadDir);
|
|
315
|
+
const plan = { from: packageRoot, to: paths.payloadDir, entries, skipped, previousVersion: previous?.version ?? null, newVersion: manifest.version ?? null, replaced: fs.existsSync(paths.payloadDir), dryRun };
|
|
316
|
+
if (dryRun) return plan;
|
|
317
|
+
|
|
318
|
+
const { payloadDir, tmpDir, prevDir } = paths;
|
|
319
|
+
try {
|
|
320
|
+
fs.mkdirSync(path.dirname(payloadDir), { recursive: true });
|
|
321
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
322
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
323
|
+
for (const e of entries) {
|
|
324
|
+
fs.cpSync(path.join(packageRoot, e), path.join(tmpDir, e), { recursive: true, dereference: false, force: true, errorOnExist: false, filter: (src) => path.basename(src) !== '.DS_Store' && path.basename(src) !== 'node_modules' });
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
try {
|
|
328
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
329
|
+
} catch {
|
|
330
|
+
/* best effort */
|
|
331
|
+
}
|
|
332
|
+
throw new CliError(`could not copy the payload into ${tmpDir}: ${err.message}`);
|
|
333
|
+
}
|
|
334
|
+
const copied = payloadManifest(fs, tmpDir);
|
|
335
|
+
if (!copied || copied.name !== PLUGIN_NAME) {
|
|
336
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
337
|
+
throw new CliError(`the copied payload at ${tmpDir} has no valid .claude-plugin/plugin.json; nothing was replaced`);
|
|
338
|
+
}
|
|
339
|
+
if (plan.replaced) {
|
|
340
|
+
try {
|
|
341
|
+
fs.rmSync(prevDir, { recursive: true, force: true });
|
|
342
|
+
fs.renameSync(payloadDir, prevDir);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
345
|
+
throw new CliError(`could not move the current payload ${payloadDir} aside to ${prevDir}: ${err.message} (nothing was replaced)`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
fs.renameSync(tmpDir, payloadDir);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
let restored = false;
|
|
352
|
+
if (plan.replaced) {
|
|
353
|
+
try {
|
|
354
|
+
fs.renameSync(prevDir, payloadDir);
|
|
355
|
+
restored = true;
|
|
356
|
+
} catch {
|
|
357
|
+
restored = false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
362
|
+
} catch {
|
|
363
|
+
/* best effort */
|
|
364
|
+
}
|
|
365
|
+
throw new CliError(`could not move ${tmpDir} into place at ${payloadDir}: ${err.message}${plan.replaced ? (restored ? ' (previous payload restored)' : ` (previous payload left at ${prevDir}; move it back by hand)`) : ''}`);
|
|
366
|
+
}
|
|
367
|
+
return plan;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// what Claude Code knows: marketplace + plugin state
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/** @returns {Promise<{known:boolean, registered:boolean|null, path:string|null, via:string, error?:string}>} */
|
|
375
|
+
export async function marketplaceState(deps) {
|
|
376
|
+
const j = await claude(deps, ['plugin', 'marketplace', 'list', '--json']);
|
|
377
|
+
const parsed = j.ok ? parseJsonLoose(j.stdout) : null;
|
|
378
|
+
if (Array.isArray(parsed)) {
|
|
379
|
+
const e = parsed.find((m) => m && m.name === MARKETPLACE);
|
|
380
|
+
return { known: true, registered: !!e, path: e ? (e.path ?? e.installLocation ?? null) : null, via: 'json' };
|
|
381
|
+
}
|
|
382
|
+
// Older CLIs without --json, or non-JSON output: parse the text listing.
|
|
383
|
+
const t = j.ok && /Configured marketplaces:/.test(j.stdout) ? j : await claude(deps, ['plugin', 'marketplace', 'list']);
|
|
384
|
+
const plain = `${t.stdout}\n${t.stderr}`.replace(ANSI_RE, '');
|
|
385
|
+
const m = new RegExp(`❯\\s*${escapeRe(MARKETPLACE)}\\s*\\n\\s*Source:\\s*[^(\\n]*\\(([^)\\n]*)\\)`, 'u').exec(plain) ?? new RegExp(`❯\\s*${escapeRe(MARKETPLACE)}\\s*\\n\\s*Source:\\s*([^\\n]*)`, 'u').exec(plain);
|
|
386
|
+
if (m) return { known: true, registered: true, path: m[1].trim() || null, via: 'text' };
|
|
387
|
+
if (t.ok || /Configured marketplaces:|No marketplaces/i.test(plain)) return { known: true, registered: false, path: null, via: 'text' };
|
|
388
|
+
return { known: false, registered: null, path: null, via: 'error', error: firstLine(t.stderr || t.stdout) || `exit ${t.code ?? 'unknown'}` };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** @returns {Promise<{known:boolean, installed:boolean|null, enabled:boolean|null, version:string|null, scope:string|null, readFromFolder:string|null, errors:string[], via:string, error?:string}>} */
|
|
392
|
+
export async function pluginState(deps) {
|
|
393
|
+
const none = { known: true, installed: false, enabled: null, version: null, scope: null, readFromFolder: null, errors: [], via: 'json' };
|
|
394
|
+
const j = await claude(deps, ['plugin', 'list', '--json']);
|
|
395
|
+
const parsed = j.ok ? parseJsonLoose(j.stdout) : null;
|
|
396
|
+
if (Array.isArray(parsed)) {
|
|
397
|
+
const e = parsed.find((p) => p && p.id === PLUGIN_ID);
|
|
398
|
+
if (!e) return none;
|
|
399
|
+
return { known: true, installed: true, enabled: e.enabled !== false, version: e.version ?? e.folderVersion ?? null, scope: e.scope ?? null, readFromFolder: e.readFromFolder ?? e.installPath ?? null, errors: Array.isArray(e.errors) ? e.errors.map(String) : [], via: 'json' };
|
|
400
|
+
}
|
|
401
|
+
const t = j.ok && /Installed plugins:/.test(j.stdout) ? j : await claude(deps, ['plugin', 'list']);
|
|
402
|
+
const plain = `${t.stdout}\n${t.stderr}`.replace(ANSI_RE, '');
|
|
403
|
+
const block = new RegExp(`❯\\s*${escapeRe(PLUGIN_ID)}\\s*\\n((?:[ \\t]+\\S[^\\n]*\\n?)*)`, 'u').exec(plain);
|
|
404
|
+
if (block) {
|
|
405
|
+
const body = block[1];
|
|
406
|
+
const version = /Version:\s*(\S+)/.exec(body)?.[1] ?? null;
|
|
407
|
+
const scope = /Scope:\s*(\S+)/.exec(body)?.[1] ?? null;
|
|
408
|
+
const from = /Read from:\s*([^\n]+)/.exec(body)?.[1]?.trim() ?? null;
|
|
409
|
+
const status = /Status:\s*([^\n]+)/.exec(body)?.[1] ?? '';
|
|
410
|
+
return { known: true, installed: true, enabled: !/disabled/i.test(status), version, scope, readFromFolder: from, errors: [], via: 'text' };
|
|
411
|
+
}
|
|
412
|
+
if (t.ok || /Installed plugins:|No plugins installed/i.test(plain)) return { ...none, via: 'text' };
|
|
413
|
+
return { known: false, installed: null, enabled: null, version: null, scope: null, readFromFolder: null, errors: [], via: 'error', error: firstLine(t.stderr || t.stdout) || `exit ${t.code ?? 'unknown'}` };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function commandFailed(label, idx, total, stepName, r, notes = []) {
|
|
417
|
+
const exitText = r.notFound ? 'not found on PATH' : r.timedOut ? 'timed out' : `exited ${r.code ?? 'unknown'}`;
|
|
418
|
+
const lines = [`singular ${label}: step ${idx}/${total} (${stepName}) failed: \`${r.command}\` ${exitText}`, ...cleanLines(`${r.stdout}\n${r.stderr}`).map((l) => ` ${l}`), ...notes.map((n) => ` ${n}`)];
|
|
419
|
+
return new CliError(lines.join('\n'), { retry: `Retry: singular ${label} (or run \`${r.command}\` yourself and then \`singular ${label}\` again)`, step: stepName, command: r.command, exitCode: r.code });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function reportFailure(deps, label, err, { json, report }) {
|
|
423
|
+
const e = err instanceof CliError ? err : new CliError(`singular ${label}: ${err?.message ?? String(err)}`);
|
|
424
|
+
const message = e.message.startsWith('singular ') ? e.message : `singular ${label}: ${e.message}`;
|
|
425
|
+
if (json) {
|
|
426
|
+
deps.stdout(`${JSON.stringify({ ...report, ok: false, error: message, retry: e.retry, failedStep: e.step, failedCommand: e.command }, null, 2)}\n`);
|
|
427
|
+
} else {
|
|
428
|
+
deps.stderr(`${message}\n${e.retry ? `${e.retry}\n` : ''}`);
|
|
429
|
+
}
|
|
430
|
+
return 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// install / update
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The five steps: (1) node + claude versions, (2) atomic payload copy, (3) marketplace add (or update when it is
|
|
439
|
+
* already registered at that path; add also re-points a marketplace registered elsewhere), (4) plugin install (or
|
|
440
|
+
* update when installed), (5) verify with `claude plugin list --json` (and enable if disabled). Any failure exits 1
|
|
441
|
+
* with the exact command that failed; a marketplace this run added is removed again so nothing stays half-registered.
|
|
442
|
+
*/
|
|
443
|
+
export async function install(deps, { dryRun = false, json = false, dir = null, label = 'install' } = {}) {
|
|
444
|
+
const total = INSTALL_STEPS;
|
|
445
|
+
const report = { ok: false, command: label, dryRun, steps: [], payloadDir: null, versions: {}, plan: null };
|
|
446
|
+
const say = (s) => {
|
|
447
|
+
if (!json) deps.stdout(`${s}\n`);
|
|
448
|
+
};
|
|
449
|
+
const step = (name, detail, extra = {}) => report.steps.push({ name, detail, ...extra });
|
|
450
|
+
let addedMarketplace = false;
|
|
451
|
+
try {
|
|
452
|
+
// 1. environment
|
|
453
|
+
const envc = await checkEnvironment(deps);
|
|
454
|
+
report.versions = { node: envc.node.version, claude: envc.claude.version };
|
|
455
|
+
if (envc.problems.length) throw new CliError(`singular ${label}: step 1/${total} (environment) failed:\n${envc.problems.map((p) => ` ${p}`).join('\n')}`, { step: 'environment', retry: `Retry: singular ${label}` });
|
|
456
|
+
say(`[1/${total}] environment: node ${envc.node.version}, claude ${envc.claude.version} (ok)`);
|
|
457
|
+
step('environment', `node ${envc.node.version}, claude ${envc.claude.version}`);
|
|
458
|
+
|
|
459
|
+
// 2. payload
|
|
460
|
+
const paths = statePaths(deps, { dir });
|
|
461
|
+
report.payloadDir = paths.payloadDir;
|
|
462
|
+
guardPayloadDir(deps, paths);
|
|
463
|
+
const plan = copyPayload(deps, paths, { dryRun });
|
|
464
|
+
report.plan = plan;
|
|
465
|
+
const versionNote = plan.previousVersion ? `${plan.previousVersion} -> ${plan.newVersion ?? '?'}` : `version ${plan.newVersion ?? '?'}`;
|
|
466
|
+
if (dryRun) {
|
|
467
|
+
say(`[2/${total}] payload: would copy ${plan.entries.join(', ')} from ${plan.from} to ${plan.to} (${versionNote}${plan.replaced ? `; current copy kept at ${paths.prevDir}` : ''})${plan.skipped.length ? `; not in this package: ${plan.skipped.join(', ')}` : ''}`);
|
|
468
|
+
} else {
|
|
469
|
+
say(`[2/${total}] payload: copied ${plan.entries.length} entries to ${plan.to} (${versionNote}${plan.replaced ? `; previous copy kept at ${paths.prevDir}` : ''})`);
|
|
470
|
+
}
|
|
471
|
+
step('payload', `${plan.entries.length} entries -> ${plan.to}`, { previousVersion: plan.previousVersion, newVersion: plan.newVersion, replaced: plan.replaced });
|
|
472
|
+
|
|
473
|
+
// 3. marketplace
|
|
474
|
+
const ms = await marketplaceState(deps);
|
|
475
|
+
const samePlace = ms.registered === true && samePath(deps.fs, ms.path, paths.payloadDir);
|
|
476
|
+
const mArgs = samePlace ? ['plugin', 'marketplace', 'update', MARKETPLACE] : ['plugin', 'marketplace', 'add', paths.payloadDir];
|
|
477
|
+
const mNote = ms.registered && !samePlace ? ` (marketplace "${MARKETPLACE}" was registered at ${ms.path ?? 'an unknown path'}; re-pointing it at the payload)` : ms.known ? '' : ` (could not read the marketplace list: ${ms.error ?? 'unknown error'}; trying add)`;
|
|
478
|
+
if (dryRun) {
|
|
479
|
+
say(`[3/${total}] marketplace: would run \`claude ${mArgs.join(' ')}\`${mNote}`);
|
|
480
|
+
step('marketplace', `would run claude ${mArgs.join(' ')}`, { registered: ms.registered, path: ms.path });
|
|
481
|
+
} else {
|
|
482
|
+
const r = await claude(deps, mArgs);
|
|
483
|
+
if (!r.ok) throw commandFailed(label, 3, total, 'marketplace', r, [`The payload copy at ${paths.payloadDir} is complete; nothing was registered.`]);
|
|
484
|
+
addedMarketplace = ms.registered !== true;
|
|
485
|
+
say(`[3/${total}] marketplace: \`${r.command}\` ok${mNote}`);
|
|
486
|
+
for (const l of cleanLines(r.stdout)) say(` ${l}`);
|
|
487
|
+
step('marketplace', r.command, { registered: ms.registered, path: ms.path, output: cleanLines(r.stdout) });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 4. plugin
|
|
491
|
+
const ps = await pluginState(deps);
|
|
492
|
+
const pArgs = ps.installed === true ? ['plugin', 'update', PLUGIN_ID] : ['plugin', 'install', PLUGIN_ID];
|
|
493
|
+
if (dryRun) {
|
|
494
|
+
say(`[4/${total}] plugin: would run \`claude ${pArgs.join(' ')}\`${ps.installed ? ` (${PLUGIN_ID} ${ps.version ?? ''} is installed)` : ''}`);
|
|
495
|
+
step('plugin', `would run claude ${pArgs.join(' ')}`, { installed: ps.installed, version: ps.version });
|
|
496
|
+
say(`[5/${total}] verify: skipped (dry run)`);
|
|
497
|
+
say(`Dry run: nothing was changed. Run \`singular ${label}\` to apply.`);
|
|
498
|
+
report.ok = true;
|
|
499
|
+
if (json) deps.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
const r = await claude(deps, pArgs);
|
|
503
|
+
if (!r.ok) {
|
|
504
|
+
const notes = [];
|
|
505
|
+
if (addedMarketplace) {
|
|
506
|
+
const rb = await claude(deps, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
|
|
507
|
+
notes.push(rb.ok ? `Marketplace "${MARKETPLACE}" added by this run was removed again (nothing is half-registered).` : `Marketplace "${MARKETPLACE}" added by this run could NOT be removed (\`${rb.command}\` ${rb.code === null ? 'failed' : `exited ${rb.code}`}); remove it with \`claude plugin marketplace remove ${MARKETPLACE}\` or just re-run.`);
|
|
508
|
+
}
|
|
509
|
+
notes.push(`The payload copy at ${paths.payloadDir} is complete and harmless; re-running registers it.`);
|
|
510
|
+
throw commandFailed(label, 4, total, 'plugin', r, notes);
|
|
511
|
+
}
|
|
512
|
+
say(`[4/${total}] plugin: \`${r.command}\` ok`);
|
|
513
|
+
for (const l of cleanLines(r.stdout)) say(` ${l}`);
|
|
514
|
+
step('plugin', r.command, { wasInstalled: ps.installed === true, output: cleanLines(r.stdout) });
|
|
515
|
+
|
|
516
|
+
// 5. verify
|
|
517
|
+
const after = await pluginState(deps);
|
|
518
|
+
if (after.installed !== true) {
|
|
519
|
+
throw new CliError(`singular ${label}: step 5/${total} (verify) failed: \`claude plugin list --json\` does not list ${PLUGIN_ID} after \`${r.command}\`${after.error ? ` (${after.error})` : ''}`, { retry: `Retry: singular ${label} (or run \`claude plugin list\` to see what Claude Code has)`, step: 'verify', command: 'claude plugin list --json' });
|
|
520
|
+
}
|
|
521
|
+
let enabledNote = '';
|
|
522
|
+
if (after.enabled === false) {
|
|
523
|
+
const en = await claude(deps, ['plugin', 'enable', PLUGIN_ID]);
|
|
524
|
+
if (!en.ok) throw commandFailed(label, 5, total, 'verify', en, [`${PLUGIN_ID} is installed but disabled.`]);
|
|
525
|
+
enabledNote = ' (was disabled; enabled it)';
|
|
526
|
+
}
|
|
527
|
+
const folderNote = after.readFromFolder && !samePath(deps.fs, after.readFromFolder, paths.payloadDir) ? ` — note: Claude Code reads it from ${after.readFromFolder}, not ${paths.payloadDir}` : '';
|
|
528
|
+
say(`[5/${total}] verify: ${PLUGIN_ID} installed, enabled, version ${after.version ?? '?'}${enabledNote}${folderNote}`);
|
|
529
|
+
step('verify', `${PLUGIN_ID} version ${after.version ?? '?'}`, { enabled: true, readFromFolder: after.readFromFolder });
|
|
530
|
+
if (after.errors.length) for (const e of after.errors) say(` ! ${e}`);
|
|
531
|
+
say(DONE_LINE);
|
|
532
|
+
report.ok = true;
|
|
533
|
+
report.message = DONE_LINE;
|
|
534
|
+
if (json) deps.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
535
|
+
return 0;
|
|
536
|
+
} catch (err) {
|
|
537
|
+
return reportFailure(deps, label, err, { json, report });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// uninstall
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
/** Remove `dir` only when it sits where this CLI would have put a payload (never the home, never the package). */
|
|
546
|
+
function safeRemoveDir(deps, dir, paths) {
|
|
547
|
+
if (!deps.fs.existsSync(dir)) return false;
|
|
548
|
+
if (dir === path.parse(dir).root || isInsideOrEqual(paths.home, dir) || isInsideOrEqual(deps.packageRoot, dir) || isInsideOrEqual(dir, deps.packageRoot)) {
|
|
549
|
+
throw new CliError(`refusing to remove ${dir}`);
|
|
550
|
+
}
|
|
551
|
+
deps.fs.rmSync(dir, { recursive: true, force: true });
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export async function uninstall(deps, { purge = false, dir = null, json = false, label = 'uninstall' } = {}) {
|
|
556
|
+
const total = UNINSTALL_STEPS;
|
|
557
|
+
const report = { ok: false, command: label, purge, steps: [], removed: [], kept: [] };
|
|
558
|
+
const say = (s) => {
|
|
559
|
+
if (!json) deps.stdout(`${s}\n`);
|
|
560
|
+
};
|
|
561
|
+
try {
|
|
562
|
+
const paths = statePaths(deps, { dir });
|
|
563
|
+
const envc = await checkEnvironment(deps);
|
|
564
|
+
if (!envc.claude.found) {
|
|
565
|
+
throw new CliError(`singular ${label}: claude is not on PATH, so the plugin cannot be unregistered. Put claude on PATH (or install Claude Code) and re-run; the payload at ${paths.payloadDir} was left in place.`, { retry: `Retry: singular ${label}` });
|
|
566
|
+
}
|
|
567
|
+
// 1. plugin
|
|
568
|
+
const u = await claude(deps, ['plugin', 'uninstall', PLUGIN_ID]);
|
|
569
|
+
const uOut = `${u.stdout}\n${u.stderr}`;
|
|
570
|
+
if (u.ok) {
|
|
571
|
+
say(`[1/${total}] plugin: \`${u.command}\` ok`);
|
|
572
|
+
report.steps.push({ name: 'plugin', command: u.command, outcome: 'removed' });
|
|
573
|
+
} else if (/not found in installed plugins|not_installed|not installed/i.test(uOut)) {
|
|
574
|
+
say(`[1/${total}] plugin: ${PLUGIN_ID} is not installed (skipped)`);
|
|
575
|
+
report.steps.push({ name: 'plugin', command: u.command, outcome: 'not-installed' });
|
|
576
|
+
} else {
|
|
577
|
+
throw commandFailed(label, 1, total, 'plugin', u);
|
|
578
|
+
}
|
|
579
|
+
// 2. marketplace
|
|
580
|
+
const m = await claude(deps, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
|
|
581
|
+
const mOut = `${m.stdout}\n${m.stderr}`;
|
|
582
|
+
if (m.ok) {
|
|
583
|
+
say(`[2/${total}] marketplace: \`${m.command}\` ok`);
|
|
584
|
+
report.steps.push({ name: 'marketplace', command: m.command, outcome: 'removed' });
|
|
585
|
+
} else if (/not found|not_configured/i.test(mOut)) {
|
|
586
|
+
say(`[2/${total}] marketplace: "${MARKETPLACE}" is not registered (skipped)`);
|
|
587
|
+
report.steps.push({ name: 'marketplace', command: m.command, outcome: 'not-registered' });
|
|
588
|
+
} else {
|
|
589
|
+
throw commandFailed(label, 2, total, 'marketplace', m);
|
|
590
|
+
}
|
|
591
|
+
// 3. files
|
|
592
|
+
for (const d of [paths.payloadDir, paths.prevDir, paths.tmpDir]) {
|
|
593
|
+
if (safeRemoveDir(deps, d, paths)) report.removed.push(d);
|
|
594
|
+
}
|
|
595
|
+
if (purge) {
|
|
596
|
+
if (safeRemoveDir(deps, paths.stateDir, paths)) report.removed.push(paths.stateDir);
|
|
597
|
+
} else if (deps.fs.existsSync(paths.stateDir)) {
|
|
598
|
+
report.kept.push(paths.stateDir);
|
|
599
|
+
}
|
|
600
|
+
say(`[3/${total}] files: ${report.removed.length ? `removed ${report.removed.join(', ')}` : 'nothing to remove'}`);
|
|
601
|
+
report.steps.push({ name: 'files', removed: report.removed });
|
|
602
|
+
if (purge) {
|
|
603
|
+
say(`Uninstalled and purged. Skills installed earlier with /singular add stay in ~/.claude/skills (remove them yourself if you want them gone).`);
|
|
604
|
+
} else {
|
|
605
|
+
say(`Uninstalled.${report.kept.length ? ` State kept at ${report.kept.join(', ')} (config, cache, log, pins); \`singular uninstall --purge\` removes it too.` : ''}`);
|
|
606
|
+
}
|
|
607
|
+
report.ok = true;
|
|
608
|
+
if (json) deps.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
609
|
+
return 0;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
return reportFailure(deps, label, err, { json, report });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// status / doctor
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
export async function collectCliStatus(deps, { dir = null } = {}) {
|
|
620
|
+
const envc = await checkEnvironment(deps);
|
|
621
|
+
const paths = statePaths(deps, { dir });
|
|
622
|
+
const manifest = payloadManifest(deps.fs, paths.payloadDir);
|
|
623
|
+
const payload = { path: paths.payloadDir, exists: deps.fs.existsSync(paths.payloadDir), version: manifest?.version ?? null, valid: !!manifest && manifest.name === PLUGIN_NAME, backup: deps.fs.existsSync(paths.prevDir) ? paths.prevDir : null };
|
|
624
|
+
let marketplace = { known: false, registered: null, path: null, via: 'skipped' };
|
|
625
|
+
let plugin = { known: false, installed: null, enabled: null, version: null, scope: null, readFromFolder: null, errors: [], via: 'skipped' };
|
|
626
|
+
if (envc.claude.found) {
|
|
627
|
+
marketplace = await marketplaceState(deps);
|
|
628
|
+
plugin = await pluginState(deps);
|
|
629
|
+
}
|
|
630
|
+
marketplace.matchesPayload = marketplace.registered === true && samePath(deps.fs, marketplace.path, paths.payloadDir);
|
|
631
|
+
let config = { path: null, mode: null, fileMode: null, warnings: [], exists: null };
|
|
632
|
+
try {
|
|
633
|
+
const file = configPath(deps.env);
|
|
634
|
+
if (!deps.fs.existsSync(file)) {
|
|
635
|
+
// status/doctor are read-only: loadConfig would create config.json from the defaults on its first run
|
|
636
|
+
// (the hooks do that on the first prompt); report the defaults instead of writing them here.
|
|
637
|
+
config = { path: file, mode: DEFAULTS.mode, fileMode: DEFAULTS.mode, warnings: [], exists: false };
|
|
638
|
+
} else {
|
|
639
|
+
const c = loadConfig(deps.env);
|
|
640
|
+
config = { path: c.path, mode: c.mode, fileMode: c.fileMode, warnings: [...(c.warnings ?? [])], exists: true };
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
config.warnings.push(`config unreadable: ${err?.message ?? err}`);
|
|
644
|
+
}
|
|
645
|
+
const logFile = logPath(deps.env);
|
|
646
|
+
const cliPkg = readJson(deps.fs, path.join(deps.packageRoot, 'package.json'));
|
|
647
|
+
return {
|
|
648
|
+
cli: { version: cliPkg?.version ?? null, packageRoot: deps.packageRoot },
|
|
649
|
+
node: envc.node,
|
|
650
|
+
claude: envc.claude,
|
|
651
|
+
environmentProblems: envc.problems,
|
|
652
|
+
stateDir: paths.stateDir,
|
|
653
|
+
payload,
|
|
654
|
+
marketplace,
|
|
655
|
+
plugin,
|
|
656
|
+
config,
|
|
657
|
+
log: { path: logFile, tail: tailLines(deps.fs, logFile, 5) },
|
|
658
|
+
env: { HOME: deps.env.HOME ?? null, CLAUDE_CONFIG_DIR: deps.env.CLAUDE_CONFIG_DIR ?? null, SINGULAR_HOME: deps.env.SINGULAR_HOME ?? null, SINGULAR_MODE: deps.env.SINGULAR_MODE ?? null },
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function formatCliStatus(s) {
|
|
663
|
+
const lines = [];
|
|
664
|
+
lines.push(`singular CLI ${s.cli.version ?? '?'} (package: ${s.cli.packageRoot})`);
|
|
665
|
+
lines.push(`node: ${s.node.version} ${s.node.ok ? `ok (>= ${MIN_NODE_MAJOR})` : `TOO OLD (need >= ${MIN_NODE_MAJOR})`}`);
|
|
666
|
+
if (!s.claude.found) lines.push('claude: not found on PATH');
|
|
667
|
+
else if (!s.claude.version) lines.push(`claude: found, version unknown (${s.claude.raw.slice(0, 80) || 'no output'})`);
|
|
668
|
+
else lines.push(`claude: ${s.claude.version} ${s.claude.ok ? `ok (>= ${MIN_CLAUDE_VERSION})` : `TOO OLD (need >= ${MIN_CLAUDE_VERSION})`}`);
|
|
669
|
+
if (!s.payload.exists) lines.push(`payload: ${s.payload.path} missing (run: singular install)`);
|
|
670
|
+
else if (!s.payload.valid) lines.push(`payload: ${s.payload.path} present but not a valid singular payload (run: singular install)`);
|
|
671
|
+
else lines.push(`payload: ${s.payload.path} version ${s.payload.version ?? '?'}${s.payload.backup ? ` (previous copy at ${s.payload.backup})` : ''}`);
|
|
672
|
+
const m = s.marketplace;
|
|
673
|
+
if (m.via === 'skipped') lines.push('marketplace: unknown (claude not available)');
|
|
674
|
+
else if (!m.known) lines.push(`marketplace: unknown (${m.error ?? 'could not list marketplaces'})`);
|
|
675
|
+
else if (!m.registered) lines.push(`marketplace: "${MARKETPLACE}" not registered (run: singular install)`);
|
|
676
|
+
else lines.push(`marketplace: "${MARKETPLACE}" registered -> ${m.path ?? '?'}${m.matchesPayload ? '' : ' (NOT the payload above; run: singular install)'}`);
|
|
677
|
+
const p = s.plugin;
|
|
678
|
+
if (p.via === 'skipped') lines.push('plugin: unknown (claude not available)');
|
|
679
|
+
else if (!p.known) lines.push(`plugin: unknown (${p.error ?? 'could not list plugins'})`);
|
|
680
|
+
else if (!p.installed) lines.push(`plugin: ${PLUGIN_ID} not installed (run: singular install)`);
|
|
681
|
+
else lines.push(`plugin: ${PLUGIN_ID} installed, ${p.enabled ? 'enabled' : 'DISABLED'}, version ${p.version ?? '?'}${p.scope ? ` (scope ${p.scope})` : ''}${p.readFromFolder && !samePath(nodeFs, p.readFromFolder, s.payload.path) ? `, read from ${p.readFromFolder}` : ''}`);
|
|
682
|
+
for (const e of p.errors ?? []) lines.push(` ! ${e}`);
|
|
683
|
+
lines.push(`config: ${s.config.path ?? '?'} mode: ${s.config.mode ?? '?'}${s.config.fileMode && s.config.fileMode !== s.config.mode ? ` (file says "${s.config.fileMode}")` : ''}`);
|
|
684
|
+
for (const w of s.config.warnings) lines.push(` ! ${w}`);
|
|
685
|
+
const ov = Object.entries(s.env).filter(([k, v]) => v && k !== 'HOME');
|
|
686
|
+
if (ov.length) lines.push(`env: ${ov.map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
687
|
+
lines.push(`log: ${s.log.path}${s.log.tail.length ? ` (last ${s.log.tail.length} line${s.log.tail.length === 1 ? '' : 's'})` : ' (empty)'}`);
|
|
688
|
+
for (const l of s.log.tail) lines.push(` ${l.slice(0, 200)}`);
|
|
689
|
+
return lines.join('\n');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Problems with one actionable hint each; empty when everything is in order. */
|
|
693
|
+
export function diagnose(s) {
|
|
694
|
+
const out = [];
|
|
695
|
+
const add = (problem, hint) => out.push({ problem, hint });
|
|
696
|
+
if (!s.node.ok) add(`Node ${s.node.version} is too old`, `install Node >= ${MIN_NODE_MAJOR} (https://nodejs.org); the hooks run with the node on PATH`);
|
|
697
|
+
if (!s.claude.found) add('claude is not on PATH', 'install Claude Code (https://docs.claude.com/en/docs/claude-code/setup) and open a new terminal');
|
|
698
|
+
else if (!s.claude.version) add('claude --version did not print a version', 'run `claude --version` yourself; reinstall Claude Code if it fails');
|
|
699
|
+
else if (!s.claude.ok) add(`Claude Code ${s.claude.version} is too old`, `run \`claude update\` (needs >= ${MIN_CLAUDE_VERSION} for the UserPromptExpansion hook)`);
|
|
700
|
+
if (!s.payload.exists) add(`payload missing at ${s.payload.path}`, 'run `singular install`');
|
|
701
|
+
else if (!s.payload.valid) add(`${s.payload.path} is not a valid singular payload`, 'run `singular install` (it replaces the folder atomically)');
|
|
702
|
+
if (s.claude.found) {
|
|
703
|
+
if (!s.marketplace.known) add(`cannot read the marketplace list (${s.marketplace.error ?? 'unknown error'})`, 'run `claude plugin marketplace list` yourself and fix what it reports');
|
|
704
|
+
else if (!s.marketplace.registered) add(`marketplace "${MARKETPLACE}" is not registered`, 'run `singular install`');
|
|
705
|
+
else if (!s.marketplace.matchesPayload) add(`marketplace "${MARKETPLACE}" points at ${s.marketplace.path ?? '?'}, not the payload ${s.payload.path}`, 'run `singular install` (it re-points the marketplace at the payload)');
|
|
706
|
+
if (!s.plugin.known) add(`cannot read the plugin list (${s.plugin.error ?? 'unknown error'})`, 'run `claude plugin list` yourself and fix what it reports');
|
|
707
|
+
else if (!s.plugin.installed) add(`${PLUGIN_ID} is not installed`, 'run `singular install`');
|
|
708
|
+
else {
|
|
709
|
+
if (s.plugin.enabled === false) add(`${PLUGIN_ID} is disabled`, `run \`claude plugin enable ${PLUGIN_ID}\``);
|
|
710
|
+
if (s.payload.version && s.plugin.version && s.payload.version !== s.plugin.version) add(`plugin version ${s.plugin.version} differs from the payload ${s.payload.version}`, 'run `singular update`, then start a new Claude Code session (or /reload-plugins)');
|
|
711
|
+
for (const e of s.plugin.errors ?? []) add(`Claude Code reports: ${e}`, 'run `singular install` to re-register the payload');
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (s.config.mode === 'auto') add('mode is "auto"', 'third-party skill text is injected without confirmation; `singular mode suggest` reverts it (see README "What auto actually costs")');
|
|
715
|
+
for (const w of s.config.warnings) if (!/TEST SEAM/.test(w)) add(`config: ${w}`, `fix ${s.config.path ?? 'config.json'} by hand (singular keeps running on defaults meanwhile)`);
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export async function status(deps, { dir = null, json = false } = {}) {
|
|
720
|
+
const s = await collectCliStatus(deps, { dir });
|
|
721
|
+
if (json) deps.stdout(`${JSON.stringify(s, null, 2)}\n`);
|
|
722
|
+
else deps.stdout(`${formatCliStatus(s)}\n`);
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export async function doctor(deps, { dir = null, json = false } = {}) {
|
|
727
|
+
const s = await collectCliStatus(deps, { dir });
|
|
728
|
+
const problems = diagnose(s);
|
|
729
|
+
if (json) {
|
|
730
|
+
deps.stdout(`${JSON.stringify({ ...s, problems, ok: problems.length === 0 }, null, 2)}\n`);
|
|
731
|
+
} else {
|
|
732
|
+
const lines = [formatCliStatus(s), ''];
|
|
733
|
+
if (!problems.length) lines.push('No problems found. Type /singular in Claude Code.');
|
|
734
|
+
else {
|
|
735
|
+
lines.push(`Problems (${problems.length}):`);
|
|
736
|
+
for (const p of problems) lines.push(` - ${p.problem}`, ` fix: ${p.hint}`);
|
|
737
|
+
}
|
|
738
|
+
deps.stdout(`${lines.join('\n')}\n`);
|
|
739
|
+
}
|
|
740
|
+
return problems.length ? 1 : 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// proxies: find / add / mode -> bin/singular.js, load -> bin/on-expand.js (direct CLI mode)
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
export function proxyTarget(deps, command, args) {
|
|
748
|
+
const script = command === 'load' ? 'on-expand.js' : 'singular.js';
|
|
749
|
+
const file = path.join(deps.packageRoot, 'bin', script);
|
|
750
|
+
return { file, argv: command === 'load' ? [file, ...args] : [file, command, ...args] };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function proxy(deps, command, args = []) {
|
|
754
|
+
const { file, argv } = proxyTarget(deps, command, args);
|
|
755
|
+
if (!deps.fs.existsSync(file)) {
|
|
756
|
+
deps.stderr(`singular ${command}: ${file} is missing from this package; reinstall singular-skill (npm install -g singular-skill)\n`);
|
|
757
|
+
return Promise.resolve(1);
|
|
758
|
+
}
|
|
759
|
+
return new Promise((resolve) => {
|
|
760
|
+
let child;
|
|
761
|
+
try {
|
|
762
|
+
child = deps.spawnImpl(deps.execPath, argv, { stdio: 'inherit', env: deps.env, windowsHide: true });
|
|
763
|
+
} catch (err) {
|
|
764
|
+
deps.stderr(`singular ${command}: could not start node: ${err?.message ?? err}\n`);
|
|
765
|
+
return resolve(1);
|
|
766
|
+
}
|
|
767
|
+
child.on('error', (err) => {
|
|
768
|
+
deps.stderr(`singular ${command}: could not start node: ${err?.message ?? err}\n`);
|
|
769
|
+
resolve(1);
|
|
770
|
+
});
|
|
771
|
+
child.on('close', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// argv
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
export function parseArgs(argv) {
|
|
780
|
+
const a = Array.isArray(argv) ? [...argv] : [];
|
|
781
|
+
const out = { command: null, dir: null, dryRun: false, json: false, purge: false, help: false, version: false, rest: [], errors: [] };
|
|
782
|
+
if (a.length === 0) {
|
|
783
|
+
out.help = true;
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
const first = a[0];
|
|
787
|
+
if (first === '-h' || first === '--help' || first === 'help') {
|
|
788
|
+
out.help = true;
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
if (first === '-v' || first === '--version' || first === 'version') {
|
|
792
|
+
out.version = true;
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
if (first.startsWith('-')) {
|
|
796
|
+
out.errors.push(`unknown option ${first}`);
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
out.command = first;
|
|
800
|
+
if (PROXY_COMMANDS.includes(first)) {
|
|
801
|
+
out.rest = a.slice(1); // passed through verbatim to the plugin scripts
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
if (!COMMANDS.includes(first)) {
|
|
805
|
+
out.errors.push(`unknown command "${first}"`);
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
for (let i = 1; i < a.length; i++) {
|
|
809
|
+
const t = a[i];
|
|
810
|
+
if (t === '--dir') {
|
|
811
|
+
const v = a[++i];
|
|
812
|
+
if (!v || v.startsWith('-')) out.errors.push('--dir needs a path');
|
|
813
|
+
else out.dir = v;
|
|
814
|
+
} else if (t.startsWith('--dir=')) out.dir = t.slice('--dir='.length) || null;
|
|
815
|
+
else if (t === '--dry-run') out.dryRun = true;
|
|
816
|
+
else if (t === '--json') out.json = true;
|
|
817
|
+
else if (t === '--purge') out.purge = true;
|
|
818
|
+
else if (t === '-h' || t === '--help') out.help = true;
|
|
819
|
+
else if (t.startsWith('-')) out.errors.push(`unknown option ${t}`);
|
|
820
|
+
else out.errors.push(`unexpected argument "${t}"`);
|
|
821
|
+
}
|
|
822
|
+
if (out.dryRun && out.command !== 'install' && out.command !== 'update') out.errors.push(`--dry-run applies to install/update only`);
|
|
823
|
+
if (out.purge && out.command !== 'uninstall') out.errors.push(`--purge applies to uninstall only`);
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** The whole CLI: returns the exit code (never throws, never calls process.exit). */
|
|
828
|
+
export async function runCli(argv, overrides = {}) {
|
|
829
|
+
const deps = defaultDeps(overrides);
|
|
830
|
+
const args = parseArgs(argv);
|
|
831
|
+
if (args.errors.length) {
|
|
832
|
+
deps.stderr(`singular: ${args.errors.join('; ')}\n\n${HELP}`);
|
|
833
|
+
return 1;
|
|
834
|
+
}
|
|
835
|
+
if (args.version) {
|
|
836
|
+
deps.stdout(`${readJson(deps.fs, path.join(deps.packageRoot, 'package.json'))?.version ?? 'unknown'}\n`);
|
|
837
|
+
return 0;
|
|
838
|
+
}
|
|
839
|
+
if (args.help) {
|
|
840
|
+
deps.stdout(HELP);
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
switch (args.command) {
|
|
845
|
+
case 'install':
|
|
846
|
+
return await install(deps, { dryRun: args.dryRun, json: args.json, dir: args.dir, label: 'install' });
|
|
847
|
+
case 'update':
|
|
848
|
+
return await install(deps, { dryRun: args.dryRun, json: args.json, dir: args.dir, label: 'update' });
|
|
849
|
+
case 'uninstall':
|
|
850
|
+
return await uninstall(deps, { purge: args.purge, dir: args.dir, json: args.json });
|
|
851
|
+
case 'status':
|
|
852
|
+
return await status(deps, { dir: args.dir, json: args.json });
|
|
853
|
+
case 'doctor':
|
|
854
|
+
return await doctor(deps, { dir: args.dir, json: args.json });
|
|
855
|
+
default:
|
|
856
|
+
return await proxy(deps, args.command, args.rest);
|
|
857
|
+
}
|
|
858
|
+
} catch (err) {
|
|
859
|
+
deps.stderr(`singular ${args.command}: ${err?.message ?? err}\n`);
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
862
|
+
}
|