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,276 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { findRepoRoot } from '../utils/repo-root.js';
|
|
9
|
+
import { t, sym } from '../utils/theme.js';
|
|
10
|
+
|
|
11
|
+
const PI_AGENT_DIR = process.env.PI_AGENT_DIR || path.join(homedir(), '.pi', 'agent');
|
|
12
|
+
|
|
13
|
+
export interface PiExtensionDiff {
|
|
14
|
+
missing: string[];
|
|
15
|
+
stale: string[];
|
|
16
|
+
upToDate: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SchemaField { key: string; label: string; hint: string; secret: boolean; required: boolean; }
|
|
20
|
+
interface OAuthProvider { key: string; instruction: string; }
|
|
21
|
+
interface InstallSchema { fields: SchemaField[]; oauth_providers: OAuthProvider[]; packages: string[]; }
|
|
22
|
+
|
|
23
|
+
export const EXTRA_PI_CONFIGS = ['pi-worktrees-settings.json'];
|
|
24
|
+
|
|
25
|
+
export async function copyExtraConfigs(srcDir: string, destDir: string): Promise<void> {
|
|
26
|
+
for (const name of EXTRA_PI_CONFIGS) {
|
|
27
|
+
const src = path.join(srcDir, name);
|
|
28
|
+
const dest = path.join(destDir, name);
|
|
29
|
+
if (await fs.pathExists(src) && !await fs.pathExists(dest)) {
|
|
30
|
+
await fs.copy(src, dest);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function fillTemplate(template: string, values: Record<string, string>): string {
|
|
36
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
export function readExistingPiValues(piAgentDir: string): Record<string, string> {
|
|
41
|
+
const values: Record<string, string> = {};
|
|
42
|
+
try {
|
|
43
|
+
const auth = JSON.parse(require('fs').readFileSync(path.join(piAgentDir, 'auth.json'), 'utf8'));
|
|
44
|
+
if (auth?.dashscope?.key) values['DASHSCOPE_API_KEY'] = auth.dashscope.key;
|
|
45
|
+
if (auth?.zai?.key) values['ZAI_API_KEY'] = auth.zai.key;
|
|
46
|
+
} catch { /* file doesn't exist or invalid */ }
|
|
47
|
+
try {
|
|
48
|
+
const models = JSON.parse(require('fs').readFileSync(path.join(piAgentDir, 'models.json'), 'utf8'));
|
|
49
|
+
if (!values['DASHSCOPE_API_KEY'] && models?.providers?.dashscope?.apiKey) {
|
|
50
|
+
values['DASHSCOPE_API_KEY'] = models.providers.dashscope.apiKey;
|
|
51
|
+
}
|
|
52
|
+
} catch { /* file doesn't exist or invalid */ }
|
|
53
|
+
return values;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPiInstalled(): boolean {
|
|
57
|
+
return spawnSync('pi', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List extension directories (contain package.json) in a base directory.
|
|
62
|
+
*/
|
|
63
|
+
async function listExtensionDirs(baseDir: string): Promise<string[]> {
|
|
64
|
+
if (!await fs.pathExists(baseDir)) return [];
|
|
65
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
66
|
+
const extDirs: string[] = [];
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.isDirectory()) continue;
|
|
69
|
+
const extPath = path.join(baseDir, entry.name);
|
|
70
|
+
const pkgPath = path.join(extPath, 'package.json');
|
|
71
|
+
if (await fs.pathExists(pkgPath)) {
|
|
72
|
+
extDirs.push(entry.name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return extDirs.sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function fileSha256(filePath: string): Promise<string> {
|
|
79
|
+
const crypto = await import('node:crypto');
|
|
80
|
+
const content = await fs.readFile(filePath);
|
|
81
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compute a hash for an extension package based on package.json + index.ts.
|
|
86
|
+
*/
|
|
87
|
+
async function extensionHash(extDir: string): Promise<string> {
|
|
88
|
+
const pkgPath = path.join(extDir, 'package.json');
|
|
89
|
+
const indexPath = path.join(extDir, 'index.ts');
|
|
90
|
+
|
|
91
|
+
const hashes: string[] = [];
|
|
92
|
+
|
|
93
|
+
if (await fs.pathExists(pkgPath)) {
|
|
94
|
+
hashes.push(await fileSha256(pkgPath));
|
|
95
|
+
}
|
|
96
|
+
if (await fs.pathExists(indexPath)) {
|
|
97
|
+
hashes.push(await fileSha256(indexPath));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return hashes.join(':');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compare extension packages between source and target directories.
|
|
105
|
+
* Returns missing, stale, and up-to-date extension names.
|
|
106
|
+
*/
|
|
107
|
+
export async function diffPiExtensions(sourceDir: string, targetDir: string): Promise<PiExtensionDiff> {
|
|
108
|
+
const sourceAbs = path.resolve(sourceDir);
|
|
109
|
+
const targetAbs = path.resolve(targetDir);
|
|
110
|
+
|
|
111
|
+
const sourceExts = await listExtensionDirs(sourceAbs);
|
|
112
|
+
const missing: string[] = [];
|
|
113
|
+
const stale: string[] = [];
|
|
114
|
+
const upToDate: string[] = [];
|
|
115
|
+
|
|
116
|
+
for (const extName of sourceExts) {
|
|
117
|
+
const srcExtPath = path.join(sourceAbs, extName);
|
|
118
|
+
const dstExtPath = path.join(targetAbs, extName);
|
|
119
|
+
|
|
120
|
+
// Check if extension exists in target
|
|
121
|
+
if (!await fs.pathExists(dstExtPath)) {
|
|
122
|
+
missing.push(extName);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if package.json exists in target
|
|
127
|
+
const dstPkgPath = path.join(dstExtPath, 'package.json');
|
|
128
|
+
if (!await fs.pathExists(dstPkgPath)) {
|
|
129
|
+
missing.push(extName);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Compare hashes
|
|
134
|
+
const [srcHash, dstHash] = await Promise.all([
|
|
135
|
+
extensionHash(srcExtPath),
|
|
136
|
+
extensionHash(dstExtPath)
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
if (srcHash !== dstHash) {
|
|
140
|
+
stale.push(extName);
|
|
141
|
+
} else {
|
|
142
|
+
upToDate.push(extName);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { missing, stale, upToDate };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function printPiCheckSummary(diff: PiExtensionDiff): void {
|
|
150
|
+
const totalDiff = diff.missing.length + diff.stale.length;
|
|
151
|
+
|
|
152
|
+
console.log(t.bold('\n Pi extension drift check\n'));
|
|
153
|
+
console.log(t.muted(` Up-to-date: ${diff.upToDate.length}`));
|
|
154
|
+
console.log(kleur.yellow(` Missing: ${diff.missing.length}`));
|
|
155
|
+
console.log(kleur.yellow(` Stale: ${diff.stale.length}`));
|
|
156
|
+
|
|
157
|
+
if (diff.missing.length > 0) {
|
|
158
|
+
console.log(kleur.yellow('\n Missing extensions:'));
|
|
159
|
+
diff.missing.forEach((f) => console.log(kleur.yellow(` - ${f}`)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (diff.stale.length > 0) {
|
|
163
|
+
console.log(kleur.yellow('\n Stale extensions:'));
|
|
164
|
+
diff.stale.forEach((f) => console.log(kleur.yellow(` - ${f}`)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (totalDiff === 0) {
|
|
168
|
+
console.log(t.success('\n ✓ Pi extensions are in sync\n'));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createInstallPiCommand(): Command {
|
|
173
|
+
const cmd = new Command('pi');
|
|
174
|
+
cmd
|
|
175
|
+
.description('Install Pi coding agent with providers, extensions, and npm packages')
|
|
176
|
+
.option('-y, --yes', 'Skip confirmation prompts', false)
|
|
177
|
+
.option('--check', 'Check Pi extension deployment drift without writing changes', false)
|
|
178
|
+
.action(async (opts) => {
|
|
179
|
+
const { yes, check } = opts;
|
|
180
|
+
const repoRoot = await findRepoRoot();
|
|
181
|
+
const piConfigDir = path.join(repoRoot, 'config', 'pi');
|
|
182
|
+
|
|
183
|
+
if (check) {
|
|
184
|
+
const sourceDir = path.join(piConfigDir, 'extensions');
|
|
185
|
+
const targetDir = path.join(PI_AGENT_DIR, 'extensions');
|
|
186
|
+
const diff = await diffPiExtensions(sourceDir, targetDir);
|
|
187
|
+
printPiCheckSummary(diff);
|
|
188
|
+
|
|
189
|
+
if (diff.missing.length > 0 || diff.stale.length > 0) {
|
|
190
|
+
console.error(kleur.red(' ✗ Pi extension drift detected. Run `xtrm install pi` to sync.\n'));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(t.bold('\n Pi Coding Agent Setup\n'));
|
|
197
|
+
|
|
198
|
+
if (!isPiInstalled()) {
|
|
199
|
+
console.log(kleur.yellow(' pi not found — installing oh-pi globally...\n'));
|
|
200
|
+
const r = spawnSync('npm', ['install', '-g', 'oh-pi'], { stdio: 'inherit' });
|
|
201
|
+
if (r.status !== 0) {
|
|
202
|
+
console.error(kleur.red('\n Failed to install oh-pi. Run: npm install -g oh-pi\n'));
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
console.log(t.success(' pi installed\n'));
|
|
206
|
+
} else {
|
|
207
|
+
const v = spawnSync('pi', ['--version'], { encoding: 'utf8' });
|
|
208
|
+
console.log(t.success(` pi ${v.stdout.trim()} already installed\n`));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const schema: InstallSchema = await fs.readJson(path.join(piConfigDir, 'install-schema.json'));
|
|
212
|
+
const existing = readExistingPiValues(PI_AGENT_DIR);
|
|
213
|
+
const values: Record<string, string> = { ...existing };
|
|
214
|
+
|
|
215
|
+
console.log(t.bold(' API Keys\n'));
|
|
216
|
+
for (const field of schema.fields) {
|
|
217
|
+
if (existing[field.key]) {
|
|
218
|
+
console.log(t.success(` ${sym.ok} ${field.label} [already set]`));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!field.required && !yes) {
|
|
222
|
+
const { include } = await prompts({ type: 'confirm', name: 'include', message: ` Configure ${field.label}? (optional)`, initial: false });
|
|
223
|
+
if (!include) continue;
|
|
224
|
+
}
|
|
225
|
+
const { value } = await prompts({ type: field.secret ? 'password' : 'text', name: 'value', message: ` ${field.label}`, hint: field.hint, validate: (v) => (field.required && !v) ? 'Required' : true });
|
|
226
|
+
if (value) values[field.key] = value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await fs.ensureDir(PI_AGENT_DIR);
|
|
230
|
+
console.log(t.muted(`\n Writing config to ${PI_AGENT_DIR}`));
|
|
231
|
+
|
|
232
|
+
for (const name of ['models.json', 'auth.json', 'settings.json']) {
|
|
233
|
+
const destPath = path.join(PI_AGENT_DIR, name);
|
|
234
|
+
if (name === 'auth.json' && await fs.pathExists(destPath) && !yes) {
|
|
235
|
+
const { overwrite } = await prompts({ type: 'confirm', name: 'overwrite', message: ` ${name} already exists — overwrite? (OAuth tokens will be lost)`, initial: false });
|
|
236
|
+
if (!overwrite) { console.log(t.muted(` skipped ${name}`)); continue; }
|
|
237
|
+
}
|
|
238
|
+
const raw = await fs.readFile(path.join(piConfigDir, `${name}.template`), 'utf8');
|
|
239
|
+
await fs.writeFile(destPath, fillTemplate(raw, values), 'utf8');
|
|
240
|
+
console.log(t.success(` ${sym.ok} ${name}`));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await fs.copy(path.join(piConfigDir, 'extensions'), path.join(PI_AGENT_DIR, 'extensions'), { overwrite: true });
|
|
244
|
+
console.log(t.success(` ${sym.ok} extensions/`));
|
|
245
|
+
|
|
246
|
+
// Register each extension with pi install -l
|
|
247
|
+
const extDirs = await listExtensionDirs(path.join(PI_AGENT_DIR, 'extensions'));
|
|
248
|
+
if (extDirs.length > 0) {
|
|
249
|
+
console.log(kleur.dim(`\n Registering ${extDirs.length} extensions...`));
|
|
250
|
+
for (const extName of extDirs) {
|
|
251
|
+
const extPath = path.join(PI_AGENT_DIR, 'extensions', extName);
|
|
252
|
+
const r = spawnSync('pi', ['install', '-l', extPath], { stdio: 'pipe', encoding: 'utf8' });
|
|
253
|
+
if (r.status === 0) {
|
|
254
|
+
console.log(t.success(` ${sym.ok} ${extName} registered`));
|
|
255
|
+
} else {
|
|
256
|
+
console.log(kleur.yellow(` ⚠ ${extName} — registration failed`));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
console.log(t.bold('\n npm Packages\n'));
|
|
262
|
+
for (const pkg of schema.packages) {
|
|
263
|
+
const r = spawnSync('pi', ['install', pkg], { stdio: 'inherit' });
|
|
264
|
+
if (r.status === 0) console.log(t.success(` ${sym.ok} ${pkg}`));
|
|
265
|
+
else console.log(kleur.yellow(` ${pkg} — failed, run manually: pi install ${pkg}`));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
console.log(t.bold('\n OAuth (manual steps)\n'));
|
|
269
|
+
for (const provider of schema.oauth_providers) {
|
|
270
|
+
console.log(t.muted(` ${provider.key}: ${provider.instruction}`));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log(t.boldGreen('\n Pi setup complete\n'));
|
|
274
|
+
});
|
|
275
|
+
return cmd;
|
|
276
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
declare const __dirname: string;
|
|
8
|
+
function resolvePkgRoot(): string {
|
|
9
|
+
const candidates = [
|
|
10
|
+
path.resolve(__dirname, '../..'),
|
|
11
|
+
path.resolve(__dirname, '../../..'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const match = candidates.find(candidate => fs.existsSync(path.join(candidate, 'project-skills')));
|
|
15
|
+
if (!match) {
|
|
16
|
+
throw new Error('Unable to locate project-skills directory from CLI runtime.');
|
|
17
|
+
}
|
|
18
|
+
return match;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PKG_ROOT = resolvePkgRoot();
|
|
22
|
+
const SKILLS_SRC = path.join(PKG_ROOT, 'project-skills', 'service-skills-set', '.claude');
|
|
23
|
+
|
|
24
|
+
const TRINITY = [
|
|
25
|
+
'creating-service-skills',
|
|
26
|
+
'using-service-skills',
|
|
27
|
+
'updating-service-skills',
|
|
28
|
+
'scoping-service-skills',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const SETTINGS_HOOKS: Record<string, unknown[]> = {
|
|
32
|
+
SessionStart: [
|
|
33
|
+
{
|
|
34
|
+
hooks: [{
|
|
35
|
+
type: 'command',
|
|
36
|
+
command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/using-service-skills/scripts/cataloger.py"',
|
|
37
|
+
}],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
PreToolUse: [
|
|
41
|
+
{
|
|
42
|
+
matcher: 'Read|Write|Edit|Glob|Grep|Bash|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
43
|
+
hooks: [{
|
|
44
|
+
type: 'command',
|
|
45
|
+
command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/using-service-skills/scripts/skill_activator.py"',
|
|
46
|
+
}],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
PostToolUse: [
|
|
50
|
+
{
|
|
51
|
+
matcher: 'Write|Edit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
52
|
+
hooks: [{
|
|
53
|
+
type: 'command',
|
|
54
|
+
command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/updating-service-skills/scripts/drift_detector.py" check-hook',
|
|
55
|
+
timeout: 10,
|
|
56
|
+
}],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const MARKER_DOC = '# [jaggers] doc-reminder';
|
|
62
|
+
const MARKER_STALENESS = '# [jaggers] skill-staleness';
|
|
63
|
+
const MARKER_CHAIN = '# [jaggers] chain-githooks';
|
|
64
|
+
|
|
65
|
+
// ─── Pure functions (exported for testing) ───────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export function mergeSettingsHooks(existing: Record<string, unknown>): {
|
|
68
|
+
result: Record<string, unknown>;
|
|
69
|
+
added: string[];
|
|
70
|
+
skipped: string[];
|
|
71
|
+
} {
|
|
72
|
+
const result = { ...existing };
|
|
73
|
+
const hooks = (result.hooks ?? {}) as Record<string, unknown>;
|
|
74
|
+
result.hooks = hooks;
|
|
75
|
+
|
|
76
|
+
const added: string[] = [];
|
|
77
|
+
const skipped: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const [event, config] of Object.entries(SETTINGS_HOOKS)) {
|
|
80
|
+
if (event in hooks) {
|
|
81
|
+
skipped.push(event);
|
|
82
|
+
} else {
|
|
83
|
+
hooks[event] = config;
|
|
84
|
+
added.push(event);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { result, added, skipped };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function installSkills(projectRoot: string, skillsSrc: string = SKILLS_SRC): Promise<{ skill: string; status: 'installed' | 'updated' }[]> {
|
|
92
|
+
const results: { skill: string; status: 'installed' | 'updated' }[] = [];
|
|
93
|
+
for (const skill of TRINITY) {
|
|
94
|
+
const src = path.join(skillsSrc, skill);
|
|
95
|
+
const dest = path.join(projectRoot, '.claude', 'skills', skill);
|
|
96
|
+
const existed = await fs.pathExists(dest);
|
|
97
|
+
if (existed) {
|
|
98
|
+
await fs.remove(dest);
|
|
99
|
+
}
|
|
100
|
+
await fs.copy(src, dest, {
|
|
101
|
+
filter: (src: string) => !src.includes('.Zone.Identifier')
|
|
102
|
+
&& !src.includes('__pycache__')
|
|
103
|
+
&& !src.includes('.pytest_cache')
|
|
104
|
+
&& !src.endsWith('.pyc'),
|
|
105
|
+
});
|
|
106
|
+
results.push({ skill, status: existed ? 'updated' : 'installed' });
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function installGitHooks(projectRoot: string, skillsSrc: string = SKILLS_SRC): Promise<{
|
|
112
|
+
hookFiles: { name: string; status: 'added' | 'already-present' }[];
|
|
113
|
+
}> {
|
|
114
|
+
// Copy git-hook scripts into target project (no back-reference to jaggers package path)
|
|
115
|
+
const gitHooksSrc = path.join(skillsSrc, 'git-hooks');
|
|
116
|
+
const gitHooksDest = path.join(projectRoot, '.claude', 'git-hooks');
|
|
117
|
+
await fs.copy(gitHooksSrc, gitHooksDest, { overwrite: true });
|
|
118
|
+
|
|
119
|
+
const docScript = path.join(projectRoot, '.claude', 'git-hooks', 'doc_reminder.py');
|
|
120
|
+
const stalenessScript = path.join(projectRoot, '.claude', 'git-hooks', 'skill_staleness.py');
|
|
121
|
+
|
|
122
|
+
const preCommit = path.join(projectRoot, '.githooks', 'pre-commit');
|
|
123
|
+
const prePush = path.join(projectRoot, '.githooks', 'pre-push');
|
|
124
|
+
|
|
125
|
+
for (const hookPath of [preCommit, prePush]) {
|
|
126
|
+
if (!await fs.pathExists(hookPath)) {
|
|
127
|
+
await fs.mkdirp(path.dirname(hookPath));
|
|
128
|
+
await fs.writeFile(hookPath, '#!/usr/bin/env bash\n', { mode: 0o755 });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const snippets: [string, string, string][] = [
|
|
133
|
+
[
|
|
134
|
+
preCommit,
|
|
135
|
+
MARKER_DOC,
|
|
136
|
+
`\n${MARKER_DOC}\nif command -v python3 &>/dev/null && [ -f "${docScript}" ]; then\n python3 "${docScript}" || true\nfi\n`,
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
prePush,
|
|
140
|
+
MARKER_STALENESS,
|
|
141
|
+
`\n${MARKER_STALENESS}\nif command -v python3 &>/dev/null && [ -f "${stalenessScript}" ]; then\n python3 "${stalenessScript}" || true\nfi\n`,
|
|
142
|
+
],
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const hookFiles: { name: string; status: 'added' | 'already-present' }[] = [];
|
|
146
|
+
|
|
147
|
+
for (const [hookPath, marker, snippet] of snippets) {
|
|
148
|
+
const content = await fs.readFile(hookPath, 'utf8');
|
|
149
|
+
const name = path.basename(hookPath);
|
|
150
|
+
if (!content.includes(marker)) {
|
|
151
|
+
await fs.writeFile(hookPath, content + snippet);
|
|
152
|
+
hookFiles.push({ name, status: 'added' });
|
|
153
|
+
} else {
|
|
154
|
+
hookFiles.push({ name, status: 'already-present' });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const hooksPathResult = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
159
|
+
cwd: projectRoot,
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
timeout: 5000,
|
|
162
|
+
});
|
|
163
|
+
const configuredHooksPath = hooksPathResult.status === 0 ? hooksPathResult.stdout.trim() : '';
|
|
164
|
+
const activeHooksDir = configuredHooksPath
|
|
165
|
+
? (path.isAbsolute(configuredHooksPath)
|
|
166
|
+
? configuredHooksPath
|
|
167
|
+
: path.join(projectRoot, configuredHooksPath))
|
|
168
|
+
: path.join(projectRoot, '.git', 'hooks');
|
|
169
|
+
|
|
170
|
+
const activationTargets = new Set([path.join(projectRoot, '.git', 'hooks'), activeHooksDir]);
|
|
171
|
+
for (const hooksDir of activationTargets) {
|
|
172
|
+
await fs.mkdirp(hooksDir);
|
|
173
|
+
|
|
174
|
+
for (const [name, sourceHook] of [['pre-commit', preCommit], ['pre-push', prePush]] as const) {
|
|
175
|
+
const targetHook = path.join(hooksDir, name);
|
|
176
|
+
if (!await fs.pathExists(targetHook)) {
|
|
177
|
+
await fs.writeFile(targetHook, '#!/usr/bin/env bash\n', { mode: 0o755 });
|
|
178
|
+
} else {
|
|
179
|
+
await fs.chmod(targetHook, 0o755);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (path.resolve(targetHook) === path.resolve(sourceHook)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const chainSnippet =
|
|
187
|
+
`\n${MARKER_CHAIN}\n` +
|
|
188
|
+
`if [ -x "${sourceHook}" ]; then\n` +
|
|
189
|
+
` "${sourceHook}" "$@"\n` +
|
|
190
|
+
'fi\n';
|
|
191
|
+
const targetContent = await fs.readFile(targetHook, 'utf8');
|
|
192
|
+
if (!targetContent.includes(MARKER_CHAIN)) {
|
|
193
|
+
await fs.writeFile(targetHook, targetContent + chainSnippet);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { hookFiles };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function installSettings(projectRoot: string): Promise<{ added: string[]; skipped: string[] }> {
|
|
202
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
203
|
+
await fs.mkdirp(path.dirname(settingsPath));
|
|
204
|
+
|
|
205
|
+
let existing: Record<string, unknown> = {};
|
|
206
|
+
if (await fs.pathExists(settingsPath)) {
|
|
207
|
+
try {
|
|
208
|
+
existing = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
|
|
209
|
+
} catch {
|
|
210
|
+
// malformed JSON — start fresh
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const { result, added, skipped } = mergeSettingsHooks(existing);
|
|
215
|
+
await fs.writeFile(settingsPath, JSON.stringify(result, null, 2) + '\n');
|
|
216
|
+
return { added, skipped };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getProjectRoot(pkgRoot: string): string {
|
|
220
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
221
|
+
encoding: 'utf8',
|
|
222
|
+
timeout: 5000,
|
|
223
|
+
});
|
|
224
|
+
if (result.status !== 0) {
|
|
225
|
+
throw new Error('Not inside a git repository. Run this command from your target project directory.');
|
|
226
|
+
}
|
|
227
|
+
const root = path.resolve(result.stdout.trim());
|
|
228
|
+
if (root === path.resolve(pkgRoot)) {
|
|
229
|
+
throw new Error('Run this from inside your TARGET project, not the jaggers-agent-tools repo itself.');
|
|
230
|
+
}
|
|
231
|
+
return root;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function createInstallServiceSkillsCommand(): Command {
|
|
235
|
+
return new Command('install-project-skill')
|
|
236
|
+
.description('Install the Service Skill Trinity into the current project')
|
|
237
|
+
.action(async () => {
|
|
238
|
+
let projectRoot: string;
|
|
239
|
+
try {
|
|
240
|
+
projectRoot = getProjectRoot(PKG_ROOT);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error(kleur.red(`\n✗ ${(err as Error).message}\n`));
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(kleur.dim(`\n Installing into: ${projectRoot}\n`));
|
|
247
|
+
|
|
248
|
+
console.log(kleur.bold('── Skills ──────────────────────────────'));
|
|
249
|
+
const skillResults = await installSkills(projectRoot);
|
|
250
|
+
for (const { skill, status } of skillResults) {
|
|
251
|
+
const icon = status === 'installed' ? kleur.green(' ✓') : kleur.yellow(' ↺');
|
|
252
|
+
console.log(`${icon} .claude/skills/${skill}/`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.log(kleur.bold('\n── settings.json ───────────────────────'));
|
|
256
|
+
const { added, skipped } = await installSettings(projectRoot);
|
|
257
|
+
for (const event of added) {
|
|
258
|
+
console.log(`${kleur.green(' ✓')} added hook: ${event}`);
|
|
259
|
+
}
|
|
260
|
+
for (const event of skipped) {
|
|
261
|
+
console.log(`${kleur.yellow(' ○')} already present: ${event} (not overwritten)`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(kleur.bold('\n── Git hooks ───────────────────────────'));
|
|
265
|
+
const { hookFiles } = await installGitHooks(projectRoot);
|
|
266
|
+
for (const { name, status } of hookFiles) {
|
|
267
|
+
if (status === 'added') {
|
|
268
|
+
console.log(`${kleur.green(' ✓')} .githooks/${name}`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log(`${kleur.yellow(' ○')} already installed: ${name}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (hookFiles.some(h => h.status === 'added')) {
|
|
274
|
+
console.log(`${kleur.green(' ✓')} activated in .git/hooks/`);
|
|
275
|
+
}
|
|
276
|
+
console.log(`${kleur.green(' ✓')} scripts → .claude/git-hooks/`);
|
|
277
|
+
|
|
278
|
+
console.log(kleur.green('\n Done.'));
|
|
279
|
+
console.log(kleur.dim(' Hooks active: SessionStart · PreToolUse · PostToolUse · pre-commit · pre-push\n'));
|
|
280
|
+
});
|
|
281
|
+
}
|