@baton-dx/cli 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,3053 @@
1
+ #!/usr/bin/env node
2
+ import { t as findSourceRoot } from "./context-detection-0m8_Fp0j.mjs";
3
+ import { access, mkdir, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { defineCommand, runMain } from "citty";
7
+ import { getAgentConfig, getAgentPath, getAllAgentKeys } from "@baton-dx/agent-paths";
8
+ import { FileNotFoundError, KEBAB_CASE_REGEX, SourceParseError, addGlobalSource, clearAgentCache, clearIdeCache, cloneGitSource, collectProfileSupportPatterns, computeIntersection, detectInstalledAgents, detectInstalledIdes, detectLegacyPaths, discoverProfilesInSourceRepo, findSourceManifest, generateLock, getAdaptersForKeys, getDefaultGlobalSource, getGlobalAiTools, getGlobalIdePlatforms, getGlobalSources, getIdePlatformTargetDir, getProfileWeight, getRegisteredIdePlatforms, idePlatformRegistry, isKnownIdePlatform, isLockedProfile, loadLockfile, loadProfileManifest, loadProjectManifest, mergeContentParts, mergeMemory, mergeMemoryWithWarnings, mergeRules, mergeRulesWithWarnings, mergeSkills, mergeSkillsWithWarnings, parseFrontmatter, parseSource, placeFile, readLock, removeGlobalSource, resolveProfileChain, resolveProfileSupport, resolveVersion, setGlobalAiTools, setGlobalIdePlatforms, sortProfilesByWeight, updateGitignore, writeLock } from "@baton-dx/core";
9
+ import * as p from "@clack/prompts";
10
+ import { homedir } from "node:os";
11
+ import { parse, stringify } from "yaml";
12
+ import Handlebars from "handlebars";
13
+ import simpleGit from "simple-git";
14
+
15
+ //#region src/commands/ai-tools/list.ts
16
+ const aiToolsListCommand = defineCommand({
17
+ meta: {
18
+ name: "list",
19
+ description: "Show saved AI tools from global config and their configuration status"
20
+ },
21
+ args: {
22
+ all: {
23
+ type: "boolean",
24
+ alias: "a",
25
+ description: "Show all supported tools, not just saved ones"
26
+ },
27
+ json: {
28
+ type: "boolean",
29
+ description: "Output machine-readable JSON",
30
+ alias: "j"
31
+ }
32
+ },
33
+ async run({ args }) {
34
+ if (!args.json) p.intro("Baton - AI Tools");
35
+ const savedTools = await getGlobalAiTools();
36
+ const allAgentKeys = getAllAgentKeys();
37
+ const keysToShow = args.all ? allAgentKeys : savedTools.length > 0 ? savedTools : allAgentKeys;
38
+ const agentStatuses = await Promise.all(keysToShow.map(async (agentKey) => {
39
+ const isSaved = savedTools.includes(agentKey);
40
+ let skillCount = 0;
41
+ let ruleCount = 0;
42
+ let agentCount = 0;
43
+ let memoryCount = 0;
44
+ let commandCount = 0;
45
+ if (isSaved) {
46
+ skillCount = await countConfigs(agentKey, "skills", "project");
47
+ ruleCount = await countConfigs(agentKey, "rules", "project");
48
+ agentCount = await countConfigs(agentKey, "agents", "project");
49
+ memoryCount = await countConfigs(agentKey, "memory", "project");
50
+ commandCount = await countConfigs(agentKey, "commands", "project");
51
+ }
52
+ const paths = {
53
+ skills: getAgentPath(agentKey, "skills", "project", ""),
54
+ rules: getAgentPath(agentKey, "rules", "project", ""),
55
+ agents: getAgentPath(agentKey, "agents", "project", ""),
56
+ memory: getAgentPath(agentKey, "memory", "project", ""),
57
+ commands: getAgentPath(agentKey, "commands", "project", "")
58
+ };
59
+ return {
60
+ key: agentKey,
61
+ name: getAgentConfig(agentKey).name,
62
+ saved: isSaved,
63
+ counts: {
64
+ skills: skillCount,
65
+ rules: ruleCount,
66
+ agents: agentCount,
67
+ memory: memoryCount,
68
+ commands: commandCount
69
+ },
70
+ paths
71
+ };
72
+ }));
73
+ if (args.json) {
74
+ console.log(JSON.stringify(agentStatuses, null, 2));
75
+ return;
76
+ }
77
+ if (savedTools.length === 0) {
78
+ p.log.warn("No AI tools saved in global config.");
79
+ p.log.info("Run 'baton ai-tools scan' to detect and save your AI tools.");
80
+ console.log("");
81
+ p.log.info(`All ${allAgentKeys.length} supported tools:`);
82
+ for (const key of allAgentKeys) {
83
+ const config = getAgentConfig(key);
84
+ console.log(` \x1b[90m- ${config.name}\x1b[0m`);
85
+ }
86
+ p.outro("Run 'baton ai-tools scan' to get started.");
87
+ return;
88
+ }
89
+ console.log(`\nSaved AI tools (${savedTools.length}):\n`);
90
+ for (const agent of agentStatuses) {
91
+ const statusColor = agent.saved ? "\x1B[32m" : "\x1B[90m";
92
+ const status = agent.saved ? "✓" : "✗";
93
+ console.log(`${statusColor}${status} ${agent.name.padEnd(20)}`);
94
+ if (agent.saved) {
95
+ if (agent.counts.skills + agent.counts.rules + agent.counts.agents + agent.counts.memory + agent.counts.commands > 0) {
96
+ const details = [];
97
+ if (agent.counts.skills > 0) details.push(`${agent.counts.skills} skills`);
98
+ if (agent.counts.rules > 0) details.push(`${agent.counts.rules} rules`);
99
+ if (agent.counts.agents > 0) details.push(`${agent.counts.agents} agents`);
100
+ if (agent.counts.memory > 0) details.push(`${agent.counts.memory} memory`);
101
+ if (agent.counts.commands > 0) details.push(`${agent.counts.commands} commands`);
102
+ console.log(` → ${details.join(", ")}`);
103
+ }
104
+ }
105
+ console.log("");
106
+ }
107
+ p.outro("Manage tools: 'baton ai-tools scan' (detect) | 'baton config set default-tools <tools>'");
108
+ }
109
+ });
110
+ /**
111
+ * Count config files of a given type for an agent
112
+ */
113
+ async function countConfigs(agentKey, configType, scope) {
114
+ try {
115
+ const dirPath = getAgentPath(agentKey, configType, scope, "").replace(/{name}.*$/, "").replace(/\/$/, "");
116
+ if (!(await stat(dirPath)).isDirectory()) return 0;
117
+ return (await readdir(dirPath)).length;
118
+ } catch (_error) {
119
+ return 0;
120
+ }
121
+ }
122
+
123
+ //#endregion
124
+ //#region src/commands/ai-tools/scan.ts
125
+ const aiToolsScanCommand = defineCommand({
126
+ meta: {
127
+ name: "scan",
128
+ description: "Scan your system for AI tools and save results to global config"
129
+ },
130
+ args: { yes: {
131
+ type: "boolean",
132
+ alias: "y",
133
+ description: "Automatically save detected tools without confirmation"
134
+ } },
135
+ async run({ args }) {
136
+ p.intro("Baton - AI Tool Scanner");
137
+ const spinner = p.spinner();
138
+ spinner.start("Scanning for AI tools...");
139
+ clearAgentCache();
140
+ const detectedAgents = await detectInstalledAgents();
141
+ const allAgentKeys = getAllAgentKeys();
142
+ const currentTools = await getGlobalAiTools();
143
+ spinner.stop("Scan complete.");
144
+ const installed = [];
145
+ const notInstalled = [];
146
+ for (const agentKey of allAgentKeys) {
147
+ const entry = {
148
+ key: agentKey,
149
+ name: getAgentConfig(agentKey).name
150
+ };
151
+ if (detectedAgents.includes(agentKey)) installed.push(entry);
152
+ else notInstalled.push(entry);
153
+ }
154
+ if (installed.length > 0) {
155
+ p.log.success(`Found ${installed.length} AI tool${installed.length !== 1 ? "s" : ""}:`);
156
+ for (const agent of installed) {
157
+ const badge = currentTools.includes(agent.key) ? " (saved)" : " (new)";
158
+ console.log(` \x1b[32m✓\x1b[0m ${agent.name}${badge}`);
159
+ }
160
+ } else {
161
+ p.log.warn("No AI tools detected on your system.");
162
+ p.outro("Scan finished.");
163
+ return;
164
+ }
165
+ if (notInstalled.length > 0) {
166
+ console.log("");
167
+ p.log.info(`Not detected (${notInstalled.length}):`);
168
+ for (const agent of notInstalled) console.log(` \x1b[90m✗ ${agent.name}\x1b[0m`);
169
+ }
170
+ const detectedKeys = installed.map((a) => a.key);
171
+ if (detectedKeys.length !== currentTools.length || detectedKeys.some((key) => !currentTools.includes(key))) {
172
+ console.log("");
173
+ let shouldSave = args.yes;
174
+ if (!shouldSave) {
175
+ const confirm = await p.confirm({ message: "Save detected tools to global config (~/.baton/config.yaml)?" });
176
+ if (p.isCancel(confirm)) {
177
+ p.outro("Scan finished (not saved).");
178
+ return;
179
+ }
180
+ shouldSave = confirm;
181
+ }
182
+ if (shouldSave) {
183
+ await setGlobalAiTools(detectedKeys);
184
+ p.log.success("Tools saved to global config.");
185
+ }
186
+ } else {
187
+ console.log("");
188
+ p.log.info("Global config is already up to date.");
189
+ }
190
+ p.outro("Scan finished.");
191
+ }
192
+ });
193
+
194
+ //#endregion
195
+ //#region src/commands/ai-tools/index.ts
196
+ const aiToolsCommand = defineCommand({
197
+ meta: {
198
+ name: "ai-tools",
199
+ description: "Manage AI tool detection and configuration"
200
+ },
201
+ subCommands: {
202
+ list: aiToolsListCommand,
203
+ scan: aiToolsScanCommand
204
+ }
205
+ });
206
+
207
+ //#endregion
208
+ //#region src/utils/build-intersection.ts
209
+ /**
210
+ * Compute the intersection for a single profile source string.
211
+ * Shared utility used by sync, config, and manage commands.
212
+ *
213
+ * @returns IntersectionResult or null if intersection cannot be computed
214
+ */
215
+ async function buildIntersection(sourceString, developerTools, cwd) {
216
+ const parsed = parseSource(sourceString);
217
+ let repoRoot;
218
+ let profileDir;
219
+ if (parsed.provider === "github" || parsed.provider === "gitlab") {
220
+ repoRoot = (await cloneGitSource({
221
+ url: parsed.url,
222
+ ref: parsed.ref,
223
+ useCache: true
224
+ })).localPath;
225
+ profileDir = parsed.subpath ? resolve(repoRoot, parsed.subpath) : repoRoot;
226
+ } else if (parsed.provider === "local" || parsed.provider === "file") {
227
+ const absolutePath = parsed.path.startsWith("/") ? parsed.path : resolve(cwd, parsed.path);
228
+ profileDir = absolutePath;
229
+ repoRoot = await findSourceRoot(absolutePath, { fallbackToStart: true });
230
+ } else return null;
231
+ let sourceManifest;
232
+ try {
233
+ sourceManifest = await findSourceManifest(repoRoot);
234
+ } catch {
235
+ sourceManifest = {
236
+ name: "unknown",
237
+ version: "0.0.0"
238
+ };
239
+ }
240
+ const profileManifest = await loadProfileManifest(resolve(profileDir, "baton.profile.yaml")).catch(() => null);
241
+ if (!profileManifest) return null;
242
+ return computeIntersection(developerTools, resolveProfileSupport(profileManifest, sourceManifest));
243
+ }
244
+
245
+ //#endregion
246
+ //#region src/utils/intersection-display.ts
247
+ /**
248
+ * Display the intersection between developer tools and profile support.
249
+ * Shows which tools/platforms will be synced, which are unsupported, and which are unavailable.
250
+ *
251
+ * Used by `baton init` (after profile selection) and `baton manage` (overview).
252
+ */
253
+ function displayIntersection(intersection) {
254
+ const hasAiData = intersection.aiTools.synced.length > 0 || intersection.aiTools.unsupported.length > 0 || intersection.aiTools.unavailable.length > 0;
255
+ const hasIdeData = intersection.idePlatforms.synced.length > 0 || intersection.idePlatforms.unsupported.length > 0 || intersection.idePlatforms.unavailable.length > 0;
256
+ if (!hasAiData && !hasIdeData) {
257
+ p.log.info("No tool or IDE intersection data available.");
258
+ return;
259
+ }
260
+ if (hasAiData) displayDimension("AI Tools", intersection.aiTools);
261
+ if (hasIdeData) displayDimension("IDE Platforms", intersection.idePlatforms);
262
+ }
263
+ /**
264
+ * Display a single dimension (AI tools or IDE platforms) of the intersection.
265
+ */
266
+ function displayDimension(label, dimension) {
267
+ const lines = [];
268
+ if (dimension.synced.length > 0) for (const item of dimension.synced) lines.push(` \u2713 ${item}`);
269
+ if (dimension.unavailable.length > 0) for (const item of dimension.unavailable) lines.push(` - ${item} (not installed)`);
270
+ if (dimension.unsupported.length > 0) for (const item of dimension.unsupported) lines.push(` ~ ${item} (not supported by profile)`);
271
+ if (lines.length > 0) p.note(lines.join("\n"), label);
272
+ }
273
+ /**
274
+ * Format a compact intersection summary for inline display.
275
+ * Example: "claude-code, cursor (AI) + vscode (IDE)"
276
+ */
277
+ function formatIntersectionSummary(intersection) {
278
+ const parts = [];
279
+ if (intersection.aiTools.synced.length > 0) parts.push(`${intersection.aiTools.synced.join(", ")} (AI)`);
280
+ if (intersection.idePlatforms.synced.length > 0) parts.push(`${intersection.idePlatforms.synced.join(", ")} (IDE)`);
281
+ if (parts.length === 0) return "No matching tools";
282
+ return parts.join(" + ");
283
+ }
284
+
285
+ //#endregion
286
+ //#region src/commands/config.ts
287
+ const CONFIG_FILE = join(homedir(), ".baton", "config.yaml");
288
+ const VALID_KEYS = [
289
+ "cache-dir",
290
+ "default-scope",
291
+ "symlink-mode",
292
+ "default-tools"
293
+ ];
294
+ async function loadConfig() {
295
+ try {
296
+ await access(CONFIG_FILE);
297
+ } catch {
298
+ return {};
299
+ }
300
+ return parse(await readFile(CONFIG_FILE, "utf-8"));
301
+ }
302
+ async function saveConfig(config) {
303
+ await mkdir(dirname(CONFIG_FILE), { recursive: true });
304
+ await writeFile(CONFIG_FILE, stringify(config), "utf-8");
305
+ }
306
+ async function showDashboard() {
307
+ p.intro("Baton Dashboard");
308
+ const [sources, aiTools, idePlatforms, projectManifest] = await Promise.all([
309
+ getGlobalSources(),
310
+ getGlobalAiTools(),
311
+ getGlobalIdePlatforms(),
312
+ loadProjectManifestSafe$1()
313
+ ]);
314
+ console.log("");
315
+ p.log.step("Global Sources");
316
+ if (sources.length === 0) p.log.info(" No sources configured. Run: baton source connect <url>");
317
+ else for (const source of sources) {
318
+ const defaultBadge = source.default ? " (default)" : "";
319
+ const desc = source.description ? ` — ${source.description}` : "";
320
+ p.log.info(` ${source.name}${defaultBadge}: ${source.url}${desc}`);
321
+ }
322
+ console.log("");
323
+ p.log.step("Developer Tools");
324
+ if (aiTools.length === 0 && idePlatforms.length === 0) p.log.info(" No tools configured. Run: baton ai-tools scan && baton ides scan");
325
+ else {
326
+ if (aiTools.length > 0) {
327
+ const toolNames = aiTools.map((key) => {
328
+ try {
329
+ return getAgentConfig(key).name;
330
+ } catch {
331
+ return key;
332
+ }
333
+ });
334
+ p.log.info(` AI Tools: ${toolNames.join(", ")}`);
335
+ }
336
+ if (idePlatforms.length > 0) p.log.info(` IDE Platforms: ${idePlatforms.join(", ")}`);
337
+ }
338
+ console.log("");
339
+ p.log.step("Current Project");
340
+ if (!projectManifest) p.log.info(" Not inside a Baton project. Run: baton init");
341
+ else if (projectManifest.profiles.length === 0) p.log.info(" No profiles installed. Run: baton manage");
342
+ else for (const profile of projectManifest.profiles) {
343
+ const version = profile.version ? ` (${profile.version})` : "";
344
+ p.log.info(` ${profile.source}${version}`);
345
+ }
346
+ if (projectManifest && projectManifest.profiles.length > 0) {
347
+ if (aiTools.length > 0 || idePlatforms.length > 0) {
348
+ const developerTools = {
349
+ aiTools,
350
+ idePlatforms
351
+ };
352
+ console.log("");
353
+ p.log.step("Active Intersections");
354
+ for (const profile of projectManifest.profiles) try {
355
+ const intersection = await buildIntersection(profile.source, developerTools, process.cwd());
356
+ if (intersection) {
357
+ const summary = formatIntersectionSummary(intersection);
358
+ p.log.info(` ${profile.source}: ${summary}`);
359
+ }
360
+ } catch {}
361
+ }
362
+ }
363
+ console.log("");
364
+ p.outro("Use 'baton config list' to view configuration settings");
365
+ }
366
+ async function loadProjectManifestSafe$1() {
367
+ try {
368
+ return await loadProjectManifest(join(process.cwd(), "baton.yaml"));
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+ const configCommand = defineCommand({
374
+ meta: {
375
+ name: "config",
376
+ description: "Show Baton dashboard overview or configure settings (set, get, list)"
377
+ },
378
+ args: {
379
+ action: {
380
+ type: "positional",
381
+ description: "Action: set, get, or list",
382
+ required: false
383
+ },
384
+ key: {
385
+ type: "positional",
386
+ description: "Configuration key",
387
+ required: false
388
+ },
389
+ value: {
390
+ type: "positional",
391
+ description: "Configuration value (for set)",
392
+ required: false
393
+ }
394
+ },
395
+ async run({ args }) {
396
+ const action = args.action;
397
+ const key = args.key;
398
+ const value = args.value;
399
+ if (!action) {
400
+ await showDashboard();
401
+ return;
402
+ }
403
+ const selectedAction = action;
404
+ if (selectedAction === "list") {
405
+ p.intro("⚙️ Baton Configuration");
406
+ const config = await loadConfig();
407
+ if (Object.keys(config).length === 0) {
408
+ p.outro("No configuration set. Using defaults.");
409
+ return;
410
+ }
411
+ console.log("");
412
+ for (const [configKey, configValue] of Object.entries(config)) console.log(`${configKey}: ${Array.isArray(configValue) ? configValue.join(", ") : configValue}`);
413
+ console.log("");
414
+ p.outro("Configuration loaded");
415
+ return;
416
+ }
417
+ if (selectedAction === "get") {
418
+ if (!key) {
419
+ p.intro("⚙️ Baton Configuration");
420
+ p.outro("Error: Missing key argument. Usage: baton config get <key>");
421
+ process.exit(1);
422
+ }
423
+ if (!VALID_KEYS.includes(key)) {
424
+ p.intro("⚙️ Baton Configuration");
425
+ p.outro(`Error: Invalid key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
426
+ process.exit(1);
427
+ }
428
+ const configValue = (await loadConfig())[key];
429
+ if (configValue === void 0) {
430
+ p.intro("⚙️ Baton Configuration");
431
+ p.outro(`"${key}" is not set (using default)`);
432
+ return;
433
+ }
434
+ console.log(Array.isArray(configValue) ? configValue.join(", ") : configValue);
435
+ return;
436
+ }
437
+ if (selectedAction === "set") {
438
+ if (!key || !value) {
439
+ p.intro("⚙️ Baton Configuration");
440
+ p.outro("Error: Missing arguments. Usage: baton config set <key> <value>");
441
+ process.exit(1);
442
+ }
443
+ if (!VALID_KEYS.includes(key)) {
444
+ p.intro("⚙️ Baton Configuration");
445
+ p.outro(`Error: Invalid key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
446
+ process.exit(1);
447
+ }
448
+ p.intro("⚙️ Baton Configuration");
449
+ const config = await loadConfig();
450
+ if (key === "default-scope") {
451
+ if (value !== "project" && value !== "global") {
452
+ p.outro("Error: default-scope must be either \"project\" or \"global\"");
453
+ process.exit(1);
454
+ }
455
+ config["default-scope"] = value;
456
+ } else if (key === "symlink-mode") {
457
+ if (value !== "true" && value !== "false") {
458
+ p.outro("Error: symlink-mode must be either \"true\" or \"false\"");
459
+ process.exit(1);
460
+ }
461
+ config["symlink-mode"] = value === "true";
462
+ } else if (key === "default-tools") config["default-tools"] = value.split(",").map((s) => s.trim());
463
+ else if (key === "cache-dir") config["cache-dir"] = value;
464
+ await saveConfig(config);
465
+ p.outro(`✓ Set ${key} = ${Array.isArray(config[key]) ? config[key].join(", ") : config[key]}`);
466
+ return;
467
+ }
468
+ p.intro("⚙️ Baton Configuration");
469
+ p.outro(`Error: Unknown action "${selectedAction}". Use: set, get, or list`);
470
+ process.exit(1);
471
+ }
472
+ });
473
+
474
+ //#endregion
475
+ //#region src/commands/diff.ts
476
+ const diffCommand = defineCommand({
477
+ meta: {
478
+ name: "diff",
479
+ description: "Compare local installed files with remote source versions to see what changed"
480
+ },
481
+ args: { nameOnly: {
482
+ type: "boolean",
483
+ description: "Show only changed filenames without content diff",
484
+ alias: "n"
485
+ } },
486
+ async run({ args }) {
487
+ p.intro("🔍 Baton Diff");
488
+ try {
489
+ const projectRoot = process.cwd();
490
+ const manifest = await loadProjectManifest(resolve(projectRoot, "baton.yaml"));
491
+ if (!manifest.profiles || manifest.profiles.length === 0) {
492
+ p.outro("⚠️ No profiles configured in baton.yaml");
493
+ process.exit(0);
494
+ }
495
+ const spinner = p.spinner();
496
+ spinner.start("Resolving profile chain...");
497
+ const allProfiles = [];
498
+ const profileLocalPaths = /* @__PURE__ */ new Map();
499
+ for (const profileSource of manifest.profiles) try {
500
+ const parsed = parseSource(profileSource.source);
501
+ let profileManifestPath;
502
+ let localPath;
503
+ if (parsed.provider === "local" || parsed.provider === "file") {
504
+ localPath = parsed.path.startsWith("/") ? parsed.path : resolve(projectRoot, parsed.path);
505
+ profileManifestPath = resolve(localPath, "baton.profile.yaml");
506
+ } else if (parsed.provider === "npm") {
507
+ spinner.message("NPM provider not yet supported for diff");
508
+ continue;
509
+ } else {
510
+ const url = parsed.url;
511
+ const subpath = parsed.provider === "github" || parsed.provider === "gitlab" ? parsed.subpath : void 0;
512
+ const cloned = await cloneGitSource({
513
+ url,
514
+ ref: profileSource.version || void 0,
515
+ subpath,
516
+ useCache: false
517
+ });
518
+ localPath = cloned.localPath;
519
+ profileManifestPath = resolve(cloned.localPath, "baton.profile.yaml");
520
+ }
521
+ const profileManifest = await loadProfileManifest(profileManifestPath);
522
+ const profileDir = dirname(profileManifestPath);
523
+ const chain = await resolveProfileChain(profileManifest, profileSource.source, profileDir);
524
+ for (const prof of chain) profileLocalPaths.set(prof.name, localPath);
525
+ allProfiles.push(...chain);
526
+ } catch (error) {
527
+ spinner.message(`Failed to resolve profile ${profileSource.source}: ${error instanceof Error ? error.message : error}`);
528
+ }
529
+ if (allProfiles.length === 0) {
530
+ spinner.stop("No profiles resolved");
531
+ p.outro("Nothing to diff. Run `baton manage` to add a profile.");
532
+ process.exit(0);
533
+ }
534
+ spinner.stop(`Resolved ${allProfiles.length} profile(s)`);
535
+ spinner.start("Merging configurations...");
536
+ const mergedSkills = mergeSkills(allProfiles);
537
+ const mergedRules = mergeRules(allProfiles);
538
+ const mergedMemory = mergeMemory(allProfiles);
539
+ const commandMap = /* @__PURE__ */ new Map();
540
+ for (const profile of allProfiles) for (const cmd of profile.manifest.ai?.commands || []) commandMap.set(cmd, profile.name);
541
+ const fileMap = /* @__PURE__ */ new Map();
542
+ for (const profile of allProfiles) for (const fileConfig of profile.manifest.files || []) {
543
+ const target = fileConfig.target || fileConfig.source;
544
+ fileMap.set(target, {
545
+ source: fileConfig.source,
546
+ target,
547
+ profileName: profile.name
548
+ });
549
+ }
550
+ const ideMap = /* @__PURE__ */ new Map();
551
+ for (const profile of allProfiles) {
552
+ if (!profile.manifest.ide) continue;
553
+ for (const [ideKey, files] of Object.entries(profile.manifest.ide)) {
554
+ if (!files) continue;
555
+ const targetDir = getIdePlatformTargetDir(ideKey);
556
+ if (!targetDir) continue;
557
+ for (const fileName of files) {
558
+ const targetPath = `${targetDir}/${fileName}`;
559
+ ideMap.set(targetPath, {
560
+ ideKey,
561
+ fileName,
562
+ targetDir,
563
+ profileName: profile.name
564
+ });
565
+ }
566
+ }
567
+ }
568
+ spinner.stop("Configurations merged");
569
+ spinner.start("Computing tool intersection...");
570
+ const globalAiTools = await getGlobalAiTools();
571
+ const detectedAgents = await detectInstalledAgents();
572
+ let syncedAiTools;
573
+ if (globalAiTools.length > 0) {
574
+ const developerTools = {
575
+ aiTools: globalAiTools,
576
+ idePlatforms: await getGlobalIdePlatforms()
577
+ };
578
+ const aggregatedSyncedAi = /* @__PURE__ */ new Set();
579
+ for (const profileSource of manifest.profiles) try {
580
+ const intersection = await buildIntersection(profileSource.source, developerTools, projectRoot);
581
+ if (intersection) for (const tool of intersection.aiTools.synced) aggregatedSyncedAi.add(tool);
582
+ } catch {}
583
+ syncedAiTools = aggregatedSyncedAi.size > 0 ? [...aggregatedSyncedAi] : [];
584
+ } else syncedAiTools = detectedAgents;
585
+ if (syncedAiTools.length === 0) {
586
+ spinner.stop("No AI tools in intersection");
587
+ p.cancel("No AI tools match. Run `baton ai-tools scan`.");
588
+ process.exit(1);
589
+ }
590
+ const adapters = getAdaptersForKeys(syncedAiTools);
591
+ spinner.stop(`Comparing for: ${syncedAiTools.join(", ")}`);
592
+ spinner.start("Comparing remote sources with placed files...");
593
+ const diffs = [];
594
+ const expectedPaths = /* @__PURE__ */ new Set();
595
+ const contentAccumulator = /* @__PURE__ */ new Map();
596
+ for (const adapter of adapters) for (const memoryEntry of mergedMemory) {
597
+ const contentParts = [];
598
+ for (const contribution of memoryEntry.contributions) {
599
+ const profileDir = profileLocalPaths.get(contribution.profileName);
600
+ if (!profileDir) continue;
601
+ const memoryFilePath = resolve(profileDir, "ai", "memory", memoryEntry.filename);
602
+ try {
603
+ contentParts.push(await readFile(memoryFilePath, "utf-8"));
604
+ } catch {}
605
+ }
606
+ if (contentParts.length === 0) continue;
607
+ const mergedContent = mergeContentParts(contentParts, memoryEntry.mergeStrategy);
608
+ const transformed = adapter.transformMemory({
609
+ filename: memoryEntry.filename,
610
+ content: mergedContent
611
+ });
612
+ const absolutePath = resolveAbsolutePath(adapter.getPath("memory", "project", transformed.filename), projectRoot);
613
+ const relativePath = toRelativePath(absolutePath, projectRoot);
614
+ expectedPaths.add(relativePath);
615
+ const existing = contentAccumulator.get(relativePath);
616
+ if (existing) existing.parts.push(transformed.content);
617
+ else contentAccumulator.set(relativePath, {
618
+ parts: [transformed.content],
619
+ absolutePath
620
+ });
621
+ }
622
+ for (const adapter of adapters) for (const skillItem of mergedSkills) {
623
+ const profileDir = profileLocalPaths.get(skillItem.profileName);
624
+ if (!profileDir) continue;
625
+ const skillSourceDir = resolve(profileDir, "ai", "skills", skillItem.name);
626
+ try {
627
+ await stat(skillSourceDir);
628
+ } catch {
629
+ continue;
630
+ }
631
+ const absoluteTargetDir = resolveAbsolutePath(adapter.getPath("skills", skillItem.scope, skillItem.name), projectRoot);
632
+ const remoteSkillFiles = await loadFilesFromDirectory(skillSourceDir);
633
+ const localSkillFiles = await loadFilesFromDirectory(absoluteTargetDir);
634
+ for (const [file, remoteContent] of Object.entries(remoteSkillFiles)) {
635
+ const relPath = toRelativePath(resolve(absoluteTargetDir, file), projectRoot);
636
+ expectedPaths.add(relPath);
637
+ addDiffEntry(diffs, relPath, remoteContent, localSkillFiles[file] ?? void 0);
638
+ }
639
+ for (const file of Object.keys(localSkillFiles)) {
640
+ const relPath = toRelativePath(resolve(absoluteTargetDir, file), projectRoot);
641
+ if (!expectedPaths.has(relPath)) addDiffEntry(diffs, relPath, void 0, localSkillFiles[file]);
642
+ }
643
+ }
644
+ for (const adapter of adapters) for (const ruleEntry of mergedRules) {
645
+ const isUniversal = ruleEntry.agents.length === 0;
646
+ const isForThisAdapter = ruleEntry.agents.includes(adapter.key);
647
+ if (!isUniversal && !isForThisAdapter) continue;
648
+ const profileDir = profileLocalPaths.get(ruleEntry.profileName);
649
+ if (!profileDir) continue;
650
+ const ruleSourcePath = resolve(profileDir, "ai", "rules", isUniversal ? "universal" : ruleEntry.agents[0], `${ruleEntry.name}.md`);
651
+ let rawContent;
652
+ try {
653
+ rawContent = await readFile(ruleSourcePath, "utf-8");
654
+ } catch {
655
+ continue;
656
+ }
657
+ const parsed = parseFrontmatter(rawContent);
658
+ const ruleFile = {
659
+ name: ruleEntry.name,
660
+ content: rawContent,
661
+ frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : void 0
662
+ };
663
+ const transformed = adapter.transformRule(ruleFile);
664
+ const absolutePath = resolveAbsolutePath(adapter.getPath("rules", "project", ruleEntry.name), projectRoot);
665
+ const relativePath = toRelativePath(absolutePath, projectRoot);
666
+ expectedPaths.add(relativePath);
667
+ const existing = contentAccumulator.get(relativePath);
668
+ if (existing) existing.parts.push(transformed.content);
669
+ else contentAccumulator.set(relativePath, {
670
+ parts: [transformed.content],
671
+ absolutePath
672
+ });
673
+ }
674
+ for (const [relativePath, entry] of contentAccumulator) addDiffEntry(diffs, relativePath, entry.parts.join("\n\n"), await readSafe(entry.absolutePath));
675
+ for (const adapter of adapters) for (const [commandName, profileName] of commandMap) {
676
+ const profileDir = profileLocalPaths.get(profileName);
677
+ if (!profileDir) continue;
678
+ const commandSourcePath = resolve(profileDir, "ai", "commands", `${commandName}.md`);
679
+ let content;
680
+ try {
681
+ content = await readFile(commandSourcePath, "utf-8");
682
+ } catch {
683
+ continue;
684
+ }
685
+ const absolutePath = resolveAbsolutePath(adapter.getPath("commands", "project", commandName), projectRoot);
686
+ const relativePath = toRelativePath(absolutePath, projectRoot);
687
+ expectedPaths.add(relativePath);
688
+ addDiffEntry(diffs, relativePath, content, await readSafe(absolutePath));
689
+ }
690
+ for (const fileEntry of fileMap.values()) {
691
+ const profileDir = profileLocalPaths.get(fileEntry.profileName);
692
+ if (!profileDir) continue;
693
+ const fileSourcePath = resolve(profileDir, "files", fileEntry.source);
694
+ let content;
695
+ try {
696
+ content = await readFile(fileSourcePath, "utf-8");
697
+ } catch {
698
+ continue;
699
+ }
700
+ const absolutePath = resolve(projectRoot, fileEntry.target);
701
+ const relativePath = fileEntry.target;
702
+ expectedPaths.add(relativePath);
703
+ addDiffEntry(diffs, relativePath, content, await readSafe(absolutePath));
704
+ }
705
+ for (const ideEntry of ideMap.values()) {
706
+ const profileDir = profileLocalPaths.get(ideEntry.profileName);
707
+ if (!profileDir) continue;
708
+ const ideSourcePath = resolve(profileDir, "ide", ideEntry.ideKey, ideEntry.fileName);
709
+ let content;
710
+ try {
711
+ content = await readFile(ideSourcePath, "utf-8");
712
+ } catch {
713
+ continue;
714
+ }
715
+ const absolutePath = resolve(projectRoot, ideEntry.targetDir, ideEntry.fileName);
716
+ const relativePath = `${ideEntry.targetDir}/${ideEntry.fileName}`;
717
+ expectedPaths.add(relativePath);
718
+ addDiffEntry(diffs, relativePath, content, await readSafe(absolutePath));
719
+ }
720
+ spinner.stop();
721
+ if (diffs.length === 0) {
722
+ p.log.success("No differences found");
723
+ p.outro("✅ Diff complete - all placed files match remote sources");
724
+ process.exit(0);
725
+ }
726
+ p.log.warning(`${diffs.length} file(s) with differences`);
727
+ for (const diff of diffs) if (args.nameOnly) {
728
+ const statusSymbol = diff.status === "added" ? "+" : diff.status === "removed" ? "-" : "~";
729
+ console.log(` ${statusSymbol} ${diff.file}`);
730
+ } else {
731
+ console.log(`\n 📄 ${diff.file} (${diff.status})`);
732
+ if (diff.status === "modified") {
733
+ const localLines = (diff.localContent || "").split("\n");
734
+ const remoteLines = (diff.remoteContent || "").split("\n");
735
+ const maxLines = Math.max(localLines.length, remoteLines.length);
736
+ let diffLines = 0;
737
+ for (let i = 0; i < maxLines && diffLines < 10; i++) {
738
+ const localLine = localLines[i] || "";
739
+ const remoteLine = remoteLines[i] || "";
740
+ if (localLine !== remoteLine) {
741
+ diffLines++;
742
+ if (localLine) console.log(` \x1b[31m- ${localLine}\x1b[0m`);
743
+ if (remoteLine) console.log(` \x1b[32m+ ${remoteLine}\x1b[0m`);
744
+ }
745
+ }
746
+ if (diffLines >= 10) console.log(" ... (more differences)");
747
+ } else if (diff.status === "added") console.log(" \x1B[32m+ New file in remote (not yet placed locally)\x1B[0m");
748
+ else console.log(" \x1B[31m- File exists locally but not in remote\x1B[0m");
749
+ }
750
+ p.outro("✅ Diff complete - differences found. Run `baton sync` to update.");
751
+ process.exit(1);
752
+ } catch (error) {
753
+ p.log.error(`Failed to run diff: ${error instanceof Error ? error.message : "Unknown error"}`);
754
+ process.exit(1);
755
+ }
756
+ }
757
+ });
758
+ /**
759
+ * Add a diff entry if remote and local content differ.
760
+ */
761
+ function addDiffEntry(diffs, file, remoteContent, localContent) {
762
+ if (diffs.some((d) => d.file === file)) return;
763
+ if (remoteContent !== void 0 && localContent === void 0) diffs.push({
764
+ file,
765
+ status: "added",
766
+ remoteContent
767
+ });
768
+ else if (remoteContent === void 0 && localContent !== void 0) diffs.push({
769
+ file,
770
+ status: "removed",
771
+ localContent
772
+ });
773
+ else if (remoteContent !== void 0 && localContent !== void 0 && remoteContent !== localContent) diffs.push({
774
+ file,
775
+ status: "modified",
776
+ remoteContent,
777
+ localContent
778
+ });
779
+ }
780
+ /**
781
+ * Read a file safely, returning undefined if it doesn't exist.
782
+ */
783
+ async function readSafe(path) {
784
+ try {
785
+ return await readFile(path, "utf-8");
786
+ } catch {
787
+ return;
788
+ }
789
+ }
790
+ /**
791
+ * Resolve a path to absolute, handling both absolute and relative paths.
792
+ */
793
+ function resolveAbsolutePath(path, projectRoot) {
794
+ if (path.startsWith("/")) return path;
795
+ return resolve(projectRoot, path);
796
+ }
797
+ /**
798
+ * Convert an absolute path to a relative path from project root.
799
+ */
800
+ function toRelativePath(absolutePath, projectRoot) {
801
+ if (absolutePath.startsWith(projectRoot)) {
802
+ const rel = absolutePath.slice(projectRoot.length);
803
+ return rel.startsWith("/") ? rel.slice(1) : rel;
804
+ }
805
+ return absolutePath;
806
+ }
807
+ /**
808
+ * Load all files from a directory recursively
809
+ */
810
+ async function loadFilesFromDirectory(dirPath) {
811
+ const files = {};
812
+ try {
813
+ const entries = await readdir(dirPath, { withFileTypes: true });
814
+ for (const entry of entries) {
815
+ const fullPath = resolve(dirPath, entry.name);
816
+ if (entry.isDirectory()) {
817
+ const subFiles = await loadFilesFromDirectory(fullPath);
818
+ for (const [subPath, content] of Object.entries(subFiles)) files[`${entry.name}/${subPath}`] = content;
819
+ } else if (entry.isFile()) {
820
+ const content = await readFile(fullPath, "utf-8");
821
+ files[entry.name] = content;
822
+ }
823
+ }
824
+ } catch {
825
+ return {};
826
+ }
827
+ return files;
828
+ }
829
+
830
+ //#endregion
831
+ //#region src/commands/ides/utils.ts
832
+ /**
833
+ * Format an IDE platform key into a display name.
834
+ */
835
+ function formatIdeName(ideKey) {
836
+ return {
837
+ vscode: "VS Code",
838
+ jetbrains: "JetBrains",
839
+ cursor: "Cursor",
840
+ windsurf: "Windsurf",
841
+ antigravity: "Antigravity",
842
+ zed: "Zed"
843
+ }[ideKey] ?? ideKey;
844
+ }
845
+
846
+ //#endregion
847
+ //#region src/commands/ides/list.ts
848
+ const idesListCommand = defineCommand({
849
+ meta: {
850
+ name: "list",
851
+ description: "Show saved IDE platforms from global config"
852
+ },
853
+ args: {
854
+ all: {
855
+ type: "boolean",
856
+ alias: "a",
857
+ description: "Show all supported platforms, not just saved ones"
858
+ },
859
+ json: {
860
+ type: "boolean",
861
+ description: "Output machine-readable JSON",
862
+ alias: "j"
863
+ }
864
+ },
865
+ async run({ args }) {
866
+ if (!args.json) p.intro("Baton - IDE Platforms");
867
+ const savedPlatforms = await getGlobalIdePlatforms();
868
+ const allIdeKeys = getRegisteredIdePlatforms();
869
+ const platformStatuses = (args.all ? allIdeKeys : savedPlatforms.length > 0 ? savedPlatforms : allIdeKeys).map((ideKey) => {
870
+ const isSaved = savedPlatforms.includes(ideKey);
871
+ const entry = idePlatformRegistry[ideKey];
872
+ return {
873
+ key: ideKey,
874
+ name: formatIdeName(ideKey),
875
+ saved: isSaved,
876
+ targetDir: entry?.targetDir ?? "unknown"
877
+ };
878
+ });
879
+ if (args.json) {
880
+ console.log(JSON.stringify(platformStatuses, null, 2));
881
+ return;
882
+ }
883
+ if (savedPlatforms.length === 0) {
884
+ p.log.warn("No IDE platforms saved in global config.");
885
+ p.log.info("Run 'baton ides scan' to detect and save your IDE platforms.");
886
+ console.log("");
887
+ p.log.info(`All ${allIdeKeys.length} supported platforms:`);
888
+ for (const key of allIdeKeys) {
889
+ const entry = idePlatformRegistry[key];
890
+ console.log(` \x1b[90m- ${formatIdeName(key)} (${entry?.targetDir ?? key})\x1b[0m`);
891
+ }
892
+ p.outro("Run 'baton ides scan' to get started.");
893
+ return;
894
+ }
895
+ console.log(`\nSaved IDE platforms (${savedPlatforms.length}):\n`);
896
+ for (const platform of platformStatuses) {
897
+ const statusColor = platform.saved ? "\x1B[32m" : "\x1B[90m";
898
+ const status = platform.saved ? "✓" : "✗";
899
+ console.log(`${statusColor}${status} ${platform.name.padEnd(20)} → ${platform.targetDir}`);
900
+ }
901
+ console.log("");
902
+ p.outro("Manage platforms: 'baton ides scan' (detect)");
903
+ }
904
+ });
905
+
906
+ //#endregion
907
+ //#region src/commands/ides/scan.ts
908
+ const idesScanCommand = defineCommand({
909
+ meta: {
910
+ name: "scan",
911
+ description: "Scan your system for IDE platforms and save results to global config"
912
+ },
913
+ args: { yes: {
914
+ type: "boolean",
915
+ alias: "y",
916
+ description: "Automatically save detected platforms without confirmation"
917
+ } },
918
+ async run({ args }) {
919
+ p.intro("Baton - IDE Platform Scanner");
920
+ const spinner = p.spinner();
921
+ spinner.start("Scanning for IDE platforms...");
922
+ clearIdeCache();
923
+ const detectedIdes = await detectInstalledIdes();
924
+ const allIdeKeys = getRegisteredIdePlatforms();
925
+ const currentPlatforms = await getGlobalIdePlatforms();
926
+ spinner.stop("Scan complete.");
927
+ const installed = [];
928
+ const notInstalled = [];
929
+ for (const ideKey of allIdeKeys) {
930
+ const entry = {
931
+ key: ideKey,
932
+ name: formatIdeName(ideKey)
933
+ };
934
+ if (detectedIdes.includes(ideKey)) installed.push(entry);
935
+ else notInstalled.push(entry);
936
+ }
937
+ if (installed.length > 0) {
938
+ p.log.success(`Found ${installed.length} IDE platform${installed.length !== 1 ? "s" : ""}:`);
939
+ for (const ide of installed) {
940
+ const badge = currentPlatforms.includes(ide.key) ? " (saved)" : " (new)";
941
+ console.log(` \x1b[32m✓\x1b[0m ${ide.name}${badge}`);
942
+ }
943
+ } else {
944
+ p.log.warn("No IDE platforms detected on your system.");
945
+ p.outro("Scan finished.");
946
+ return;
947
+ }
948
+ if (notInstalled.length > 0) {
949
+ console.log("");
950
+ p.log.info(`Not detected (${notInstalled.length}):`);
951
+ for (const ide of notInstalled) console.log(` \x1b[90m✗ ${ide.name}\x1b[0m`);
952
+ }
953
+ const detectedKeys = installed.map((i) => i.key);
954
+ if (detectedKeys.length !== currentPlatforms.length || detectedKeys.some((key) => !currentPlatforms.includes(key))) {
955
+ console.log("");
956
+ let shouldSave = args.yes;
957
+ if (!shouldSave) {
958
+ const confirm = await p.confirm({ message: "Save detected platforms to global config (~/.baton/config.yaml)?" });
959
+ if (p.isCancel(confirm)) {
960
+ p.outro("Scan finished (not saved).");
961
+ return;
962
+ }
963
+ shouldSave = confirm;
964
+ }
965
+ if (shouldSave) {
966
+ await setGlobalIdePlatforms(detectedKeys);
967
+ p.log.success("Platforms saved to global config.");
968
+ }
969
+ } else {
970
+ console.log("");
971
+ p.log.info("Global config is already up to date.");
972
+ }
973
+ p.outro("Scan finished.");
974
+ }
975
+ });
976
+
977
+ //#endregion
978
+ //#region src/commands/ides/index.ts
979
+ const idesCommand = defineCommand({
980
+ meta: {
981
+ name: "ides",
982
+ description: "Manage IDE platform detection and configuration"
983
+ },
984
+ subCommands: {
985
+ list: idesListCommand,
986
+ scan: idesScanCommand
987
+ }
988
+ });
989
+
990
+ //#endregion
991
+ //#region src/utils/profile-selection.ts
992
+ /**
993
+ * Discovers and prompts user to select a profile from a source.
994
+ * Used by `baton init --profile` and `baton manage` (add profile).
995
+ *
996
+ * @param sourceString - Source string to discover profiles from (e.g., "github:org/repo")
997
+ * @returns Final source string with selected profile path (e.g., "github:org/repo/frontend")
998
+ */
999
+ async function selectProfileFromSource(sourceString) {
1000
+ const parsedSource = parseSource(sourceString);
1001
+ if ((parsedSource.provider === "github" || parsedSource.provider === "gitlab") && !parsedSource.subpath) {
1002
+ const spinner = p.spinner();
1003
+ spinner.start("Cloning repository to discover profiles...");
1004
+ try {
1005
+ const cloned = await cloneGitSource({
1006
+ url: parsedSource.url,
1007
+ ref: parsedSource.ref,
1008
+ useCache: true
1009
+ });
1010
+ spinner.stop("✅ Repository cloned");
1011
+ const profiles = await discoverProfilesInSourceRepo(cloned.localPath);
1012
+ if (profiles.length === 0) {
1013
+ p.cancel("❌ No profiles found in the source repository");
1014
+ process.exit(1);
1015
+ }
1016
+ const selectedProfilePath = await p.select({
1017
+ message: "Select a profile from this source:",
1018
+ options: profiles.map((profile) => ({
1019
+ value: profile.path,
1020
+ label: profile.name,
1021
+ hint: profile.description ? `${profile.description} (v${profile.version})` : `v${profile.version}`
1022
+ }))
1023
+ });
1024
+ if (p.isCancel(selectedProfilePath)) {
1025
+ p.cancel("❌ Profile selection cancelled");
1026
+ process.exit(0);
1027
+ }
1028
+ if (selectedProfilePath === ".") return sourceString;
1029
+ return `${parsedSource.ref ? `${parsedSource.provider}:${parsedSource.org}/${parsedSource.repo}@${parsedSource.ref}` : `${parsedSource.provider}:${parsedSource.org}/${parsedSource.repo}`}/${selectedProfilePath}`;
1030
+ } catch (error) {
1031
+ spinner.stop("❌ Failed to clone repository");
1032
+ p.cancel(`❌ Failed to discover profiles: ${error instanceof Error ? error.message : String(error)}`);
1033
+ process.exit(1);
1034
+ }
1035
+ }
1036
+ if (parsedSource.provider === "file" || parsedSource.provider === "local") {
1037
+ const profiles = await discoverProfilesInSourceRepo(parsedSource.path.startsWith("/") ? parsedSource.path : resolve(process.cwd(), parsedSource.path));
1038
+ if (profiles.length === 0) return sourceString;
1039
+ if (profiles.length === 1) return profiles[0].path === "." ? sourceString : `${sourceString}/${profiles[0].path}`;
1040
+ const selectedProfilePath = await p.select({
1041
+ message: "Select a profile from this source:",
1042
+ options: profiles.map((profile) => ({
1043
+ value: profile.path,
1044
+ label: profile.name,
1045
+ hint: profile.description ? `${profile.description} (v${profile.version})` : `v${profile.version}`
1046
+ }))
1047
+ });
1048
+ if (p.isCancel(selectedProfilePath)) {
1049
+ p.cancel("❌ Profile selection cancelled");
1050
+ process.exit(0);
1051
+ }
1052
+ if (selectedProfilePath === ".") return sourceString;
1053
+ return `${sourceString}/${selectedProfilePath}`;
1054
+ }
1055
+ return sourceString;
1056
+ }
1057
+ /**
1058
+ * Discovers and prompts user to select multiple profiles from a source.
1059
+ * Returns array of full source strings with profile paths.
1060
+ *
1061
+ * Used by `baton init` to allow installing multiple profiles at once.
1062
+ *
1063
+ * @param sourceString - Source string to discover profiles from (e.g., "github:org/repo")
1064
+ * @returns Array of source strings with selected profile paths (e.g., ["github:org/repo/frontend", "github:org/repo/backend"])
1065
+ */
1066
+ async function selectMultipleProfilesFromSource(sourceString, options) {
1067
+ const parsedSource = parseSource(sourceString);
1068
+ if ((parsedSource.provider === "github" || parsedSource.provider === "gitlab") && !parsedSource.subpath) {
1069
+ const spinner = p.spinner();
1070
+ spinner.start("Cloning repository to discover profiles...");
1071
+ try {
1072
+ const cloned = await cloneGitSource({
1073
+ url: parsedSource.url,
1074
+ ref: parsedSource.ref,
1075
+ useCache: true
1076
+ });
1077
+ spinner.stop("✅ Repository cloned");
1078
+ const profiles = await discoverProfilesInSourceRepo(cloned.localPath);
1079
+ if (profiles.length === 0) {
1080
+ p.cancel("❌ No profiles found in the source repository");
1081
+ process.exit(1);
1082
+ }
1083
+ if (profiles.length === 1) {
1084
+ p.note(`Using profile: ${profiles[0].name}`, "Profile");
1085
+ return [constructProfilePath(parsedSource, profiles[0].path)];
1086
+ }
1087
+ if (options?.nonInteractive) {
1088
+ p.note(`Auto-selecting all ${profiles.length} profile(s)`, "Non-interactive mode");
1089
+ return profiles.map((prof) => constructProfilePath(parsedSource, prof.path));
1090
+ }
1091
+ const selected = await p.multiselect({
1092
+ message: "Select profile(s) to install: (Space to select, Enter to continue)",
1093
+ options: profiles.map((prof) => ({
1094
+ value: prof.path,
1095
+ label: prof.name,
1096
+ hint: prof.description ? `${prof.description} (v${prof.version})` : `v${prof.version}`
1097
+ })),
1098
+ required: true
1099
+ });
1100
+ if (p.isCancel(selected)) {
1101
+ p.cancel("❌ Profile selection cancelled");
1102
+ process.exit(0);
1103
+ }
1104
+ return selected.map((path) => constructProfilePath(parsedSource, path));
1105
+ } catch (error) {
1106
+ spinner.stop("❌ Failed to clone repository");
1107
+ p.cancel(`❌ Failed to discover profiles: ${error instanceof Error ? error.message : String(error)}`);
1108
+ process.exit(1);
1109
+ }
1110
+ }
1111
+ if (parsedSource.provider === "file" || parsedSource.provider === "local") {
1112
+ const profiles = await discoverProfilesInSourceRepo(parsedSource.path.startsWith("/") ? parsedSource.path : resolve(process.cwd(), parsedSource.path));
1113
+ if (profiles.length === 0) return [sourceString];
1114
+ if (profiles.length === 1) {
1115
+ p.note(`Using profile: ${profiles[0].name}`, "Profile");
1116
+ return [profiles[0].path === "." ? sourceString : `${sourceString}/${profiles[0].path}`];
1117
+ }
1118
+ if (options?.nonInteractive) {
1119
+ p.note(`Auto-selecting all ${profiles.length} profile(s)`, "Non-interactive mode");
1120
+ return profiles.map((prof) => prof.path === "." ? sourceString : `${sourceString}/${prof.path}`);
1121
+ }
1122
+ const selected = await p.multiselect({
1123
+ message: "Select profile(s) to install: (Space to select, Enter to continue)",
1124
+ options: profiles.map((prof) => ({
1125
+ value: prof.path,
1126
+ label: prof.name,
1127
+ hint: prof.description ? `${prof.description} (v${prof.version})` : `v${prof.version}`
1128
+ })),
1129
+ required: true
1130
+ });
1131
+ if (p.isCancel(selected)) {
1132
+ p.cancel("❌ Profile selection cancelled");
1133
+ process.exit(0);
1134
+ }
1135
+ return selected.map((path) => path === "." ? sourceString : `${sourceString}/${path}`);
1136
+ }
1137
+ return [sourceString];
1138
+ }
1139
+ /**
1140
+ * Helper: Constructs full source path with profile subpath.
1141
+ *
1142
+ * @param parsed - Parsed source object (github or gitlab only)
1143
+ * @param profilePath - Profile path (e.g., ".", "frontend", "backend")
1144
+ * @returns Full source string (e.g., "github:org/repo/frontend")
1145
+ */
1146
+ function constructProfilePath(parsed, profilePath) {
1147
+ if (profilePath === ".") return parsed.ref ? `${parsed.provider}:${parsed.org}/${parsed.repo}@${parsed.ref}` : `${parsed.provider}:${parsed.org}/${parsed.repo}`;
1148
+ return `${parsed.ref ? `${parsed.provider}:${parsed.org}/${parsed.repo}@${parsed.ref}` : `${parsed.provider}:${parsed.org}/${parsed.repo}`}/${profilePath}`;
1149
+ }
1150
+
1151
+ //#endregion
1152
+ //#region src/utils/run-baton-sync.ts
1153
+ /**
1154
+ * Run `baton sync` as a child process, inheriting stdio for interactive output.
1155
+ */
1156
+ async function runBatonSync(cwd) {
1157
+ const { spawn } = await import("node:child_process");
1158
+ await new Promise((done) => {
1159
+ const syncProcess = spawn("baton", ["sync"], {
1160
+ cwd,
1161
+ stdio: "inherit"
1162
+ });
1163
+ syncProcess.on("close", (code) => {
1164
+ if (code === 0) p.log.success("Profiles synced successfully!");
1165
+ else p.log.warn(`Sync finished with exit code ${code}`);
1166
+ done();
1167
+ });
1168
+ syncProcess.on("error", (error) => {
1169
+ p.log.warn(`Failed to run sync: ${error.message}`);
1170
+ done();
1171
+ });
1172
+ });
1173
+ }
1174
+
1175
+ //#endregion
1176
+ //#region src/commands/init.ts
1177
+ const initCommand = defineCommand({
1178
+ meta: {
1179
+ name: "init",
1180
+ description: "Initialize Baton in your project with an interactive setup wizard"
1181
+ },
1182
+ args: {
1183
+ yes: {
1184
+ type: "boolean",
1185
+ alias: "y",
1186
+ description: "Skip all prompts and use defaults"
1187
+ },
1188
+ force: {
1189
+ type: "boolean",
1190
+ description: "Overwrite existing baton.yaml"
1191
+ },
1192
+ profile: {
1193
+ type: "string",
1194
+ description: "Profile source to install (skips profile selection)"
1195
+ }
1196
+ },
1197
+ async run({ args }) {
1198
+ const isInteractive = !args.yes;
1199
+ const cwd = process.cwd();
1200
+ p.intro("🎯 Welcome to Baton");
1201
+ try {
1202
+ await readFile(join(cwd, "baton.yaml"));
1203
+ if (!args.force) {
1204
+ p.cancel("baton.yaml already exists in this directory.");
1205
+ p.note("Use `baton manage` to modify your project configuration\nor `baton sync` to sync your profiles.\n\nTo reinitialize, use `baton init --force`.", "Already initialized");
1206
+ process.exit(1);
1207
+ }
1208
+ p.log.warn("Overwriting existing baton.yaml (--force)");
1209
+ } catch (_error) {}
1210
+ const spinner = p.spinner();
1211
+ await autoScanAiTools(spinner, isInteractive);
1212
+ await autoScanIdePlatforms(spinner, isInteractive);
1213
+ let profileSources;
1214
+ if (args.profile) profileSources = await selectMultipleProfilesFromSource(args.profile, { nonInteractive: !isInteractive });
1215
+ else {
1216
+ const globalSources = await getGlobalSources();
1217
+ if (globalSources.length === 0) {
1218
+ p.cancel("No sources configured.");
1219
+ p.note("Connect a source repository first:\n\n baton source connect <url>\n\nExample:\n baton source connect github:my-org/dx-config", "No sources found");
1220
+ process.exit(1);
1221
+ } else if (globalSources.length === 1 && globalSources[0].default) {
1222
+ const defaultSource = globalSources[0];
1223
+ p.note(`Using default source: ${defaultSource.name}\n${defaultSource.url}`, "Source");
1224
+ profileSources = await selectMultipleProfilesFromSource(defaultSource.url, { nonInteractive: !isInteractive });
1225
+ } else if (!isInteractive) profileSources = await selectMultipleProfilesFromSource((await getDefaultGlobalSource())?.url || globalSources[0].url, { nonInteractive: true });
1226
+ else {
1227
+ const defaultSource = await getDefaultGlobalSource();
1228
+ const selectedUrl = await p.select({
1229
+ message: "Select a source repository:",
1230
+ options: globalSources.map((s) => ({
1231
+ value: s.url,
1232
+ label: s.default ? `${s.name} [default]` : s.name,
1233
+ hint: s.description || s.url
1234
+ })),
1235
+ initialValue: defaultSource?.url
1236
+ });
1237
+ if (p.isCancel(selectedUrl)) {
1238
+ p.cancel("Setup cancelled.");
1239
+ process.exit(0);
1240
+ }
1241
+ profileSources = await selectMultipleProfilesFromSource(selectedUrl);
1242
+ }
1243
+ }
1244
+ await showProfileIntersections(profileSources);
1245
+ const yamlContent = stringify({ profiles: profileSources.map((source) => ({ source })) });
1246
+ spinner.start("Creating baton.yaml...");
1247
+ await writeFile(join(cwd, "baton.yaml"), yamlContent, "utf-8");
1248
+ spinner.stop("✅ Created baton.yaml");
1249
+ spinner.start("Updating .gitignore...");
1250
+ const gitignorePath = join(cwd, ".gitignore");
1251
+ let gitignoreContent = "";
1252
+ try {
1253
+ gitignoreContent = await readFile(gitignorePath, "utf-8");
1254
+ } catch (_error) {}
1255
+ if (!gitignoreContent.includes(".baton/")) {
1256
+ await writeFile(gitignorePath, gitignoreContent ? `${gitignoreContent}\n\n# Baton cache\n.baton/\n` : "# Baton cache\n.baton/\n", "utf-8");
1257
+ spinner.stop("✅ Added .baton/ to .gitignore");
1258
+ } else spinner.stop("✅ .gitignore already contains .baton/");
1259
+ spinner.start("Creating .baton directory...");
1260
+ try {
1261
+ await mkdir(join(cwd, ".baton"), { recursive: true });
1262
+ spinner.stop("✅ Created .baton directory");
1263
+ } catch (_error) {
1264
+ spinner.stop("✅ .baton directory already exists");
1265
+ }
1266
+ if (profileSources.length > 0) {
1267
+ const shouldSync = isInteractive ? await p.confirm({
1268
+ message: "Sync profiles now?",
1269
+ initialValue: true
1270
+ }) : true;
1271
+ if (!p.isCancel(shouldSync) && shouldSync) await runBatonSync(cwd);
1272
+ else p.log.info("Run 'baton sync' later to apply your profiles.");
1273
+ }
1274
+ p.outro("Baton initialized successfully!");
1275
+ }
1276
+ });
1277
+ /**
1278
+ * Auto-scan AI tools if none are configured in global config.
1279
+ * In interactive mode: shows results and asks for confirmation.
1280
+ * In non-interactive mode (--yes): auto-saves detected tools.
1281
+ */
1282
+ async function autoScanAiTools(spinner, isInteractive) {
1283
+ const existingTools = await getGlobalAiTools();
1284
+ if (existingTools.length > 0) {
1285
+ p.log.info(`AI tools already configured: ${existingTools.join(", ")}`);
1286
+ return;
1287
+ }
1288
+ spinner.start("Scanning for installed AI tools...");
1289
+ clearAgentCache();
1290
+ const detectedTools = await detectInstalledAgents();
1291
+ spinner.stop(detectedTools.length > 0 ? `Found ${detectedTools.length} AI tool${detectedTools.length !== 1 ? "s" : ""}: ${detectedTools.join(", ")}` : "No AI tools detected.");
1292
+ if (detectedTools.length === 0) {
1293
+ p.log.warn("No AI tools detected. You can run 'baton ai-tools scan' later.");
1294
+ return;
1295
+ }
1296
+ if (isInteractive) {
1297
+ const shouldSave = await p.confirm({
1298
+ message: "Save detected AI tools to global config?",
1299
+ initialValue: true
1300
+ });
1301
+ if (p.isCancel(shouldSave) || !shouldSave) {
1302
+ p.log.info("Skipped saving AI tools. Run 'baton ai-tools scan' later.");
1303
+ return;
1304
+ }
1305
+ }
1306
+ await setGlobalAiTools(detectedTools);
1307
+ p.log.success("AI tools saved to global config.");
1308
+ }
1309
+ /**
1310
+ * Auto-scan IDE platforms if none are configured in global config.
1311
+ * In interactive mode: shows results and asks for confirmation.
1312
+ * In non-interactive mode (--yes): auto-saves detected platforms.
1313
+ */
1314
+ async function autoScanIdePlatforms(spinner, isInteractive) {
1315
+ const existingPlatforms = await getGlobalIdePlatforms();
1316
+ if (existingPlatforms.length > 0) {
1317
+ p.log.info(`IDE platforms already configured: ${existingPlatforms.join(", ")}`);
1318
+ return;
1319
+ }
1320
+ spinner.start("Scanning for installed IDE platforms...");
1321
+ clearIdeCache();
1322
+ const detectedIdes = await detectInstalledIdes();
1323
+ spinner.stop(detectedIdes.length > 0 ? `Found ${detectedIdes.length} IDE platform${detectedIdes.length !== 1 ? "s" : ""}: ${detectedIdes.join(", ")}` : "No IDE platforms detected.");
1324
+ if (detectedIdes.length === 0) {
1325
+ p.log.warn("No IDE platforms detected. You can run 'baton ides scan' later.");
1326
+ return;
1327
+ }
1328
+ if (isInteractive) {
1329
+ const shouldSave = await p.confirm({
1330
+ message: "Save detected IDE platforms to global config?",
1331
+ initialValue: true
1332
+ });
1333
+ if (p.isCancel(shouldSave) || !shouldSave) {
1334
+ p.log.info("Skipped saving IDE platforms. Run 'baton ides scan' later.");
1335
+ return;
1336
+ }
1337
+ }
1338
+ await setGlobalIdePlatforms(detectedIdes);
1339
+ p.log.success("IDE platforms saved to global config.");
1340
+ }
1341
+ /**
1342
+ * Load source/profile manifests for each selected profile and display
1343
+ * the intersection between developer tools and profile support.
1344
+ *
1345
+ * Gracefully handles errors (e.g., missing source manifest) — the intersection
1346
+ * display is informational and should not block init.
1347
+ */
1348
+ async function showProfileIntersections(profileSources) {
1349
+ const aiTools = await getGlobalAiTools();
1350
+ const idePlatforms = await getGlobalIdePlatforms();
1351
+ if (aiTools.length === 0 && idePlatforms.length === 0) return;
1352
+ const developerTools = {
1353
+ aiTools,
1354
+ idePlatforms
1355
+ };
1356
+ for (const sourceString of profileSources) try {
1357
+ const parsed = parseSource(sourceString);
1358
+ let repoRoot;
1359
+ let profileDir;
1360
+ if (parsed.provider === "github" || parsed.provider === "gitlab") {
1361
+ repoRoot = (await cloneGitSource({
1362
+ url: parsed.url,
1363
+ ref: parsed.ref,
1364
+ useCache: true
1365
+ })).localPath;
1366
+ profileDir = parsed.subpath ? resolve(repoRoot, parsed.subpath) : repoRoot;
1367
+ } else if (parsed.provider === "local" || parsed.provider === "file") {
1368
+ const absolutePath = parsed.path.startsWith("/") ? parsed.path : resolve(process.cwd(), parsed.path);
1369
+ profileDir = absolutePath;
1370
+ repoRoot = await findSourceRoot(absolutePath, { fallbackToStart: true });
1371
+ } else continue;
1372
+ let sourceManifest;
1373
+ try {
1374
+ sourceManifest = await findSourceManifest(repoRoot);
1375
+ } catch {
1376
+ sourceManifest = {
1377
+ name: "unknown",
1378
+ version: "0.0.0"
1379
+ };
1380
+ }
1381
+ const profileManifest = await loadProfileManifest(resolve(profileDir, "baton.profile.yaml")).catch(() => null);
1382
+ if (!profileManifest) continue;
1383
+ const intersection = computeIntersection(developerTools, resolveProfileSupport(profileManifest, sourceManifest));
1384
+ if (intersection.aiTools.synced.length > 0 || intersection.aiTools.unavailable.length > 0 || intersection.idePlatforms.synced.length > 0 || intersection.idePlatforms.unavailable.length > 0) {
1385
+ p.log.step(`Intersection for ${profileManifest.name}`);
1386
+ displayIntersection(intersection);
1387
+ }
1388
+ } catch {}
1389
+ }
1390
+
1391
+ //#endregion
1392
+ //#region src/commands/manage.ts
1393
+ async function loadProjectManifestSafe(cwd) {
1394
+ try {
1395
+ return await loadProjectManifest(join(cwd, "baton.yaml"));
1396
+ } catch {
1397
+ return null;
1398
+ }
1399
+ }
1400
+ async function hasLockfile(cwd) {
1401
+ try {
1402
+ await access(join(cwd, "baton.lock"));
1403
+ return true;
1404
+ } catch {
1405
+ return false;
1406
+ }
1407
+ }
1408
+ async function showOverview(cwd) {
1409
+ const [manifest, sources, synced] = await Promise.all([
1410
+ loadProjectManifestSafe(cwd),
1411
+ getGlobalSources(),
1412
+ hasLockfile(cwd)
1413
+ ]);
1414
+ if (!manifest) {
1415
+ p.log.warn("Could not load baton.yaml");
1416
+ return;
1417
+ }
1418
+ p.log.step("Installed Profiles");
1419
+ if (manifest.profiles.length === 0) p.log.info(" No profiles installed.");
1420
+ else for (const profile of manifest.profiles) {
1421
+ const version = profile.version ? ` (${profile.version})` : "";
1422
+ const matchingSource = sources.find((s) => profile.source.includes(s.url) || profile.source.includes(s.name));
1423
+ const sourceName = matchingSource ? ` [${matchingSource.name}]` : "";
1424
+ p.log.info(` ${profile.source}${version}${sourceName}`);
1425
+ }
1426
+ if (manifest.profiles.length > 0) {
1427
+ const aiTools = await getGlobalAiTools();
1428
+ const idePlatforms = await getGlobalIdePlatforms();
1429
+ if (aiTools.length > 0 || idePlatforms.length > 0) {
1430
+ const developerTools = {
1431
+ aiTools,
1432
+ idePlatforms
1433
+ };
1434
+ console.log("");
1435
+ p.log.step("Tool Intersection");
1436
+ for (const profile of manifest.profiles) try {
1437
+ const intersection = await buildIntersection(profile.source, developerTools, cwd);
1438
+ if (intersection) {
1439
+ const summary = formatIntersectionSummary(intersection);
1440
+ p.log.info(` ${profile.source}: ${summary}`);
1441
+ displayIntersection(intersection);
1442
+ }
1443
+ } catch {}
1444
+ }
1445
+ }
1446
+ console.log("");
1447
+ p.log.step("Sync Status");
1448
+ if (synced) p.log.info(" Synced (baton.lock exists)");
1449
+ else p.log.info(" Not synced — run 'baton sync' to sync profiles");
1450
+ console.log("");
1451
+ p.log.step("Global Sources");
1452
+ if (sources.length === 0) p.log.info(" No sources configured. Run: baton source connect <url>");
1453
+ else for (const source of sources) {
1454
+ const defaultBadge = source.default ? " (default)" : "";
1455
+ p.log.info(` ${source.name}${defaultBadge}: ${source.url}`);
1456
+ }
1457
+ }
1458
+ async function handleAddProfile(cwd) {
1459
+ const manifestPath = join(cwd, "baton.yaml");
1460
+ const manifest = await loadProjectManifestSafe(cwd);
1461
+ if (!manifest) {
1462
+ p.log.error("Could not load baton.yaml");
1463
+ return;
1464
+ }
1465
+ const globalSources = await getGlobalSources();
1466
+ if (globalSources.length === 0) {
1467
+ p.log.warn("No global sources configured. Run: baton source connect <url>");
1468
+ return;
1469
+ }
1470
+ let sourceString;
1471
+ if (globalSources.length === 1) {
1472
+ sourceString = globalSources[0].url;
1473
+ p.log.info(`Using source: ${globalSources[0].name} (${sourceString})`);
1474
+ } else {
1475
+ const defaultSource = await getDefaultGlobalSource();
1476
+ const selectedUrl = await p.select({
1477
+ message: "Select a source repository:",
1478
+ options: globalSources.map((s) => ({
1479
+ value: s.url,
1480
+ label: s.default ? `${s.name} [default]` : s.name,
1481
+ hint: s.description || s.url
1482
+ })),
1483
+ initialValue: defaultSource?.url
1484
+ });
1485
+ if (p.isCancel(selectedUrl)) {
1486
+ p.log.warn("Cancelled.");
1487
+ return;
1488
+ }
1489
+ sourceString = selectedUrl;
1490
+ }
1491
+ const selectedSource = await selectProfileFromSource(sourceString);
1492
+ if (manifest.profiles.some((pr) => pr.source === selectedSource)) {
1493
+ p.log.warn(`Profile "${selectedSource}" is already installed.`);
1494
+ return;
1495
+ }
1496
+ manifest.profiles.push({ source: selectedSource });
1497
+ await writeFile(manifestPath, stringify(manifest), "utf-8");
1498
+ p.log.success(`Added profile: ${selectedSource}`);
1499
+ const shouldSync = await p.confirm({
1500
+ message: "Sync profiles now?",
1501
+ initialValue: true
1502
+ });
1503
+ if (p.isCancel(shouldSync) || !shouldSync) {
1504
+ p.log.info("Run 'baton sync' later to apply the new profile.");
1505
+ return;
1506
+ }
1507
+ await runBatonSync(cwd);
1508
+ }
1509
+ async function handleRemoveProfile(cwd) {
1510
+ const manifestPath = join(cwd, "baton.yaml");
1511
+ const manifest = await loadProjectManifestSafe(cwd);
1512
+ if (!manifest) {
1513
+ p.log.error("Could not load baton.yaml");
1514
+ return;
1515
+ }
1516
+ if (manifest.profiles.length === 0) {
1517
+ p.log.warn("No profiles installed.");
1518
+ return;
1519
+ }
1520
+ const selected = await p.select({
1521
+ message: "Which profile do you want to remove?",
1522
+ options: manifest.profiles.map((pr) => ({
1523
+ value: pr.source,
1524
+ label: pr.source,
1525
+ hint: pr.version ? `v${pr.version}` : void 0
1526
+ }))
1527
+ });
1528
+ if (p.isCancel(selected)) {
1529
+ p.log.warn("Cancelled.");
1530
+ return;
1531
+ }
1532
+ const profileSource = selected;
1533
+ const confirmed = await p.confirm({
1534
+ message: `Remove profile "${profileSource}"?`,
1535
+ initialValue: false
1536
+ });
1537
+ if (p.isCancel(confirmed) || !confirmed) {
1538
+ p.log.warn("Cancelled.");
1539
+ return;
1540
+ }
1541
+ const profileIndex = manifest.profiles.findIndex((pr) => pr.source === profileSource);
1542
+ manifest.profiles.splice(profileIndex, 1);
1543
+ await writeFile(manifestPath, stringify(manifest), "utf-8");
1544
+ p.log.success(`Removed profile: ${profileSource}`);
1545
+ p.log.info("Run 'baton sync' to clean up synced files.");
1546
+ }
1547
+ async function handleRemoveBaton(cwd) {
1548
+ p.log.warn("This will remove Baton from your project:");
1549
+ p.log.info(" - baton.yaml (project manifest)");
1550
+ p.log.info(" - baton.lock (lockfile)");
1551
+ const confirmed = await p.confirm({
1552
+ message: "Are you sure you want to remove Baton from this project?",
1553
+ initialValue: false
1554
+ });
1555
+ if (p.isCancel(confirmed) || !confirmed) {
1556
+ p.log.warn("Cancelled.");
1557
+ return false;
1558
+ }
1559
+ await rm(join(cwd, "baton.yaml"), { force: true });
1560
+ await rm(join(cwd, "baton.lock"), { force: true });
1561
+ p.log.success("Baton has been removed from this project.");
1562
+ p.log.info("Note: Synced files (rules, skills, memory) were not removed.");
1563
+ p.log.info("Run 'baton sync' before removing to clean up, or delete them manually.");
1564
+ return true;
1565
+ }
1566
+ const manageCommand = defineCommand({
1567
+ meta: {
1568
+ name: "manage",
1569
+ description: "Interactive project management wizard for Baton"
1570
+ },
1571
+ async run() {
1572
+ const cwd = process.cwd();
1573
+ if (!await loadProjectManifestSafe(cwd)) {
1574
+ p.intro("Baton Manage");
1575
+ p.cancel("baton.yaml not found. Run 'baton init' first.");
1576
+ process.exit(1);
1577
+ }
1578
+ p.intro("Baton Manage");
1579
+ while (true) {
1580
+ const action = await p.select({
1581
+ message: "What would you like to do?",
1582
+ options: [
1583
+ {
1584
+ value: "overview",
1585
+ label: "Overview",
1586
+ hint: "Show project configuration"
1587
+ },
1588
+ {
1589
+ value: "add-profile",
1590
+ label: "Add profile",
1591
+ hint: "Add a profile from a source"
1592
+ },
1593
+ {
1594
+ value: "remove-profile",
1595
+ label: "Remove profile",
1596
+ hint: "Remove an installed profile"
1597
+ },
1598
+ {
1599
+ value: "remove-baton",
1600
+ label: "Remove Baton",
1601
+ hint: "Remove Baton from this project"
1602
+ },
1603
+ {
1604
+ value: "quit",
1605
+ label: "Quit"
1606
+ }
1607
+ ]
1608
+ });
1609
+ if (p.isCancel(action) || action === "quit") {
1610
+ p.outro("Goodbye!");
1611
+ return;
1612
+ }
1613
+ if (action === "overview") {
1614
+ console.log("");
1615
+ await showOverview(cwd);
1616
+ console.log("");
1617
+ } else if (action === "add-profile") {
1618
+ console.log("");
1619
+ await handleAddProfile(cwd);
1620
+ console.log("");
1621
+ } else if (action === "remove-profile") {
1622
+ console.log("");
1623
+ await handleRemoveProfile(cwd);
1624
+ console.log("");
1625
+ } else if (action === "remove-baton") {
1626
+ console.log("");
1627
+ if (await handleRemoveBaton(cwd)) {
1628
+ p.outro("Goodbye!");
1629
+ return;
1630
+ }
1631
+ console.log("");
1632
+ }
1633
+ }
1634
+ }
1635
+ });
1636
+
1637
+ //#endregion
1638
+ //#region src/commands/profile/index.ts
1639
+ const profileCommand = defineCommand({
1640
+ meta: {
1641
+ name: "profile",
1642
+ description: "Manage profiles (create, list, remove)"
1643
+ },
1644
+ subCommands: {
1645
+ create: () => import("./create-CqfUSGj7.mjs").then((m) => m.createCommand),
1646
+ list: () => import("./list-o76RXPxE.mjs").then((m) => m.profileListCommand),
1647
+ remove: () => import("./remove-BB883RDx.mjs").then((m) => m.profileRemoveCommand)
1648
+ }
1649
+ });
1650
+
1651
+ //#endregion
1652
+ //#region src/commands/source/connect.ts
1653
+ /**
1654
+ * Command: baton source connect
1655
+ *
1656
+ * Connects a source repository to the global configuration (~/.baton/config.yaml).
1657
+ * Once connected, sources can be auto-selected in `baton init`.
1658
+ */
1659
+ const connectCommand = defineCommand({
1660
+ meta: {
1661
+ name: "connect",
1662
+ description: "Connect a source repository to your global config"
1663
+ },
1664
+ args: {
1665
+ url: {
1666
+ type: "positional",
1667
+ description: "Source URL (github:org/repo, ../path)",
1668
+ required: true
1669
+ },
1670
+ name: {
1671
+ type: "string",
1672
+ description: "Custom name for the source (kebab-case)"
1673
+ },
1674
+ description: {
1675
+ type: "string",
1676
+ description: "Source description"
1677
+ }
1678
+ },
1679
+ async run({ args }) {
1680
+ const url = args.url;
1681
+ const customName = args.name;
1682
+ if (customName && !KEBAB_CASE_REGEX.test(customName)) {
1683
+ p.cancel("Source name must be kebab-case (e.g., my-source)");
1684
+ process.exit(1);
1685
+ }
1686
+ try {
1687
+ parseSource(url);
1688
+ } catch (error) {
1689
+ const message = error instanceof SourceParseError ? error.message : `Invalid source: ${error.message}`;
1690
+ p.cancel(message);
1691
+ process.exit(1);
1692
+ }
1693
+ try {
1694
+ await addGlobalSource(url, {
1695
+ name: args.name,
1696
+ description: args.description
1697
+ });
1698
+ const displayName = args.name || url;
1699
+ p.log.success(`Connected source: ${displayName}`);
1700
+ const shouldSync = await p.confirm({ message: "Would you like to sync profiles from this source now?" });
1701
+ if (p.isCancel(shouldSync) || !shouldSync) {
1702
+ p.outro("Source connected. Run 'baton init' to set up profiles.");
1703
+ return;
1704
+ }
1705
+ p.outro("Starting profile sync...");
1706
+ const profiles = await selectMultipleProfilesFromSource(url);
1707
+ if (profiles.length > 0) {
1708
+ p.log.success(`Selected ${profiles.length} profile(s) for sync.`);
1709
+ p.note("Run 'baton init' in your project directory to install these profiles.", "Next step");
1710
+ }
1711
+ } catch (error) {
1712
+ p.cancel(`Failed to connect source: ${error.message}`);
1713
+ process.exit(1);
1714
+ }
1715
+ }
1716
+ });
1717
+
1718
+ //#endregion
1719
+ //#region src/commands/source/create.ts
1720
+ const __dirname$1 = dirname(fileURLToPath(import.meta.url));
1721
+ async function runInteractiveWizard(overrides = {}) {
1722
+ p.intro("Create a new Baton source repository");
1723
+ let name;
1724
+ if (overrides.name) name = overrides.name;
1725
+ else {
1726
+ const result = await p.text({
1727
+ message: "What is the name of your source repository?",
1728
+ placeholder: "my-team-profile",
1729
+ validate: (value) => {
1730
+ if (!value) return "Name is required";
1731
+ if (!KEBAB_CASE_REGEX.test(value)) return "Name must be in kebab-case (lowercase, hyphens only)";
1732
+ }
1733
+ });
1734
+ if (p.isCancel(result)) {
1735
+ p.cancel("Operation cancelled.");
1736
+ process.exit(0);
1737
+ }
1738
+ name = String(result);
1739
+ }
1740
+ let git;
1741
+ if (overrides.git !== void 0) git = overrides.git;
1742
+ else {
1743
+ const result = await p.confirm({
1744
+ message: "Initialize Git repository?",
1745
+ initialValue: true
1746
+ });
1747
+ if (p.isCancel(result)) {
1748
+ p.cancel("Operation cancelled.");
1749
+ process.exit(0);
1750
+ }
1751
+ git = result;
1752
+ }
1753
+ let withInitialProfile;
1754
+ if (overrides.withInitialProfile !== void 0) withInitialProfile = overrides.withInitialProfile;
1755
+ else {
1756
+ const result = await p.confirm({
1757
+ message: "Create initial profile in profiles/default/?",
1758
+ initialValue: true
1759
+ });
1760
+ if (p.isCancel(result)) {
1761
+ p.cancel("Operation cancelled.");
1762
+ process.exit(0);
1763
+ }
1764
+ withInitialProfile = result;
1765
+ }
1766
+ return {
1767
+ name,
1768
+ git,
1769
+ withInitialProfile
1770
+ };
1771
+ }
1772
+ /**
1773
+ * Recursively copy a directory and apply Handlebars variable substitution to text files
1774
+ */
1775
+ async function copyDirectory(src, dest, variables) {
1776
+ await mkdir(dest, { recursive: true });
1777
+ const entries = await readdir(src, { withFileTypes: true });
1778
+ for (const entry of entries) {
1779
+ const srcPath = join(src, entry.name);
1780
+ const destPath = join(dest, entry.name);
1781
+ if (entry.isDirectory()) await copyDirectory(srcPath, destPath, variables);
1782
+ else if (entry.isFile()) {
1783
+ const content = await readFile(srcPath, "utf-8");
1784
+ if (new Set([
1785
+ ".png",
1786
+ ".jpg",
1787
+ ".jpeg",
1788
+ ".gif",
1789
+ ".ico",
1790
+ ".woff",
1791
+ ".woff2",
1792
+ ".ttf",
1793
+ ".eot"
1794
+ ]).has(entry.name.substring(entry.name.lastIndexOf(".")))) await writeFile(destPath, content);
1795
+ else await writeFile(destPath, Handlebars.compile(content, { noEscape: true })(variables));
1796
+ }
1797
+ }
1798
+ }
1799
+ /**
1800
+ * Initialize Git repository and create initial commit
1801
+ */
1802
+ async function initializeGit(targetDir) {
1803
+ const git = simpleGit(targetDir);
1804
+ await git.init();
1805
+ await git.add(".");
1806
+ await git.commit("Initial baton source setup");
1807
+ }
1808
+ /**
1809
+ * Generate README.md with next steps
1810
+ */
1811
+ async function generateReadme(targetDir, options) {
1812
+ const { name, withInitialProfile } = options;
1813
+ const org = name.includes("-") ? name.split("-")[0] : name;
1814
+ const readmeContent = `# ${name}
1815
+
1816
+ Baton source repository.
1817
+
1818
+ ## Usage
1819
+
1820
+ Add profiles from this source repository to your project:
1821
+
1822
+ \`\`\`bash
1823
+ # From GitHub (after you push)
1824
+ baton init --profile github:${org}/${name}/profiles/default
1825
+
1826
+ # Or locally (for testing)
1827
+ baton init --profile file:./${name}/profiles/default
1828
+ \`\`\`
1829
+
1830
+ ## Next Steps
1831
+
1832
+ 1. **Customize your profiles:**
1833
+ - Edit \`baton.source.yaml\` to configure the source metadata
1834
+ - Modify profiles in \`profiles/*/baton.profile.yaml\`
1835
+ - Add project-specific configurations to \`profiles/*/files/\`
1836
+ - Customize AI tool configs in \`profiles/*/ai/\`
1837
+
1838
+ 2. **Create additional profiles:**
1839
+ \`\`\`bash
1840
+ cd ${name}
1841
+ baton profile create frontend
1842
+ baton profile create backend
1843
+ \`\`\`
1844
+
1845
+ 3. **Set up Git remote:**
1846
+ \`\`\`bash
1847
+ git remote add origin https://github.com/${org}/${name}.git
1848
+ git push -u origin main
1849
+ \`\`\`
1850
+
1851
+ 4. **Share with your team:**
1852
+ - Publish to GitHub for team-wide access
1853
+ - Team members can use: \`baton init --profile github:${org}/${name}/profiles/default\`
1854
+
1855
+ ## Structure
1856
+
1857
+ - \`baton.source.yaml\` - Source repository manifest
1858
+ - \`profiles/\` - Container for all profiles
1859
+ ${withInitialProfile ? " - `profiles/default/` - Default profile\n - `baton.profile.yaml` - Profile manifest\n - `ai/` - AI tool configurations\n - `files/` - Dotfiles and configs to sync\n - `ide/` - IDE settings\n" : ""}
1860
+ ## Learn More
1861
+
1862
+ - [Baton Documentation](https://github.com/baton-dx/baton)
1863
+ - [Source Schema](https://github.com/baton-dx/baton/blob/main/docs/source-schema.md)
1864
+ - [Profile Schema](https://github.com/baton-dx/baton/blob/main/docs/profile-schema.md)
1865
+
1866
+ ---
1867
+
1868
+ Generated with \`baton source create\`
1869
+ `;
1870
+ await writeFile(join(targetDir, "README.md"), readmeContent);
1871
+ }
1872
+ /**
1873
+ * Scaffold a source repository
1874
+ */
1875
+ async function scaffoldSourceRepo(options) {
1876
+ const { name, git, withInitialProfile } = options;
1877
+ const targetDir = join(process.cwd(), name);
1878
+ await mkdir(join(targetDir, "profiles"), { recursive: true });
1879
+ const sourceManifest = `name: "${name}"
1880
+ version: "0.1.0"
1881
+ description: "Baton source repository"
1882
+
1883
+ ${withInitialProfile ? `profiles:
1884
+ - name: "default"
1885
+ path: "profiles/default"
1886
+ description: "Default profile configuration"
1887
+ ` : ""}
1888
+ metadata:
1889
+ created: "${(/* @__PURE__ */ new Date()).getFullYear()}"
1890
+ `;
1891
+ await writeFile(join(targetDir, "baton.source.yaml"), sourceManifest);
1892
+ if (withInitialProfile) {
1893
+ const profileDir = join(targetDir, "profiles", "default");
1894
+ await mkdir(profileDir, { recursive: true });
1895
+ await copyDirectory(join(__dirname$1, "..", "src", "templates", "profile", "minimal"), profileDir, { name: "default" });
1896
+ }
1897
+ await generateReadme(targetDir, options);
1898
+ if (git) await initializeGit(targetDir);
1899
+ return targetDir;
1900
+ }
1901
+ const sourceCreateCommand = defineCommand({
1902
+ meta: {
1903
+ name: "source create",
1904
+ description: "Create a new source repository with an interactive wizard"
1905
+ },
1906
+ args: {
1907
+ name: {
1908
+ type: "positional",
1909
+ description: "Name of the source repository (kebab-case)",
1910
+ required: false
1911
+ },
1912
+ yes: {
1913
+ type: "boolean",
1914
+ description: "Skip interactive wizard and use defaults",
1915
+ default: false
1916
+ }
1917
+ },
1918
+ async run({ args }) {
1919
+ const providedName = args.name;
1920
+ const yesArg = args.yes;
1921
+ if (providedName && !KEBAB_CASE_REGEX.test(providedName)) {
1922
+ console.error("Error: Name must be in kebab-case (lowercase, hyphens only)");
1923
+ process.exit(1);
1924
+ }
1925
+ const overrides = { name: providedName || void 0 };
1926
+ if (yesArg) {
1927
+ overrides.name = overrides.name || "my-source";
1928
+ overrides.git = true;
1929
+ overrides.withInitialProfile = false;
1930
+ }
1931
+ const options = await runInteractiveWizard(overrides);
1932
+ const spinner = p.spinner();
1933
+ spinner.start("Creating source repository...");
1934
+ try {
1935
+ const targetDir = await scaffoldSourceRepo(options);
1936
+ spinner.stop(`Source repository created at ${targetDir}`);
1937
+ const features = [];
1938
+ if (options.withInitialProfile) features.push("Initial Profile: profiles/default/");
1939
+ if (options.git) features.push("Git: Initialized with initial commit");
1940
+ if (features.length > 0) p.note(features.join("\n"), "Features");
1941
+ const org = options.name.includes("-") ? options.name.split("-")[0] : options.name;
1942
+ const nextSteps = [];
1943
+ nextSteps.push(` cd ${options.name}`);
1944
+ nextSteps.push(" # Customize your profile (see README.md)");
1945
+ if (options.git) {
1946
+ nextSteps.push(` git remote add origin https://github.com/${org}/${options.name}.git`);
1947
+ nextSteps.push(" git push -u origin main");
1948
+ }
1949
+ nextSteps.push("");
1950
+ nextSteps.push(" # Share with your team:");
1951
+ nextSteps.push(` baton source connect https://github.com/${org}/${options.name}.git`);
1952
+ p.outro(`Source repository "${options.name}" created successfully!\n\nNext steps:\n${nextSteps.join("\n")}`);
1953
+ } catch (error) {
1954
+ spinner.stop("Failed to create source repository");
1955
+ throw error;
1956
+ }
1957
+ }
1958
+ });
1959
+
1960
+ //#endregion
1961
+ //#region src/commands/source/disconnect.ts
1962
+ /**
1963
+ * Command: baton source disconnect
1964
+ *
1965
+ * Disconnects a source repository from the global configuration.
1966
+ * Shows a warning about profiles that depend on this source before removing.
1967
+ */
1968
+ const disconnectCommand = defineCommand({
1969
+ meta: {
1970
+ name: "disconnect",
1971
+ description: "Disconnect a source repository from your global config"
1972
+ },
1973
+ args: { source: {
1974
+ type: "positional",
1975
+ description: "Source name or URL to disconnect",
1976
+ required: true
1977
+ } },
1978
+ async run({ args }) {
1979
+ const sourceIdentifier = args.source;
1980
+ const matchedSource = (await getGlobalSources()).find((s) => s.name === sourceIdentifier || s.url === sourceIdentifier);
1981
+ if (!matchedSource) {
1982
+ p.cancel(`Source "${sourceIdentifier}" not found in global configuration.`);
1983
+ process.exit(1);
1984
+ }
1985
+ p.log.warn(`Disconnecting source "${matchedSource.name}" (${matchedSource.url}) will affect any projects using profiles from this source.`);
1986
+ p.log.info("Projects that reference this source will no longer be able to sync or update their profiles.");
1987
+ const confirmed = await p.confirm({
1988
+ message: `Are you sure you want to disconnect source "${matchedSource.name}"?`,
1989
+ initialValue: false
1990
+ });
1991
+ if (p.isCancel(confirmed) || !confirmed) {
1992
+ p.cancel("Operation cancelled.");
1993
+ process.exit(0);
1994
+ }
1995
+ try {
1996
+ await removeGlobalSource(sourceIdentifier);
1997
+ p.outro(`Disconnected source: ${matchedSource.name}`);
1998
+ } catch (error) {
1999
+ p.cancel(`Failed to disconnect source: ${error.message}`);
2000
+ process.exit(1);
2001
+ }
2002
+ }
2003
+ });
2004
+
2005
+ //#endregion
2006
+ //#region src/commands/source/list.ts
2007
+ /**
2008
+ * Command: baton source list
2009
+ *
2010
+ * Lists all registered global sources from ~/.baton/config.yaml.
2011
+ */
2012
+ const listCommand = defineCommand({
2013
+ meta: {
2014
+ name: "list",
2015
+ description: "List all global sources"
2016
+ },
2017
+ async run() {
2018
+ const sources = await getGlobalSources();
2019
+ if (sources.length === 0) {
2020
+ p.log.info("No global sources configured.");
2021
+ p.note("Add a source with:\n baton source connect <url>", "Tip");
2022
+ return;
2023
+ }
2024
+ console.log("\n🌐 Global Sources\n");
2025
+ console.log("┌──────────────────┬─────────────────────────────────────┬─────────┐");
2026
+ console.log("│ Name │ URL │ Default │");
2027
+ console.log("├──────────────────┼─────────────────────────────────────┼─────────┤");
2028
+ for (const source of sources) {
2029
+ const name = source.name.padEnd(16);
2030
+ const url = truncate(source.url, 35).padEnd(35);
2031
+ const def = source.default ? "✓" : "";
2032
+ console.log(`│ ${name} │ ${url} │ ${def.padEnd(7)} │`);
2033
+ if (source.description) {
2034
+ const desc = ` ${truncate(source.description, 33)}`.padEnd(35);
2035
+ console.log(`│ │ ${desc} │ │`);
2036
+ }
2037
+ }
2038
+ console.log("└──────────────────┴─────────────────────────────────────┴─────────┘\n");
2039
+ }
2040
+ });
2041
+ /**
2042
+ * Truncates a string to the specified length, adding "..." if truncated.
2043
+ */
2044
+ function truncate(str, maxLength) {
2045
+ if (str.length <= maxLength) return str;
2046
+ return `${str.slice(0, maxLength - 3)}...`;
2047
+ }
2048
+
2049
+ //#endregion
2050
+ //#region src/commands/source/index.ts
2051
+ const sourceCommand = defineCommand({
2052
+ meta: {
2053
+ name: "source",
2054
+ description: "Manage source repositories (create, list, connect, disconnect)"
2055
+ },
2056
+ subCommands: {
2057
+ create: sourceCreateCommand,
2058
+ list: listCommand,
2059
+ connect: connectCommand,
2060
+ disconnect: disconnectCommand
2061
+ }
2062
+ });
2063
+
2064
+ //#endregion
2065
+ //#region src/commands/sync.ts
2066
+ const validCategories = [
2067
+ "ai",
2068
+ "files",
2069
+ "ide"
2070
+ ];
2071
+ /** Get or initialize placed files for a profile, avoiding unsafe `as` casts on Map.get(). */
2072
+ function getOrCreatePlacedFiles(map, profileName) {
2073
+ let files = map.get(profileName);
2074
+ if (!files) {
2075
+ files = {};
2076
+ map.set(profileName, files);
2077
+ }
2078
+ return files;
2079
+ }
2080
+ /**
2081
+ * Recursively copy all files from sourceDir to targetDir.
2082
+ * Returns the number of files written (skips identical content).
2083
+ */
2084
+ async function copyDirectoryRecursive(sourceDir, targetDir) {
2085
+ await mkdir(targetDir, { recursive: true });
2086
+ const entries = await readdir(sourceDir, { withFileTypes: true });
2087
+ let placed = 0;
2088
+ for (const entry of entries) {
2089
+ const sourcePath = resolve(sourceDir, entry.name);
2090
+ const targetPath = resolve(targetDir, entry.name);
2091
+ if (entry.isDirectory()) placed += await copyDirectoryRecursive(sourcePath, targetPath);
2092
+ else {
2093
+ const content = await readFile(sourcePath, "utf-8");
2094
+ if (await readFile(targetPath, "utf-8").catch(() => void 0) !== content) {
2095
+ await writeFile(targetPath, content, "utf-8");
2096
+ placed++;
2097
+ }
2098
+ }
2099
+ }
2100
+ return placed;
2101
+ }
2102
+ /**
2103
+ * Collect profile-support-based .gitignore patterns and update .gitignore.
2104
+ * Uses ALL tools the profile supports (not just the developer's intersection)
2105
+ * to ensure consistent .gitignore across all team members.
2106
+ */
2107
+ async function updateGitignorePatterns(params) {
2108
+ const { allIntersections, adapters, ideMap, mergedSkills, mergedRules, mergedMemory, mergedCommandCount, fileMap, projectRoot, spinner } = params;
2109
+ const profileSupportedAiTools = /* @__PURE__ */ new Set();
2110
+ const profileSupportedIdePlatforms = /* @__PURE__ */ new Set();
2111
+ if (allIntersections) for (const intersection of allIntersections.values()) {
2112
+ for (const tool of intersection.aiTools.synced) profileSupportedAiTools.add(tool);
2113
+ for (const tool of intersection.aiTools.unavailable) profileSupportedAiTools.add(tool);
2114
+ for (const plat of intersection.idePlatforms.synced) profileSupportedIdePlatforms.add(plat);
2115
+ for (const plat of intersection.idePlatforms.unavailable) profileSupportedIdePlatforms.add(plat);
2116
+ }
2117
+ else {
2118
+ for (const adapter of adapters) profileSupportedAiTools.add(adapter.key);
2119
+ for (const entry of ideMap.values()) profileSupportedIdePlatforms.add(entry.ideKey);
2120
+ }
2121
+ const hasContent = mergedSkills.length > 0 || mergedRules.length > 0 || mergedMemory.length > 0 || mergedCommandCount > 0;
2122
+ const gitignorePatterns = collectProfileSupportPatterns({
2123
+ profileAiTools: [...profileSupportedAiTools],
2124
+ profileIdePlatforms: [...profileSupportedIdePlatforms],
2125
+ fileTargets: [...fileMap.values()].map((f) => f.target),
2126
+ hasContent
2127
+ });
2128
+ if (gitignorePatterns.length > 0) {
2129
+ spinner.start("Updating .gitignore...");
2130
+ const updated = await updateGitignore(projectRoot, gitignorePatterns);
2131
+ spinner.stop(updated ? "Updated .gitignore with synced patterns" : ".gitignore already up to date");
2132
+ }
2133
+ }
2134
+ /**
2135
+ * Generate and write the baton.lock lockfile from placed files and profile metadata.
2136
+ */
2137
+ async function writeLockData(params) {
2138
+ const { allProfiles, sourceShas, placedFiles, projectRoot, spinner } = params;
2139
+ spinner.start("Updating lockfile...");
2140
+ const lockPackages = {};
2141
+ for (const profile of allProfiles) lockPackages[profile.name] = {
2142
+ source: profile.source,
2143
+ resolved: profile.source,
2144
+ version: profile.manifest.version,
2145
+ sha: sourceShas.get(profile.source) || "unknown",
2146
+ files: placedFiles.get(profile.name) || {}
2147
+ };
2148
+ await writeLock(generateLock(lockPackages), resolve(projectRoot, "baton.lock"));
2149
+ spinner.stop("Lockfile updated");
2150
+ }
2151
+ /**
2152
+ * Detect and remove files that were in the previous lockfile but are no longer
2153
+ * part of the current sync. Cleans up empty parent directories.
2154
+ */
2155
+ async function cleanupOrphanedFiles(params) {
2156
+ const { previousPaths, placedFiles, projectRoot, dryRun, autoYes, spinner } = params;
2157
+ if (previousPaths.size === 0) return;
2158
+ const currentPaths = /* @__PURE__ */ new Set();
2159
+ for (const files of placedFiles.values()) for (const filePath of Object.keys(files)) currentPaths.add(filePath);
2160
+ const orphanedPaths = [...previousPaths].filter((prev) => !currentPaths.has(prev));
2161
+ if (orphanedPaths.length === 0) return;
2162
+ if (dryRun) {
2163
+ p.log.warn(`Would remove ${orphanedPaths.length} orphaned file(s):`);
2164
+ for (const orphanedPath of orphanedPaths) p.log.info(` Removed: ${orphanedPath}`);
2165
+ return;
2166
+ }
2167
+ p.log.warn(`Found ${orphanedPaths.length} orphaned file(s) to remove:`);
2168
+ for (const orphanedPath of orphanedPaths) p.log.info(` Removed: ${orphanedPath}`);
2169
+ let shouldRemove = autoYes;
2170
+ if (!autoYes) {
2171
+ const confirmed = await p.confirm({
2172
+ message: `Remove ${orphanedPaths.length} orphaned file(s)?`,
2173
+ initialValue: true
2174
+ });
2175
+ if (p.isCancel(confirmed)) {
2176
+ p.log.info("Skipped orphan removal.");
2177
+ shouldRemove = false;
2178
+ } else shouldRemove = confirmed;
2179
+ }
2180
+ if (!shouldRemove) {
2181
+ p.log.info("Orphan removal skipped.");
2182
+ return;
2183
+ }
2184
+ spinner.start("Removing orphaned files...");
2185
+ let removedCount = 0;
2186
+ for (const orphanedPath of orphanedPaths) {
2187
+ const absolutePath = orphanedPath.startsWith("/") ? orphanedPath : resolve(projectRoot, orphanedPath);
2188
+ try {
2189
+ await unlink(absolutePath);
2190
+ removedCount++;
2191
+ let dir = dirname(absolutePath);
2192
+ while (dir !== projectRoot && dir.startsWith(projectRoot)) try {
2193
+ if ((await readdir(dir)).length === 0) {
2194
+ await rmdir(dir);
2195
+ dir = dirname(dir);
2196
+ } else break;
2197
+ } catch {
2198
+ break;
2199
+ }
2200
+ } catch {}
2201
+ }
2202
+ spinner.stop(`Removed ${removedCount} orphaned file(s)`);
2203
+ }
2204
+ const syncCommand = defineCommand({
2205
+ meta: {
2206
+ name: "sync",
2207
+ description: "Sync all profiles, skills, agents, and rules to installed AI tools"
2208
+ },
2209
+ args: {
2210
+ "dry-run": {
2211
+ type: "boolean",
2212
+ description: "Show what would be done without writing files",
2213
+ default: false
2214
+ },
2215
+ category: {
2216
+ type: "string",
2217
+ description: "Sync only a specific category: ai, files, or ide",
2218
+ required: false
2219
+ },
2220
+ yes: {
2221
+ type: "boolean",
2222
+ description: "Run non-interactively (no prompts)",
2223
+ default: false
2224
+ },
2225
+ verbose: {
2226
+ type: "boolean",
2227
+ alias: "v",
2228
+ description: "Show detailed output for each placed file",
2229
+ default: false
2230
+ }
2231
+ },
2232
+ async run({ args }) {
2233
+ const dryRun = args["dry-run"];
2234
+ const categoryArg = args.category;
2235
+ const autoYes = args.yes;
2236
+ const verbose = args.verbose;
2237
+ let category;
2238
+ if (categoryArg) {
2239
+ if (!validCategories.includes(categoryArg)) {
2240
+ p.cancel(`Invalid category "${categoryArg}". Valid categories: ${validCategories.join(", ")}`);
2241
+ process.exit(1);
2242
+ }
2243
+ category = categoryArg;
2244
+ }
2245
+ const syncAi = !category || category === "ai";
2246
+ const syncFiles = !category || category === "files";
2247
+ const syncIde = !category || category === "ide";
2248
+ p.intro(category ? `🔄 Baton Sync (category: ${category})` : "🔄 Baton Sync");
2249
+ const stats = {
2250
+ created: 0,
2251
+ errors: 0
2252
+ };
2253
+ try {
2254
+ const projectRoot = process.cwd();
2255
+ const manifestPath = resolve(projectRoot, "baton.yaml");
2256
+ let projectManifest;
2257
+ try {
2258
+ projectManifest = await loadProjectManifest(manifestPath);
2259
+ } catch (error) {
2260
+ if (error instanceof FileNotFoundError) p.cancel("baton.yaml not found. Run `baton init` first.");
2261
+ else p.cancel(`Failed to load baton.yaml: ${error instanceof Error ? error.message : String(error)}`);
2262
+ process.exit(1);
2263
+ }
2264
+ const previousPaths = /* @__PURE__ */ new Set();
2265
+ try {
2266
+ const previousLock = await readLock(resolve(projectRoot, "baton.lock"));
2267
+ for (const pkg of Object.values(previousLock.packages)) for (const filePath of Object.keys(pkg.integrity)) previousPaths.add(filePath);
2268
+ } catch {}
2269
+ const spinner = p.spinner();
2270
+ spinner.start("Resolving profile chain...");
2271
+ const allProfiles = [];
2272
+ const sourceShas = /* @__PURE__ */ new Map();
2273
+ for (const profileSource of projectManifest.profiles || []) try {
2274
+ if (verbose) p.log.info(`Resolving source: ${profileSource.source}`);
2275
+ const parsed = parseSource(profileSource.source);
2276
+ let manifestPath;
2277
+ if (parsed.provider === "local" || parsed.provider === "file") {
2278
+ const absolutePath = parsed.path.startsWith("/") ? parsed.path : resolve(projectRoot, parsed.path);
2279
+ manifestPath = resolve(absolutePath, "baton.profile.yaml");
2280
+ try {
2281
+ const git = simpleGit(absolutePath);
2282
+ await git.checkIsRepo();
2283
+ const sha = await git.revparse(["HEAD"]);
2284
+ sourceShas.set(profileSource.source, sha.trim());
2285
+ } catch {
2286
+ sourceShas.set(profileSource.source, "local");
2287
+ }
2288
+ } else {
2289
+ const url = parsed.provider === "github" || parsed.provider === "gitlab" ? parsed.url : parsed.provider === "git" ? parsed.url : "";
2290
+ if (!url) throw new Error(`Invalid source: ${profileSource.source}`);
2291
+ const cloned = await cloneGitSource({
2292
+ url,
2293
+ ref: profileSource.version,
2294
+ subpath: "subpath" in parsed ? parsed.subpath : void 0,
2295
+ useCache: true
2296
+ });
2297
+ manifestPath = resolve(cloned.localPath, "baton.profile.yaml");
2298
+ sourceShas.set(profileSource.source, cloned.sha);
2299
+ }
2300
+ const manifest = await loadProfileManifest(manifestPath);
2301
+ const profileDir = dirname(manifestPath);
2302
+ const chain = await resolveProfileChain(manifest, profileSource.source, profileDir);
2303
+ allProfiles.push(...chain);
2304
+ } catch (error) {
2305
+ spinner.stop(`Failed to resolve profile ${profileSource.source}: ${error}`);
2306
+ stats.errors++;
2307
+ }
2308
+ if (allProfiles.length === 0) {
2309
+ spinner.stop("No profiles configured");
2310
+ p.outro("Nothing to sync. Run `baton manage` to add a profile.");
2311
+ process.exit(2);
2312
+ }
2313
+ spinner.stop(`Resolved ${allProfiles.length} profile(s)`);
2314
+ const weightSortedProfiles = sortProfilesByWeight(allProfiles);
2315
+ spinner.start("Merging configurations...");
2316
+ const allWeightWarnings = [];
2317
+ const skillsResult = mergeSkillsWithWarnings(weightSortedProfiles);
2318
+ const mergedSkills = skillsResult.skills;
2319
+ allWeightWarnings.push(...skillsResult.warnings);
2320
+ const rulesResult = mergeRulesWithWarnings(weightSortedProfiles);
2321
+ const mergedRules = rulesResult.rules;
2322
+ allWeightWarnings.push(...rulesResult.warnings);
2323
+ const memoryResult = mergeMemoryWithWarnings(weightSortedProfiles);
2324
+ const mergedMemory = memoryResult.entries;
2325
+ allWeightWarnings.push(...memoryResult.warnings);
2326
+ const commandMap = /* @__PURE__ */ new Map();
2327
+ const lockedCommands = /* @__PURE__ */ new Set();
2328
+ const commandOwner = /* @__PURE__ */ new Map();
2329
+ for (const profile of weightSortedProfiles) {
2330
+ const weight = getProfileWeight(profile);
2331
+ const locked = isLockedProfile(profile);
2332
+ for (const cmd of profile.manifest.ai?.commands || []) {
2333
+ if (lockedCommands.has(cmd)) continue;
2334
+ const existing = commandOwner.get(cmd);
2335
+ if (existing && existing.weight === weight && existing.profileName !== profile.name) allWeightWarnings.push({
2336
+ key: cmd,
2337
+ category: "command",
2338
+ profileA: existing.profileName,
2339
+ profileB: profile.name,
2340
+ weight
2341
+ });
2342
+ commandMap.set(cmd, profile.name);
2343
+ commandOwner.set(cmd, {
2344
+ profileName: profile.name,
2345
+ weight
2346
+ });
2347
+ if (locked) lockedCommands.add(cmd);
2348
+ }
2349
+ }
2350
+ const mergedCommandCount = commandMap.size;
2351
+ const fileMap = /* @__PURE__ */ new Map();
2352
+ const lockedFiles = /* @__PURE__ */ new Set();
2353
+ const fileOwner = /* @__PURE__ */ new Map();
2354
+ for (const profile of weightSortedProfiles) {
2355
+ const weight = getProfileWeight(profile);
2356
+ const locked = isLockedProfile(profile);
2357
+ for (const fileConfig of profile.manifest.files || []) {
2358
+ const target = fileConfig.target || fileConfig.source;
2359
+ if (lockedFiles.has(target)) continue;
2360
+ const existing = fileOwner.get(target);
2361
+ if (existing && existing.weight === weight && existing.profileName !== profile.name) allWeightWarnings.push({
2362
+ key: target,
2363
+ category: "file",
2364
+ profileA: existing.profileName,
2365
+ profileB: profile.name,
2366
+ weight
2367
+ });
2368
+ fileMap.set(target, {
2369
+ source: fileConfig.source,
2370
+ target,
2371
+ profileName: profile.name
2372
+ });
2373
+ fileOwner.set(target, {
2374
+ profileName: profile.name,
2375
+ weight
2376
+ });
2377
+ if (locked) lockedFiles.add(target);
2378
+ }
2379
+ }
2380
+ const mergedFileCount = fileMap.size;
2381
+ const ideMap = /* @__PURE__ */ new Map();
2382
+ const lockedIdeConfigs = /* @__PURE__ */ new Set();
2383
+ const ideOwner = /* @__PURE__ */ new Map();
2384
+ for (const profile of weightSortedProfiles) {
2385
+ if (!profile.manifest.ide) continue;
2386
+ const weight = getProfileWeight(profile);
2387
+ const locked = isLockedProfile(profile);
2388
+ for (const [ideKey, files] of Object.entries(profile.manifest.ide)) {
2389
+ if (!files) continue;
2390
+ const targetDir = getIdePlatformTargetDir(ideKey);
2391
+ if (!targetDir) {
2392
+ if (!isKnownIdePlatform(ideKey)) p.log.warn(`Unknown IDE platform "${ideKey}" in profile "${profile.name}" — skipping. Register it in the IDE platform registry.`);
2393
+ continue;
2394
+ }
2395
+ for (const fileName of files) {
2396
+ const targetPath = `${targetDir}/${fileName}`;
2397
+ if (lockedIdeConfigs.has(targetPath)) continue;
2398
+ const existing = ideOwner.get(targetPath);
2399
+ if (existing && existing.weight === weight && existing.profileName !== profile.name) allWeightWarnings.push({
2400
+ key: targetPath,
2401
+ category: "ide",
2402
+ profileA: existing.profileName,
2403
+ profileB: profile.name,
2404
+ weight
2405
+ });
2406
+ ideMap.set(targetPath, {
2407
+ ideKey,
2408
+ fileName,
2409
+ targetDir,
2410
+ profileName: profile.name
2411
+ });
2412
+ ideOwner.set(targetPath, {
2413
+ profileName: profile.name,
2414
+ weight
2415
+ });
2416
+ if (locked) lockedIdeConfigs.add(targetPath);
2417
+ }
2418
+ }
2419
+ }
2420
+ const mergedIdeCount = ideMap.size;
2421
+ spinner.stop(`Merged: ${mergedSkills.length} skills, ${mergedRules.length} rules, ${mergedMemory.length} memory files, ${mergedCommandCount} commands, ${mergedFileCount} files, ${mergedIdeCount} IDE configs`);
2422
+ if (allWeightWarnings.length > 0) for (const w of allWeightWarnings) p.log.warn(`Weight conflict: "${w.profileA}" and "${w.profileB}" both define ${w.category} "${w.key}" with weight ${w.weight}. Last declared wins.`);
2423
+ spinner.start("Computing tool intersection...");
2424
+ const globalAiTools = await getGlobalAiTools();
2425
+ const globalIdePlatforms = await getGlobalIdePlatforms();
2426
+ const detectedAgents = await detectInstalledAgents();
2427
+ let syncedAiTools;
2428
+ let syncedIdePlatforms = null;
2429
+ let allIntersections = null;
2430
+ if (globalAiTools.length > 0) {
2431
+ const developerTools = {
2432
+ aiTools: globalAiTools,
2433
+ idePlatforms: globalIdePlatforms
2434
+ };
2435
+ const aggregatedSyncedAi = /* @__PURE__ */ new Set();
2436
+ const aggregatedSyncedIde = /* @__PURE__ */ new Set();
2437
+ allIntersections = /* @__PURE__ */ new Map();
2438
+ for (const profileSource of projectManifest.profiles || []) try {
2439
+ const intersection = await buildIntersection(profileSource.source, developerTools, projectRoot);
2440
+ if (intersection) {
2441
+ allIntersections.set(profileSource.source, intersection);
2442
+ for (const tool of intersection.aiTools.synced) aggregatedSyncedAi.add(tool);
2443
+ for (const platform of intersection.idePlatforms.synced) aggregatedSyncedIde.add(platform);
2444
+ }
2445
+ } catch {}
2446
+ syncedAiTools = aggregatedSyncedAi.size > 0 ? [...aggregatedSyncedAi] : [];
2447
+ syncedIdePlatforms = [...aggregatedSyncedIde];
2448
+ } else {
2449
+ syncedAiTools = detectedAgents;
2450
+ syncedIdePlatforms = null;
2451
+ if (detectedAgents.length > 0) {
2452
+ p.log.warn("No AI tools configured. Run `baton ai-tools scan` to configure your tools.");
2453
+ p.log.info(`Falling back to detected tools: ${detectedAgents.join(", ")}`);
2454
+ }
2455
+ }
2456
+ if (syncedAiTools.length === 0 && detectedAgents.length === 0) {
2457
+ spinner.stop("No AI tools available");
2458
+ p.cancel("No AI tools found. Install an AI coding tool first.");
2459
+ process.exit(1);
2460
+ }
2461
+ if (syncedAiTools.length === 0) {
2462
+ spinner.stop("No AI tools in intersection");
2463
+ p.cancel("No AI tools match between your configuration and profile support. Run `baton ai-tools scan` or check your profile's supported tools.");
2464
+ process.exit(1);
2465
+ }
2466
+ if (allIntersections) for (const [source, intersection] of allIntersections) if (verbose) {
2467
+ p.log.step(`Intersection for ${source}`);
2468
+ displayIntersection(intersection);
2469
+ } else {
2470
+ const summary = formatIntersectionSummary(intersection);
2471
+ p.log.info(`Syncing for: ${summary}`);
2472
+ }
2473
+ const ideSummary = syncedIdePlatforms && syncedIdePlatforms.length > 0 ? ` | IDE platforms: ${syncedIdePlatforms.join(", ")}` : "";
2474
+ spinner.stop(`Syncing AI tools: ${syncedAiTools.join(", ")}${ideSummary}`);
2475
+ spinner.start("Checking for legacy paths...");
2476
+ const legacyFiles = await detectLegacyPaths(projectRoot);
2477
+ if (legacyFiles.length > 0 && !dryRun) {
2478
+ spinner.stop(`Found ${legacyFiles.length} legacy file(s)`);
2479
+ if (!autoYes) {
2480
+ p.note(`Found legacy configuration files:\n${legacyFiles.map((f) => ` - ${f.legacyPath}`).join("\n")}`, "Legacy Files");
2481
+ p.log.warn("Run migration manually with appropriate action (migrate/copy/skip)");
2482
+ }
2483
+ } else spinner.stop("No legacy files found");
2484
+ spinner.start("Processing configurations...");
2485
+ const adapters = getAdaptersForKeys(syncedAiTools);
2486
+ const placementConfig = {
2487
+ mode: "copy",
2488
+ projectRoot
2489
+ };
2490
+ const placedFiles = /* @__PURE__ */ new Map();
2491
+ const profileLocalPaths = /* @__PURE__ */ new Map();
2492
+ for (const profileSource of projectManifest.profiles || []) {
2493
+ const parsed = parseSource(profileSource.source);
2494
+ if (parsed.provider === "local" || parsed.provider === "file") {
2495
+ const localPath = parsed.path.startsWith("/") ? parsed.path : resolve(projectRoot, parsed.path);
2496
+ for (const prof of allProfiles) if (prof.source === profileSource.source) profileLocalPaths.set(prof.name, localPath);
2497
+ } else if (parsed.provider === "github" || parsed.provider === "gitlab" || parsed.provider === "git") {
2498
+ const cloned = await cloneGitSource({
2499
+ url: parsed.provider === "git" ? parsed.url : parsed.url,
2500
+ ref: profileSource.version,
2501
+ subpath: "subpath" in parsed ? parsed.subpath : void 0,
2502
+ useCache: true
2503
+ });
2504
+ for (const prof of allProfiles) if (prof.source === profileSource.source) profileLocalPaths.set(prof.name, cloned.localPath);
2505
+ }
2506
+ }
2507
+ const contentAccumulator = /* @__PURE__ */ new Map();
2508
+ if (!dryRun && syncAi) for (const adapter of adapters) {
2509
+ if (verbose) p.log.step(`[${adapter.key}] Placing memory files...`);
2510
+ for (const memoryEntry of mergedMemory) try {
2511
+ const contentParts = [];
2512
+ for (const contribution of memoryEntry.contributions) {
2513
+ const profileDir = profileLocalPaths.get(contribution.profileName);
2514
+ if (!profileDir) {
2515
+ spinner.message(`Warning: Could not resolve local path for profile ${contribution.profileName}`);
2516
+ continue;
2517
+ }
2518
+ const memoryFilePath = resolve(profileDir, "ai", "memory", memoryEntry.filename);
2519
+ try {
2520
+ const content = await readFile(memoryFilePath, "utf-8");
2521
+ contentParts.push(content);
2522
+ } catch {
2523
+ spinner.message(`Warning: Could not read ${memoryFilePath}`);
2524
+ }
2525
+ }
2526
+ if (contentParts.length === 0) continue;
2527
+ const mergedContent = mergeContentParts(contentParts, memoryEntry.mergeStrategy);
2528
+ const transformed = adapter.transformMemory({
2529
+ filename: memoryEntry.filename,
2530
+ content: mergedContent
2531
+ });
2532
+ const targetPath = adapter.getPath("memory", "project", transformed.filename);
2533
+ const absolutePath = targetPath.startsWith("/") ? targetPath : resolve(projectRoot, targetPath);
2534
+ const existing = contentAccumulator.get(absolutePath);
2535
+ if (existing) {
2536
+ existing.parts.push(transformed.content);
2537
+ for (const c of memoryEntry.contributions) existing.profiles.add(c.profileName);
2538
+ } else {
2539
+ const profiles = /* @__PURE__ */ new Set();
2540
+ for (const c of memoryEntry.contributions) profiles.add(c.profileName);
2541
+ contentAccumulator.set(absolutePath, {
2542
+ parts: [transformed.content],
2543
+ adapter,
2544
+ type: "memory",
2545
+ name: transformed.filename,
2546
+ profiles
2547
+ });
2548
+ }
2549
+ } catch (error) {
2550
+ spinner.message(`Error placing ${memoryEntry.filename} for ${adapter.name}: ${error}`);
2551
+ stats.errors++;
2552
+ }
2553
+ }
2554
+ if (!dryRun && syncAi) for (const adapter of adapters) {
2555
+ if (verbose) p.log.step(`[${adapter.key}] Placing skills...`);
2556
+ for (const skillItem of mergedSkills) try {
2557
+ const profileDir = profileLocalPaths.get(skillItem.profileName);
2558
+ if (!profileDir) {
2559
+ spinner.message(`Warning: Could not resolve local path for profile ${skillItem.profileName}`);
2560
+ continue;
2561
+ }
2562
+ const skillSourceDir = resolve(profileDir, "ai", "skills", skillItem.name);
2563
+ try {
2564
+ await stat(skillSourceDir);
2565
+ } catch {
2566
+ spinner.message(`Warning: Skill directory not found: ${skillSourceDir}`);
2567
+ continue;
2568
+ }
2569
+ const targetSkillPath = adapter.getPath("skills", skillItem.scope, skillItem.name);
2570
+ const absoluteTargetDir = targetSkillPath.startsWith("/") ? targetSkillPath : resolve(projectRoot, targetSkillPath);
2571
+ const placed = await copyDirectoryRecursive(skillSourceDir, absoluteTargetDir);
2572
+ stats.created += placed;
2573
+ const profileFiles = getOrCreatePlacedFiles(placedFiles, skillItem.profileName);
2574
+ try {
2575
+ profileFiles[targetSkillPath] = {
2576
+ content: await readFile(resolve(skillSourceDir, "index.md"), "utf-8"),
2577
+ tool: adapter.key,
2578
+ category: "ai"
2579
+ };
2580
+ } catch {
2581
+ profileFiles[targetSkillPath] = {
2582
+ content: skillItem.name,
2583
+ tool: adapter.key,
2584
+ category: "ai"
2585
+ };
2586
+ }
2587
+ if (verbose) {
2588
+ const label = placed > 0 ? `${placed} file(s) created` : "unchanged, skipped";
2589
+ p.log.info(` -> ${absoluteTargetDir}/ (${label})`);
2590
+ }
2591
+ } catch (error) {
2592
+ spinner.message(`Error placing skill ${skillItem.name} for ${adapter.name}: ${error}`);
2593
+ stats.errors++;
2594
+ }
2595
+ }
2596
+ if (!dryRun && syncAi) for (const adapter of adapters) {
2597
+ if (verbose) p.log.step(`[${adapter.key}] Placing rules...`);
2598
+ for (const ruleEntry of mergedRules) try {
2599
+ const isUniversal = ruleEntry.agents.length === 0;
2600
+ const isForThisAdapter = ruleEntry.agents.includes(adapter.key);
2601
+ if (!isUniversal && !isForThisAdapter) continue;
2602
+ const profileDir = profileLocalPaths.get(ruleEntry.profileName);
2603
+ if (!profileDir) {
2604
+ spinner.message(`Warning: Could not resolve local path for profile ${ruleEntry.profileName}`);
2605
+ continue;
2606
+ }
2607
+ const ruleSourcePath = resolve(profileDir, "ai", "rules", isUniversal ? "universal" : ruleEntry.agents[0], `${ruleEntry.name}.md`);
2608
+ let rawContent;
2609
+ try {
2610
+ rawContent = await readFile(ruleSourcePath, "utf-8");
2611
+ } catch {
2612
+ spinner.message(`Warning: Could not read rule file: ${ruleSourcePath}`);
2613
+ continue;
2614
+ }
2615
+ const parsed = parseFrontmatter(rawContent);
2616
+ const ruleFile = {
2617
+ name: ruleEntry.name,
2618
+ content: rawContent,
2619
+ frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : void 0
2620
+ };
2621
+ const transformed = adapter.transformRule(ruleFile);
2622
+ const targetPath = adapter.getPath("rules", "project", ruleEntry.name);
2623
+ const absolutePath = targetPath.startsWith("/") ? targetPath : resolve(projectRoot, targetPath);
2624
+ const existing = contentAccumulator.get(absolutePath);
2625
+ if (existing) {
2626
+ existing.parts.push(transformed.content);
2627
+ existing.profiles.add(ruleEntry.profileName);
2628
+ } else contentAccumulator.set(absolutePath, {
2629
+ parts: [transformed.content],
2630
+ adapter,
2631
+ type: "rules",
2632
+ name: ruleEntry.name,
2633
+ profiles: new Set([ruleEntry.profileName])
2634
+ });
2635
+ } catch (error) {
2636
+ spinner.message(`Error placing rule ${ruleEntry.name} for ${adapter.name}: ${error}`);
2637
+ stats.errors++;
2638
+ }
2639
+ }
2640
+ if (!dryRun && syncAi) for (const [absolutePath, entry] of contentAccumulator) try {
2641
+ const combinedContent = entry.parts.join("\n\n");
2642
+ const result = await placeFile(combinedContent, entry.adapter, entry.type, "project", entry.name, placementConfig);
2643
+ if (result.action !== "skipped") stats.created++;
2644
+ for (const profileName of entry.profiles) {
2645
+ const pf = getOrCreatePlacedFiles(placedFiles, profileName);
2646
+ pf[result.path] = {
2647
+ content: combinedContent,
2648
+ tool: entry.adapter.key,
2649
+ category: "ai"
2650
+ };
2651
+ }
2652
+ if (verbose) {
2653
+ const label = result.action === "skipped" ? "unchanged, skipped" : result.action;
2654
+ p.log.info(` -> ${result.path} (${label})`);
2655
+ }
2656
+ } catch (error) {
2657
+ spinner.message(`Error placing accumulated content to ${absolutePath}: ${error}`);
2658
+ stats.errors++;
2659
+ }
2660
+ if (!dryRun && syncAi) for (const adapter of adapters) {
2661
+ if (verbose) p.log.step(`[${adapter.key}] Placing commands...`);
2662
+ for (const profile of allProfiles) {
2663
+ const profileDir = profileLocalPaths.get(profile.name);
2664
+ if (!profileDir) continue;
2665
+ const commandNames = profile.manifest.ai?.commands || [];
2666
+ for (const commandName of commandNames) try {
2667
+ const commandSourcePath = resolve(profileDir, "ai", "commands", `${commandName}.md`);
2668
+ let content;
2669
+ try {
2670
+ content = await readFile(commandSourcePath, "utf-8");
2671
+ } catch {
2672
+ continue;
2673
+ }
2674
+ const result = await placeFile(content, adapter, "commands", "project", commandName, placementConfig);
2675
+ if (result.action !== "skipped") stats.created++;
2676
+ const pf = getOrCreatePlacedFiles(placedFiles, profile.name);
2677
+ pf[result.path] = {
2678
+ content,
2679
+ tool: adapter.key,
2680
+ category: "ai"
2681
+ };
2682
+ if (verbose) {
2683
+ const label = result.action === "skipped" ? "unchanged, skipped" : result.action;
2684
+ p.log.info(` -> ${result.path} (${label})`);
2685
+ }
2686
+ } catch (error) {
2687
+ spinner.message(`Error placing command ${commandName} for ${adapter.name}: ${error}`);
2688
+ stats.errors++;
2689
+ }
2690
+ }
2691
+ }
2692
+ if (!dryRun && syncFiles) for (const fileEntry of fileMap.values()) try {
2693
+ const profileDir = profileLocalPaths.get(fileEntry.profileName);
2694
+ if (!profileDir) continue;
2695
+ const fileSourcePath = resolve(profileDir, "files", fileEntry.source);
2696
+ let content;
2697
+ try {
2698
+ content = await readFile(fileSourcePath, "utf-8");
2699
+ } catch {
2700
+ continue;
2701
+ }
2702
+ const targetPath = resolve(projectRoot, fileEntry.target);
2703
+ await mkdir(dirname(targetPath), { recursive: true });
2704
+ if (await readFile(targetPath, "utf-8").catch(() => void 0) !== content) {
2705
+ await writeFile(targetPath, content, "utf-8");
2706
+ stats.created++;
2707
+ if (verbose) p.log.info(` -> ${fileEntry.target} (created)`);
2708
+ } else if (verbose) p.log.info(` -> ${fileEntry.target} (unchanged, skipped)`);
2709
+ const fpf = getOrCreatePlacedFiles(placedFiles, fileEntry.profileName);
2710
+ fpf[fileEntry.target] = {
2711
+ content,
2712
+ category: "files"
2713
+ };
2714
+ } catch (error) {
2715
+ spinner.message(`Error placing file ${fileEntry.source}: ${error}`);
2716
+ stats.errors++;
2717
+ }
2718
+ if (!dryRun && syncIde) for (const ideEntry of ideMap.values()) try {
2719
+ if (syncedIdePlatforms !== null && !syncedIdePlatforms.includes(ideEntry.ideKey)) {
2720
+ if (verbose) p.log.info(` -> ${ideEntry.targetDir}/${ideEntry.fileName} (skipped — IDE platform "${ideEntry.ideKey}" not in intersection)`);
2721
+ continue;
2722
+ }
2723
+ const profileDir = profileLocalPaths.get(ideEntry.profileName);
2724
+ if (!profileDir) continue;
2725
+ const ideSourcePath = resolve(profileDir, "ide", ideEntry.ideKey, ideEntry.fileName);
2726
+ let content;
2727
+ try {
2728
+ content = await readFile(ideSourcePath, "utf-8");
2729
+ } catch {
2730
+ continue;
2731
+ }
2732
+ const targetPath = resolve(projectRoot, ideEntry.targetDir, ideEntry.fileName);
2733
+ await mkdir(dirname(targetPath), { recursive: true });
2734
+ if (await readFile(targetPath, "utf-8").catch(() => void 0) !== content) {
2735
+ await writeFile(targetPath, content, "utf-8");
2736
+ stats.created++;
2737
+ if (verbose) p.log.info(` -> ${ideEntry.targetDir}/${ideEntry.fileName} (created)`);
2738
+ } else if (verbose) p.log.info(` -> ${ideEntry.targetDir}/${ideEntry.fileName} (unchanged, skipped)`);
2739
+ const ideRelPath = `${ideEntry.targetDir}/${ideEntry.fileName}`;
2740
+ const ipf = getOrCreatePlacedFiles(placedFiles, ideEntry.profileName);
2741
+ ipf[ideRelPath] = {
2742
+ content,
2743
+ tool: ideEntry.ideKey,
2744
+ category: "ide"
2745
+ };
2746
+ } catch (error) {
2747
+ spinner.message(`Error placing IDE config ${ideEntry.fileName}: ${error}`);
2748
+ stats.errors++;
2749
+ }
2750
+ spinner.stop(dryRun ? `Would place files for ${adapters.length} agent(s)` : `Placed ${stats.created} file(s) for ${adapters.length} agent(s)`);
2751
+ if (!dryRun) await updateGitignorePatterns({
2752
+ allIntersections,
2753
+ adapters,
2754
+ ideMap,
2755
+ mergedSkills,
2756
+ mergedRules,
2757
+ mergedMemory,
2758
+ mergedCommandCount,
2759
+ fileMap,
2760
+ projectRoot,
2761
+ spinner
2762
+ });
2763
+ if (!dryRun) await writeLockData({
2764
+ allProfiles,
2765
+ sourceShas,
2766
+ placedFiles,
2767
+ projectRoot,
2768
+ spinner
2769
+ });
2770
+ await cleanupOrphanedFiles({
2771
+ previousPaths,
2772
+ placedFiles,
2773
+ projectRoot,
2774
+ dryRun,
2775
+ autoYes,
2776
+ spinner
2777
+ });
2778
+ if (dryRun) {
2779
+ const parts = [];
2780
+ if (syncAi) {
2781
+ parts.push(` • ${mergedSkills.length} skills`);
2782
+ parts.push(` • ${mergedRules.length} rules`);
2783
+ parts.push(` • ${mergedMemory.length} memory files`);
2784
+ parts.push(` • ${mergedCommandCount} commands`);
2785
+ }
2786
+ if (syncFiles) parts.push(` • ${mergedFileCount} files`);
2787
+ if (syncIde) {
2788
+ const filteredIdeCount = syncedIdePlatforms !== null ? [...ideMap.values()].filter((e) => syncedIdePlatforms.includes(e.ideKey)).length : mergedIdeCount;
2789
+ parts.push(` • ${filteredIdeCount} IDE configs`);
2790
+ }
2791
+ const categoryLabel = category ? ` (category: ${category})` : "";
2792
+ p.outro(`[Dry Run${categoryLabel}] Would sync:\n${parts.join("\n")}\n\nFor ${adapters.length} agent(s): ${syncedAiTools.join(", ")}`);
2793
+ } else {
2794
+ const categoryLabel = category ? ` (category: ${category})` : "";
2795
+ p.outro(`✅ Sync complete${categoryLabel}! Configurations updated.`);
2796
+ }
2797
+ process.exit(stats.errors > 0 ? 1 : 0);
2798
+ } catch (error) {
2799
+ p.cancel(`Sync failed: ${error}`);
2800
+ process.exit(1);
2801
+ }
2802
+ }
2803
+ });
2804
+
2805
+ //#endregion
2806
+ //#region src/commands/update.ts
2807
+ const updateCommand = defineCommand({
2808
+ meta: {
2809
+ name: "update",
2810
+ description: "Check for and apply updates to installed profiles and packages"
2811
+ },
2812
+ args: {
2813
+ "dry-run": {
2814
+ type: "boolean",
2815
+ description: "Show available updates without applying them",
2816
+ default: false
2817
+ },
2818
+ yes: {
2819
+ type: "boolean",
2820
+ description: "Apply all updates without confirmation prompts",
2821
+ default: false
2822
+ }
2823
+ },
2824
+ async run({ args }) {
2825
+ const dryRun = args["dry-run"];
2826
+ const autoConfirm = args.yes;
2827
+ p.intro("Baton Update");
2828
+ const cwd = process.cwd();
2829
+ const manifestPath = resolve(cwd, "baton.yaml");
2830
+ const lockfilePath = resolve(cwd, "baton.lock");
2831
+ const spinner = p.spinner();
2832
+ spinner.start("Loading project configuration");
2833
+ let manifest;
2834
+ try {
2835
+ manifest = await loadProjectManifest(manifestPath);
2836
+ } catch (error) {
2837
+ spinner.stop("Failed to load baton.yaml");
2838
+ p.cancel(error instanceof Error ? error.message : "Could not load project manifest");
2839
+ process.exit(1);
2840
+ }
2841
+ let lockfile = null;
2842
+ try {
2843
+ lockfile = await loadLockfile(lockfilePath);
2844
+ spinner.stop("Configuration loaded");
2845
+ } catch {
2846
+ spinner.stop("Configuration loaded (no lockfile found)");
2847
+ p.note("No lockfile found. Run 'baton sync' first to create one.");
2848
+ }
2849
+ spinner.start("Checking for updates");
2850
+ const updateCandidates = [];
2851
+ for (const profile of manifest.profiles || []) try {
2852
+ const parsed = parseSource(profile.source);
2853
+ if (parsed.provider === "local" || parsed.provider === "file") continue;
2854
+ const packageName = getPackageName(parsed);
2855
+ const currentVersion = lockfile?.packages[packageName]?.version || profile.version || "HEAD";
2856
+ const latestVersion = await getLatestVersion(parsed);
2857
+ if (currentVersion !== latestVersion) {
2858
+ const changes = await getChangeSummary(parsed, currentVersion, latestVersion);
2859
+ updateCandidates.push({
2860
+ name: packageName,
2861
+ source: profile.source,
2862
+ currentVersion,
2863
+ latestVersion,
2864
+ changes
2865
+ });
2866
+ }
2867
+ } catch (error) {
2868
+ if (error instanceof SourceParseError) p.log.warn(`Skipping invalid source: ${profile.source}`);
2869
+ }
2870
+ spinner.stop("Update check complete");
2871
+ if (updateCandidates.length === 0) {
2872
+ p.outro("All packages are up to date!");
2873
+ process.exit(0);
2874
+ }
2875
+ p.note(`Found ${updateCandidates.length} update${updateCandidates.length > 1 ? "s" : ""}`);
2876
+ for (const candidate of updateCandidates) {
2877
+ console.log(`\n📦 ${candidate.name}: ${candidate.currentVersion} → ${candidate.latestVersion}`);
2878
+ if (candidate.changes.length > 0) {
2879
+ console.log(" Changes:");
2880
+ for (const change of candidate.changes) console.log(` - ${change}`);
2881
+ }
2882
+ }
2883
+ if (dryRun) {
2884
+ p.outro("Dry-run mode enabled. No changes were made.\nRun 'baton update' without --dry-run to apply updates.");
2885
+ process.exit(0);
2886
+ }
2887
+ if (!autoConfirm) {
2888
+ const confirmed = await p.confirm({
2889
+ message: `Apply ${updateCandidates.length} update${updateCandidates.length > 1 ? "s" : ""}?`,
2890
+ initialValue: true
2891
+ });
2892
+ if (p.isCancel(confirmed) || !confirmed) {
2893
+ p.cancel("Update cancelled");
2894
+ process.exit(0);
2895
+ }
2896
+ }
2897
+ spinner.start("Applying updates");
2898
+ const updatedPackages = {};
2899
+ for (const candidate of updateCandidates) try {
2900
+ const parsed = parseSource(candidate.source);
2901
+ const url = parsed.provider === "github" || parsed.provider === "gitlab" ? parsed.url : parsed.provider === "git" ? parsed.url : "";
2902
+ if (!url) {
2903
+ spinner.stop("Update failed");
2904
+ p.cancel(`Cannot update local source: ${candidate.name}`);
2905
+ process.exit(1);
2906
+ }
2907
+ const clonedSource = await cloneGitSource({
2908
+ url,
2909
+ ref: candidate.latestVersion,
2910
+ subpath: parsed.provider !== "local" && "subpath" in parsed ? parsed.subpath : void 0
2911
+ });
2912
+ const files = {};
2913
+ updatedPackages[candidate.name] = {
2914
+ source: candidate.source,
2915
+ resolved: url,
2916
+ version: candidate.latestVersion,
2917
+ sha: clonedSource.sha,
2918
+ files
2919
+ };
2920
+ } catch (error) {
2921
+ spinner.stop("Update failed");
2922
+ p.cancel(`Failed to update ${candidate.name}: ${error instanceof Error ? error.message : "Unknown error"}`);
2923
+ process.exit(1);
2924
+ }
2925
+ const newLock = generateLock(updatedPackages);
2926
+ if (lockfile) {
2927
+ lockfile.packages = {
2928
+ ...lockfile.packages,
2929
+ ...newLock.packages
2930
+ };
2931
+ lockfile.locked_at = (/* @__PURE__ */ new Date()).toISOString();
2932
+ await writeLock(lockfile, lockfilePath);
2933
+ } else await writeLock(newLock, lockfilePath);
2934
+ spinner.stop("Updates applied successfully");
2935
+ p.outro(`✅ Updated ${updateCandidates.length} package${updateCandidates.length > 1 ? "s" : ""}!\n\nRun 'baton sync' to apply the updated configurations.`);
2936
+ process.exit(0);
2937
+ }
2938
+ });
2939
+ function getPackageName(parsed) {
2940
+ if (parsed.provider === "local" || parsed.provider === "file") return parsed.path;
2941
+ if (parsed.provider === "github" || parsed.provider === "gitlab") return `${parsed.org}/${parsed.repo}`;
2942
+ if (parsed.provider === "npm") return parsed.scope ? `${parsed.scope}/${parsed.package}` : parsed.package;
2943
+ if (parsed.provider === "git") return parsed.url;
2944
+ return "unknown";
2945
+ }
2946
+ async function getLatestVersion(parsed) {
2947
+ if (parsed.provider === "local") return "local";
2948
+ const url = parsed.provider === "github" || parsed.provider === "gitlab" ? parsed.url : parsed.provider === "git" ? parsed.url : "";
2949
+ if (!url) return "HEAD";
2950
+ return await resolveVersion(url, "latest");
2951
+ }
2952
+ async function getChangeSummary(parsed, fromVersion, toVersion) {
2953
+ try {
2954
+ const url = parsed.provider === "github" || parsed.provider === "gitlab" ? parsed.url : parsed.provider === "git" ? parsed.url : "";
2955
+ if (!url) return [`Updated from ${fromVersion} to ${toVersion}`];
2956
+ const clonedSource = await cloneGitSource({
2957
+ url,
2958
+ ref: toVersion,
2959
+ subpath: parsed.provider !== "local" && "subpath" in parsed ? parsed.subpath : void 0
2960
+ });
2961
+ const simpleGit = (await import("simple-git")).default;
2962
+ return (await simpleGit(clonedSource.localPath).log({
2963
+ from: fromVersion,
2964
+ to: toVersion,
2965
+ maxCount: 5
2966
+ })).all.map((commit) => commit.message.split("\n")[0]);
2967
+ } catch {
2968
+ return [`Updated from ${fromVersion} to ${toVersion}`];
2969
+ }
2970
+ }
2971
+
2972
+ //#endregion
2973
+ //#region src/index.ts
2974
+ const __dirname = dirname(fileURLToPath(import.meta.url));
2975
+ let packageJson = {};
2976
+ try {
2977
+ packageJson = JSON.parse(await readFile(join(__dirname, "../package.json"), "utf-8"));
2978
+ } catch {}
2979
+ runMain(defineCommand({
2980
+ meta: {
2981
+ name: "baton",
2982
+ version: packageJson.version,
2983
+ description: "The package manager for Developer Experience & AI configuration. Manages Skills, Rules, Agents, Memory Files as versioned profiles."
2984
+ },
2985
+ args: {
2986
+ version: {
2987
+ type: "boolean",
2988
+ alias: "v",
2989
+ description: "Show version number"
2990
+ },
2991
+ yes: {
2992
+ type: "boolean",
2993
+ alias: "y",
2994
+ description: "Suppress all interactive prompts (non-interactive mode)"
2995
+ },
2996
+ "dry-run": {
2997
+ type: "boolean",
2998
+ description: "Show what would be done without writing any files"
2999
+ },
3000
+ verbose: {
3001
+ type: "boolean",
3002
+ description: "Enable debug logging"
3003
+ }
3004
+ },
3005
+ subCommands: {
3006
+ init: initCommand,
3007
+ sync: syncCommand,
3008
+ update: updateCommand,
3009
+ diff: diffCommand,
3010
+ manage: manageCommand,
3011
+ config: configCommand,
3012
+ source: sourceCommand,
3013
+ profile: profileCommand,
3014
+ "ai-tools": aiToolsCommand,
3015
+ ides: idesCommand
3016
+ },
3017
+ run({ args }) {
3018
+ if (Object.keys(args).length === 0) {
3019
+ console.log(`baton v${packageJson.version}`);
3020
+ console.log("");
3021
+ console.log("The package manager for Developer Experience & AI configuration.");
3022
+ console.log("");
3023
+ console.log("Usage:");
3024
+ console.log(" baton <command> [options]");
3025
+ console.log("");
3026
+ console.log("Available commands:");
3027
+ console.log(" init Initialize Baton in your project");
3028
+ console.log(" sync Sync all configurations to installed AI tools");
3029
+ console.log(" update Check for and apply updates to installed packages");
3030
+ console.log(" diff Compare local files with remote source versions");
3031
+ console.log(" manage Interactive project management wizard");
3032
+ console.log(" config Show dashboard overview or configure settings");
3033
+ console.log("");
3034
+ console.log("Resource commands:");
3035
+ console.log(" source Manage source repositories (create, list, connect, disconnect)");
3036
+ console.log(" profile Manage profiles (create, list, remove)");
3037
+ console.log(" ai-tools Manage AI tool detection and configuration");
3038
+ console.log(" ides Manage IDE platform detection and configuration");
3039
+ console.log("");
3040
+ console.log("Global Options:");
3041
+ console.log(" --help, -h Show this help message");
3042
+ console.log(" --version, -v Show version number");
3043
+ console.log(" --yes, -y Suppress all interactive prompts");
3044
+ console.log(" --dry-run Show what would be done without writing files");
3045
+ console.log(" --verbose Enable debug logging");
3046
+ return;
3047
+ }
3048
+ }
3049
+ }));
3050
+
3051
+ //#endregion
3052
+ export { };
3053
+ //# sourceMappingURL=index.mjs.map