sneakoscope 0.7.6 → 0.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import fsp from 'node:fs/promises';
4
+ import readline from 'node:readline/promises';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { ensureDir, exists, packageRoot, runProcess, which, writeTextAtomic } from '../core/fsx.mjs';
7
+ import { getCodexInfo } from '../core/codex-adapter.mjs';
8
+ import { formatHarnessConflictReport, llmHarnessCleanupPrompt, scanHarnessConflicts } from '../core/harness-conflicts.mjs';
9
+ import { installSkills } from '../core/init.mjs';
10
+ import { context7ConfigToml, DOLLAR_SKILL_NAMES, GETDESIGN_REFERENCE, hasContext7ConfigText, RECOMMENDED_SKILLS } from '../core/routes.mjs';
11
+ import { platformTmuxInstallHint, tmuxReadiness } from '../core/tmux-ui.mjs';
12
+
13
+ export async function postinstall({ bootstrap }) {
14
+ const installRoot = path.resolve(process.env.INIT_CWD || process.cwd());
15
+ const conflictScan = await scanHarnessConflicts(installRoot);
16
+ if (conflictScan.hard_block) {
17
+ await postinstallHarnessConflictNotice(conflictScan);
18
+ return;
19
+ }
20
+ console.log('\nSKS installed.');
21
+ const shim = await ensureSksCommandDuringInstall();
22
+ if (shim.status === 'present') console.log(`SKS command: available (${shim.command}).`);
23
+ else if (shim.status === 'created') console.log(`SKS command: shim created at ${shim.command}.`);
24
+ else if (shim.status === 'created_not_on_path') console.log(`SKS command: shim created at ${shim.command}. Add ${path.dirname(shim.command)} to PATH, or run npx -y -p sneakoscope sks.`);
25
+ else if (shim.status === 'skipped') console.log(`SKS command: skipped (${shim.reason}).`);
26
+ else console.log(`SKS command: shim unavailable. Use npx -y -p sneakoscope sks. ${shim.error || ''}`.trim());
27
+ const context7Install = await ensureGlobalContext7DuringInstall();
28
+ if (context7Install.status === 'present') console.log('Context7 MCP: already configured for Codex.');
29
+ else if (context7Install.status === 'installed') console.log('Context7 MCP: configured for Codex.');
30
+ else if (context7Install.status === 'codex_missing') console.log('Context7 MCP: Codex CLI missing. Install @openai/codex or set SKS_CODEX_BIN, then run `sks context7 setup --scope global` or `sks setup` in a project.');
31
+ else if (context7Install.status === 'skipped') console.log(`Context7 MCP: skipped (${context7Install.reason}).`);
32
+ else if (context7Install.status === 'failed') console.log(`Context7 MCP: auto setup failed. Run \`sks context7 setup --scope global\` or \`sks setup\`. ${context7Install.error || ''}`.trim());
33
+ const globalSkills = await ensureGlobalCodexSkillsDuringInstall();
34
+ if (globalSkills.status === 'installed') console.log(`Codex App global $ skills: installed in ${globalSkills.root} (${globalSkills.installed_count} skills).`);
35
+ else if (globalSkills.status === 'partial') console.log(`Codex App global $ skills: partial in ${globalSkills.root}; missing ${globalSkills.missing_skills.join(', ')}. Run \`sks doctor --fix\`.`);
36
+ else if (globalSkills.status === 'skipped') console.log(`Codex App global $ skills: skipped (${globalSkills.reason}).`);
37
+ else if (globalSkills.status === 'failed') console.log(`Codex App global $ skills: auto setup failed. Run \`sks doctor --fix\`. ${globalSkills.error || ''}`.trim());
38
+ const getdesignSkill = await ensureGlobalGetdesignSkillDuringInstall();
39
+ if (getdesignSkill.status === 'installed') console.log('getdesign Codex skill: installed.');
40
+ else if (getdesignSkill.status === 'present') console.log('getdesign Codex skill: already available.');
41
+ else if (getdesignSkill.status === 'skills_cli_missing') console.log(`getdesign Codex skill: skills CLI missing; generated getdesign-reference skill is installed. Later run \`${getdesignSkill.install}\` if the skills CLI is available.`);
42
+ else if (getdesignSkill.status === 'skipped') console.log(`getdesign Codex skill: skipped (${getdesignSkill.reason}).`);
43
+ else if (getdesignSkill.status === 'failed') console.log(`getdesign Codex skill: auto setup failed; generated getdesign-reference skill remains available. ${getdesignSkill.error || ''}`.trim());
44
+ const bootstrapDecision = await postinstallBootstrapDecision(installRoot);
45
+ if (bootstrapDecision.run) {
46
+ console.log(`SKS bootstrap: ${bootstrapDecision.reason}.`);
47
+ await runPostinstallBootstrap(installRoot, bootstrap);
48
+ return;
49
+ }
50
+ console.log('\nNext:');
51
+ console.log(' sks bootstrap');
52
+ console.log(`\nSKS bootstrap was not run automatically: ${bootstrapDecision.reason}.`);
53
+ console.log('This initializes the current project, installs SKS Codex App skills, verifies Codex App/Context7 readiness, and checks tmux/runtime dependencies.');
54
+ console.log('Dependency repair: sks deps check; sks deps install tmux');
55
+ console.log('Open runtime after readiness is green: sks\n');
56
+ }
57
+
58
+ async function postinstallHarnessConflictNotice(conflictScan) {
59
+ console.log('\nSneakoscope Codex package installed, but SKS setup is blocked.');
60
+ console.log(formatHarnessConflictReport(conflictScan, { includePrompt: false }));
61
+ console.log('\nWhat this means: npm can finish installing the package, but `sks setup` and `sks doctor --fix` will refuse to activate SKS until the conflicting harness is removed with human approval.');
62
+ console.log('No files were removed by postinstall.');
63
+ console.log('Cleanup requires a human-approved Codex App session. Recommended model: GPT-5.5, reasoning: high.');
64
+ if (shouldAskPostinstallQuestion()) {
65
+ const answer = await askPostinstallQuestion('Show the cleanup prompt now? [y/N] ');
66
+ if (/^(y|yes|예|네|응)$/i.test(answer.trim())) {
67
+ console.log('\nCleanup prompt:\n');
68
+ console.log(llmHarnessCleanupPrompt(conflictScan));
69
+ } else {
70
+ console.log('Cleanup prompt skipped. You can print it later with: sks conflicts prompt');
71
+ }
72
+ } else {
73
+ console.log('Print the cleanup prompt later with: sks conflicts prompt');
74
+ }
75
+ console.log('After approved cleanup, rerun: sks setup && sks doctor --fix && sks selftest --mock\n');
76
+ }
77
+
78
+ function shouldAskPostinstallQuestion() {
79
+ if (process.env.SKS_POSTINSTALL_PROMPT === '1') return true;
80
+ return Boolean(input.isTTY && output.isTTY && process.env.CI !== 'true' && process.env.SKS_POSTINSTALL_NO_PROMPT !== '1');
81
+ }
82
+
83
+ export async function postinstallBootstrapDecision(root) {
84
+ if (process.env.SKS_POSTINSTALL_NO_BOOTSTRAP === '1') return { run: false, reason: 'SKS_POSTINSTALL_NO_BOOTSTRAP=1' };
85
+ if (process.env.SKS_POSTINSTALL_BOOTSTRAP === '0') return { run: false, reason: 'SKS_POSTINSTALL_BOOTSTRAP=0' };
86
+ const candidate = await isProjectSetupCandidate(path.resolve(root || process.cwd()));
87
+ if (!candidate && process.env.SKS_POSTINSTALL_BOOTSTRAP !== '1') return { run: false, reason: 'no project marker found in install cwd' };
88
+ if (process.env.SKS_POSTINSTALL_BOOTSTRAP === '1') return { run: true, reason: 'forced by SKS_POSTINSTALL_BOOTSTRAP=1' };
89
+ return { run: true, reason: 'auto-running sks setup --bootstrap --install-scope global --force' };
90
+ }
91
+
92
+ async function runPostinstallBootstrap(root, bootstrap) {
93
+ const previousCwd = process.cwd();
94
+ process.chdir(path.resolve(root || previousCwd));
95
+ try {
96
+ await bootstrap(['--from-postinstall', '--install-scope', 'global', '--force']);
97
+ } finally {
98
+ process.chdir(previousCwd);
99
+ }
100
+ }
101
+
102
+ export async function askPostinstallQuestion(question) {
103
+ const rl = readline.createInterface({ input, output });
104
+ try {
105
+ return await rl.question(question);
106
+ } finally {
107
+ rl.close();
108
+ }
109
+ }
110
+
111
+ export async function ensureSksCommandDuringInstall(opts = {}) {
112
+ if (process.env.SKS_SKIP_POSTINSTALL_SHIM === '1' && !opts.force) return { status: 'skipped', reason: 'SKS_SKIP_POSTINSTALL_SHIM=1' };
113
+ const pathEnv = opts.pathEnv ?? process.env.PATH ?? '';
114
+ const existing = await findCommandOnPath('sks', pathEnv);
115
+ if (isStableSksBin(existing)) return { status: 'present', command: existing };
116
+ const nodeBin = opts.nodeBin || process.execPath;
117
+ const target = opts.target || path.join(packageRoot(), 'bin', 'sks.mjs');
118
+ const dirs = candidateShimDirs(pathEnv, opts.home || process.env.HOME);
119
+ const script = process.platform === 'win32'
120
+ ? `@echo off\r\n"${nodeBin}" "${target}" %*\r\n`
121
+ : `#!/bin/sh\nexec "${nodeBin}" "${target}" "$@"\n`;
122
+ const suffix = process.platform === 'win32' ? '.cmd' : '';
123
+ let createdFallback = null;
124
+ let lastError = '';
125
+ for (const entry of dirs) {
126
+ const dest = path.join(entry.dir, `sks${suffix}`);
127
+ try {
128
+ await ensureDir(entry.dir);
129
+ await writeTextAtomic(dest, script);
130
+ if (process.platform !== 'win32') await fsp.chmod(dest, 0o755).catch(() => {});
131
+ if (entry.onPath) return { status: 'created', command: dest };
132
+ createdFallback ||= dest;
133
+ } catch (err) {
134
+ lastError = err.message;
135
+ }
136
+ }
137
+ if (createdFallback) return { status: 'created_not_on_path', command: createdFallback };
138
+ return { status: 'failed', error: lastError };
139
+ }
140
+
141
+ function candidateShimDirs(pathEnv, home) {
142
+ const seen = new Set();
143
+ const out = [];
144
+ for (const raw of String(pathEnv || '').split(path.delimiter).filter(Boolean)) {
145
+ const dir = path.resolve(raw);
146
+ if (seen.has(dir) || isTransientNpmBinPath(dir)) continue;
147
+ seen.add(dir);
148
+ out.push({ dir, onPath: true });
149
+ }
150
+ for (const raw of [home && path.join(home, '.local', 'bin'), home && path.join(home, 'bin')].filter(Boolean)) {
151
+ const dir = path.resolve(raw);
152
+ if (seen.has(dir)) continue;
153
+ seen.add(dir);
154
+ out.push({ dir, onPath: false });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ async function findCommandOnPath(name, pathEnv) {
160
+ const suffixes = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
161
+ for (const dir of String(pathEnv || '').split(path.delimiter).filter(Boolean)) {
162
+ for (const suffix of suffixes) {
163
+ const candidate = path.join(dir, `${name}${suffix}`);
164
+ if (await exists(candidate)) return candidate;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ async function ensureGlobalContext7DuringInstall() {
171
+ if (process.env.SKS_SKIP_POSTINSTALL_CONTEXT7 === '1') return { status: 'skipped', reason: 'SKS_SKIP_POSTINSTALL_CONTEXT7=1' };
172
+ const codex = await getCodexInfo().catch(() => ({}));
173
+ if (!codex.bin) return { status: 'codex_missing' };
174
+ const list = await runProcess(codex.bin, ['mcp', 'list'], { timeoutMs: 8000, maxOutputBytes: 32 * 1024 }).catch((err) => ({ code: 1, stderr: err.message, stdout: '' }));
175
+ if (list.code === 0 && /context7/i.test(`${list.stdout}\n${list.stderr}`)) return { status: 'present' };
176
+ const add = await runProcess(codex.bin, ['mcp', 'add', 'context7', '--', 'npx', '-y', '@upstash/context7-mcp@latest'], { timeoutMs: 30000, maxOutputBytes: 64 * 1024 }).catch((err) => ({ code: 1, stderr: err.message, stdout: '' }));
177
+ if (add.code === 0) return { status: 'installed' };
178
+ return { status: 'failed', error: `${add.stderr || add.stdout || 'codex mcp add failed'}`.trim() };
179
+ }
180
+
181
+ export async function ensureGlobalCodexSkillsDuringInstall(opts = {}) {
182
+ if (process.env.SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS === '1' && !opts.force) return { status: 'skipped', reason: 'SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS=1' };
183
+ const home = opts.home || process.env.HOME || os.homedir();
184
+ if (!home) return { status: 'skipped', reason: 'home directory unavailable' };
185
+ const root = globalCodexSkillsRoot(home);
186
+ try {
187
+ const install = await installSkills(home);
188
+ const skills = await checkRequiredSkills(home, root);
189
+ return { status: skills.ok ? 'installed' : 'partial', root, installed_count: install.installed_skills.length, removed_aliases: install.removed_agent_skill_aliases, missing_skills: skills.missing };
190
+ } catch (err) {
191
+ return { status: 'failed', root, error: err.message };
192
+ }
193
+ }
194
+
195
+ async function ensureGlobalGetdesignSkillDuringInstall() {
196
+ if (process.env.SKS_SKIP_POSTINSTALL_GETDESIGN === '1') return { status: 'skipped', reason: 'SKS_SKIP_POSTINSTALL_GETDESIGN=1' };
197
+ const pathEnv = process.env.PATH || '';
198
+ const skillsBin = await findCommandOnPath('skills', pathEnv);
199
+ if (!skillsBin) return { status: 'skills_cli_missing', install: GETDESIGN_REFERENCE.codex_skill_install };
200
+ const add = await runProcess(skillsBin, ['add', GETDESIGN_REFERENCE.codex_skill], {
201
+ timeoutMs: 30000,
202
+ maxOutputBytes: 64 * 1024
203
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
204
+ const out = `${add.stdout || ''}\n${add.stderr || ''}`;
205
+ if (add.code === 0) return { status: /already|exists|present/i.test(out) ? 'present' : 'installed', command: skillsBin };
206
+ if (/already|exists|present/i.test(out)) return { status: 'present', command: skillsBin };
207
+ return { status: 'failed', command: skillsBin, error: out.trim() || 'skills add failed' };
208
+ }
209
+
210
+ export async function ensureRelatedCliTools(args = []) {
211
+ const skip = args.includes('--skip-cli-tools') || process.env.SKS_SKIP_CLI_TOOLS === '1';
212
+ const codex = await ensureCodexCliTool({ skip });
213
+ const tmux = await tmuxReadiness().catch((err) => ({ ok: false, version: null, error: err.message }));
214
+ return {
215
+ codex,
216
+ tmux: {
217
+ ok: Boolean(tmux.ok),
218
+ bin: tmux.bin || null,
219
+ version: tmux.version || null,
220
+ min_version: tmux.min_version || '3.0',
221
+ current_session: Boolean(tmux.current_session),
222
+ install_hint: tmux.ok ? null : platformTmuxInstallHint(),
223
+ error: tmux.error || null
224
+ }
225
+ };
226
+ }
227
+
228
+ export async function ensureCodexCliTool({ skip = false } = {}) {
229
+ if (skip) return { status: 'skipped', reason: 'SKS_SKIP_CLI_TOOLS=1 or --skip-cli-tools' };
230
+ const before = await getCodexInfo().catch(() => ({}));
231
+ if (before.bin) return { status: 'present', bin: before.bin, version: before.version || null };
232
+ const npmBin = await which('npm');
233
+ if (!npmBin) return { status: 'failed', error: 'npm not found on PATH; install Codex CLI manually with npm i -g @openai/codex@latest.' };
234
+ const install = await runProcess(npmBin, ['i', '-g', '@openai/codex@latest'], {
235
+ timeoutMs: 120000,
236
+ maxOutputBytes: 128 * 1024
237
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
238
+ if (install.code !== 0) {
239
+ return { status: 'failed', error: `${install.stderr || install.stdout || 'npm i -g @openai/codex@latest failed'}`.trim() };
240
+ }
241
+ const after = await getCodexInfo().catch(() => ({}));
242
+ return {
243
+ status: after.bin ? 'installed' : 'installed_not_on_path',
244
+ bin: after.bin || null,
245
+ version: after.version || null,
246
+ hint: after.bin ? null : 'npm completed, but codex is not on PATH. Restart the shell or set SKS_CODEX_BIN.'
247
+ };
248
+ }
249
+
250
+ async function isProjectSetupCandidate(root) {
251
+ const markers = ['package.json', '.git', 'AGENTS.md', '.codex', '.sneakoscope'];
252
+ for (const marker of markers) {
253
+ if (await exists(path.join(root, marker))) return true;
254
+ }
255
+ return false;
256
+ }
257
+
258
+ export async function checkContext7(root) {
259
+ const projectPath = path.join(root, '.codex', 'config.toml');
260
+ const globalPath = path.join(process.env.HOME || '', '.codex', 'config.toml');
261
+ const projectText = await safeReadText(projectPath);
262
+ const globalText = await safeReadText(globalPath);
263
+ const codex = await getCodexInfo().catch(() => ({}));
264
+ let list = { checked: false, ok: false, stdout: '', stderr: '' };
265
+ if (codex.bin) {
266
+ const out = await runProcess(codex.bin, ['mcp', 'list'], { timeoutMs: 8000, maxOutputBytes: 32 * 1024 }).catch((err) => ({ code: 1, stderr: err.message, stdout: '' }));
267
+ list = { checked: true, ok: out.code === 0 && /context7/i.test(`${out.stdout}\n${out.stderr}`), stdout: out.stdout || '', stderr: out.stderr || '' };
268
+ }
269
+ const result = {
270
+ project: { path: projectPath, ok: hasContext7ConfigText(projectText) },
271
+ global: { path: globalPath, ok: hasContext7ConfigText(globalText) },
272
+ codex_mcp_list: list
273
+ };
274
+ result.ok = result.project.ok || result.codex_mcp_list.ok || (result.global.ok && !list.checked);
275
+ return result;
276
+ }
277
+
278
+ export async function ensureProjectContext7Config(root, transport = 'local') {
279
+ const configPath = path.join(root, '.codex', 'config.toml');
280
+ await ensureDir(path.dirname(configPath));
281
+ const current = await safeReadText(configPath);
282
+ const block = context7ConfigToml(transport).trim();
283
+ const existingBlock = /(^|\n)\[mcp_servers\.context7\]\n[\s\S]*?(?=\n\[[^\]]+\]|\s*$)/;
284
+ if (existingBlock.test(current)) {
285
+ const next = current.replace(existingBlock, `$1${block}\n`);
286
+ if (next === current) return false;
287
+ await writeTextAtomic(configPath, next.endsWith('\n') ? next : `${next}\n`);
288
+ return true;
289
+ }
290
+ if (hasContext7ConfigText(current)) return false;
291
+ await writeTextAtomic(configPath, `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`);
292
+ return true;
293
+ }
294
+
295
+ export async function checkRequiredSkills(root, skillRoot = root ? path.join(root, '.agents', 'skills') : globalCodexSkillsRoot()) {
296
+ const missing = [];
297
+ for (const name of [...DOLLAR_SKILL_NAMES, ...RECOMMENDED_SKILLS]) {
298
+ if (!(await exists(path.join(skillRoot, name, 'SKILL.md')))) missing.push(name);
299
+ }
300
+ return { ok: missing.length === 0, root: skillRoot, missing };
301
+ }
302
+
303
+ export function globalCodexSkillsRoot(home = process.env.HOME || os.homedir()) {
304
+ return path.join(home, '.agents', 'skills');
305
+ }
306
+
307
+ function isStableSksBin(candidate) {
308
+ return Boolean(candidate) && !isTransientNpmBinPath(candidate);
309
+ }
310
+
311
+ function isTransientNpmBinPath(candidate) {
312
+ const normalized = String(candidate || '').split(path.sep).join('/');
313
+ return normalized.includes('/_npx/')
314
+ || normalized.includes('/_cacache/tmp/')
315
+ || /\/npm-cache\/_npx\//.test(normalized)
316
+ || (/\/node_modules\/\.bin\/sks$/.test(normalized) && normalized.includes('/.npm-cache/'));
317
+ }
318
+
319
+ async function safeReadText(file, fallback = '') {
320
+ try {
321
+ return await fsp.readFile(file, 'utf8');
322
+ } catch {
323
+ return fallback;
324
+ }
325
+ }