@codebyplan/cli 3.0.3 → 3.2.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.
Files changed (2) hide show
  1. package/dist/cli.js +415 -56
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -37,7 +37,7 @@ var VERSION, PACKAGE_NAME;
37
37
  var init_version = __esm({
38
38
  "src/lib/version.ts"() {
39
39
  "use strict";
40
- VERSION = "3.0.3";
40
+ VERSION = "3.2.0";
41
41
  PACKAGE_NAME = "@codebyplan/cli";
42
42
  }
43
43
  });
@@ -465,6 +465,7 @@ async function executeSyncToLocal(options) {
465
465
  const worktree = await isGitWorktree(projectPath);
466
466
  const byType = {};
467
467
  const totals = { created: 0, updated: 0, deleted: 0, unchanged: 0 };
468
+ const dbOnlyFiles = [];
468
469
  for (const [syncKey, typeName] of Object.entries(syncKeyToType)) {
469
470
  if (worktree && typeName === "command") {
470
471
  byType["commands"] = { created: [], updated: [], deleted: [], unchanged: [] };
@@ -489,6 +490,13 @@ async function executeSyncToLocal(options) {
489
490
  const fullPath = join2(targetDir, relPath);
490
491
  const localContent = localFiles.get(relPath);
491
492
  if (localContent === void 0) {
493
+ const remoteFile = remoteFiles.find((f) => f.name === name);
494
+ dbOnlyFiles.push({
495
+ type: typeName,
496
+ name,
497
+ category: remoteFile?.category ?? null,
498
+ localPath: fullPath
499
+ });
492
500
  if (!dryRun) {
493
501
  await mkdir(dirname(fullPath), { recursive: true });
494
502
  await writeFile(fullPath, content, "utf-8");
@@ -659,7 +667,7 @@ async function executeSyncToLocal(options) {
659
667
  if (!dryRun) {
660
668
  await apiPut(`/repos/${repoId}`, { claude_sync_at: (/* @__PURE__ */ new Date()).toISOString() });
661
669
  }
662
- return { byType, totals };
670
+ return { byType, totals, dbOnlyFiles };
663
671
  }
664
672
  var typeConfig, syncKeyToType;
665
673
  var init_sync_engine = __esm({
@@ -1055,6 +1063,90 @@ async function confirmProceed(message) {
1055
1063
  rl.close();
1056
1064
  }
1057
1065
  }
1066
+ function parseReviewAction(input, fallback) {
1067
+ const a = input.trim().toLowerCase();
1068
+ switch (a) {
1069
+ case "d":
1070
+ case "delete":
1071
+ return { action: "delete", all: false };
1072
+ case "p":
1073
+ case "pull":
1074
+ return { action: "pull", all: false };
1075
+ case "s":
1076
+ case "push":
1077
+ return { action: "push", all: false };
1078
+ case "k":
1079
+ case "skip":
1080
+ return { action: "skip", all: false };
1081
+ case "da":
1082
+ return { action: "delete", all: true };
1083
+ case "pa":
1084
+ return { action: "pull", all: true };
1085
+ case "sa":
1086
+ return { action: "push", all: true };
1087
+ case "ka":
1088
+ return { action: "skip", all: true };
1089
+ case "":
1090
+ return { action: fallback, all: false };
1091
+ default:
1092
+ return { action: fallback, all: false };
1093
+ }
1094
+ }
1095
+ async function promptReviewMode() {
1096
+ const rl = createInterface2({ input: stdin2, output: stdout2 });
1097
+ try {
1098
+ const answer = await rl.question(" Review [o]ne-by-one or [f]older-by-folder? ");
1099
+ const a = answer.trim().toLowerCase();
1100
+ return a === "f" || a === "folder" ? "folder" : "file";
1101
+ } finally {
1102
+ rl.close();
1103
+ }
1104
+ }
1105
+ async function reviewFilesOneByOne(items, label, plannedAction) {
1106
+ const rl = createInterface2({ input: stdin2, output: stdout2 });
1107
+ const results = [];
1108
+ try {
1109
+ let applyAll = null;
1110
+ for (const item of items) {
1111
+ if (applyAll) {
1112
+ results.push(applyAll);
1113
+ continue;
1114
+ }
1115
+ const planned = plannedAction(item);
1116
+ const answer = await rl.question(
1117
+ ` ${label(item)} (${planned}) \u2014 [d]elete [p]ull pu[s]h s[k]ip: `
1118
+ );
1119
+ const { action, all } = parseReviewAction(answer, planned);
1120
+ results.push(action);
1121
+ if (all) applyAll = action;
1122
+ }
1123
+ } finally {
1124
+ rl.close();
1125
+ }
1126
+ return results;
1127
+ }
1128
+ async function reviewFolder(folderName, items, label, plannedAction) {
1129
+ console.log(`
1130
+ ${folderName} (${items.length} files):`);
1131
+ for (const item of items) {
1132
+ console.log(` ${label(item)} (${plannedAction(item)})`);
1133
+ }
1134
+ const rl = createInterface2({ input: stdin2, output: stdout2 });
1135
+ let answer;
1136
+ try {
1137
+ answer = await rl.question(
1138
+ ` Action for all: [d]elete [p]ull pu[s]h s[k]ip [o]ne-by-one: `
1139
+ );
1140
+ } finally {
1141
+ rl.close();
1142
+ }
1143
+ const a = answer.trim().toLowerCase();
1144
+ if (a === "o" || a === "one-by-one") {
1145
+ return reviewFilesOneByOne(items, label, plannedAction);
1146
+ }
1147
+ const { action } = parseReviewAction(a, "skip");
1148
+ return items.map(() => action);
1149
+ }
1058
1150
  var init_confirm = __esm({
1059
1151
  "src/cli/confirm.ts"() {
1060
1152
  "use strict";
@@ -1062,7 +1154,7 @@ var init_confirm = __esm({
1062
1154
  });
1063
1155
 
1064
1156
  // src/lib/tech-detect.ts
1065
- import { readFile as readFile6, access } from "node:fs/promises";
1157
+ import { readFile as readFile6, access, readdir as readdir4 } from "node:fs/promises";
1066
1158
  import { join as join6 } from "node:path";
1067
1159
  async function fileExists(filePath) {
1068
1160
  try {
@@ -1072,15 +1164,56 @@ async function fileExists(filePath) {
1072
1164
  return false;
1073
1165
  }
1074
1166
  }
1075
- async function detectTechStack(projectPath) {
1167
+ async function discoverMonorepoApps(projectPath) {
1168
+ const apps = [];
1169
+ const patterns = [];
1170
+ try {
1171
+ const raw = await readFile6(join6(projectPath, "pnpm-workspace.yaml"), "utf-8");
1172
+ const matches = raw.match(/^\s*-\s*['"]?([^'"#\n]+)['"]?/gm);
1173
+ if (matches) {
1174
+ for (const m of matches) {
1175
+ const pattern = m.replace(/^\s*-\s*['"]?/, "").replace(/['"]?\s*$/, "").trim();
1176
+ if (pattern) patterns.push(pattern);
1177
+ }
1178
+ }
1179
+ } catch {
1180
+ }
1181
+ if (patterns.length === 0) {
1182
+ try {
1183
+ const raw = await readFile6(join6(projectPath, "package.json"), "utf-8");
1184
+ const pkg = JSON.parse(raw);
1185
+ const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
1186
+ if (ws) patterns.push(...ws);
1187
+ } catch {
1188
+ }
1189
+ }
1190
+ for (const pattern of patterns) {
1191
+ if (pattern.endsWith("/*")) {
1192
+ const dir = pattern.slice(0, -2);
1193
+ const absDir = join6(projectPath, dir);
1194
+ try {
1195
+ const entries = await readdir4(absDir, { withFileTypes: true });
1196
+ for (const entry of entries) {
1197
+ if (entry.isDirectory()) {
1198
+ const relPath = join6(dir, entry.name);
1199
+ const absPath = join6(absDir, entry.name);
1200
+ if (await fileExists(join6(absPath, "package.json"))) {
1201
+ apps.push({ name: entry.name, path: relPath, absPath });
1202
+ }
1203
+ }
1204
+ }
1205
+ } catch {
1206
+ }
1207
+ }
1208
+ }
1209
+ return apps;
1210
+ }
1211
+ async function detectFromDirectory(dirPath) {
1076
1212
  const seen = /* @__PURE__ */ new Map();
1077
1213
  try {
1078
- const raw = await readFile6(join6(projectPath, "package.json"), "utf-8");
1214
+ const raw = await readFile6(join6(dirPath, "package.json"), "utf-8");
1079
1215
  const pkg = JSON.parse(raw);
1080
- const allDeps = {
1081
- ...pkg.dependencies,
1082
- ...pkg.devDependencies
1083
- };
1216
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1084
1217
  for (const depName of Object.keys(allDeps)) {
1085
1218
  const rule = PACKAGE_MAP[depName];
1086
1219
  if (rule) {
@@ -1088,13 +1221,23 @@ async function detectTechStack(projectPath) {
1088
1221
  if (!seen.has(key)) {
1089
1222
  seen.set(key, { name: rule.name, category: rule.category });
1090
1223
  }
1224
+ continue;
1225
+ }
1226
+ for (const { prefix, rule: prefixRule } of PACKAGE_PREFIX_MAP) {
1227
+ if (depName.startsWith(prefix)) {
1228
+ const key = prefixRule.name.toLowerCase();
1229
+ if (!seen.has(key)) {
1230
+ seen.set(key, { name: prefixRule.name, category: prefixRule.category });
1231
+ }
1232
+ break;
1233
+ }
1091
1234
  }
1092
1235
  }
1093
1236
  } catch {
1094
1237
  }
1095
1238
  for (const { file, rule } of CONFIG_FILE_MAP) {
1096
1239
  const key = rule.name.toLowerCase();
1097
- if (!seen.has(key) && await fileExists(join6(projectPath, file))) {
1240
+ if (!seen.has(key) && await fileExists(join6(dirPath, file))) {
1098
1241
  seen.set(key, { name: rule.name, category: rule.category });
1099
1242
  }
1100
1243
  }
@@ -1104,33 +1247,95 @@ async function detectTechStack(projectPath) {
1104
1247
  return a.name.localeCompare(b.name);
1105
1248
  });
1106
1249
  }
1250
+ async function detectTechStack(projectPath) {
1251
+ const repo = await detectFromDirectory(projectPath);
1252
+ const discoveredApps = await discoverMonorepoApps(projectPath);
1253
+ const apps = [];
1254
+ for (const app of discoveredApps) {
1255
+ const stack = await detectFromDirectory(app.absPath);
1256
+ if (stack.length > 0) {
1257
+ apps.push({ name: app.name, path: app.path, stack });
1258
+ }
1259
+ }
1260
+ const flatMap = /* @__PURE__ */ new Map();
1261
+ for (const entry of repo) {
1262
+ flatMap.set(entry.name.toLowerCase(), entry);
1263
+ }
1264
+ for (const app of apps) {
1265
+ for (const entry of app.stack) {
1266
+ const key = entry.name.toLowerCase();
1267
+ if (!flatMap.has(key)) {
1268
+ flatMap.set(key, entry);
1269
+ }
1270
+ }
1271
+ }
1272
+ const flat = Array.from(flatMap.values()).sort((a, b) => {
1273
+ const catCmp = a.category.localeCompare(b.category);
1274
+ if (catCmp !== 0) return catCmp;
1275
+ return a.name.localeCompare(b.name);
1276
+ });
1277
+ return { repo, apps, flat };
1278
+ }
1107
1279
  function mergeTechStack(remote, detected) {
1280
+ const remoteResult = Array.isArray(remote) ? { repo: remote, apps: [], flat: remote } : remote;
1108
1281
  const seen = /* @__PURE__ */ new Map();
1109
- for (const entry of remote) {
1282
+ for (const entry of remoteResult.flat) {
1110
1283
  seen.set(entry.name.toLowerCase(), entry);
1111
1284
  }
1112
1285
  const added = [];
1113
- for (const entry of detected) {
1286
+ for (const entry of detected.flat) {
1114
1287
  const key = entry.name.toLowerCase();
1115
1288
  if (!seen.has(key)) {
1116
1289
  seen.set(key, entry);
1117
1290
  added.push(entry);
1118
1291
  }
1119
1292
  }
1120
- const merged = Array.from(seen.values()).sort((a, b) => {
1293
+ const flat = Array.from(seen.values()).sort((a, b) => {
1121
1294
  const catCmp = a.category.localeCompare(b.category);
1122
1295
  if (catCmp !== 0) return catCmp;
1123
1296
  return a.name.localeCompare(b.name);
1124
1297
  });
1298
+ const merged = {
1299
+ repo: detected.repo,
1300
+ apps: detected.apps,
1301
+ flat
1302
+ };
1125
1303
  return { merged, added };
1126
1304
  }
1127
1305
  function parseTechStack(raw) {
1306
+ if (Array.isArray(raw)) {
1307
+ return raw.filter(
1308
+ (item) => typeof item === "object" && item !== null && typeof item.name === "string" && typeof item.category === "string"
1309
+ );
1310
+ }
1311
+ if (typeof raw === "object" && raw !== null && "flat" in raw) {
1312
+ return parseTechStack(raw.flat);
1313
+ }
1314
+ return [];
1315
+ }
1316
+ function parseAppTechStacks(raw) {
1128
1317
  if (!Array.isArray(raw)) return [];
1129
1318
  return raw.filter(
1130
- (item) => typeof item === "object" && item !== null && typeof item.name === "string" && typeof item.category === "string"
1131
- );
1319
+ (item) => typeof item === "object" && item !== null && typeof item.name === "string" && typeof item.path === "string" && Array.isArray(item.stack)
1320
+ ).map((item) => ({
1321
+ name: item.name,
1322
+ path: item.path,
1323
+ stack: parseTechStack(item.stack)
1324
+ }));
1325
+ }
1326
+ function parseTechStackResult(raw) {
1327
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && "flat" in raw) {
1328
+ const obj = raw;
1329
+ return {
1330
+ repo: parseTechStack(obj.repo),
1331
+ apps: parseAppTechStacks(obj.apps),
1332
+ flat: parseTechStack(obj.flat)
1333
+ };
1334
+ }
1335
+ const flat = parseTechStack(raw);
1336
+ return { repo: flat, apps: [], flat };
1132
1337
  }
1133
- var PACKAGE_MAP, CONFIG_FILE_MAP;
1338
+ var PACKAGE_MAP, PACKAGE_PREFIX_MAP, CONFIG_FILE_MAP;
1134
1339
  var init_tech_detect = __esm({
1135
1340
  "src/lib/tech-detect.ts"() {
1136
1341
  "use strict";
@@ -1173,17 +1378,50 @@ var init_tech_detect = __esm({
1173
1378
  playwright: { name: "Playwright", category: "testing" },
1174
1379
  "@playwright/test": { name: "Playwright", category: "testing" },
1175
1380
  cypress: { name: "Cypress", category: "testing" },
1381
+ supertest: { name: "Supertest", category: "testing" },
1176
1382
  // Build tools
1177
1383
  turbo: { name: "Turborepo", category: "build" },
1178
1384
  vite: { name: "Vite", category: "build" },
1179
1385
  webpack: { name: "Webpack", category: "build" },
1180
1386
  esbuild: { name: "esbuild", category: "build" },
1181
1387
  rollup: { name: "Rollup", category: "build" },
1388
+ nx: { name: "Nx", category: "build" },
1389
+ lerna: { name: "Lerna", category: "build" },
1390
+ tsup: { name: "tsup", category: "build" },
1391
+ "@swc/core": { name: "SWC", category: "build" },
1392
+ parcel: { name: "Parcel", category: "build" },
1182
1393
  // Tools
1183
1394
  eslint: { name: "ESLint", category: "tool" },
1184
1395
  prettier: { name: "Prettier", category: "tool" },
1185
- "@biomejs/biome": { name: "Biome", category: "tool" }
1186
- };
1396
+ "@biomejs/biome": { name: "Biome", category: "tool" },
1397
+ storybook: { name: "Storybook", category: "tool" },
1398
+ // Component libs
1399
+ "@mui/material": { name: "MUI", category: "component-lib" },
1400
+ "@chakra-ui/react": { name: "Chakra UI", category: "component-lib" },
1401
+ "@mantine/core": { name: "Mantine", category: "component-lib" },
1402
+ // GraphQL
1403
+ graphql: { name: "GraphQL", category: "graphql" },
1404
+ "@apollo/client": { name: "Apollo Client", category: "graphql" },
1405
+ urql: { name: "urql", category: "graphql" },
1406
+ "graphql-request": { name: "graphql-request", category: "graphql" },
1407
+ // Documentation
1408
+ typedoc: { name: "TypeDoc", category: "documentation" },
1409
+ "@docusaurus/core": { name: "Docusaurus", category: "documentation" },
1410
+ vitepress: { name: "VitePress", category: "documentation" },
1411
+ // Code quality
1412
+ husky: { name: "Husky", category: "quality" },
1413
+ "lint-staged": { name: "lint-staged", category: "quality" },
1414
+ commitlint: { name: "commitlint", category: "quality" },
1415
+ "@commitlint/cli": { name: "commitlint", category: "quality" },
1416
+ // Mobile
1417
+ "react-native": { name: "React Native", category: "mobile" },
1418
+ expo: { name: "Expo", category: "mobile" }
1419
+ };
1420
+ PACKAGE_PREFIX_MAP = [
1421
+ { prefix: "@radix-ui/", rule: { name: "Radix UI", category: "component-lib" } },
1422
+ { prefix: "@storybook/", rule: { name: "Storybook", category: "tool" } },
1423
+ { prefix: "@testing-library/", rule: { name: "Testing Library", category: "testing" } }
1424
+ ];
1187
1425
  CONFIG_FILE_MAP = [
1188
1426
  { file: "tsconfig.json", rule: { name: "TypeScript", category: "language" } },
1189
1427
  { file: "next.config.js", rule: { name: "Next.js", category: "framework" } },
@@ -1195,7 +1433,13 @@ var init_tech_detect = __esm({
1195
1433
  { file: "docker-compose.yml", rule: { name: "Docker", category: "deployment" } },
1196
1434
  { file: "docker-compose.yaml", rule: { name: "Docker", category: "deployment" } },
1197
1435
  { file: "Dockerfile", rule: { name: "Docker", category: "deployment" } },
1198
- { file: "vercel.json", rule: { name: "Vercel", category: "deployment" } }
1436
+ { file: "vercel.json", rule: { name: "Vercel", category: "deployment" } },
1437
+ { file: ".storybook/main.js", rule: { name: "Storybook", category: "tool" } },
1438
+ { file: ".storybook/main.ts", rule: { name: "Storybook", category: "tool" } },
1439
+ { file: ".storybook/main.mjs", rule: { name: "Storybook", category: "tool" } },
1440
+ { file: "components.json", rule: { name: "shadcn/ui", category: "component-lib" } },
1441
+ { file: "nx.json", rule: { name: "Nx", category: "build" } },
1442
+ { file: "lerna.json", rule: { name: "Lerna", category: "build" } }
1199
1443
  ];
1200
1444
  }
1201
1445
  });
@@ -1205,7 +1449,7 @@ var sync_exports = {};
1205
1449
  __export(sync_exports, {
1206
1450
  runSync: () => runSync
1207
1451
  });
1208
- import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir2, chmod as chmod2 } from "node:fs/promises";
1452
+ import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir2, chmod as chmod2, unlink as unlink2 } from "node:fs/promises";
1209
1453
  import { join as join7, dirname as dirname2 } from "node:path";
1210
1454
  async function runSync() {
1211
1455
  const flags = parseFlags(3);
@@ -1252,7 +1496,7 @@ async function runSync() {
1252
1496
  localContent: local.content,
1253
1497
  remoteContent: null,
1254
1498
  pushContent: reverseSubstituteVariables(local.content, repoData),
1255
- filePath: null,
1499
+ filePath: getLocalFilePath(claudeDir, projectPath, { type: local.type, name: local.name, category: local.category }),
1256
1500
  type: local.type,
1257
1501
  name: local.name,
1258
1502
  category: local.category,
@@ -1295,7 +1539,7 @@ async function runSync() {
1295
1539
  action,
1296
1540
  localContent: local.content,
1297
1541
  remoteContent: resolvedRemote,
1298
- pushContent: action === "push" ? reverseSubstituteVariables(local.content, repoData) : null,
1542
+ pushContent: reverseSubstituteVariables(local.content, repoData),
1299
1543
  filePath: getLocalFilePath(claudeDir, projectPath, remote),
1300
1544
  type: local.type,
1301
1545
  name: local.name,
@@ -1306,48 +1550,106 @@ async function runSync() {
1306
1550
  }
1307
1551
  const pulls = plan.filter((p) => p.action === "pull");
1308
1552
  const pushes = plan.filter((p) => p.action === "push");
1309
- if (pulls.length > 0) {
1310
- console.log(` Pull (DB \u2192 local): ${pulls.length}`);
1311
- for (const p of pulls) console.log(` \u2193 ${p.displayPath}`);
1553
+ const remoteOnly = plan.filter((p) => p.action === "pull" && p.localContent === null);
1554
+ const contentPulls = pulls.filter((p) => p.localContent !== null);
1555
+ if (contentPulls.length > 0) {
1556
+ console.log(` Pull (DB \u2192 local): ${contentPulls.length}`);
1557
+ for (const p of contentPulls) console.log(` \u2193 ${p.displayPath}`);
1312
1558
  }
1313
1559
  if (pushes.length > 0) {
1314
1560
  console.log(` Push (local \u2192 DB): ${pushes.length}`);
1315
1561
  for (const p of pushes) console.log(` \u2191 ${p.displayPath}`);
1316
1562
  }
1317
- if (pulls.length === 0 && pushes.length === 0) {
1563
+ if (remoteOnly.length > 0) {
1564
+ console.log(`
1565
+ DB-only (not on disk): ${remoteOnly.length}`);
1566
+ for (const p of remoteOnly) console.log(` \u2715 ${p.displayPath}`);
1567
+ }
1568
+ if (contentPulls.length === 0 && pushes.length === 0 && remoteOnly.length === 0) {
1318
1569
  console.log(" All .claude/ files in sync.");
1319
1570
  }
1320
1571
  if (plan.length > 0 && !dryRun) {
1321
- if (!force && pulls.length + pushes.length > 0) {
1322
- console.log();
1323
- const confirmed = await confirmProceed();
1324
- if (!confirmed) {
1325
- console.log(" Cancelled.\n");
1326
- return;
1572
+ if (!force) {
1573
+ const agreed = await confirmProceed(`
1574
+ Agree with sync? [Y/n] `);
1575
+ if (!agreed) {
1576
+ const mode = await promptReviewMode();
1577
+ if (mode === "file") {
1578
+ const actions = await reviewFilesOneByOne(
1579
+ plan,
1580
+ (p) => p.displayPath,
1581
+ (p) => p.action
1582
+ );
1583
+ for (let i = 0; i < plan.length; i++) {
1584
+ plan[i].action = actions[i];
1585
+ }
1586
+ } else {
1587
+ const groups = groupByType(plan);
1588
+ for (const [typeName, items] of groups) {
1589
+ const actions = await reviewFolder(
1590
+ typeName,
1591
+ items,
1592
+ (p) => p.displayPath,
1593
+ (p) => p.action
1594
+ );
1595
+ for (let i = 0; i < items.length; i++) {
1596
+ items[i].action = actions[i];
1597
+ }
1598
+ }
1599
+ }
1327
1600
  }
1328
1601
  }
1329
- for (const p of pulls) {
1330
- if (p.filePath && p.remoteContent !== null) {
1331
- await mkdir2(dirname2(p.filePath), { recursive: true });
1332
- await writeFile3(p.filePath, p.remoteContent, "utf-8");
1333
- if (p.isHook) await chmod2(p.filePath, 493);
1602
+ const toPull = plan.filter((p) => p.action === "pull");
1603
+ const toPush = plan.filter((p) => p.action === "push");
1604
+ const toDelete = plan.filter((p) => p.action === "delete");
1605
+ const skipped = plan.filter((p) => p.action === "skip");
1606
+ if (toPull.length + toPush.length + toDelete.length === 0) {
1607
+ console.log("\n All items skipped \u2014 no changes applied.");
1608
+ } else {
1609
+ for (const p of toPull) {
1610
+ if (p.filePath && p.remoteContent !== null) {
1611
+ await mkdir2(dirname2(p.filePath), { recursive: true });
1612
+ await writeFile3(p.filePath, p.remoteContent, "utf-8");
1613
+ if (p.isHook) await chmod2(p.filePath, 493);
1614
+ }
1615
+ }
1616
+ const toUpsert = toPush.filter((p) => p.pushContent !== null).map((p) => ({
1617
+ type: p.type,
1618
+ name: p.name,
1619
+ category: p.category,
1620
+ content: p.pushContent
1621
+ }));
1622
+ if (toUpsert.length > 0) {
1623
+ await apiPost("/sync/files", {
1624
+ repo_id: repoId,
1625
+ files: toUpsert
1626
+ });
1334
1627
  }
1628
+ if (toDelete.length > 0) {
1629
+ const deleteKeys = toDelete.map((p) => ({
1630
+ type: p.type,
1631
+ name: p.name,
1632
+ category: p.category
1633
+ }));
1634
+ await apiPost("/sync/files", {
1635
+ repo_id: repoId,
1636
+ delete_keys: deleteKeys
1637
+ });
1638
+ for (const p of toDelete) {
1639
+ if (p.filePath) {
1640
+ try {
1641
+ await unlink2(p.filePath);
1642
+ } catch {
1643
+ }
1644
+ }
1645
+ }
1646
+ }
1647
+ await apiPut(`/repos/${repoId}`, { claude_sync_at: (/* @__PURE__ */ new Date()).toISOString() });
1648
+ console.log(
1649
+ `
1650
+ Applied: ${toPull.length} pulled, ${toPush.length} pushed, ${toDelete.length} deleted` + (skipped.length > 0 ? `, ${skipped.length} skipped` : "")
1651
+ );
1335
1652
  }
1336
- const toUpsert = pushes.filter((p) => p.pushContent !== null).map((p) => ({
1337
- type: p.type,
1338
- name: p.name,
1339
- category: p.category,
1340
- content: p.pushContent
1341
- }));
1342
- if (toUpsert.length > 0) {
1343
- await apiPost("/sync/files", {
1344
- repo_id: repoId,
1345
- files: toUpsert
1346
- });
1347
- }
1348
- await apiPut(`/repos/${repoId}`, { claude_sync_at: (/* @__PURE__ */ new Date()).toISOString() });
1349
- console.log(`
1350
- Applied: ${pulls.length} pulled, ${pushes.length} pushed`);
1351
1653
  } else if (dryRun) {
1352
1654
  console.log("\n (dry-run \u2014 no changes)");
1353
1655
  }
@@ -1449,14 +1751,17 @@ async function syncConfig(repoId, projectPath, dryRun) {
1449
1751
  async function syncTechStack(repoId, projectPath, dryRun) {
1450
1752
  try {
1451
1753
  const detected = await detectTechStack(projectPath);
1452
- if (detected.length === 0) {
1754
+ if (detected.flat.length === 0) {
1453
1755
  console.log(" No tech stack detected.");
1454
1756
  return;
1455
1757
  }
1456
1758
  const repoRes = await apiGet(`/repos/${repoId}`);
1457
- const remote = parseTechStack(repoRes.data.tech_stack);
1759
+ const remote = parseTechStackResult(repoRes.data.tech_stack);
1458
1760
  const { merged, added } = mergeTechStack(remote, detected);
1459
- console.log(` ${detected.length} detected${added.length > 0 ? ` (${added.length} new)` : ""}`);
1761
+ console.log(` ${detected.flat.length} detected${added.length > 0 ? ` (${added.length} new)` : ""}`);
1762
+ if (detected.apps.length > 0) {
1763
+ console.log(` Apps: ${detected.apps.map((a) => a.name).join(", ")}`);
1764
+ }
1460
1765
  for (const entry of added) {
1461
1766
  console.log(` + ${entry.name} (${entry.category})`);
1462
1767
  }
@@ -1467,6 +1772,25 @@ async function syncTechStack(repoId, projectPath, dryRun) {
1467
1772
  console.log(" Tech stack detection skipped.");
1468
1773
  }
1469
1774
  }
1775
+ function groupByType(items) {
1776
+ const groups = /* @__PURE__ */ new Map();
1777
+ const typeLabels = {
1778
+ command: "Commands",
1779
+ agent: "Agents",
1780
+ skill: "Skills",
1781
+ rule: "Rules",
1782
+ hook: "Hooks",
1783
+ template: "Templates",
1784
+ settings: "Settings"
1785
+ };
1786
+ for (const item of items) {
1787
+ const label = typeLabels[item.type] ?? item.type;
1788
+ const group = groups.get(label) ?? [];
1789
+ group.push(item);
1790
+ groups.set(label, group);
1791
+ }
1792
+ return groups;
1793
+ }
1470
1794
  function getLocalFilePath(claudeDir, projectPath, remote) {
1471
1795
  const typeConfig2 = {
1472
1796
  command: { dir: "commands", ext: ".md" },
@@ -24031,17 +24355,52 @@ function registerWriteTools(server) {
24031
24355
  }, async ({ repo_id, project_path }) => {
24032
24356
  try {
24033
24357
  const syncResult = await executeSyncToLocal({ repoId: repo_id, projectPath: project_path });
24034
- const { byType, totals } = syncResult;
24358
+ const { byType, totals, dbOnlyFiles } = syncResult;
24035
24359
  const summary = {
24036
24360
  ...byType,
24037
24361
  totals: { created: totals.created, updated: totals.updated, deleted: totals.deleted },
24038
24362
  message: totals.created + totals.updated + totals.deleted === 0 ? "All files up to date" : `Synced: ${totals.created} created, ${totals.updated} updated, ${totals.deleted} deleted`
24039
24363
  };
24364
+ if (dbOnlyFiles.length > 0) {
24365
+ summary.db_only_files = dbOnlyFiles;
24366
+ summary.message += `
24367
+
24368
+ ${dbOnlyFiles.length} file(s) exist in DB but were missing locally (recreated). Use delete_claude_files to remove them from DB if they were intentionally deleted.`;
24369
+ }
24040
24370
  return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
24041
24371
  } catch (err) {
24042
24372
  return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
24043
24373
  }
24044
24374
  });
24375
+ server.registerTool("delete_claude_files", {
24376
+ description: "Soft-delete claude files from the CodeByPlan DB. Use after sync_claude_files reports db_only_files that should be removed. Each file is identified by type, name, and optional category.",
24377
+ inputSchema: {
24378
+ repo_id: external_exports.string().uuid().describe("Repository ID"),
24379
+ files: external_exports.array(external_exports.object({
24380
+ type: external_exports.string().describe("File type: command, agent, skill, rule, hook, template, docs_stack"),
24381
+ name: external_exports.string().describe("File name"),
24382
+ category: external_exports.string().nullable().optional().describe("Category (for commands: e.g. 'development/checkpoint')")
24383
+ })).describe("Files to soft-delete from DB")
24384
+ }
24385
+ }, async ({ repo_id, files }) => {
24386
+ try {
24387
+ const res = await apiPost("/sync/files", {
24388
+ repo_id,
24389
+ delete_keys: files
24390
+ });
24391
+ return {
24392
+ content: [{
24393
+ type: "text",
24394
+ text: JSON.stringify({
24395
+ deleted: res.data.deleted,
24396
+ message: `Soft-deleted ${res.data.deleted} file(s) from DB. Run sync_claude_files to clean up local copies.`
24397
+ }, null, 2)
24398
+ }]
24399
+ };
24400
+ } catch (err) {
24401
+ return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
24402
+ }
24403
+ });
24045
24404
  server.registerTool("update_session_state", {
24046
24405
  description: "Update session state for a repo. Actions: activate (deactivates other repos), deactivate, pause, refresh, clear_refresh.",
24047
24406
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codebyplan/cli",
3
- "version": "3.0.3",
3
+ "version": "3.2.0",
4
4
  "description": "MCP server for CodeByPlan — AI-powered development planning and tracking",
5
5
  "type": "module",
6
6
  "bin": {