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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// lib/commands/status.js — `singular status`: config, cache stats, loaded pins, persistent installs, recent log lines.
|
|
2
|
+
//
|
|
3
|
+
// node bin/singular.js status [--json] [--cwd <dir>] (bin/singular.js dispatches here)
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { cacheGet, cacheStats } from '../cache.js';
|
|
10
|
+
import { configPath, defaultHomeDir, hardenEnv, homeDir, loadConfig, SEAM_VARS } from '../config.js';
|
|
11
|
+
import { TAG } from '../context.js';
|
|
12
|
+
import { logPath } from '../log.js';
|
|
13
|
+
import { COOLDOWN_KEY } from '../registry.js';
|
|
14
|
+
import { buildIndex } from '../skill-index.js';
|
|
15
|
+
import { detectStack } from '../stack.js';
|
|
16
|
+
|
|
17
|
+
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
|
+
|
|
19
|
+
function readJson(file) {
|
|
20
|
+
try {
|
|
21
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
return v && typeof v === 'object' ? v : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tailLines(file, n) {
|
|
29
|
+
try {
|
|
30
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
31
|
+
return text.split('\n').filter(Boolean).slice(-n);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function collectStatus({ env: rawEnv = process.env, cwd } = {}) {
|
|
38
|
+
const dir = path.resolve(cwd || process.cwd());
|
|
39
|
+
const hardened = hardenEnv(rawEnv, { cwd: dir });
|
|
40
|
+
const env = hardened.env;
|
|
41
|
+
const config = loadConfig(env);
|
|
42
|
+
const home = homeDir(env);
|
|
43
|
+
let cooldownUntil = null;
|
|
44
|
+
try {
|
|
45
|
+
const v = cacheGet(COOLDOWN_KEY, 1, env);
|
|
46
|
+
if (v && Number(v.until) > Date.now()) cooldownUntil = Number(v.until);
|
|
47
|
+
} catch {
|
|
48
|
+
cooldownUntil = null;
|
|
49
|
+
}
|
|
50
|
+
const indexOpts = { cwd: dir, home: env.HOME };
|
|
51
|
+
if (env.CLAUDE_CONFIG_DIR) indexOpts.configDir = env.CLAUDE_CONFIG_DIR;
|
|
52
|
+
const [index, stack] = await Promise.all([buildIndex(indexOpts).catch(() => []), detectStack(dir).catch(() => ({ summary: '', tags: [] }))]);
|
|
53
|
+
const byScope = {};
|
|
54
|
+
for (const s of index) byScope[s.scope] = (byScope[s.scope] ?? 0) + 1;
|
|
55
|
+
const pkg = readJson(path.join(PLUGIN_ROOT, 'package.json')) ?? {};
|
|
56
|
+
return {
|
|
57
|
+
version: pkg.version ?? null,
|
|
58
|
+
pluginRoot: PLUGIN_ROOT,
|
|
59
|
+
home,
|
|
60
|
+
configPath: configPath(env),
|
|
61
|
+
defaultConfigPath: path.join(defaultHomeDir(env), 'config.json'),
|
|
62
|
+
config: { ...config },
|
|
63
|
+
fileMode: config.fileMode ?? config.mode,
|
|
64
|
+
configWarnings: [...hardened.warnings, ...(config.warnings ?? [])],
|
|
65
|
+
envOverrides: {
|
|
66
|
+
SINGULAR_HOME: rawEnv.SINGULAR_HOME ?? null,
|
|
67
|
+
SINGULAR_MODE: rawEnv.SINGULAR_MODE ?? null,
|
|
68
|
+
CLAUDE_CONFIG_DIR: rawEnv.CLAUDE_CONFIG_DIR ?? null,
|
|
69
|
+
...Object.fromEntries(SEAM_VARS.map((k) => [k, rawEnv[k] ?? null])),
|
|
70
|
+
},
|
|
71
|
+
testSeams: hardened.seams?.active ?? [],
|
|
72
|
+
cache: cacheStats(env),
|
|
73
|
+
cooldownUntil,
|
|
74
|
+
loaded: readJson(path.join(home, 'loaded.json')) ?? {},
|
|
75
|
+
installed: readJson(path.join(home, 'installed.json')) ?? {},
|
|
76
|
+
index: { total: index.length, byScope },
|
|
77
|
+
stack,
|
|
78
|
+
cwd: dir,
|
|
79
|
+
log: { path: logPath(env), tail: tailLines(logPath(env), 5) },
|
|
80
|
+
autoWarned: fs.existsSync(path.join(home, '.auto-mode-warned')),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatStatus(s) {
|
|
85
|
+
const lines = [];
|
|
86
|
+
lines.push(`${TAG} singular ${s.version ?? '?'} plugin root: ${s.pluginRoot}`);
|
|
87
|
+
lines.push(`state dir: ${s.home}`);
|
|
88
|
+
lines.push(`config: ${s.configPath}`);
|
|
89
|
+
for (const [k, v] of Object.entries(s.config)) lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
90
|
+
for (const w of s.configWarnings) lines.push(` ! ${w}`);
|
|
91
|
+
const ov = Object.entries(s.envOverrides).filter(([, v]) => v);
|
|
92
|
+
if (ov.length) lines.push(`env overrides: ${ov.map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
93
|
+
if (Array.isArray(s.testSeams) && s.testSeams.length) lines.push(` TEST SEAMS ACTIVE (not for real use, see docs/testing.md): ${s.testSeams.join(', ')}`);
|
|
94
|
+
if (s.fileMode && s.fileMode !== s.config.mode) lines.push(` effective mode "${s.config.mode}" (file says "${s.fileMode}"; the environment can only lower the mode, and "auto" counts only in ${s.defaultConfigPath})`);
|
|
95
|
+
if (s.config.mode === 'auto') lines.push(' WARNING: mode "auto" injects allowlisted third-party skill text without confirmation (see README "What auto actually costs"). /singular mode suggest reverts it.');
|
|
96
|
+
if (Array.isArray(s.config.pinnedSkills) && s.config.pinnedSkills.length) lines.push(` pinnedSkills (injected once per session): ${s.config.pinnedSkills.join(', ')}`);
|
|
97
|
+
lines.push(`installed skills indexed: ${s.index.total} (${Object.entries(s.index.byScope).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'})`);
|
|
98
|
+
lines.push(`stack (${s.cwd}): ${s.stack.summary || '(unknown)'}`);
|
|
99
|
+
const c = s.cache;
|
|
100
|
+
const age = (ts) => (ts ? `${Math.round((Date.now() - ts) / 60000)} min ago` : '-');
|
|
101
|
+
lines.push(`cache: ${c.entries} entries, ${(c.bytes / 1024).toFixed(1)} KB, newest ${age(c.newestTs)}, oldest ${age(c.oldestTs)} (${c.dir})`);
|
|
102
|
+
if (s.cooldownUntil) {
|
|
103
|
+
const d = new Date(s.cooldownUntil);
|
|
104
|
+
lines.push(`skills.sh: rate-limited (HTTP 429) — no requests until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}; cached catalogs still rank`);
|
|
105
|
+
}
|
|
106
|
+
const loaded = Object.entries(s.loaded);
|
|
107
|
+
lines.push(`loaded pins (/singular load, ephemeral): ${loaded.length}`);
|
|
108
|
+
for (const [key, v] of loaded) lines.push(` ${key} -> ${v.sha ?? '?'} loads ${v.loads ?? '?'} last ${v.lastTs ? new Date(v.lastTs).toISOString() : '?'}${Array.isArray(v.stripped) && v.stripped.length ? ` stripped: ${v.stripped.length}` : ''}`);
|
|
109
|
+
const installed = Object.entries(s.installed);
|
|
110
|
+
lines.push(`persistent installs (/singular add): ${installed.length}`);
|
|
111
|
+
for (const [key, v] of installed) lines.push(` ${key} -> ${v.sha ?? '?'} ${v.path ?? ''} audit ${v.audit ?? '?'}`);
|
|
112
|
+
lines.push(`log: ${s.log.path}`);
|
|
113
|
+
for (const l of s.log.tail) lines.push(` ${l.slice(0, 200)}`);
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `singular status [--json] [--cwd <dir>]`. Text mode prints the status followed by `trailer` (the one-screen
|
|
119
|
+
* help, passed in by the dispatcher); --json prints the structured status only. Returns the exit code.
|
|
120
|
+
*/
|
|
121
|
+
export async function main(argv = process.argv.slice(2), { trailer = '' } = {}) {
|
|
122
|
+
const json = argv.includes('--json');
|
|
123
|
+
const i = argv.indexOf('--cwd');
|
|
124
|
+
const cwd = i >= 0 ? argv[i + 1] : null;
|
|
125
|
+
const s = await collectStatus({ env: process.env, cwd });
|
|
126
|
+
process.stdout.write(json ? `${JSON.stringify(s, null, 2)}\n` : `${formatStatus(s)}\n${trailer ? `\n${trailer}\n` : ''}`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// lib/config.js — singular configuration.
|
|
2
|
+
//
|
|
3
|
+
// loadConfig(env) -> Config (DEFAULTS merged with $SINGULAR_HOME/config.json; created on first run)
|
|
4
|
+
// DEFAULTS -> the built-in defaults (frozen)
|
|
5
|
+
// configPath(env) -> absolute path of config.json
|
|
6
|
+
// homeDir(env) -> $SINGULAR_HOME (absolute only) or ~/.claude/singular (mkdir -p)
|
|
7
|
+
// defaultHomeDir(env) -> ~/.claude/singular regardless of SINGULAR_HOME
|
|
8
|
+
// isAllowlisted(source, list)-> owner or owner/repo match, case-insensitive
|
|
9
|
+
// validateConfig(raw) -> {config, warnings} (pure; used by loadConfig and /singular status)
|
|
10
|
+
// hardenEnv(env, {cwd}) -> copy of env with untrusted SINGULAR_HOME / HOME / TMPDIR overrides removed
|
|
11
|
+
// testSeams(env, {cwd}) -> the test-only seams (docs/testing.md), validated; never a user feature
|
|
12
|
+
// tmpdirOf(env) -> the temp base for clones and npx runs, from the (hardened) env, never process.env alone
|
|
13
|
+
//
|
|
14
|
+
// Environment variables reach the hook process from the user's shell AND from a repository's committed
|
|
15
|
+
// `.claude/settings.json` `env` block, so the environment is treated as untrusted and can only LOWER
|
|
16
|
+
// privilege (docs/security.md §1):
|
|
17
|
+
// SINGULAR_HOME state directory: honoured only when absolute and (in hook mode) neither inside nor an
|
|
18
|
+
// ancestor of the project cwd (an ancestor would let a checkout named `cache` be the cache dir)
|
|
19
|
+
// TMPDIR/TMP/TEMP a value inside the project cwd is replaced by the platform default (the temp cwd of a clone or
|
|
20
|
+
// `npx skills` run must never sit inside the repository: npm would read its .npmrc)
|
|
21
|
+
// SINGULAR_MODE can only downgrade the file's mode (off < local < suggest < auto); never raises it
|
|
22
|
+
// mode "auto" is honoured only when it comes from the default ~/.claude/singular/config.json
|
|
23
|
+
//
|
|
24
|
+
// Test seams (docs/testing.md; NOT user features; every honoured seam is logged as a warning and listed by
|
|
25
|
+
// /singular status, and production behaviour is byte-identical when they are unset):
|
|
26
|
+
// SINGULAR_REGISTRY_BASE http(s)://localhost|127.0.0.1|[::1][:port][/path] replaces https://skills.sh
|
|
27
|
+
// SINGULAR_GIT_BASE absolute local path or file:// URL (neither inside nor above the project) replaces
|
|
28
|
+
// https://github.com, and skill text is then fetched with git instead of `npx skills use`
|
|
29
|
+
// SINGULAR_SKILLS_DIR absolute path (neither inside nor above the project) replaces ~/.claude/skills for /singular add
|
|
30
|
+
// SINGULAR_TEST_ALLOW_AUTO=1 honours mode "auto" in a redirected SINGULAR_HOME (still never from the env)
|
|
31
|
+
// Remote http(s) git bases and non-loopback registry hosts are refused: a repository's env block must not be
|
|
32
|
+
// able to redirect a trusted ref to another host.
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
export const MODES = Object.freeze(['off', 'local', 'suggest', 'auto']);
|
|
39
|
+
export const SEAM_VARS = Object.freeze(['SINGULAR_REGISTRY_BASE', 'SINGULAR_GIT_BASE', 'SINGULAR_SKILLS_DIR', 'SINGULAR_TEST_ALLOW_AUTO']);
|
|
40
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
41
|
+
|
|
42
|
+
export const DEFAULTS = Object.freeze({
|
|
43
|
+
mode: 'suggest',
|
|
44
|
+
allowlist: Object.freeze(['anthropics', 'vercel-labs', 'microsoft', 'multica-ai']),
|
|
45
|
+
installFloor: 1000,
|
|
46
|
+
maxLocalSkills: 3,
|
|
47
|
+
maxCandidates: 2,
|
|
48
|
+
minPromptChars: 40,
|
|
49
|
+
remoteTimeoutMs: 2500,
|
|
50
|
+
totalBudgetMs: 6000,
|
|
51
|
+
contextBudgetChars: 2000,
|
|
52
|
+
cacheTtlHours: 24,
|
|
53
|
+
skillsCliVersion: '1.5.26',
|
|
54
|
+
requireAuditPass: true,
|
|
55
|
+
stackDetection: true,
|
|
56
|
+
blockedRefs: Object.freeze([]),
|
|
57
|
+
pinnedSkills: Object.freeze([]),
|
|
58
|
+
prefetch: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const CLI_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
62
|
+
|
|
63
|
+
function realHome() {
|
|
64
|
+
try {
|
|
65
|
+
const h = os.userInfo().homedir;
|
|
66
|
+
if (h && path.isAbsolute(h)) return h;
|
|
67
|
+
} catch {
|
|
68
|
+
/* no passwd entry */
|
|
69
|
+
}
|
|
70
|
+
return os.homedir();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve symlinks in `p` as far as the path exists (the longest existing ancestor is realpath'd, the
|
|
75
|
+
* rest re-joined), so `/tmp/x` and `/private/tmp/x`, or `<repo>/alias -> <repo>`, compare equal.
|
|
76
|
+
*/
|
|
77
|
+
function realResolve(p) {
|
|
78
|
+
let cur = path.resolve(p);
|
|
79
|
+
const rest = [];
|
|
80
|
+
for (;;) {
|
|
81
|
+
try {
|
|
82
|
+
return path.join(fs.realpathSync.native(cur), ...rest.reverse());
|
|
83
|
+
} catch {
|
|
84
|
+
const parent = path.dirname(cur);
|
|
85
|
+
if (parent === cur) return path.join(cur, ...rest.reverse());
|
|
86
|
+
rest.push(path.basename(cur));
|
|
87
|
+
cur = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isInside(child, parent) {
|
|
93
|
+
const c = realResolve(child);
|
|
94
|
+
const p = realResolve(parent);
|
|
95
|
+
return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `p` is the project, inside it, or an ancestor of it: a path a repository could reach or be checked out under. */
|
|
99
|
+
function touchesProject(p, cwd) {
|
|
100
|
+
return isInside(p, cwd) || isInside(cwd, p);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The platform's default temp directory, independent of TMPDIR/TMP/TEMP. */
|
|
104
|
+
function platformTmpdir(env = process.env) {
|
|
105
|
+
if (process.platform === 'win32') return path.join(env.SystemRoot || env.SYSTEMROOT || env.windir || 'C:\\Windows', 'Temp');
|
|
106
|
+
return '/tmp';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Temp base for pinned clones and `npx skills use` runs: TMPDIR (TEMP/TMP on Windows) from the given env when it is
|
|
111
|
+
* an absolute path, else os.tmpdir(). Callers pass the hardened env, in which an in-project value has already been
|
|
112
|
+
* replaced, so a repository's env block can never place the work dir (and npm's config walk-up) inside itself.
|
|
113
|
+
*/
|
|
114
|
+
export function tmpdirOf(env = process.env) {
|
|
115
|
+
const keys = process.platform === 'win32' ? ['TEMP', 'TMP'] : ['TMPDIR', 'TMP', 'TEMP'];
|
|
116
|
+
for (const k of keys) {
|
|
117
|
+
const v = env && env[k] != null ? String(env[k]).trim() : '';
|
|
118
|
+
if (v && path.isAbsolute(v)) return path.resolve(v);
|
|
119
|
+
}
|
|
120
|
+
return os.tmpdir();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** ~/.claude/singular for the given env (HOME override honoured; SINGULAR_HOME ignored). */
|
|
124
|
+
export function defaultHomeDir(env = process.env) {
|
|
125
|
+
return path.join(env.HOME || os.homedir(), '.claude', 'singular');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Is SINGULAR_HOME usable? Only absolute paths count; relative values are ignored (they would resolve against the project cwd). */
|
|
129
|
+
export function redirectedHome(env = process.env) {
|
|
130
|
+
const raw = env.SINGULAR_HOME && String(env.SINGULAR_HOME).trim();
|
|
131
|
+
if (!raw) return null;
|
|
132
|
+
if (!path.isAbsolute(raw)) return null;
|
|
133
|
+
return path.resolve(raw);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function homeDir(env = process.env) {
|
|
137
|
+
const dir = redirectedHome(env) ?? defaultHomeDir(env);
|
|
138
|
+
try {
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
} catch {
|
|
141
|
+
/* read-only FS: callers still get a path */
|
|
142
|
+
}
|
|
143
|
+
return dir;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function configPath(env = process.env) {
|
|
147
|
+
return path.join(homeDir(env), 'config.json');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Copy of `env` with environment overrides that could hand a repository control of singular removed:
|
|
152
|
+
* a relative SINGULAR_HOME, a SINGULAR_HOME inside `cwd` (the project) or an ancestor of it (the cache
|
|
153
|
+
* or state dir would then be a sibling checkout), a HOME that differs from the account's real home AND lies
|
|
154
|
+
* inside `cwd`, and a TMPDIR/TMP/TEMP inside `cwd` (replaced by the platform default: a clone or npx work dir
|
|
155
|
+
* inside the repository would hand npm the repository's .npmrc). Invalid test seams are removed too; honoured
|
|
156
|
+
* ones are reported as warnings so every entrypoint logs them. Returns {env, warnings, seams}. Pure; never throws.
|
|
157
|
+
*/
|
|
158
|
+
export function hardenEnv(env = process.env, { cwd } = {}) {
|
|
159
|
+
const out = { ...env };
|
|
160
|
+
const warnings = [];
|
|
161
|
+
const raw = out.SINGULAR_HOME && String(out.SINGULAR_HOME).trim();
|
|
162
|
+
if (raw) {
|
|
163
|
+
if (!path.isAbsolute(raw)) {
|
|
164
|
+
warnings.push(`SINGULAR_HOME=${raw} ignored (must be an absolute path)`);
|
|
165
|
+
delete out.SINGULAR_HOME;
|
|
166
|
+
} else if (cwd && isInside(raw, cwd)) {
|
|
167
|
+
warnings.push(`SINGULAR_HOME=${raw} ignored (inside the project directory ${cwd})`);
|
|
168
|
+
delete out.SINGULAR_HOME;
|
|
169
|
+
} else if (cwd && isInside(cwd, raw)) {
|
|
170
|
+
warnings.push(`SINGULAR_HOME=${raw} ignored (an ancestor of the project directory ${cwd})`);
|
|
171
|
+
delete out.SINGULAR_HOME;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (cwd) {
|
|
175
|
+
for (const k of ['TMPDIR', 'TMP', 'TEMP']) {
|
|
176
|
+
const v = out[k] && String(out[k]).trim();
|
|
177
|
+
if (!v) continue;
|
|
178
|
+
if (!path.isAbsolute(v) || isInside(v, cwd)) {
|
|
179
|
+
const fallback = platformTmpdir(out);
|
|
180
|
+
warnings.push(`${k}=${v} ignored (${path.isAbsolute(v) ? 'inside the project directory' : 'relative; would resolve inside the project directory'} ${cwd}); using ${fallback}`);
|
|
181
|
+
out[k] = fallback;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const home = out.HOME && String(out.HOME).trim();
|
|
186
|
+
if (home && cwd) {
|
|
187
|
+
const real = realHome();
|
|
188
|
+
if (path.resolve(home) !== path.resolve(real) && isInside(home, cwd)) {
|
|
189
|
+
warnings.push(`HOME=${home} ignored (inside the project directory ${cwd}); using ${real}`);
|
|
190
|
+
out.HOME = real;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const seams = testSeams(out, { cwd });
|
|
194
|
+
for (const v of seams.invalid) delete out[v];
|
|
195
|
+
warnings.push(...seams.warnings);
|
|
196
|
+
return { env: out, warnings, seams };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Parse and validate the test-only seams (see the header). Each is honoured only in its narrow form:
|
|
201
|
+
* registryBase http(s) URL whose host is loopback (a fixture server); trailing slash dropped
|
|
202
|
+
* gitBase absolute local path or file:// URL, neither inside nor an ancestor of `cwd` when given; never http(s)
|
|
203
|
+
* (an ancestor base would resolve <base>/<owner>/<repo> to a sibling or the project checkout itself)
|
|
204
|
+
* skillsDir absolute path, neither inside nor an ancestor of `cwd` when given
|
|
205
|
+
* allowAuto the literal value "1"
|
|
206
|
+
* Returns {registryBase, gitBase, skillsDir, allowAuto, active, invalid, warnings}; `invalid` names the
|
|
207
|
+
* variables that must be dropped, `warnings` explains every drop and announces every active seam. Pure.
|
|
208
|
+
*/
|
|
209
|
+
export function testSeams(env = process.env, { cwd } = {}) {
|
|
210
|
+
const out = { registryBase: null, gitBase: null, skillsDir: null, allowAuto: false, active: [], invalid: [], warnings: [] };
|
|
211
|
+
const note = 'test seam, see docs/testing.md';
|
|
212
|
+
const get = (k) => (env && env[k] != null ? String(env[k]).trim() : '');
|
|
213
|
+
const outsideProject = (p) => !cwd || !touchesProject(p, cwd);
|
|
214
|
+
|
|
215
|
+
const reg = get('SINGULAR_REGISTRY_BASE');
|
|
216
|
+
if (reg) {
|
|
217
|
+
let ok = false;
|
|
218
|
+
try {
|
|
219
|
+
const u = new URL(reg);
|
|
220
|
+
ok = (u.protocol === 'http:' || u.protocol === 'https:') && LOOPBACK_HOSTS.has(u.hostname) && !u.search && !u.hash;
|
|
221
|
+
if (ok) out.registryBase = `${u.origin}${u.pathname.replace(/\/+$/, '')}`;
|
|
222
|
+
} catch {
|
|
223
|
+
ok = false;
|
|
224
|
+
}
|
|
225
|
+
if (ok) out.active.push('SINGULAR_REGISTRY_BASE');
|
|
226
|
+
else {
|
|
227
|
+
out.invalid.push('SINGULAR_REGISTRY_BASE');
|
|
228
|
+
out.warnings.push(`SINGULAR_REGISTRY_BASE=${reg} ignored (${note}: only http(s)://localhost, 127.0.0.1 or [::1] is honoured)`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const git = get('SINGULAR_GIT_BASE');
|
|
233
|
+
if (git) {
|
|
234
|
+
let local = null;
|
|
235
|
+
if (/^file:\/\//i.test(git)) {
|
|
236
|
+
try {
|
|
237
|
+
const u = new URL(git);
|
|
238
|
+
if (u.protocol === 'file:' && (!u.hostname || u.hostname === 'localhost')) local = decodeURIComponent(u.pathname);
|
|
239
|
+
} catch {
|
|
240
|
+
local = null;
|
|
241
|
+
}
|
|
242
|
+
} else if (path.isAbsolute(git)) local = git;
|
|
243
|
+
if (local && path.isAbsolute(local) && outsideProject(local)) {
|
|
244
|
+
out.gitBase = /^file:\/\//i.test(git) ? `file://${path.resolve(local).replace(/\/+$/, '')}` : path.resolve(local).replace(/\/+$/, '');
|
|
245
|
+
out.active.push('SINGULAR_GIT_BASE');
|
|
246
|
+
} else {
|
|
247
|
+
out.invalid.push('SINGULAR_GIT_BASE');
|
|
248
|
+
out.warnings.push(`SINGULAR_GIT_BASE=${git} ignored (${note}: must be an absolute local path or file:// URL neither inside nor above the project directory; remote hosts are never honoured)`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const skills = get('SINGULAR_SKILLS_DIR');
|
|
253
|
+
if (skills) {
|
|
254
|
+
if (path.isAbsolute(skills) && outsideProject(skills)) {
|
|
255
|
+
out.skillsDir = path.resolve(skills);
|
|
256
|
+
out.active.push('SINGULAR_SKILLS_DIR');
|
|
257
|
+
} else {
|
|
258
|
+
out.invalid.push('SINGULAR_SKILLS_DIR');
|
|
259
|
+
out.warnings.push(`SINGULAR_SKILLS_DIR=${skills} ignored (${note}: must be an absolute path neither inside nor above the project directory)`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const auto = get('SINGULAR_TEST_ALLOW_AUTO');
|
|
264
|
+
if (auto) {
|
|
265
|
+
if (auto === '1') {
|
|
266
|
+
out.allowAuto = true;
|
|
267
|
+
out.active.push('SINGULAR_TEST_ALLOW_AUTO');
|
|
268
|
+
} else {
|
|
269
|
+
out.invalid.push('SINGULAR_TEST_ALLOW_AUTO');
|
|
270
|
+
out.warnings.push(`SINGULAR_TEST_ALLOW_AUTO=${auto} ignored (${note}: only "1" is honoured)`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const v of out.active) out.warnings.push(`TEST SEAM ACTIVE: ${v}=${get(v)} (${note}; never set this in a real shell)`);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The honoured registry base URL (no trailing slash) or null when the seam is unset / invalid. */
|
|
278
|
+
export function seamRegistryBase(env = process.env) {
|
|
279
|
+
return testSeams(env).registryBase;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The honoured git base (absolute path or file:// URL, no trailing slash) or null. */
|
|
283
|
+
export function seamGitBase(env = process.env) {
|
|
284
|
+
return testSeams(env).gitBase;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The honoured /singular add target directory or null. */
|
|
288
|
+
export function seamSkillsDir(env = process.env) {
|
|
289
|
+
return testSeams(env).skillsDir;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** `source` is owner/repo (or a bare owner); entries are `owner` or `owner/repo`, exact, case-insensitive. */
|
|
293
|
+
export function isAllowlisted(source, allowlist) {
|
|
294
|
+
const src = String(source ?? '').trim().toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
295
|
+
if (!src) return false;
|
|
296
|
+
const owner = src.split('/')[0];
|
|
297
|
+
for (const raw of Array.isArray(allowlist) ? allowlist : []) {
|
|
298
|
+
const entry = String(raw ?? '').trim().toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
299
|
+
if (!entry) continue;
|
|
300
|
+
if (entry.includes('/')) {
|
|
301
|
+
if (entry === src) return true;
|
|
302
|
+
} else if (entry === owner) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isStringArray(v) {
|
|
310
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Merge a raw object over DEFAULTS, type-checking every known key (a wrong type
|
|
315
|
+
* falls back to the default and yields a warning). Unknown keys are kept as-is.
|
|
316
|
+
*/
|
|
317
|
+
export function validateConfig(raw) {
|
|
318
|
+
const warnings = [];
|
|
319
|
+
const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
320
|
+
if (src !== raw) warnings.push('config is not a JSON object; using defaults');
|
|
321
|
+
const cfg = { ...src };
|
|
322
|
+
const fallback = (key, why) => {
|
|
323
|
+
warnings.push(`${key}: ${why}; using default ${JSON.stringify(DEFAULTS[key])}`);
|
|
324
|
+
cfg[key] = Array.isArray(DEFAULTS[key]) ? [...DEFAULTS[key]] : DEFAULTS[key];
|
|
325
|
+
};
|
|
326
|
+
for (const [key, def] of Object.entries(DEFAULTS)) {
|
|
327
|
+
if (!(key in src)) {
|
|
328
|
+
cfg[key] = Array.isArray(def) ? [...def] : def;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const v = src[key];
|
|
332
|
+
if (key === 'mode') {
|
|
333
|
+
if (typeof v !== 'string' || !MODES.includes(v.toLowerCase())) fallback(key, `must be one of ${MODES.join('|')}`);
|
|
334
|
+
else cfg.mode = v.toLowerCase();
|
|
335
|
+
} else if (key === 'skillsCliVersion') {
|
|
336
|
+
if (typeof v !== 'string' || !CLI_VERSION_RE.test(v)) fallback(key, 'must be a semver string like "1.5.26"');
|
|
337
|
+
} else if (Array.isArray(def)) {
|
|
338
|
+
if (!isStringArray(v)) fallback(key, 'must be an array of strings');
|
|
339
|
+
else cfg[key] = v.map((s) => s.trim()).filter(Boolean);
|
|
340
|
+
} else if (typeof def === 'number') {
|
|
341
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) fallback(key, 'must be a non-negative number');
|
|
342
|
+
} else if (typeof def === 'boolean') {
|
|
343
|
+
if (typeof v !== 'boolean') fallback(key, 'must be true or false');
|
|
344
|
+
} else if (typeof v !== typeof def) {
|
|
345
|
+
fallback(key, `must be a ${typeof def}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { config: cfg, warnings };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Load the config. Creates config.json from DEFAULTS on first run. Never throws:
|
|
353
|
+
* an unreadable or corrupt file yields DEFAULTS plus `warnings`.
|
|
354
|
+
* The result carries non-enumerable `warnings`, `path`, `fileMode` and `redirected` for diagnostics.
|
|
355
|
+
*/
|
|
356
|
+
export function loadConfig(env = process.env) {
|
|
357
|
+
const file = configPath(env);
|
|
358
|
+
const redirected = redirectedHome(env) !== null;
|
|
359
|
+
let raw = null;
|
|
360
|
+
const warnings = [];
|
|
361
|
+
let missing = false;
|
|
362
|
+
try {
|
|
363
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
364
|
+
try {
|
|
365
|
+
raw = JSON.parse(text);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
warnings.push(`config.json is not valid JSON (${err.message}); using defaults`);
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
// Only a genuinely missing file is created from the defaults; anything else (EISDIR, EACCES, ...) is
|
|
371
|
+
// reported so /singular status shows a "!" line instead of silently pretending the file is absent.
|
|
372
|
+
if (err && err.code === 'ENOENT') missing = true;
|
|
373
|
+
else warnings.push(`config.json unreadable (${err?.code ?? err?.message ?? 'unknown error'}); using defaults`);
|
|
374
|
+
}
|
|
375
|
+
if (missing) {
|
|
376
|
+
try {
|
|
377
|
+
fs.writeFileSync(file, `${JSON.stringify(DEFAULTS, null, 2)}\n`, { flag: 'wx' });
|
|
378
|
+
} catch {
|
|
379
|
+
/* raced or read-only: fine */
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const { config, warnings: w2 } = validateConfig(raw ?? {});
|
|
383
|
+
warnings.push(...w2);
|
|
384
|
+
const fileMode = config.mode;
|
|
385
|
+
if (config.mode === 'auto' && redirected) {
|
|
386
|
+
if (testSeams(env).allowAuto) {
|
|
387
|
+
warnings.push(`TEST SEAM: SINGULAR_TEST_ALLOW_AUTO=1 honours mode "auto" in the redirected ${homeDir(env)} (test-only; never set this in a real shell)`);
|
|
388
|
+
} else {
|
|
389
|
+
warnings.push(`mode "auto" ignored: it is honoured only in the default ${defaultHomeDir(env)}/config.json, not in a SINGULAR_HOME redirect; using "suggest"`);
|
|
390
|
+
config.mode = 'suggest';
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (env.SINGULAR_HOME && !redirected && String(env.SINGULAR_HOME).trim()) {
|
|
394
|
+
warnings.push(`SINGULAR_HOME=${env.SINGULAR_HOME} ignored (must be an absolute path)`);
|
|
395
|
+
}
|
|
396
|
+
if (env.SINGULAR_MODE) {
|
|
397
|
+
const m = String(env.SINGULAR_MODE).trim().toLowerCase();
|
|
398
|
+
if (!MODES.includes(m)) warnings.push(`SINGULAR_MODE=${env.SINGULAR_MODE} ignored (not one of ${MODES.join('|')})`);
|
|
399
|
+
else if (MODES.indexOf(m) > MODES.indexOf(config.mode)) warnings.push(`SINGULAR_MODE=${m} ignored (the environment can only lower the mode below "${config.mode}")`);
|
|
400
|
+
else config.mode = m;
|
|
401
|
+
}
|
|
402
|
+
Object.defineProperty(config, 'warnings', { value: warnings, enumerable: false });
|
|
403
|
+
Object.defineProperty(config, 'path', { value: file, enumerable: false });
|
|
404
|
+
Object.defineProperty(config, 'fileMode', { value: fileMode, enumerable: false });
|
|
405
|
+
Object.defineProperty(config, 'redirected', { value: redirected, enumerable: false });
|
|
406
|
+
return config;
|
|
407
|
+
}
|