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,170 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import prompts from 'prompts';
|
|
5
|
+
import { getCandidatePaths } from '../core/context.js';
|
|
6
|
+
import { calculateDiff } from '../core/diff.js';
|
|
7
|
+
import { executeSync } from '../core/sync-executor.js';
|
|
8
|
+
import { findRepoRoot } from '../utils/repo-root.js';
|
|
9
|
+
import { getManifestPath } from '../core/manifest.js';
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import Conf from 'conf';
|
|
14
|
+
|
|
15
|
+
function formatTargetLabel(target: string): string {
|
|
16
|
+
const normalized = target.replace(/\\/g, '/').toLowerCase();
|
|
17
|
+
if (normalized.endsWith('/.agents/skills') || normalized.includes('/.agents/skills/')) return '~/.agents/skills';
|
|
18
|
+
if (normalized.endsWith('/.claude') || normalized.includes('/.claude/')) return '~/.claude';
|
|
19
|
+
return path.basename(target);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatRelativeTime(timestamp: number): string {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const diff = now - timestamp;
|
|
25
|
+
|
|
26
|
+
const minutes = Math.floor(diff / 60000);
|
|
27
|
+
const hours = Math.floor(diff / 3600000);
|
|
28
|
+
const days = Math.floor(diff / 86400000);
|
|
29
|
+
|
|
30
|
+
if (minutes < 1) return 'just now';
|
|
31
|
+
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
32
|
+
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
33
|
+
return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createStatusCommand(): Command {
|
|
37
|
+
return new Command('status')
|
|
38
|
+
.description('Show status and optionally sync target environments')
|
|
39
|
+
.option('--json', 'Output machine-readable JSON', false)
|
|
40
|
+
.action(async (opts) => {
|
|
41
|
+
const { json } = opts;
|
|
42
|
+
|
|
43
|
+
const repoRoot = await findRepoRoot();
|
|
44
|
+
|
|
45
|
+
// Auto-detect all existing environments (no prompt needed for read-only view)
|
|
46
|
+
const candidates = getCandidatePaths();
|
|
47
|
+
const targets: string[] = [];
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
if (await fs.pathExists(c.path)) targets.push(c.path);
|
|
50
|
+
}
|
|
51
|
+
if (targets.length === 0) {
|
|
52
|
+
console.log(kleur.yellow('\n No agent environments found (~/.claude, ~/.agents/skills)\n'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TargetStatus {
|
|
57
|
+
path: string;
|
|
58
|
+
name: string;
|
|
59
|
+
lastSync: string | null;
|
|
60
|
+
changes: Record<string, { missing: string[]; outdated: string[]; drifted: string[] }>;
|
|
61
|
+
totalChanges: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const results: TargetStatus[] = [];
|
|
65
|
+
|
|
66
|
+
for (const target of targets) {
|
|
67
|
+
const manifestPath = getManifestPath(target);
|
|
68
|
+
let lastSync: string | null = null;
|
|
69
|
+
try {
|
|
70
|
+
if (await fs.pathExists(manifestPath)) {
|
|
71
|
+
const manifest = await fs.readJson(manifestPath);
|
|
72
|
+
if (manifest.lastSync) lastSync = manifest.lastSync;
|
|
73
|
+
}
|
|
74
|
+
} catch { /* ignore */ }
|
|
75
|
+
|
|
76
|
+
const changeSet = await calculateDiff(repoRoot, target);
|
|
77
|
+
const totalChanges = Object.values(changeSet).reduce(
|
|
78
|
+
(sum: number, c: any) => sum + c.missing.length + c.outdated.length + c.drifted.length, 0,
|
|
79
|
+
) as number;
|
|
80
|
+
|
|
81
|
+
results.push({ path: target, name: formatTargetLabel(target), lastSync, changes: changeSet as any, totalChanges });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── JSON output ──────────────────────────────────────────────────
|
|
85
|
+
if (json) {
|
|
86
|
+
console.log(JSON.stringify({ targets: results }, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Table output ─────────────────────────────────────────────────
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
92
|
+
const Table = require('cli-table3');
|
|
93
|
+
|
|
94
|
+
const table = new Table({
|
|
95
|
+
head: [
|
|
96
|
+
kleur.bold('Target'),
|
|
97
|
+
kleur.bold(kleur.green('+ New')),
|
|
98
|
+
kleur.bold(kleur.yellow('↑ Update')),
|
|
99
|
+
kleur.bold(kleur.red('! Drift')),
|
|
100
|
+
kleur.bold('Last Sync'),
|
|
101
|
+
],
|
|
102
|
+
style: { head: [], border: [] },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
for (const r of results) {
|
|
106
|
+
const missing = Object.values(r.changes).reduce((s: number, c: any) => s + c.missing.length, 0) as number;
|
|
107
|
+
const outdated = Object.values(r.changes).reduce((s: number, c: any) => s + c.outdated.length, 0) as number;
|
|
108
|
+
const drifted = Object.values(r.changes).reduce((s: number, c: any) => s + c.drifted.length, 0) as number;
|
|
109
|
+
|
|
110
|
+
const lastSyncStr = r.lastSync
|
|
111
|
+
? kleur.gray(formatRelativeTime(new Date(r.lastSync).getTime()))
|
|
112
|
+
: kleur.gray('never');
|
|
113
|
+
|
|
114
|
+
table.push([
|
|
115
|
+
r.totalChanges > 0 ? kleur.bold(r.name) : r.name,
|
|
116
|
+
missing > 0 ? kleur.green(String(missing)) : kleur.gray('—'),
|
|
117
|
+
outdated > 0 ? kleur.yellow(String(outdated)) : kleur.gray('—'),
|
|
118
|
+
drifted > 0 ? kleur.red(String(drifted)) : kleur.gray('—'),
|
|
119
|
+
lastSyncStr,
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('\n' + table.toString());
|
|
124
|
+
|
|
125
|
+
const totalPending = results.reduce((s, r) => s + r.totalChanges, 0);
|
|
126
|
+
|
|
127
|
+
if (totalPending === 0) {
|
|
128
|
+
console.log(kleur.green('\n ✓ All environments up-to-date\n'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pending = results.filter(r => r.totalChanges > 0);
|
|
133
|
+
console.log(kleur.yellow(`\n ⚠ ${totalPending} pending change${totalPending !== 1 ? 's' : ''} across ${pending.length} environment${pending.length !== 1 ? 's' : ''}\n`));
|
|
134
|
+
|
|
135
|
+
// ── Inline sync offer ────────────────────────────────────────────
|
|
136
|
+
const { selected } = await prompts({
|
|
137
|
+
type: 'multiselect',
|
|
138
|
+
name: 'selected',
|
|
139
|
+
message: 'Select environments to sync:',
|
|
140
|
+
choices: pending.map(r => ({
|
|
141
|
+
title: `${r.name} ${kleur.gray(`(${r.totalChanges} change${r.totalChanges !== 1 ? 's' : ''})`)}`,
|
|
142
|
+
value: r.path,
|
|
143
|
+
selected: true,
|
|
144
|
+
})),
|
|
145
|
+
hint: '- Space to toggle. Enter to confirm. Esc to skip.',
|
|
146
|
+
instructions: false,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!selected || selected.length === 0) {
|
|
150
|
+
console.log(kleur.gray(' Skipped. Run xtrm sync anytime to apply.\n'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const toSync = pending.filter(r => selected.includes(r.path));
|
|
155
|
+
|
|
156
|
+
// Reuse the already-computed changeSets — no second diff needed
|
|
157
|
+
const store = new Conf({ projectName: 'xtrm-manager' });
|
|
158
|
+
const syncMode = (store.get('syncMode') as string) || 'copy';
|
|
159
|
+
|
|
160
|
+
let totalSynced = 0;
|
|
161
|
+
for (const r of toSync) {
|
|
162
|
+
console.log(kleur.bold(`\n → ${r.name}`));
|
|
163
|
+
const count = await executeSync(repoRoot, r.path, r.changes as any, syncMode as any, 'sync', false);
|
|
164
|
+
totalSynced += count;
|
|
165
|
+
console.log(kleur.green(` ✓ ${count} item${count !== 1 ? 's' : ''} synced`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(kleur.bold().green(`\n✓ Done — ${totalSynced} item${totalSynced !== 1 ? 's' : ''} synced\n`));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { t } from '../utils/theme.js';
|
|
6
|
+
|
|
7
|
+
interface WorktreeInfo {
|
|
8
|
+
path: string;
|
|
9
|
+
branch: string;
|
|
10
|
+
head: string;
|
|
11
|
+
prunable: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Parse `git worktree list --porcelain` output into WorktreeInfo array */
|
|
15
|
+
function listXtWorktrees(repoRoot: string): WorktreeInfo[] {
|
|
16
|
+
const r = spawnSync('git', ['worktree', 'list', '--porcelain'], {
|
|
17
|
+
cwd: repoRoot, encoding: 'utf8', stdio: 'pipe',
|
|
18
|
+
});
|
|
19
|
+
if (r.status !== 0) return [];
|
|
20
|
+
|
|
21
|
+
const worktrees: WorktreeInfo[] = [];
|
|
22
|
+
let current: Partial<WorktreeInfo> = {};
|
|
23
|
+
|
|
24
|
+
for (const line of (r.stdout ?? '').split('\n')) {
|
|
25
|
+
if (line.startsWith('worktree ')) {
|
|
26
|
+
if (current.path && current.branch?.startsWith('refs/heads/xt/')) {
|
|
27
|
+
worktrees.push(current as WorktreeInfo);
|
|
28
|
+
}
|
|
29
|
+
current = { path: line.slice('worktree '.length), prunable: false };
|
|
30
|
+
} else if (line.startsWith('HEAD ')) {
|
|
31
|
+
current.head = line.slice('HEAD '.length);
|
|
32
|
+
} else if (line.startsWith('branch ')) {
|
|
33
|
+
current.branch = line.slice('branch '.length);
|
|
34
|
+
} else if (line === 'prunable') {
|
|
35
|
+
current.prunable = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Flush last entry
|
|
39
|
+
if (current.path && current.branch?.startsWith('refs/heads/xt/')) {
|
|
40
|
+
worktrees.push(current as WorktreeInfo);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return worktrees;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check if a branch has been merged into main */
|
|
47
|
+
function isMergedIntoMain(branch: string, repoRoot: string): boolean {
|
|
48
|
+
const branchShort = branch.replace('refs/heads/', '');
|
|
49
|
+
const r = spawnSync('git', ['branch', '--merged', 'origin/main', '--list', branchShort], {
|
|
50
|
+
cwd: repoRoot, encoding: 'utf8', stdio: 'pipe',
|
|
51
|
+
});
|
|
52
|
+
return (r.stdout ?? '').includes(branchShort);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Check if a branch has an open or merged PR */
|
|
56
|
+
function getPrStatus(branch: string, repoRoot: string): string {
|
|
57
|
+
const branchShort = branch.replace('refs/heads/', '');
|
|
58
|
+
const r = spawnSync('gh', ['pr', 'list', '--head', branchShort, '--state', 'all', '--json', 'state,url', '--limit', '1'], {
|
|
59
|
+
cwd: repoRoot, encoding: 'utf8', stdio: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
if (r.status !== 0) return 'unknown';
|
|
62
|
+
try {
|
|
63
|
+
const data = JSON.parse(r.stdout ?? '[]');
|
|
64
|
+
if (data.length === 0) return 'no PR';
|
|
65
|
+
return `${data[0].state.toLowerCase()} (${data[0].url})`;
|
|
66
|
+
} catch {
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getRepoRoot(cwd: string): string {
|
|
72
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
73
|
+
return r.ok ? r.stdout.trim() : cwd;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createWorktreeCommand(): Command {
|
|
77
|
+
const cmd = new Command('worktree')
|
|
78
|
+
.description('Manage xt session worktrees');
|
|
79
|
+
|
|
80
|
+
cmd.command('list')
|
|
81
|
+
.description('List all active xt/* worktrees with status')
|
|
82
|
+
.action(async () => {
|
|
83
|
+
const repoRoot = getRepoRoot(process.cwd());
|
|
84
|
+
const worktrees = listXtWorktrees(repoRoot);
|
|
85
|
+
|
|
86
|
+
if (worktrees.length === 0) {
|
|
87
|
+
console.log(kleur.dim('\n No xt worktrees found\n'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(t.bold(`\n xt worktrees (${worktrees.length})\n`));
|
|
92
|
+
for (const wt of worktrees) {
|
|
93
|
+
const branch = wt.branch.replace('refs/heads/', '');
|
|
94
|
+
const merged = isMergedIntoMain(wt.branch, repoRoot);
|
|
95
|
+
const status = merged ? kleur.green('merged') : kleur.yellow('open');
|
|
96
|
+
const prunable = wt.prunable ? kleur.dim(' [prunable]') : '';
|
|
97
|
+
console.log(` ${status} ${kleur.bold(branch)}${prunable}`);
|
|
98
|
+
console.log(kleur.dim(` ${wt.path}`));
|
|
99
|
+
}
|
|
100
|
+
console.log('');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
cmd.command('clean')
|
|
104
|
+
.description('Remove worktrees whose branch has been merged into main')
|
|
105
|
+
.option('-y, --yes', 'Skip confirmation prompt', false)
|
|
106
|
+
.action(async (opts) => {
|
|
107
|
+
const repoRoot = getRepoRoot(process.cwd());
|
|
108
|
+
const worktrees = listXtWorktrees(repoRoot);
|
|
109
|
+
const merged = worktrees.filter(wt => isMergedIntoMain(wt.branch, repoRoot));
|
|
110
|
+
|
|
111
|
+
if (merged.length === 0) {
|
|
112
|
+
console.log(kleur.dim('\n No merged xt worktrees to clean\n'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(t.bold(`\n ${merged.length} merged worktree(s) to remove:\n`));
|
|
117
|
+
for (const wt of merged) {
|
|
118
|
+
console.log(kleur.dim(` - ${wt.path} (${wt.branch.replace('refs/heads/', '')})`));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let doRemove = opts.yes;
|
|
122
|
+
if (!opts.yes) {
|
|
123
|
+
const { confirm } = await prompts({
|
|
124
|
+
type: 'confirm',
|
|
125
|
+
name: 'confirm',
|
|
126
|
+
message: `Remove ${merged.length} worktree(s)?`,
|
|
127
|
+
initial: true,
|
|
128
|
+
});
|
|
129
|
+
doRemove = confirm;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!doRemove) {
|
|
133
|
+
console.log(kleur.dim(' Cancelled\n'));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const wt of merged) {
|
|
138
|
+
const r = spawnSync('git', ['worktree', 'remove', wt.path, '--force'], {
|
|
139
|
+
cwd: repoRoot, encoding: 'utf8', stdio: 'pipe',
|
|
140
|
+
});
|
|
141
|
+
if (r.status === 0) {
|
|
142
|
+
console.log(t.success(` ✓ Removed ${wt.path}`));
|
|
143
|
+
} else {
|
|
144
|
+
console.log(kleur.yellow(` ⚠ Could not remove ${wt.path}: ${(r.stderr ?? '').trim()}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log('');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
cmd.command('remove <name>')
|
|
151
|
+
.description('Manually remove a specific xt worktree by branch name or path')
|
|
152
|
+
.option('-y, --yes', 'Skip confirmation', false)
|
|
153
|
+
.action(async (name: string, opts) => {
|
|
154
|
+
const repoRoot = getRepoRoot(process.cwd());
|
|
155
|
+
const worktrees = listXtWorktrees(repoRoot);
|
|
156
|
+
const target = worktrees.find(wt =>
|
|
157
|
+
wt.path === name ||
|
|
158
|
+
wt.branch === `refs/heads/${name}` ||
|
|
159
|
+
wt.branch === `refs/heads/xt/${name}`
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (!target) {
|
|
163
|
+
console.error(kleur.red(`\n ✗ No xt worktree found matching "${name}"\n`));
|
|
164
|
+
console.log(kleur.dim(' Run: xt worktree list\n'));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let doRemove = opts.yes;
|
|
169
|
+
if (!opts.yes) {
|
|
170
|
+
const { confirm } = await prompts({
|
|
171
|
+
type: 'confirm',
|
|
172
|
+
name: 'confirm',
|
|
173
|
+
message: `Remove ${target.path}?`,
|
|
174
|
+
initial: false,
|
|
175
|
+
});
|
|
176
|
+
doRemove = confirm;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!doRemove) { console.log(kleur.dim(' Cancelled\n')); return; }
|
|
180
|
+
|
|
181
|
+
const r = spawnSync('git', ['worktree', 'remove', target.path, '--force'], {
|
|
182
|
+
cwd: repoRoot, encoding: 'utf8', stdio: 'pipe',
|
|
183
|
+
});
|
|
184
|
+
if (r.status === 0) {
|
|
185
|
+
console.log(t.success(`\n ✓ Removed ${target.path}\n`));
|
|
186
|
+
} else {
|
|
187
|
+
console.error(kleur.red(`\n ✗ Failed: ${(r.stderr ?? '').trim()}\n`));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return cmd;
|
|
193
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import Conf from 'conf';
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
import prompts from 'prompts';
|
|
8
|
+
import kleur from 'kleur';
|
|
9
|
+
import type { SyncMode } from '../types/config.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export interface Context {
|
|
13
|
+
targets: string[];
|
|
14
|
+
syncMode: 'copy' | 'symlink' | 'prune';
|
|
15
|
+
config: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GetContextOptions {
|
|
19
|
+
selector?: string;
|
|
20
|
+
createMissingDirs?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let config: Conf | null = null;
|
|
24
|
+
|
|
25
|
+
function getConfig(): Conf {
|
|
26
|
+
if (!config) {
|
|
27
|
+
config = new Conf({
|
|
28
|
+
projectName: 'xtrm-cli',
|
|
29
|
+
defaults: {
|
|
30
|
+
syncMode: 'copy',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return config;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getCandidatePaths(): Array<{ label: string; path: string }> {
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
const appData = process.env.APPDATA;
|
|
41
|
+
const isWindows = process.platform === 'win32';
|
|
42
|
+
|
|
43
|
+
const paths = [
|
|
44
|
+
{ label: '~/.claude (hooks + skills)', path: path.join(home, '.claude') },
|
|
45
|
+
{ label: '~/.agents/skills', path: path.join(home, '.agents', 'skills') },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
if (isWindows && appData) {
|
|
49
|
+
paths.push({ label: 'Claude (AppData)', path: path.join(appData, 'Claude') });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return paths;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveTargets(
|
|
56
|
+
selector: string | undefined,
|
|
57
|
+
candidates: Array<{ label: string; path: string }>,
|
|
58
|
+
): string[] | null {
|
|
59
|
+
if (!selector) return null;
|
|
60
|
+
|
|
61
|
+
const normalized = selector.trim().toLowerCase();
|
|
62
|
+
if (normalized === '*' || normalized === 'all') {
|
|
63
|
+
return candidates.map(candidate => candidate.path);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(`Unknown install target selector '${selector}'. Use '*' or 'all'.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getContext(options: GetContextOptions = {}): Promise<Context> {
|
|
70
|
+
const { selector, createMissingDirs = true } = options;
|
|
71
|
+
const choices = [];
|
|
72
|
+
const candidates = getCandidatePaths();
|
|
73
|
+
const directTargets = resolveTargets(selector, candidates);
|
|
74
|
+
|
|
75
|
+
if (directTargets) {
|
|
76
|
+
const activeConfig = getConfig();
|
|
77
|
+
if (createMissingDirs) {
|
|
78
|
+
for (const target of directTargets) {
|
|
79
|
+
await fs.ensureDir(target);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
targets: directTargets,
|
|
85
|
+
syncMode: activeConfig.get('syncMode') as SyncMode,
|
|
86
|
+
config: activeConfig,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const activeConfig = getConfig();
|
|
91
|
+
|
|
92
|
+
for (const c of candidates) {
|
|
93
|
+
const exists = await fs.pathExists(c.path);
|
|
94
|
+
const icon = exists ? kleur.green('●') : kleur.gray('○');
|
|
95
|
+
const desc = exists ? 'Found' : 'Not found (will create)';
|
|
96
|
+
|
|
97
|
+
choices.push({
|
|
98
|
+
title: `${icon} ${c.label} (${c.path})`,
|
|
99
|
+
description: desc,
|
|
100
|
+
value: c.path,
|
|
101
|
+
selected: exists, // Pre-select existing environments
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await prompts({
|
|
106
|
+
type: 'multiselect',
|
|
107
|
+
name: 'targets',
|
|
108
|
+
message: 'Select target environment(s):',
|
|
109
|
+
choices: choices,
|
|
110
|
+
hint: '- Space to select. Return to submit',
|
|
111
|
+
instructions: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (response.targets === undefined) {
|
|
115
|
+
console.log(kleur.gray('\nCancelled.'));
|
|
116
|
+
process.exit(130);
|
|
117
|
+
}
|
|
118
|
+
if (response.targets.length === 0) {
|
|
119
|
+
console.log(kleur.gray('No targets selected.'));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ensure directories exist for selected targets
|
|
124
|
+
if (createMissingDirs) {
|
|
125
|
+
for (const target of response.targets) {
|
|
126
|
+
await fs.ensureDir(target);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
targets: response.targets,
|
|
132
|
+
syncMode: activeConfig.get('syncMode') as SyncMode,
|
|
133
|
+
config: activeConfig,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function resetContext(): void {
|
|
139
|
+
getConfig().clear();
|
|
140
|
+
console.log(kleur.yellow('Configuration cleared.'));
|
|
141
|
+
}
|
package/src/core/diff.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { join, normalize } from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { hashDirectory, getNewestMtime } from '../utils/hash.js';
|
|
4
|
+
import type { ChangeSet } from '../types/config.js';
|
|
5
|
+
import { getAdapter } from '../adapters/registry.js';
|
|
6
|
+
import { detectAdapter } from '../adapters/registry.js';
|
|
7
|
+
|
|
8
|
+
// Items to ignore from diff scanning (similar to .gitignore)
|
|
9
|
+
const IGNORED_ITEMS = new Set(['__pycache__', '.DS_Store', 'Thumbs.db', '.gitkeep', 'node_modules']);
|
|
10
|
+
|
|
11
|
+
export class PruneModeReadError extends Error {
|
|
12
|
+
constructor(path: string) {
|
|
13
|
+
super(`Cannot read ${path} in prune mode — aborting to prevent accidental deletion`);
|
|
14
|
+
this.name = 'PruneModeReadError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function calculateDiff(repoRoot: string, systemRoot: string, pruneMode: boolean = false): Promise<ChangeSet> {
|
|
19
|
+
const adapter = detectAdapter(systemRoot);
|
|
20
|
+
const isClaude = adapter?.toolName === 'claude-code';
|
|
21
|
+
const isQwen = adapter?.toolName === 'qwen';
|
|
22
|
+
const normalizedRoot = normalize(systemRoot).replace(/\\/g, '/');
|
|
23
|
+
const isAgentsSkills = normalizedRoot.includes('.agents/skills');
|
|
24
|
+
|
|
25
|
+
const changeSet: ChangeSet = {
|
|
26
|
+
skills: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
27
|
+
hooks: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
28
|
+
config: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
29
|
+
commands: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
30
|
+
'qwen-commands': { missing: [], outdated: [], drifted: [], total: 0 },
|
|
31
|
+
'antigravity-workflows': { missing: [], outdated: [], drifted: [], total: 0 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Load installed file hashes from manifest for precise drift classification
|
|
35
|
+
const manifestPath = join(systemRoot, '.jaggers-sync-manifest.json');
|
|
36
|
+
let installedHashes: Record<string, string> | null = null;
|
|
37
|
+
try {
|
|
38
|
+
if (await fs.pathExists(manifestPath)) {
|
|
39
|
+
const manifest = await fs.readJson(manifestPath);
|
|
40
|
+
if (manifest.fileHashes && typeof manifest.fileHashes === 'object') {
|
|
41
|
+
installedHashes = manifest.fileHashes;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Manifest unreadable — fall back to mtime heuristic
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ~/.agents/skills: skills-only, mapped directly (repoRoot/skills/* → systemRoot/*)
|
|
49
|
+
if (isAgentsSkills) {
|
|
50
|
+
const repoPath = join(repoRoot, 'skills');
|
|
51
|
+
if (!(await fs.pathExists(repoPath))) return changeSet;
|
|
52
|
+
|
|
53
|
+
const items = (await fs.readdir(repoPath)).filter(i => !IGNORED_ITEMS.has(i));
|
|
54
|
+
changeSet.skills.total = items.length;
|
|
55
|
+
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
await compareItem('skills', item, join(repoPath, item), join(systemRoot, item), changeSet, pruneMode, installedHashes);
|
|
58
|
+
}
|
|
59
|
+
return changeSet;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 1. Folders: Skills & Hooks & Commands
|
|
63
|
+
const folders = ['skills', 'hooks'];
|
|
64
|
+
if (isQwen) folders.push('qwen-commands');
|
|
65
|
+
else if (!isClaude) folders.push('commands');
|
|
66
|
+
|
|
67
|
+
for (const category of folders) {
|
|
68
|
+
let repoPath: string;
|
|
69
|
+
let systemPath: string;
|
|
70
|
+
|
|
71
|
+
if (category === 'qwen-commands') {
|
|
72
|
+
repoPath = join(repoRoot, '.qwen', 'commands');
|
|
73
|
+
systemPath = join(systemRoot, 'commands');
|
|
74
|
+
} else {
|
|
75
|
+
repoPath = join(repoRoot, category);
|
|
76
|
+
systemPath = join(systemRoot, category);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!(await fs.pathExists(repoPath))) continue;
|
|
80
|
+
|
|
81
|
+
const items = (await fs.readdir(repoPath)).filter(i => !IGNORED_ITEMS.has(i));
|
|
82
|
+
(changeSet[category as keyof ChangeSet] as any).total = items.length;
|
|
83
|
+
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
await compareItem(
|
|
86
|
+
category as keyof ChangeSet,
|
|
87
|
+
item,
|
|
88
|
+
join(repoPath, item),
|
|
89
|
+
join(systemPath, item),
|
|
90
|
+
changeSet,
|
|
91
|
+
pruneMode,
|
|
92
|
+
installedHashes,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. Config Files (Explicit Mapping)
|
|
98
|
+
const configMapping = {
|
|
99
|
+
'settings.json': { repo: 'config/settings.json', sys: 'settings.json' },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const [name, paths] of Object.entries(configMapping)) {
|
|
103
|
+
// settings.json is path-transformed on install for all agent environments,
|
|
104
|
+
// so the hash always differs — skip to prevent perpetual false "1 update"
|
|
105
|
+
if (name === 'settings.json' && adapter !== null) continue;
|
|
106
|
+
|
|
107
|
+
const itemRepoPath = join(repoRoot, paths.repo);
|
|
108
|
+
const itemSystemPath = join(systemRoot, paths.sys);
|
|
109
|
+
|
|
110
|
+
if (await fs.pathExists(itemRepoPath)) {
|
|
111
|
+
await compareItem('config', name, itemRepoPath, itemSystemPath, changeSet, false, installedHashes);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return changeSet;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function compareItem(
|
|
119
|
+
category: keyof ChangeSet,
|
|
120
|
+
item: string,
|
|
121
|
+
repoPath: string,
|
|
122
|
+
systemPath: string,
|
|
123
|
+
changeSet: ChangeSet,
|
|
124
|
+
pruneMode: boolean = false,
|
|
125
|
+
installedHashes: Record<string, string> | null = null,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const cat = changeSet[category] as any;
|
|
128
|
+
|
|
129
|
+
if (!(await fs.pathExists(systemPath))) {
|
|
130
|
+
cat.missing.push(item);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const repoHash = await hashDirectory(repoPath);
|
|
135
|
+
|
|
136
|
+
// Wrap system-side hash read in try/catch for prune mode safety
|
|
137
|
+
let systemHash: string;
|
|
138
|
+
try {
|
|
139
|
+
systemHash = await hashDirectory(systemPath);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (pruneMode) {
|
|
142
|
+
throw new PruneModeReadError(systemPath);
|
|
143
|
+
}
|
|
144
|
+
// If not in prune mode, treat as missing
|
|
145
|
+
cat.missing.push(item);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (repoHash !== systemHash) {
|
|
150
|
+
const manifestKey = `${category}/${item}`;
|
|
151
|
+
const installedHash = installedHashes?.[manifestKey];
|
|
152
|
+
|
|
153
|
+
if (installedHash !== undefined) {
|
|
154
|
+
// Manifest-based classification: compare system against installed snapshot
|
|
155
|
+
if (systemHash !== installedHash) {
|
|
156
|
+
// System was modified after last install → drifted (local changes)
|
|
157
|
+
cat.drifted.push(item);
|
|
158
|
+
} else {
|
|
159
|
+
// System matches install snapshot but repo changed → outdated
|
|
160
|
+
cat.outdated.push(item);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
// No manifest data: fall back to mtime heuristic
|
|
164
|
+
const repoMtime = await getNewestMtime(repoPath);
|
|
165
|
+
const systemMtime = await getNewestMtime(systemPath);
|
|
166
|
+
|
|
167
|
+
if (systemMtime > repoMtime + 2000) {
|
|
168
|
+
cat.drifted.push(item);
|
|
169
|
+
} else {
|
|
170
|
+
cat.outdated.push(item);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|