xtrm-cli 2.1.4
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 +55937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/index.js +151 -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/lib/transform-gemini.js +119 -0
- package/package.json +43 -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/help.ts +171 -0
- package/src/commands/install-project.ts +566 -0
- package/src/commands/install-service-skills.ts +251 -0
- package/src/commands/install.ts +534 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +143 -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/sync-executor.ts +399 -0
- package/src/index.ts +69 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +222 -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 +467 -0
- package/src/utils/theme.ts +37 -0
- package/test/context.test.ts +33 -0
- package/test/hooks.test.ts +277 -0
- package/test/install-project.test.ts +235 -0
- package/test/install-service-skills.test.ts +111 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface Skill {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
content: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Command {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Hook {
|
|
14
|
+
name?: string;
|
|
15
|
+
type?: string;
|
|
16
|
+
command?: string;
|
|
17
|
+
script?: string;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
events?: string[];
|
|
20
|
+
matcher?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MCPServer {
|
|
24
|
+
type?: 'stdio' | 'http' | 'sse';
|
|
25
|
+
command?: string;
|
|
26
|
+
args?: string[];
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
url?: string;
|
|
29
|
+
serverUrl?: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SyncOptions {
|
|
35
|
+
dryRun?: boolean;
|
|
36
|
+
yes?: boolean;
|
|
37
|
+
prune?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ManifestItem {
|
|
41
|
+
type: 'skill' | 'hook' | 'config' | 'command';
|
|
42
|
+
name: string;
|
|
43
|
+
hash: string;
|
|
44
|
+
lastSync: string;
|
|
45
|
+
source: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Manifest {
|
|
49
|
+
version: string;
|
|
50
|
+
lastSync: string;
|
|
51
|
+
items: Record<string, ManifestItem>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import { parse, stringify } from 'comment-json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Atomic Configuration Handler with Vault Pattern
|
|
8
|
+
* Ensures safe read/write operations with protection against corruption during crashes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Protected keys that should never be overwritten if they exist locally
|
|
12
|
+
const PROTECTED_KEYS = [
|
|
13
|
+
'permissions.allow', // User-defined permissions
|
|
14
|
+
'hooks.UserPromptSubmit', // Claude hooks
|
|
15
|
+
'hooks.SessionStart',
|
|
16
|
+
'hooks.PreToolUse',
|
|
17
|
+
'hooks.BeforeAgent', // Gemini hooks
|
|
18
|
+
'hooks.BeforeTool', // Gemini hooks
|
|
19
|
+
'security', // Auth secrets/OAuth data
|
|
20
|
+
'general', // Personal preferences
|
|
21
|
+
'enabledPlugins', // User-enabled/disabled plugins
|
|
22
|
+
'model', // User's preferred model
|
|
23
|
+
'skillSuggestions.enabled' // User preferences
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a key path is exactly protected or a parent of a protected key
|
|
28
|
+
*/
|
|
29
|
+
export function isProtectedPath(keyPath: string): boolean {
|
|
30
|
+
return PROTECTED_KEYS.some(protectedPath =>
|
|
31
|
+
keyPath === protectedPath || protectedPath.startsWith(keyPath + '.')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a key path is a protected key or a child of a protected key
|
|
37
|
+
*/
|
|
38
|
+
export function isValueProtected(keyPath: string): boolean {
|
|
39
|
+
return PROTECTED_KEYS.some(protectedPath =>
|
|
40
|
+
keyPath === protectedPath || keyPath.startsWith(protectedPath + '.')
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deep merge two objects, preserving protected values from the original
|
|
46
|
+
*/
|
|
47
|
+
export function deepMergeWithProtection(original: any, updates: any, currentPath: string = ''): any {
|
|
48
|
+
const result = { ...original };
|
|
49
|
+
|
|
50
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
51
|
+
const keyPath = currentPath ? `${currentPath}.${key}` : key;
|
|
52
|
+
|
|
53
|
+
// If this specific value is protected and exists locally, skip it
|
|
54
|
+
if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Special handling for mcpServers: merge individual server entries
|
|
59
|
+
if (key === 'mcpServers' && typeof value === 'object' && value !== null &&
|
|
60
|
+
typeof original[key] === 'object' && original[key] !== null) {
|
|
61
|
+
|
|
62
|
+
result[key] = { ...original[key] }; // Start with original servers
|
|
63
|
+
|
|
64
|
+
// Add servers from updates that don't exist in original
|
|
65
|
+
for (const [serverName, serverConfig] of Object.entries(value)) {
|
|
66
|
+
if (!result[key].hasOwnProperty(serverName)) {
|
|
67
|
+
result[key][serverName] = serverConfig;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (
|
|
71
|
+
typeof value === 'object' &&
|
|
72
|
+
value !== null &&
|
|
73
|
+
!Array.isArray(value) &&
|
|
74
|
+
typeof original[key] === 'object' &&
|
|
75
|
+
original[key] !== null &&
|
|
76
|
+
!Array.isArray(original[key])
|
|
77
|
+
) {
|
|
78
|
+
// Recursively merge nested objects
|
|
79
|
+
result[key] = deepMergeWithProtection(original[key], value, keyPath);
|
|
80
|
+
} else {
|
|
81
|
+
// Overwrite with new value for non-protected keys
|
|
82
|
+
result[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface AtomicWriteOptions {
|
|
90
|
+
preserveComments?: boolean;
|
|
91
|
+
backupOnSuccess?: boolean;
|
|
92
|
+
backupSuffix?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Atomically write data to a file using a temporary file
|
|
97
|
+
*/
|
|
98
|
+
export async function atomicWrite(filePath: string, data: any, options: AtomicWriteOptions = {}): Promise<void> {
|
|
99
|
+
const {
|
|
100
|
+
preserveComments = false,
|
|
101
|
+
backupOnSuccess = false,
|
|
102
|
+
backupSuffix = '.bak'
|
|
103
|
+
} = options;
|
|
104
|
+
|
|
105
|
+
const tempFilePath = `${filePath}.tmp.${randomUUID()}`;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
let content: string;
|
|
109
|
+
if (preserveComments) {
|
|
110
|
+
content = stringify(data, null, 2);
|
|
111
|
+
} else {
|
|
112
|
+
content = JSON.stringify(data, null, 2);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await fs.writeFile(tempFilePath, content, 'utf8');
|
|
116
|
+
|
|
117
|
+
const tempStats = await fs.stat(tempFilePath);
|
|
118
|
+
if (tempStats.size === 0) {
|
|
119
|
+
throw new Error('Temporary file is empty - write failed');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (backupOnSuccess && await fs.pathExists(filePath)) {
|
|
123
|
+
const backupPath = `${filePath}${backupSuffix}`;
|
|
124
|
+
await fs.copy(filePath, backupPath);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await fs.rename(tempFilePath, filePath);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
try {
|
|
130
|
+
if (await fs.pathExists(tempFilePath)) {
|
|
131
|
+
await fs.unlink(tempFilePath);
|
|
132
|
+
}
|
|
133
|
+
} catch (cleanupError) { }
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Safely read a JSON configuration file with error handling
|
|
140
|
+
*/
|
|
141
|
+
export async function safeReadConfig(filePath: string): Promise<any> {
|
|
142
|
+
try {
|
|
143
|
+
if (!(await fs.pathExists(filePath))) {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return parse(content);
|
|
151
|
+
} catch (parseError) {
|
|
152
|
+
return JSON.parse(content);
|
|
153
|
+
}
|
|
154
|
+
} catch (error: any) {
|
|
155
|
+
if (error.code === 'ENOENT') return {};
|
|
156
|
+
throw new Error(`Failed to read config file: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface MergeOptions {
|
|
161
|
+
preserveComments?: boolean;
|
|
162
|
+
backupOnSuccess?: boolean;
|
|
163
|
+
dryRun?: boolean;
|
|
164
|
+
resolvedLocalConfig?: any;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface MergeResult {
|
|
168
|
+
updated: boolean;
|
|
169
|
+
changes: string[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Perform a safe merge of repository config with local config
|
|
174
|
+
*/
|
|
175
|
+
export async function safeMergeConfig(localConfigPath: string, repoConfig: any, options: MergeOptions = {}): Promise<MergeResult> {
|
|
176
|
+
const {
|
|
177
|
+
preserveComments = true,
|
|
178
|
+
backupOnSuccess = true,
|
|
179
|
+
dryRun = false,
|
|
180
|
+
resolvedLocalConfig = null
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
const localConfig = resolvedLocalConfig || await safeReadConfig(localConfigPath);
|
|
184
|
+
const changes: string[] = [];
|
|
185
|
+
|
|
186
|
+
if (localConfig.mcpServers && typeof localConfig.mcpServers === 'object') {
|
|
187
|
+
const localServerNames = Object.keys(localConfig.mcpServers);
|
|
188
|
+
if (localServerNames.length > 0) {
|
|
189
|
+
changes.push(`Preserved ${localServerNames.length} local mcpServers: ${localServerNames.join(', ')}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (repoConfig.mcpServers && typeof repoConfig.mcpServers === 'object') {
|
|
194
|
+
const repoServerNames = Object.keys(repoConfig.mcpServers);
|
|
195
|
+
const newServerNames = repoServerNames.filter(name =>
|
|
196
|
+
!localConfig.mcpServers || !localConfig.mcpServers.hasOwnProperty(name)
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (newServerNames.length > 0) {
|
|
200
|
+
changes.push(`Added ${newServerNames.length} new non-conflicting mcpServers from repository: ${newServerNames.join(', ')}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const mergedConfig = deepMergeWithProtection(localConfig, repoConfig);
|
|
205
|
+
const configsAreEqual = JSON.stringify(localConfig) === JSON.stringify(mergedConfig);
|
|
206
|
+
|
|
207
|
+
if (!configsAreEqual && !dryRun) {
|
|
208
|
+
await atomicWrite(localConfigPath, mergedConfig, {
|
|
209
|
+
preserveComments,
|
|
210
|
+
backupOnSuccess
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
updated: !configsAreEqual,
|
|
216
|
+
changes
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getProtectedKeys(): string[] {
|
|
221
|
+
return [...PROTECTED_KEYS];
|
|
222
|
+
}
|
|
@@ -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
|
+
}
|