codebyplan 1.0.0 → 1.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.
Files changed (2) hide show
  1. package/dist/cli.js +313 -132
  2. package/package.json +14 -1
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ var VERSION, PACKAGE_NAME;
14
14
  var init_version = __esm({
15
15
  "src/lib/version.ts"() {
16
16
  "use strict";
17
- VERSION = "1.0.0";
17
+ VERSION = "1.1.0";
18
18
  PACKAGE_NAME = "codebyplan";
19
19
  }
20
20
  });
@@ -127,7 +127,7 @@ var init_api = __esm({
127
127
  init_version();
128
128
  API_KEY = process.env.CODEBYPLAN_API_KEY ?? "";
129
129
  BASE_URL = (process.env.CODEBYPLAN_API_URL ?? "https://codebyplan.com").replace(/\/$/, "");
130
- REQUEST_TIMEOUT_MS = 3e4;
130
+ REQUEST_TIMEOUT_MS = 12e4;
131
131
  MAX_RETRIES = 3;
132
132
  BASE_DELAY_MS = 1e3;
133
133
  ApiError = class extends Error {
@@ -197,11 +197,7 @@ var TEMPLATE_MANAGED_KEYS, TEMPLATE_MANAGED_PERMISSION_KEYS, ARRAY_PERMISSION_KE
197
197
  var init_settings_merge = __esm({
198
198
  "src/lib/settings-merge.ts"() {
199
199
  "use strict";
200
- TEMPLATE_MANAGED_KEYS = [
201
- "attribution",
202
- "hooks",
203
- "statusLine"
204
- ];
200
+ TEMPLATE_MANAGED_KEYS = ["attribution", "hooks", "statusLine"];
205
201
  TEMPLATE_MANAGED_PERMISSION_KEYS = [
206
202
  "deny",
207
203
  "ask",
@@ -255,12 +251,13 @@ function mergeDiscoveredHooks(existing, discovered, hooksRelPath = ".claude/hook
255
251
  merged[meta.event] = [];
256
252
  }
257
253
  const eventEntries = merged[meta.event];
254
+ const alreadyRegistered = eventEntries.some(
255
+ (m) => m.hooks.some((h) => h.command === command)
256
+ );
257
+ if (alreadyRegistered) continue;
258
258
  const matcherEntry = eventEntries.find((m) => m.matcher === meta.matcher);
259
259
  if (matcherEntry) {
260
- const exists = matcherEntry.hooks.some((h) => h.command === command);
261
- if (!exists) {
262
- matcherEntry.hooks.push({ type: "command", command });
263
- }
260
+ matcherEntry.hooks.push({ type: "command", command });
264
261
  } else {
265
262
  eventEntries.push({
266
263
  matcher: meta.matcher,
@@ -280,7 +277,10 @@ function stripDiscoveredHooks(config, hooksRelPath = ".claude/hooks") {
280
277
  (h) => !(h.command && h.command.startsWith(prefix) && h.command.endsWith(".sh"))
281
278
  );
282
279
  if (filteredHooks.length > 0) {
283
- filteredMatchers.push({ matcher: matcher.matcher, hooks: filteredHooks });
280
+ filteredMatchers.push({
281
+ matcher: matcher.matcher,
282
+ hooks: filteredHooks
283
+ });
284
284
  }
285
285
  }
286
286
  if (filteredMatchers.length > 0) {
@@ -307,17 +307,25 @@ function substituteVariables(content, repoData) {
307
307
  }
308
308
  return result;
309
309
  }
310
+ function escapeRegex(str) {
311
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
312
+ }
310
313
  function reverseSubstituteVariables(content, repoData) {
311
314
  const entries = [];
312
315
  for (const [name, resolver] of Object.entries(TEMPLATE_VARIABLES)) {
313
316
  const value = resolver(repoData);
314
- if (value.length < 3) continue;
317
+ if (value.length === 0) continue;
315
318
  entries.push([value, `{{${name}}}`]);
316
319
  }
317
320
  entries.sort((a, b) => b[0].length - a[0].length);
318
321
  let result = content;
319
322
  for (const [value, placeholder] of entries) {
320
- result = result.replaceAll(value, placeholder);
323
+ if (value.length < 8) {
324
+ const pattern = new RegExp(`\\b${escapeRegex(value)}\\b`, "g");
325
+ result = result.replace(pattern, placeholder);
326
+ } else {
327
+ result = result.replaceAll(value, placeholder);
328
+ }
321
329
  }
322
330
  return result;
323
331
  }
@@ -341,7 +349,16 @@ var sync_engine_exports = {};
341
349
  __export(sync_engine_exports, {
342
350
  executeSyncToLocal: () => executeSyncToLocal
343
351
  });
344
- import { readdir as readdir2, readFile as readFile2, writeFile, unlink, mkdir, rmdir, chmod, stat } from "node:fs/promises";
352
+ import {
353
+ readdir as readdir2,
354
+ readFile as readFile2,
355
+ writeFile,
356
+ unlink,
357
+ mkdir,
358
+ rmdir,
359
+ chmod,
360
+ stat
361
+ } from "node:fs/promises";
345
362
  import { join as join2, dirname } from "node:path";
346
363
  function getTypeDir(claudeDir, dir) {
347
364
  if (dir === "commands") return join2(claudeDir, dir, "cbp");
@@ -416,13 +433,23 @@ async function executeSyncToLocal(options) {
416
433
  const dbOnlyFiles = [];
417
434
  for (const [syncKey, typeName] of Object.entries(syncKeyToType)) {
418
435
  if (worktree && typeName === "command") {
419
- byType["commands"] = { created: [], updated: [], deleted: [], unchanged: [] };
436
+ byType["commands"] = {
437
+ created: [],
438
+ updated: [],
439
+ deleted: [],
440
+ unchanged: []
441
+ };
420
442
  continue;
421
443
  }
422
444
  const cfg = typeConfig[typeName];
423
445
  const targetDir = getTypeDir(claudeDir, cfg.dir);
424
446
  const remoteFiles = syncData[syncKey] ?? [];
425
- const result = { created: [], updated: [], deleted: [], unchanged: [] };
447
+ const result = {
448
+ created: [],
449
+ updated: [],
450
+ deleted: [],
451
+ unchanged: []
452
+ };
426
453
  if (!dryRun) {
427
454
  await mkdir(targetDir, { recursive: true });
428
455
  }
@@ -483,7 +510,12 @@ async function executeSyncToLocal(options) {
483
510
  const syncKey = "docs_stack";
484
511
  const targetDir = join2(projectPath, "docs", "stack");
485
512
  const remoteFiles = syncData[syncKey] ?? [];
486
- const result = { created: [], updated: [], deleted: [], unchanged: [] };
513
+ const result = {
514
+ created: [],
515
+ updated: [],
516
+ deleted: [],
517
+ unchanged: []
518
+ };
487
519
  if (remoteFiles.length > 0 && !dryRun) {
488
520
  await mkdir(targetDir, { recursive: true });
489
521
  }
@@ -492,7 +524,10 @@ async function executeSyncToLocal(options) {
492
524
  for (const remote of remoteFiles) {
493
525
  const relPath = remote.category ? join2(remote.category, remote.name) : remote.name;
494
526
  const substituted = substituteVariables(remote.content, repoData);
495
- remotePathMap.set(relPath, { content: substituted, name: `${remote.category ?? ""}/${remote.name}` });
527
+ remotePathMap.set(relPath, {
528
+ content: substituted,
529
+ name: `${remote.category ?? ""}/${remote.name}`
530
+ });
496
531
  }
497
532
  for (const [relPath, { content, name }] of remotePathMap) {
498
533
  const fullPath = join2(targetDir, relPath);
@@ -531,7 +566,9 @@ async function executeSyncToLocal(options) {
531
566
  const globalSettingsFiles = syncData.global_settings ?? [];
532
567
  let globalSettings = {};
533
568
  for (const gf of globalSettingsFiles) {
534
- const parsed = JSON.parse(substituteVariables(gf.content, repoData));
569
+ const parsed = JSON.parse(
570
+ substituteVariables(gf.content, repoData)
571
+ );
535
572
  globalSettings = { ...globalSettings, ...parsed };
536
573
  }
537
574
  const specialTypes = {
@@ -540,7 +577,12 @@ async function executeSyncToLocal(options) {
540
577
  };
541
578
  for (const [typeName, getPath] of Object.entries(specialTypes)) {
542
579
  const remoteFiles = syncData[typeName] ?? [];
543
- const result = { created: [], updated: [], deleted: [], unchanged: [] };
580
+ const result = {
581
+ created: [],
582
+ updated: [],
583
+ deleted: [],
584
+ unchanged: []
585
+ };
544
586
  for (const remote of remoteFiles) {
545
587
  const targetPath = getPath(remote.name);
546
588
  const remoteContent = substituteVariables(remote.content, repoData);
@@ -551,11 +593,14 @@ async function executeSyncToLocal(options) {
551
593
  }
552
594
  if (typeName === "settings") {
553
595
  const repoSettings = JSON.parse(remoteContent);
554
- const combinedTemplate = mergeGlobalAndRepoSettings(globalSettings, repoSettings);
596
+ const combinedTemplate = mergeGlobalAndRepoSettings(
597
+ globalSettings,
598
+ repoSettings
599
+ );
555
600
  const hooksDir = join2(projectPath, ".claude", "hooks");
556
601
  const discovered = await discoverHooks(hooksDir);
557
602
  if (localContent === void 0) {
558
- let finalSettings = stripPermissionsAllow(combinedTemplate);
603
+ const finalSettings = stripPermissionsAllow(combinedTemplate);
559
604
  if (discovered.size > 0) {
560
605
  finalSettings.hooks = mergeDiscoveredHooks(
561
606
  finalSettings.hooks ?? {},
@@ -564,7 +609,11 @@ async function executeSyncToLocal(options) {
564
609
  }
565
610
  if (!dryRun) {
566
611
  await mkdir(dirname(targetPath), { recursive: true });
567
- await writeFile(targetPath, JSON.stringify(finalSettings, null, 2) + "\n", "utf-8");
612
+ await writeFile(
613
+ targetPath,
614
+ JSON.stringify(finalSettings, null, 2) + "\n",
615
+ "utf-8"
616
+ );
568
617
  }
569
618
  result.created.push(remote.name);
570
619
  totals.created++;
@@ -625,28 +674,32 @@ async function executeSyncToLocal(options) {
625
674
  });
626
675
  const fileRepoUpdates = [];
627
676
  const syncTimestamp = (/* @__PURE__ */ new Date()).toISOString();
628
- for (const [syncKey] of Object.entries(syncKeyToType)) {
677
+ for (const [syncKey, typeName] of Object.entries(syncKeyToType)) {
629
678
  const remoteFiles = syncData[syncKey] ?? [];
630
679
  for (const file of remoteFiles) {
631
- if (file.id) {
632
- fileRepoUpdates.push({
633
- claude_file_id: file.id,
634
- last_synced_at: syncTimestamp,
635
- sync_status: "synced"
636
- });
637
- }
680
+ fileRepoUpdates.push({
681
+ claude_file_id: file.id ?? void 0,
682
+ file_type: typeName,
683
+ file_name: file.name,
684
+ file_category: file.category ?? null,
685
+ file_scope: file.scope ?? "shared",
686
+ last_synced_at: syncTimestamp,
687
+ sync_status: "synced"
688
+ });
638
689
  }
639
690
  }
640
691
  for (const typeName of ["claude_md", "settings"]) {
641
692
  const remoteFiles = syncData[typeName] ?? [];
642
693
  for (const file of remoteFiles) {
643
- if (file.id) {
644
- fileRepoUpdates.push({
645
- claude_file_id: file.id,
646
- last_synced_at: syncTimestamp,
647
- sync_status: "synced"
648
- });
649
- }
694
+ fileRepoUpdates.push({
695
+ claude_file_id: file.id ?? void 0,
696
+ file_type: typeName,
697
+ file_name: file.name,
698
+ file_category: file.category ?? null,
699
+ file_scope: file.scope ?? `local:${repoId}`,
700
+ last_synced_at: syncTimestamp,
701
+ sync_status: "synced"
702
+ });
650
703
  }
651
704
  }
652
705
  if (fileRepoUpdates.length > 0) {
@@ -748,7 +801,9 @@ async function runSetup() {
748
801
  console.log("\n CodeByPlan Setup\n");
749
802
  console.log(" This will configure Claude Code to use CodeByPlan.\n");
750
803
  console.log(" 1. Sign up at https://codebyplan.com");
751
- console.log(" 2. Create an API key at https://codebyplan.com/settings/api-keys/\n");
804
+ console.log(
805
+ " 2. Create an API key at https://codebyplan.com/settings/api-keys/\n"
806
+ );
752
807
  try {
753
808
  const apiKey = (await rl.question(" Enter your API key: ")).trim();
754
809
  if (!apiKey) {
@@ -780,8 +835,10 @@ async function runSetup() {
780
835
  console.log(" API key is valid!\n");
781
836
  }
782
837
  } else {
783
- console.log(` Warning: API returned status ${res.status}, but continuing.
784
- `);
838
+ console.log(
839
+ ` Warning: API returned status ${res.status}, but continuing.
840
+ `
841
+ );
785
842
  }
786
843
  console.log(" Where should the MCP server be configured?\n");
787
844
  console.log(" 1. Global \u2014 available in all projects (~/.claude.json)");
@@ -795,14 +852,20 @@ async function runSetup() {
795
852
  console.log(` Done! Config written to ${configPath}
796
853
  `);
797
854
  if (scope === "project") {
798
- console.log(" Note: .mcp.json contains your API key \u2014 add it to .gitignore.\n");
855
+ console.log(
856
+ " Note: .mcp.json contains your API key \u2014 add it to .gitignore.\n"
857
+ );
799
858
  }
800
859
  } else {
801
860
  console.log(" Warning: Could not verify the saved configuration.\n");
802
- console.log(` Manually add to ~/.claude.json under mcpServers.codebyplan:
803
- `);
804
- console.log(` { "url": "https://codebyplan.com/mcp", "headers": { "x-api-key": "${apiKey}" } }
805
- `);
861
+ console.log(
862
+ ` Manually add to ~/.claude.json under mcpServers.codebyplan:
863
+ `
864
+ );
865
+ console.log(
866
+ ` { "url": "https://codebyplan.com/mcp", "headers": { "x-api-key": "${apiKey}" } }
867
+ `
868
+ );
806
869
  }
807
870
  if (repos.length > 0) {
808
871
  console.log(" Initialize this project?\n");
@@ -827,14 +890,22 @@ async function runSetup() {
827
890
  const projectPath = process.cwd();
828
891
  try {
829
892
  const worktreesRes = await apiGet(`/worktrees?repo_id=${selectedRepo.id}`);
830
- const match = worktreesRes.data.find((wt) => projectPath === wt.path || projectPath.startsWith(wt.path + "/"));
893
+ const match = worktreesRes.data.find(
894
+ (wt) => projectPath === wt.path || projectPath.startsWith(wt.path + "/")
895
+ );
831
896
  if (match) worktreeId = match.id;
832
897
  } catch {
833
898
  }
834
899
  const codebyplanPath = join3(projectPath, ".codebyplan.json");
835
- const codebyplanConfig = { repo_id: selectedRepo.id };
900
+ const codebyplanConfig = {
901
+ repo_id: selectedRepo.id
902
+ };
836
903
  if (worktreeId) codebyplanConfig.worktree_id = worktreeId;
837
- await writeFile2(codebyplanPath, JSON.stringify(codebyplanConfig, null, 2) + "\n", "utf-8");
904
+ await writeFile2(
905
+ codebyplanPath,
906
+ JSON.stringify(codebyplanConfig, null, 2) + "\n",
907
+ "utf-8"
908
+ );
838
909
  console.log(` Created ${codebyplanPath}`);
839
910
  console.log("\n Running initial sync...\n");
840
911
  try {
@@ -845,8 +916,10 @@ async function runSetup() {
845
916
  });
846
917
  const totalChanges = syncResult.totals.created + syncResult.totals.updated + syncResult.totals.deleted;
847
918
  if (totalChanges > 0) {
848
- console.log(` Synced: ${syncResult.totals.created} created, ${syncResult.totals.updated} updated, ${syncResult.totals.deleted} deleted
849
- `);
919
+ console.log(
920
+ ` Synced: ${syncResult.totals.created} created, ${syncResult.totals.updated} updated, ${syncResult.totals.deleted} deleted
921
+ `
922
+ );
850
923
  } else {
851
924
  console.log(" All files already up to date.\n");
852
925
  }
@@ -858,7 +931,9 @@ async function runSetup() {
858
931
  }
859
932
  }
860
933
  }
861
- console.log(" Setup complete! Start a new Claude Code session to begin.\n");
934
+ console.log(
935
+ " Setup complete! Start a new Claude Code session to begin.\n"
936
+ );
862
937
  } finally {
863
938
  rl.close();
864
939
  }
@@ -918,14 +993,48 @@ var init_config = __esm({
918
993
  // src/cli/fileMapper.ts
919
994
  import { readdir as readdir3, readFile as readFile5 } from "node:fs/promises";
920
995
  import { join as join5, extname } from "node:path";
996
+ function extractScope(content, type) {
997
+ if (type === "hook") {
998
+ const match = content.match(/^#\s*@scope:\s*(\S+)/m);
999
+ if (match) {
1000
+ const raw = match[1];
1001
+ return raw === "shared" ? "shared" : `local:${raw}`;
1002
+ }
1003
+ return "shared";
1004
+ }
1005
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
1006
+ if (fmMatch) {
1007
+ const scopeLine = fmMatch[1].match(/^scope:\s*(\S+)/m);
1008
+ if (scopeLine) {
1009
+ const raw = scopeLine[1];
1010
+ return raw === "shared" ? "shared" : `local:${raw}`;
1011
+ }
1012
+ if (/^scope\b/m.test(fmMatch[1])) {
1013
+ console.error(
1014
+ ` Warning: frontmatter contains "scope" but could not parse it. Expected format: "scope: shared" or "scope: <repo-name>". Defaulting to "shared".`
1015
+ );
1016
+ }
1017
+ }
1018
+ return "shared";
1019
+ }
921
1020
  function compositeKey(type, name, category) {
922
1021
  return category ? `${type}:${category}/${name}` : `${type}:${name}`;
923
1022
  }
924
1023
  async function scanLocalFiles(claudeDir, projectPath) {
925
1024
  const result = /* @__PURE__ */ new Map();
926
1025
  await scanCommands(join5(claudeDir, "commands", "cbp"), result);
927
- await scanSubfolderType(join5(claudeDir, "agents"), "agent", "AGENT.md", result);
928
- await scanSubfolderType(join5(claudeDir, "skills"), "skill", "SKILL.md", result);
1026
+ await scanSubfolderType(
1027
+ join5(claudeDir, "agents"),
1028
+ "agent",
1029
+ "AGENT.md",
1030
+ result
1031
+ );
1032
+ await scanSubfolderType(
1033
+ join5(claudeDir, "skills"),
1034
+ "skill",
1035
+ "SKILL.md",
1036
+ result
1037
+ );
929
1038
  await scanFlatType(join5(claudeDir, "rules"), "rule", ".md", result);
930
1039
  await scanFlatType(join5(claudeDir, "hooks"), "hook", ".sh", result);
931
1040
  await scanTemplates(join5(claudeDir, "templates"), result);
@@ -944,14 +1053,19 @@ async function scanCommandsRecursive(baseDir, currentDir, result) {
944
1053
  }
945
1054
  for (const entry of entries) {
946
1055
  if (entry.isDirectory()) {
947
- await scanCommandsRecursive(baseDir, join5(currentDir, entry.name), result);
1056
+ await scanCommandsRecursive(
1057
+ baseDir,
1058
+ join5(currentDir, entry.name),
1059
+ result
1060
+ );
948
1061
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
949
1062
  const name = entry.name.slice(0, -3);
950
1063
  const content = await readFile5(join5(currentDir, entry.name), "utf-8");
951
1064
  const relDir = currentDir.slice(baseDir.length + 1);
952
1065
  const category = relDir || null;
1066
+ const scope = extractScope(content, "command");
953
1067
  const key = compositeKey("command", name, category);
954
- result.set(key, { type: "command", name, category, content });
1068
+ result.set(key, { type: "command", name, category, content, scope });
955
1069
  }
956
1070
  }
957
1071
  }
@@ -967,8 +1081,15 @@ async function scanSubfolderType(dir, type, fileName, result) {
967
1081
  const filePath = join5(dir, entry.name, fileName);
968
1082
  try {
969
1083
  const content = await readFile5(filePath, "utf-8");
1084
+ const scope = extractScope(content, type);
970
1085
  const key = compositeKey(type, entry.name, null);
971
- result.set(key, { type, name: entry.name, category: null, content });
1086
+ result.set(key, {
1087
+ type,
1088
+ name: entry.name,
1089
+ category: null,
1090
+ content,
1091
+ scope
1092
+ });
972
1093
  } catch {
973
1094
  }
974
1095
  }
@@ -985,8 +1106,9 @@ async function scanFlatType(dir, type, ext, result) {
985
1106
  if (entry.isFile() && entry.name.endsWith(ext)) {
986
1107
  const name = entry.name.slice(0, -ext.length);
987
1108
  const content = await readFile5(join5(dir, entry.name), "utf-8");
1109
+ const scope = extractScope(content, type);
988
1110
  const key = compositeKey(type, name, null);
989
- result.set(key, { type, name, category: null, content });
1111
+ result.set(key, { type, name, category: null, content, scope });
990
1112
  }
991
1113
  }
992
1114
  }
@@ -1000,8 +1122,15 @@ async function scanTemplates(dir, result) {
1000
1122
  for (const entry of entries) {
1001
1123
  if (entry.isFile() && extname(entry.name)) {
1002
1124
  const content = await readFile5(join5(dir, entry.name), "utf-8");
1125
+ const scope = extractScope(content, "template");
1003
1126
  const key = compositeKey("template", entry.name, null);
1004
- result.set(key, { type: "template", name: entry.name, category: null, content });
1127
+ result.set(key, {
1128
+ type: "template",
1129
+ name: entry.name,
1130
+ category: null,
1131
+ content,
1132
+ scope
1133
+ });
1005
1134
  }
1006
1135
  }
1007
1136
  }
@@ -1035,7 +1164,13 @@ async function scanSettings(claudeDir, projectPath, result) {
1035
1164
  }
1036
1165
  const content = JSON.stringify(parsed, null, 2) + "\n";
1037
1166
  const key = compositeKey("settings", "settings", null);
1038
- result.set(key, { type: "settings", name: "settings", category: null, content });
1167
+ result.set(key, {
1168
+ type: "settings",
1169
+ name: "settings",
1170
+ category: null,
1171
+ content,
1172
+ scope: "shared"
1173
+ });
1039
1174
  }
1040
1175
  var init_fileMapper = __esm({
1041
1176
  "src/cli/fileMapper.ts"() {
@@ -1069,7 +1204,9 @@ async function confirmProceed(message) {
1069
1204
  const a = answer.trim().toLowerCase();
1070
1205
  if (a === "" || a === "y" || a === "yes") return true;
1071
1206
  if (a === "n" || a === "no") return false;
1072
- console.log(` Unknown option "${answer.trim()}". Valid: y/yes, n/no, or Enter for yes.`);
1207
+ console.log(
1208
+ ` Unknown option "${answer.trim()}". Valid: y/yes, n/no, or Enter for yes.`
1209
+ );
1073
1210
  }
1074
1211
  } catch (err) {
1075
1212
  if (isAbortError(err)) throw new SyncCancelledError();
@@ -1202,11 +1339,16 @@ async function promptReviewMode() {
1202
1339
  const rl = createInterface2({ input: stdin2, output: stdout2 });
1203
1340
  try {
1204
1341
  while (true) {
1205
- const answer = await rl.question(" Review [o]ne-by-one or [f]older-by-folder? ");
1342
+ const answer = await rl.question(
1343
+ " Review [o]ne-by-one or [f]older-by-folder? "
1344
+ );
1206
1345
  const a = answer.trim().toLowerCase();
1207
- if (a === "o" || a === "one-by-one" || a === "one" || a === "file") return "file";
1346
+ if (a === "o" || a === "one-by-one" || a === "one" || a === "file")
1347
+ return "file";
1208
1348
  if (a === "f" || a === "folder") return "folder";
1209
- console.log(` Unknown option "${answer.trim()}". Valid: o/one-by-one, f/folder`);
1349
+ console.log(
1350
+ ` Unknown option "${answer.trim()}". Valid: o/one-by-one, f/folder`
1351
+ );
1210
1352
  }
1211
1353
  } catch (err) {
1212
1354
  if (isAbortError(err)) throw new SyncCancelledError();
@@ -1245,7 +1387,9 @@ async function reviewFilesOneByOne(items, label, plannedAction, recommendedActio
1245
1387
  break;
1246
1388
  }
1247
1389
  if (result.action === null) {
1248
- console.log(` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(rec, hasContent, false)}`);
1390
+ console.log(
1391
+ ` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(rec, hasContent, false)}`
1392
+ );
1249
1393
  continue;
1250
1394
  }
1251
1395
  results.push(result.action);
@@ -1282,7 +1426,13 @@ async function reviewFolder(folderName, items, label, plannedAction, recommended
1282
1426
  const a = answer.trim().toLowerCase();
1283
1427
  if (a === "o" || a === "one-by-one") {
1284
1428
  rl.close();
1285
- return reviewFilesOneByOne(items, label, plannedAction, recommendedAction, content);
1429
+ return reviewFilesOneByOne(
1430
+ items,
1431
+ label,
1432
+ plannedAction,
1433
+ recommendedAction,
1434
+ content
1435
+ );
1286
1436
  }
1287
1437
  if (a === "r" || a === "recommended") {
1288
1438
  return items.map(
@@ -1303,11 +1453,13 @@ async function reviewFolder(folderName, items, label, plannedAction, recommended
1303
1453
  if (result.action !== null) {
1304
1454
  return items.map(() => result.action);
1305
1455
  }
1306
- console.log(` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(
1307
- recommendedAction ? recommendedAction(items[0]) : plannedAction(items[0]),
1308
- false,
1309
- true
1310
- )} [o]ne-by-one`);
1456
+ console.log(
1457
+ ` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(
1458
+ recommendedAction ? recommendedAction(items[0]) : plannedAction(items[0]),
1459
+ false,
1460
+ true
1461
+ )} [o]ne-by-one`
1462
+ );
1311
1463
  }
1312
1464
  } catch (err) {
1313
1465
  if (isAbortError(err)) throw new SyncCancelledError();
@@ -1893,7 +2045,10 @@ async function runSync() {
1893
2045
  if (!dryRun) {
1894
2046
  try {
1895
2047
  await apiDelete("/sync/lock", { repo_id: repoId });
1896
- } catch {
2048
+ } catch (err) {
2049
+ console.error(
2050
+ ` Warning: failed to release sync lock: ${err instanceof Error ? err.message : String(err)}`
2051
+ );
1897
2052
  }
1898
2053
  }
1899
2054
  }
@@ -1906,37 +2061,42 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1906
2061
  localFiles = await scanLocalFiles(claudeDir, projectPath);
1907
2062
  } catch {
1908
2063
  }
1909
- const [defaultsRes, repoSyncRes, repoRes, syncStateRes, fileReposRes] = await Promise.all([
1910
- apiGet("/sync/defaults"),
1911
- apiGet("/sync/files", { repo_id: repoId }),
1912
- apiGet(`/repos/${repoId}`),
1913
- apiGet("/sync/state", {
1914
- repo_id: repoId
1915
- }),
1916
- apiGet("/sync/file-repos", {
1917
- repo_id: repoId
1918
- })
1919
- ]);
2064
+ const [defaultsRes, repoSyncRes, repoRes, , fileReposRes] = await Promise.all(
2065
+ [
2066
+ apiGet("/sync/defaults"),
2067
+ apiGet("/sync/files", { repo_id: repoId }),
2068
+ apiGet(`/repos/${repoId}`),
2069
+ apiGet("/sync/state", {
2070
+ repo_id: repoId
2071
+ }),
2072
+ apiGet("/sync/file-repos", {
2073
+ repo_id: repoId
2074
+ })
2075
+ ]
2076
+ );
1920
2077
  const syncStartTime = Date.now();
1921
2078
  const repoData = repoRes.data;
1922
2079
  const remoteDefaults = flattenSyncData(defaultsRes.data);
1923
2080
  const remoteRepoFiles = flattenSyncData(repoSyncRes.data);
1924
- const syncState = syncStateRes.data;
1925
2081
  const fileRepoHashes = /* @__PURE__ */ new Map();
1926
2082
  const fileRepoByClaudeFileId = /* @__PURE__ */ new Map();
1927
2083
  for (const entry of fileReposRes.data ?? []) {
1928
- if (entry.claude_files) {
1929
- const key = compositeKey(
1930
- entry.claude_files.type,
1931
- entry.claude_files.name,
1932
- entry.claude_files.category
2084
+ const baseKey = compositeKey(
2085
+ entry.file_type,
2086
+ entry.file_name,
2087
+ entry.file_category
2088
+ );
2089
+ const scopedKey = `${baseKey}:${entry.file_scope}`;
2090
+ fileRepoHashes.set(scopedKey, entry.last_synced_content_hash);
2091
+ if (!fileRepoHashes.has(baseKey)) {
2092
+ fileRepoHashes.set(baseKey, entry.last_synced_content_hash);
2093
+ }
2094
+ if (entry.claude_file_id) {
2095
+ fileRepoByClaudeFileId.set(
2096
+ entry.claude_file_id,
2097
+ entry.last_synced_content_hash
1933
2098
  );
1934
- fileRepoHashes.set(key, entry.last_synced_content_hash);
1935
2099
  }
1936
- fileRepoByClaudeFileId.set(
1937
- entry.claude_file_id,
1938
- entry.last_synced_content_hash
1939
- );
1940
2100
  }
1941
2101
  const remoteFiles = new Map([...remoteDefaults, ...remoteRepoFiles]);
1942
2102
  console.log(
@@ -1965,6 +2125,7 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1965
2125
  type: local.type,
1966
2126
  name: local.name,
1967
2127
  category: local.category,
2128
+ scope: local.scope,
1968
2129
  isHook: local.type === "hook",
1969
2130
  claudeFileId: null
1970
2131
  });
@@ -1984,6 +2145,7 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1984
2145
  type: remote.type,
1985
2146
  name: remote.name,
1986
2147
  category: remote.category ?? null,
2148
+ scope: remote.scope ?? "shared",
1987
2149
  isHook: remote.type === "hook",
1988
2150
  claudeFileId: remote.id ?? null
1989
2151
  });
@@ -1993,7 +2155,8 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1993
2155
  continue;
1994
2156
  }
1995
2157
  const localHash = contentHash(local.content);
1996
- const lastSyncedHash = fileRepoHashes.get(key) ?? null;
2158
+ const scopedKey = `${key}:${local.scope}`;
2159
+ const lastSyncedHash = fileRepoHashes.get(scopedKey) ?? fileRepoHashes.get(key) ?? null;
1997
2160
  const localChanged = lastSyncedHash ? localHash !== lastSyncedHash : true;
1998
2161
  let action;
1999
2162
  if (force) {
@@ -2003,8 +2166,8 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2003
2166
  } else if (lastSyncedHash === null) {
2004
2167
  action = "conflict";
2005
2168
  } else {
2006
- const remoteDbHash = remote.content_hash ?? null;
2007
- const remoteChanged = remoteDbHash ? remoteDbHash !== lastSyncedHash : true;
2169
+ const remoteResolvedHash = contentHash(resolvedRemote);
2170
+ const remoteChanged = remoteResolvedHash !== lastSyncedHash;
2008
2171
  if (remoteChanged) {
2009
2172
  action = "conflict";
2010
2173
  } else {
@@ -2023,6 +2186,7 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2023
2186
  type: local.type,
2024
2187
  name: local.name,
2025
2188
  category: local.category,
2189
+ scope: local.scope,
2026
2190
  isHook: local.type === "hook",
2027
2191
  claudeFileId: remote.id ?? null
2028
2192
  });
@@ -2123,7 +2287,8 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2123
2287
  type: p.type,
2124
2288
  name: p.name,
2125
2289
  category: p.category,
2126
- content: p.pushContent
2290
+ content: p.pushContent,
2291
+ scope: p.scope
2127
2292
  }));
2128
2293
  if (toUpsert.length > 0) {
2129
2294
  await apiPost("/sync/files", {
@@ -2146,38 +2311,16 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2146
2311
  if (p.filePath) {
2147
2312
  try {
2148
2313
  await unlink2(p.filePath);
2149
- } catch {
2314
+ } catch (err) {
2315
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
2316
+ console.error(
2317
+ ` Warning: failed to delete ${p.filePath}: ${err.message}`
2318
+ );
2319
+ }
2150
2320
  }
2151
2321
  }
2152
2322
  }
2153
2323
  }
2154
- const unresolvedConflicts = plan.filter(
2155
- (p) => p.action === "conflict" || p.action === "skip" && p.localContent !== null && p.remoteContent !== null
2156
- );
2157
- if (unresolvedConflicts.length > 0) {
2158
- let stored = 0;
2159
- for (const p of unresolvedConflicts) {
2160
- if (p.claudeFileId) {
2161
- try {
2162
- await apiPost("/sync/conflicts", {
2163
- repo_id: repoId,
2164
- claude_file_id: p.claudeFileId,
2165
- conflict_type: "both_modified",
2166
- local_content: p.localContent,
2167
- remote_content: p.remoteContent
2168
- });
2169
- stored++;
2170
- } catch {
2171
- }
2172
- }
2173
- }
2174
- if (stored > 0) {
2175
- console.log(
2176
- `
2177
- ${stored} conflict(s) stored in DB for later resolution.`
2178
- );
2179
- }
2180
- }
2181
2324
  const syncDurationMs = Date.now() - syncStartTime;
2182
2325
  await apiPost("/sync/state", {
2183
2326
  repo_id: repoId,
@@ -2194,9 +2337,13 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2194
2337
  const syncTimestamp = (/* @__PURE__ */ new Date()).toISOString();
2195
2338
  const fileRepoUpdates = [];
2196
2339
  for (const p of toPull) {
2197
- if (p.claudeFileId && p.remoteContent !== null) {
2340
+ if (p.remoteContent !== null) {
2198
2341
  fileRepoUpdates.push({
2199
- claude_file_id: p.claudeFileId,
2342
+ claude_file_id: p.claudeFileId ?? void 0,
2343
+ file_type: p.type,
2344
+ file_name: p.name,
2345
+ file_category: p.category,
2346
+ file_scope: p.scope,
2200
2347
  last_synced_at: syncTimestamp,
2201
2348
  last_synced_content_hash: contentHash(p.remoteContent),
2202
2349
  sync_status: "synced"
@@ -2204,9 +2351,13 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2204
2351
  }
2205
2352
  }
2206
2353
  for (const p of toPush) {
2207
- if (p.claudeFileId && p.localContent !== null) {
2354
+ if (p.localContent !== null) {
2208
2355
  fileRepoUpdates.push({
2209
- claude_file_id: p.claudeFileId,
2356
+ claude_file_id: p.claudeFileId ?? void 0,
2357
+ file_type: p.type,
2358
+ file_name: p.name,
2359
+ file_category: p.category,
2360
+ file_scope: p.scope,
2210
2361
  last_synced_at: syncTimestamp,
2211
2362
  last_synced_content_hash: contentHash(p.localContent),
2212
2363
  sync_status: "synced"
@@ -2227,6 +2378,36 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2227
2378
  Applied: ${toPull.length} pulled, ${toPush.length} pushed, ${toDelete.length} deleted` + (skipped.length > 0 ? `, ${skipped.length} skipped` : "")
2228
2379
  );
2229
2380
  }
2381
+ const unresolvedConflicts = plan.filter(
2382
+ (p) => p.action === "conflict" || p.action === "skip" && p.localContent !== null && p.remoteContent !== null
2383
+ );
2384
+ if (unresolvedConflicts.length > 0) {
2385
+ let stored = 0;
2386
+ for (const p of unresolvedConflicts) {
2387
+ try {
2388
+ await apiPost("/sync/conflicts", {
2389
+ repo_id: repoId,
2390
+ claude_file_id: p.claudeFileId ?? void 0,
2391
+ file_type: p.type,
2392
+ file_name: p.name,
2393
+ file_category: p.category,
2394
+ file_scope: p.scope,
2395
+ conflict_type: "both_modified",
2396
+ local_content: p.localContent,
2397
+ remote_content: p.remoteContent
2398
+ });
2399
+ stored++;
2400
+ } catch (err) {
2401
+ console.error(`Failed to store conflict for ${p.displayPath}:`, err);
2402
+ }
2403
+ }
2404
+ if (stored > 0) {
2405
+ console.log(
2406
+ `
2407
+ ${stored} conflict(s) stored in DB for later resolution.`
2408
+ );
2409
+ }
2410
+ }
2230
2411
  } else if (dryRun) {
2231
2412
  console.log("\n (dry-run \u2014 no changes)");
2232
2413
  }
@@ -2521,7 +2702,7 @@ function getLocalFilePath(claudeDir, projectPath, remote) {
2521
2702
  }
2522
2703
  function getSyncVersion() {
2523
2704
  try {
2524
- return "1.0.0";
2705
+ return "1.1.0";
2525
2706
  } catch {
2526
2707
  return "unknown";
2527
2708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI for CodeByPlan — AI-powered development planning and tracking",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,10 @@
14
14
  "build": "tsc",
15
15
  "build:npm": "node esbuild.npm.mjs",
16
16
  "prepublishOnly": "npm run build:npm",
17
+ "lint": "eslint",
18
+ "lint:fix": "eslint --fix",
19
+ "format": "prettier --write \"src/**/*.ts\"",
20
+ "format:check": "prettier --check \"src/**/*.ts\"",
17
21
  "test": "vitest run",
18
22
  "test:watch": "vitest",
19
23
  "test:coverage": "vitest run --coverage"
@@ -40,9 +44,18 @@
40
44
  "node": ">=18"
41
45
  },
42
46
  "devDependencies": {
47
+ "@eslint/js": "^9.18.0",
43
48
  "@types/node": "^20",
49
+ "@vitest/eslint-plugin": "^1.1.44",
44
50
  "esbuild": "^0.25",
51
+ "eslint": "^9.18.0",
52
+ "eslint-config-prettier": "^10.0.1",
53
+ "eslint-plugin-no-secrets": "^2.2.1",
54
+ "eslint-plugin-prettier": "^5.2.2",
55
+ "eslint-plugin-security": "^3.0.1",
56
+ "globals": "^17.0.0",
45
57
  "typescript": "^5",
58
+ "typescript-eslint": "^8.20.0",
46
59
  "vitest": "^4.0.18"
47
60
  }
48
61
  }