xtrm-cli 0.5.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/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
|
|
3
|
+
// ── ASCII art ───────────────────────────────────────────────────────────────
|
|
4
|
+
const ART = [
|
|
5
|
+
' ██╗ ██╗████████╗██████╗ ███╗ ███╗ ████████╗ ██████╗ ██████╗ ██╗ ███████╗',
|
|
6
|
+
' ╚██╗██╔╝╚══██╔══╝██╔══██╗████╗ ████║ ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██╔════╝',
|
|
7
|
+
' ╚███╔╝ ██║ ██████╔╝██╔████╔██║ ██║ ██║ ██║██║ ██║██║ ███████╗',
|
|
8
|
+
' ██╔██╗ ██║ ██╔══██╗██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██║ ╚════██║',
|
|
9
|
+
' ██╔╝ ██╗ ██║ ██║ ██║██║ ╚═╝ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗███████║',
|
|
10
|
+
' ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const ART_WIDTH = 84;
|
|
14
|
+
|
|
15
|
+
// ── Detection helpers ───────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function isTTY(): boolean { return Boolean(process.stdout.isTTY); }
|
|
18
|
+
|
|
19
|
+
function hasColor(): boolean {
|
|
20
|
+
if (process.env.NO_COLOR !== undefined) return false;
|
|
21
|
+
if (process.env.FORCE_COLOR !== undefined) return true;
|
|
22
|
+
if (process.env.TERM === 'dumb') return false;
|
|
23
|
+
return isTTY();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isCI(): boolean { return Boolean(process.env.CI); }
|
|
27
|
+
function isDumb(): boolean { return process.env.TERM === 'dumb'; }
|
|
28
|
+
function columns(): number { return process.stdout.columns ?? 80; }
|
|
29
|
+
|
|
30
|
+
function hasUnicode(): boolean {
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
return Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM);
|
|
33
|
+
}
|
|
34
|
+
const lang = (process.env.LANG ?? process.env.LC_ALL ?? process.env.LC_CTYPE ?? '').toUpperCase();
|
|
35
|
+
return lang.includes('UTF') || lang === '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasTruecolor(): boolean {
|
|
39
|
+
const ct = (process.env.COLORTERM ?? '').toLowerCase();
|
|
40
|
+
return ct === 'truecolor' || ct === '24bit';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Tier selection ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
type Tier = 0 | 1 | 2 | 3 | 4;
|
|
46
|
+
|
|
47
|
+
function selectTier(): Tier {
|
|
48
|
+
if (!isTTY() || isDumb() || isCI()) return 4;
|
|
49
|
+
if (!hasColor()) return 4;
|
|
50
|
+
if (!hasUnicode()) return 3;
|
|
51
|
+
const cols = columns();
|
|
52
|
+
if (cols < 40) return 4;
|
|
53
|
+
if (cols < 85) return 2;
|
|
54
|
+
if (hasTruecolor()) return 0;
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Press-any-key with passive 1s auto-timeout ─────────────────────────────
|
|
59
|
+
|
|
60
|
+
function pressAnyKey(): Promise<void> {
|
|
61
|
+
return new Promise(resolve => {
|
|
62
|
+
process.stdout.write(kleur.dim('\n press any key to continue...'));
|
|
63
|
+
process.stdin.setRawMode(true);
|
|
64
|
+
process.stdin.resume();
|
|
65
|
+
|
|
66
|
+
let done = false;
|
|
67
|
+
const finish = (): void => {
|
|
68
|
+
if (done) return;
|
|
69
|
+
done = true;
|
|
70
|
+
process.stdin.setRawMode(false);
|
|
71
|
+
process.stdin.pause();
|
|
72
|
+
process.stdout.write('\r\x1b[2K');
|
|
73
|
+
resolve();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
process.stdin.once('data', finish);
|
|
77
|
+
setTimeout(finish, 4000);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Typewriter tagline (bold-white, 42ms per char) ─────────────────────────
|
|
82
|
+
|
|
83
|
+
function delay(ms: number): Promise<void> {
|
|
84
|
+
return new Promise(r => setTimeout(r, ms));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function typewriterTagline(text: string): Promise<void> {
|
|
88
|
+
const CHAR_DELAY = 42; // ms per character
|
|
89
|
+
const prefix = ` \x1b[2m\u2014\x1b[0m \x1b[1m\x1b[37m`;
|
|
90
|
+
const suffix = `\x1b[0m \x1b[2m\u2014\x1b[0m`;
|
|
91
|
+
|
|
92
|
+
process.stdout.write(prefix);
|
|
93
|
+
for (const ch of text) {
|
|
94
|
+
process.stdout.write(ch);
|
|
95
|
+
await delay(CHAR_DELAY);
|
|
96
|
+
}
|
|
97
|
+
process.stdout.write(suffix + '\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Truecolor gradient (item 7: 170°→230° teal→indigo, sat=0.6) ───────────
|
|
101
|
+
|
|
102
|
+
const HUE_START = 170;
|
|
103
|
+
const HUE_END = 230;
|
|
104
|
+
const SAT = 0.6;
|
|
105
|
+
const LIG = 0.72;
|
|
106
|
+
|
|
107
|
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
108
|
+
const a = s * Math.min(l, 1 - l);
|
|
109
|
+
const f = (n: number): number => {
|
|
110
|
+
const k = (n + h / 30) % 12;
|
|
111
|
+
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
|
112
|
+
};
|
|
113
|
+
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const RESET = '\x1b[0m';
|
|
117
|
+
|
|
118
|
+
function gradientLine(line: string): string {
|
|
119
|
+
let out = '';
|
|
120
|
+
const len = line.length;
|
|
121
|
+
for (let i = 0; i < len; i++) {
|
|
122
|
+
const t = len > 1 ? i / (len - 1) : 0;
|
|
123
|
+
const hue = HUE_START + t * (HUE_END - HUE_START);
|
|
124
|
+
const [r, g, b] = hslToRgb(hue, SAT, LIG);
|
|
125
|
+
out += `\x1b[38;2;${r};${g};${b}m` + line[i];
|
|
126
|
+
}
|
|
127
|
+
return out + RESET;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Renderers ───────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
async function renderTier0(version: string): Promise<void> {
|
|
133
|
+
const rule = '\x1b[2m ' + '─'.repeat(ART_WIDTH - 1) + '\x1b[0m';
|
|
134
|
+
|
|
135
|
+
process.stdout.write('\n');
|
|
136
|
+
for (const line of ART) {
|
|
137
|
+
process.stdout.write(gradientLine(line) + '\n');
|
|
138
|
+
}
|
|
139
|
+
process.stdout.write(rule + '\n');
|
|
140
|
+
process.stdout.write(' \x1b[2mv' + version + '\x1b[0m\n');
|
|
141
|
+
await typewriterTagline('Sync agent tools across AI environments');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderTier1(version: string): void {
|
|
145
|
+
console.log('');
|
|
146
|
+
for (const line of ART) { console.log(kleur.cyan(line)); }
|
|
147
|
+
console.log(kleur.dim(' ' + '─'.repeat(ART_WIDTH - 1)));
|
|
148
|
+
console.log(kleur.dim(' v' + version));
|
|
149
|
+
console.log(
|
|
150
|
+
' ' + kleur.dim('\u2014') + ' ' +
|
|
151
|
+
kleur.bold().white('Sync agent tools across AI environments') +
|
|
152
|
+
' ' + kleur.dim('\u2014'),
|
|
153
|
+
);
|
|
154
|
+
console.log('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderTier2(version: string): void {
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(
|
|
160
|
+
kleur.cyan(' ◈ ') +
|
|
161
|
+
kleur.bold().white('xtrm-tools') +
|
|
162
|
+
kleur.dim(' v' + version),
|
|
163
|
+
);
|
|
164
|
+
console.log(kleur.dim(' Sync agent tools across AI environments'));
|
|
165
|
+
console.log('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderTier3(version: string): void {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(kleur.bold(' xtrm-tools') + kleur.dim(' v' + version));
|
|
171
|
+
console.log(kleur.dim(' Sync agent tools across AI environments'));
|
|
172
|
+
console.log('');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderTier4(version: string): void {
|
|
176
|
+
console.log('');
|
|
177
|
+
console.log('xtrm-tools v' + version);
|
|
178
|
+
console.log('Sync agent tools across AI environments');
|
|
179
|
+
console.log('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export async function printBanner(version: string): Promise<void> {
|
|
185
|
+
const tier = selectTier();
|
|
186
|
+
switch (tier) {
|
|
187
|
+
case 0: await renderTier0(version); break;
|
|
188
|
+
case 1: renderTier1(version); break;
|
|
189
|
+
case 2: renderTier2(version); break;
|
|
190
|
+
case 3: renderTier3(version); break;
|
|
191
|
+
case 4: renderTier4(version); return;
|
|
192
|
+
}
|
|
193
|
+
if (isTTY()) await pressAnyKey();
|
|
194
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ConfigAdapter for Claude Code only.
|
|
6
|
+
*
|
|
7
|
+
* ARCHITECTURAL DECISION (v2.0.0): xtrm-tools now supports Claude Code exclusively.
|
|
8
|
+
* Hook translation for Gemini/Qwen was removed due to fragile, undocumented ecosystems.
|
|
9
|
+
* See PROJECT-SKILLS-ARCHITECTURE.md Section 3.1 for details.
|
|
10
|
+
*/
|
|
11
|
+
export class ConfigAdapter {
|
|
12
|
+
systemRoot: string;
|
|
13
|
+
homeDir: string;
|
|
14
|
+
hooksDir: string;
|
|
15
|
+
|
|
16
|
+
constructor(systemRoot: string) {
|
|
17
|
+
this.systemRoot = systemRoot;
|
|
18
|
+
this.homeDir = os.homedir();
|
|
19
|
+
this.hooksDir = path.join(this.systemRoot, 'hooks');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Adapt hooks config for Claude Code format.
|
|
24
|
+
* Transforms flat hook definitions into Claude's wrapped format.
|
|
25
|
+
*/
|
|
26
|
+
adaptHooksConfig(canonicalHooks: any): any {
|
|
27
|
+
if (!canonicalHooks) return {};
|
|
28
|
+
|
|
29
|
+
const hooksConfig = JSON.parse(JSON.stringify(canonicalHooks));
|
|
30
|
+
this.resolveHookScripts(hooksConfig);
|
|
31
|
+
return hooksConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve hook script paths and transform into Claude's command format.
|
|
36
|
+
* Converts { script: "foo.py" } → { type: "command", command: "python3 /full/path/foo.py" }
|
|
37
|
+
*/
|
|
38
|
+
resolveHookScripts(hooksConfig: any): void {
|
|
39
|
+
if (hooksConfig.hooks) {
|
|
40
|
+
for (const [event, hooks] of Object.entries(hooksConfig.hooks)) {
|
|
41
|
+
if (Array.isArray(hooks)) {
|
|
42
|
+
// Transform flat hooks into Claude's wrapped format:
|
|
43
|
+
// { matcher?, hooks: [{ type, command, timeout }] }
|
|
44
|
+
hooksConfig.hooks[event] = hooks.map((hook: any) => {
|
|
45
|
+
if (hook.script) {
|
|
46
|
+
const resolvedScriptPath = this.resolvePath(path.join(this.hooksDir, hook.script));
|
|
47
|
+
const command = this.buildScriptCommand(hook.script, resolvedScriptPath);
|
|
48
|
+
const innerHook: any = { type: "command", command };
|
|
49
|
+
if (hook.timeout) innerHook.timeout = hook.timeout;
|
|
50
|
+
|
|
51
|
+
const wrapper: any = { hooks: [innerHook] };
|
|
52
|
+
if (hook.matcher) wrapper.matcher = hook.matcher;
|
|
53
|
+
return wrapper;
|
|
54
|
+
}
|
|
55
|
+
return hook;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (hooksConfig.statusLine && hooksConfig.statusLine.script) {
|
|
61
|
+
const resolvedScriptPath = this.resolvePath(path.join(this.hooksDir, hooksConfig.statusLine.script));
|
|
62
|
+
const command = this.buildScriptCommand(hooksConfig.statusLine.script, resolvedScriptPath);
|
|
63
|
+
hooksConfig.statusLine = { type: "command", command };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
buildScriptCommand(scriptName: string, resolvedPath: string): string {
|
|
68
|
+
const ext = path.extname(scriptName).toLowerCase();
|
|
69
|
+
if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
|
|
70
|
+
return `node "${resolvedPath}"`;
|
|
71
|
+
} else if (ext === '.sh') {
|
|
72
|
+
return `bash "${resolvedPath}"`;
|
|
73
|
+
} else {
|
|
74
|
+
const pythonBin = process.platform === 'win32' ? 'python' : 'python3';
|
|
75
|
+
return `${pythonBin} "${resolvedPath}"`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
resolvePath(p: string): string {
|
|
80
|
+
if (!p || typeof p !== 'string') return p;
|
|
81
|
+
let resolved = p.replace(/~\//g, this.homeDir + '/').replace(/\${HOME}/g, this.homeDir);
|
|
82
|
+
|
|
83
|
+
// Windows compatibility: use forward slashes in config files
|
|
84
|
+
if (process.platform === 'win32') {
|
|
85
|
+
resolved = resolved.replace(/\\/g, '/');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import { parse, stringify } from 'comment-json';
|
|
5
|
+
import kleur from 'kleur';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Safely inject hook configuration into settings.json
|
|
9
|
+
*/
|
|
10
|
+
export async function injectHookConfig(targetDir: string, repoRoot: string): Promise<boolean> {
|
|
11
|
+
const settingsPath = path.join(targetDir, 'settings.json');
|
|
12
|
+
|
|
13
|
+
if (!(await fs.pathExists(settingsPath))) {
|
|
14
|
+
console.log(kleur.yellow(` [!] settings.json not found in ${targetDir}. Skipping auto-config.`));
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const rawContent = await fs.readFile(settingsPath, 'utf8');
|
|
20
|
+
const settings = parse(rawContent);
|
|
21
|
+
|
|
22
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
23
|
+
console.log(kleur.yellow(` [!] settings.json is not a valid object. Skipping auto-config.`));
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Define our required hooks
|
|
28
|
+
const requiredHooks = [
|
|
29
|
+
{
|
|
30
|
+
name: 'skill-suggestion',
|
|
31
|
+
path: path.join(targetDir, 'hooks', 'skill-suggestion.py'), // It's python!
|
|
32
|
+
events: ['userPromptSubmit'],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'serena-workflow-reminder',
|
|
36
|
+
path: path.join(targetDir, 'hooks', 'serena-workflow-reminder.py'),
|
|
37
|
+
events: ['toolUse'],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
let modified = false;
|
|
42
|
+
|
|
43
|
+
if (!Array.isArray((settings as any).hooks)) {
|
|
44
|
+
(settings as any).hooks = [];
|
|
45
|
+
modified = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const req of requiredHooks) {
|
|
49
|
+
const exists = (settings as any).hooks.find((h: any) => h.name === req.name || h.path === req.path);
|
|
50
|
+
|
|
51
|
+
if (!exists) {
|
|
52
|
+
console.log(kleur.blue(` [+] Adding hook: ${req.name}`));
|
|
53
|
+
(settings as any).hooks.push(req);
|
|
54
|
+
modified = true;
|
|
55
|
+
} else {
|
|
56
|
+
// Optional: Update path if it changed
|
|
57
|
+
if (exists.path !== req.path) {
|
|
58
|
+
console.log(kleur.blue(` [^] Updating hook path: ${req.name}`));
|
|
59
|
+
exists.path = req.path;
|
|
60
|
+
modified = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (modified) {
|
|
66
|
+
// Backup
|
|
67
|
+
const backupPath = `${settingsPath}.bak`;
|
|
68
|
+
await fs.copy(settingsPath, backupPath);
|
|
69
|
+
console.log(kleur.gray(` [i] Backup created at settings.json.bak`));
|
|
70
|
+
|
|
71
|
+
// Write back with comments preserved
|
|
72
|
+
await fs.writeFile(settingsPath, stringify(settings, null, 2));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return false;
|
|
77
|
+
} catch (err: any) {
|
|
78
|
+
console.error(kleur.red(` [!] Error parsing settings.json: ${err.message}`));
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import kleur from 'kleur';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Environment file location: ~/.config/jaggers-agent-tools/.env
|
|
9
|
+
*/
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'jaggers-agent-tools');
|
|
11
|
+
const ENV_FILE = path.join(CONFIG_DIR, '.env');
|
|
12
|
+
const ENV_EXAMPLE_FILE = path.join(CONFIG_DIR, '.env.example');
|
|
13
|
+
|
|
14
|
+
interface EnvVarConfig {
|
|
15
|
+
description: string;
|
|
16
|
+
example: string;
|
|
17
|
+
getUrl: () => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Required environment variables for MCP servers
|
|
22
|
+
*/
|
|
23
|
+
const REQUIRED_ENV_VARS: Record<string, EnvVarConfig> = {
|
|
24
|
+
CONTEXT7_API_KEY: {
|
|
25
|
+
description: 'Context7 MCP server API key',
|
|
26
|
+
example: 'ctx7sk-your-api-key-here',
|
|
27
|
+
getUrl: () => 'https://context7.com/',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure config directory and .env file exist
|
|
33
|
+
*/
|
|
34
|
+
export function ensureEnvFile(): boolean {
|
|
35
|
+
// Create config directory if missing
|
|
36
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
37
|
+
fs.ensureDirSync(CONFIG_DIR);
|
|
38
|
+
console.log(kleur.gray(` Created config directory: ${CONFIG_DIR}`));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create .env.example if missing
|
|
42
|
+
if (!fs.existsSync(ENV_EXAMPLE_FILE)) {
|
|
43
|
+
createEnvExample();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create .env if missing
|
|
47
|
+
if (!fs.existsSync(ENV_FILE)) {
|
|
48
|
+
createEnvFile();
|
|
49
|
+
return false; // File was created (user needs to fill it)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return true; // File already exists
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createEnvExample(): void {
|
|
56
|
+
const content = [
|
|
57
|
+
'# Jaggers Agent Tools - Environment Variables',
|
|
58
|
+
'# Copy this file to .env and fill in your actual values',
|
|
59
|
+
'',
|
|
60
|
+
...Object.entries(REQUIRED_ENV_VARS).map(([key, config]) => {
|
|
61
|
+
return [
|
|
62
|
+
`# ${config.description}`,
|
|
63
|
+
`# Get your key from: ${config.getUrl()}`,
|
|
64
|
+
`${key}=${config.example}`,
|
|
65
|
+
'',
|
|
66
|
+
].join('\n');
|
|
67
|
+
}),
|
|
68
|
+
'# See config/.env.example in the repository for all available options',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
|
|
72
|
+
fs.writeFileSync(ENV_EXAMPLE_FILE, content);
|
|
73
|
+
console.log(kleur.gray(` Created example file: ${ENV_EXAMPLE_FILE}`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createEnvFile(): void {
|
|
77
|
+
const content = [
|
|
78
|
+
'# Jaggers Agent Tools - Environment Variables',
|
|
79
|
+
'# Generated automatically by jaggers-agent-tools CLI',
|
|
80
|
+
'',
|
|
81
|
+
'# Copy values from .env.example and fill in your actual keys',
|
|
82
|
+
'',
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
fs.writeFileSync(ENV_FILE, content);
|
|
86
|
+
console.log(kleur.green(` Created environment file: ${ENV_FILE}`));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load environment variables from .env file
|
|
91
|
+
* Also loads from process.env (which takes precedence)
|
|
92
|
+
*/
|
|
93
|
+
export function loadEnvFile(): Record<string, string> {
|
|
94
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
95
|
+
const envConfig = dotenv.parse(fs.readFileSync(ENV_FILE));
|
|
96
|
+
|
|
97
|
+
// Merge with process.env (process.env takes precedence)
|
|
98
|
+
for (const [key, value] of Object.entries(envConfig)) {
|
|
99
|
+
if (!process.env[key]) {
|
|
100
|
+
process.env[key] = value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return envConfig;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if required environment variables are set.
|
|
112
|
+
* Pass an optional `subset` of var names to check only those.
|
|
113
|
+
* Known vars (in REQUIRED_ENV_VARS) get rich metadata; unknown vars are
|
|
114
|
+
* still checked so server-defined ${VAR} references are never silently dropped.
|
|
115
|
+
* Returns array of missing variable names.
|
|
116
|
+
*/
|
|
117
|
+
export function checkRequiredEnvVars(subset?: string[]): string[] {
|
|
118
|
+
const missing: string[] = [];
|
|
119
|
+
const keysToCheck = subset ?? Object.keys(REQUIRED_ENV_VARS);
|
|
120
|
+
|
|
121
|
+
for (const key of keysToCheck) {
|
|
122
|
+
if (!process.env[key]) {
|
|
123
|
+
missing.push(key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return missing;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Prompt user to enter missing environment variables interactively.
|
|
132
|
+
* Saves provided values to .env and sets them in process.env.
|
|
133
|
+
* Returns true if all missing vars were provided (sync can continue).
|
|
134
|
+
*/
|
|
135
|
+
export async function handleMissingEnvVars(missing: string[]): Promise<boolean> {
|
|
136
|
+
if (missing.length === 0) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// @ts-ignore
|
|
141
|
+
const prompts = (await import('prompts')).default;
|
|
142
|
+
|
|
143
|
+
const answers: Record<string, string> = {};
|
|
144
|
+
|
|
145
|
+
for (const key of missing) {
|
|
146
|
+
const config = REQUIRED_ENV_VARS[key];
|
|
147
|
+
if (config) {
|
|
148
|
+
console.log(kleur.yellow(`\n ⚠️ ${config.description} is required`));
|
|
149
|
+
console.log(kleur.dim(` Get your key from: ${config.getUrl()}`));
|
|
150
|
+
} else {
|
|
151
|
+
console.log(kleur.yellow(`\n ⚠️ ${key} is required by a selected MCP server`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { value } = await prompts({
|
|
155
|
+
type: 'text',
|
|
156
|
+
name: 'value',
|
|
157
|
+
message: `Enter ${key}:`,
|
|
158
|
+
validate: (v: string) => v.trim().length > 0 || 'Value cannot be empty'
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (!value) {
|
|
162
|
+
console.log(kleur.gray(` Skipped — ${key} not provided. MCP server will be skipped.`));
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
answers[key] = value.trim();
|
|
167
|
+
process.env[key] = value.trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Persist to .env file
|
|
171
|
+
let envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, 'utf8') : '';
|
|
172
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
173
|
+
const line = `${key}=${value}`;
|
|
174
|
+
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
175
|
+
if (regex.test(envContent)) {
|
|
176
|
+
envContent = envContent.replace(regex, line);
|
|
177
|
+
} else {
|
|
178
|
+
envContent += `\n${line}\n`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
fs.writeFileSync(ENV_FILE, envContent);
|
|
182
|
+
console.log(kleur.green(` ✓ Saved to ${ENV_FILE}`));
|
|
183
|
+
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getEnvFilePath(): string {
|
|
188
|
+
return ENV_FILE;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getConfigDir(): string {
|
|
192
|
+
return CONFIG_DIR;
|
|
193
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
export async function hashFile(filePath: string): Promise<string> {
|
|
6
|
+
const content = await fs.readFile(filePath);
|
|
7
|
+
return createHash('md5').update(content).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function hashDirectory(dirPath: string): Promise<string> {
|
|
11
|
+
if (!(await fs.pathExists(dirPath))) return '';
|
|
12
|
+
const stats = await fs.stat(dirPath);
|
|
13
|
+
|
|
14
|
+
if (!stats.isDirectory()) {
|
|
15
|
+
return hashFile(dirPath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const children = await fs.readdir(dirPath);
|
|
19
|
+
const childHashes = await Promise.all(
|
|
20
|
+
children.sort().map(async (child) => {
|
|
21
|
+
const h = await hashDirectory(join(dirPath, child));
|
|
22
|
+
return `${child}:${h}`;
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
return createHash('md5').update(childHashes.join('|')).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getNewestMtime(targetPath: string): Promise<number> {
|
|
29
|
+
if (!(await fs.pathExists(targetPath))) return 0;
|
|
30
|
+
|
|
31
|
+
const stats = await fs.stat(targetPath);
|
|
32
|
+
let maxTime = 0;
|
|
33
|
+
|
|
34
|
+
if (stats.isDirectory()) {
|
|
35
|
+
const children = await fs.readdir(targetPath);
|
|
36
|
+
for (const child of children) {
|
|
37
|
+
const childTime = await getNewestMtime(join(targetPath, child));
|
|
38
|
+
if (childTime > maxTime) maxTime = childTime;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return maxTime;
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
async function walkUp(startDir: string): Promise<string | null> {
|
|
5
|
+
let dir = path.resolve(startDir);
|
|
6
|
+
|
|
7
|
+
while (true) {
|
|
8
|
+
const skillsPath = path.join(dir, 'skills');
|
|
9
|
+
const hooksPath = path.join(dir, 'hooks');
|
|
10
|
+
|
|
11
|
+
if (await fs.pathExists(skillsPath) && await fs.pathExists(hooksPath)) {
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parent = path.dirname(dir);
|
|
16
|
+
if (parent === dir) return null;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Finds the jaggers-agent-tools repo root by:
|
|
23
|
+
* 1. Walking up from process.cwd() — works when run inside a cloned repo.
|
|
24
|
+
* 2. Walking up from __dirname — works when run via `npx`, where the
|
|
25
|
+
* package is extracted to a temp/cache directory that contains skills/ & hooks/.
|
|
26
|
+
*/
|
|
27
|
+
export async function findRepoRoot(): Promise<string> {
|
|
28
|
+
const fromCwd = await walkUp(process.cwd());
|
|
29
|
+
if (fromCwd) return fromCwd;
|
|
30
|
+
|
|
31
|
+
// __dirname is cli/dist/ inside the package — two levels up is the repo root.
|
|
32
|
+
const fromBundle = await walkUp(path.resolve(__dirname, '..', '..'));
|
|
33
|
+
if (fromBundle) return fromBundle;
|
|
34
|
+
|
|
35
|
+
throw new Error(
|
|
36
|
+
'Could not locate jaggers-agent-tools repo root.\n' +
|
|
37
|
+
'Run via `npx -y github:Jaggerxtrm/jaggers-agent-tools` or from within the cloned repository.'
|
|
38
|
+
);
|
|
39
|
+
}
|