codebyplan 1.0.0 → 1.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 +396 -134
  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.2.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,17 +993,58 @@ 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);
1041
+ await scanCategorizedType(
1042
+ join5(claudeDir, "context"),
1043
+ "context",
1044
+ ".md",
1045
+ result
1046
+ );
1047
+ await scanDocsRecursive(join5(claudeDir, "docs"), result);
932
1048
  await scanSettings(claudeDir, projectPath, result);
933
1049
  return result;
934
1050
  }
@@ -944,14 +1060,19 @@ async function scanCommandsRecursive(baseDir, currentDir, result) {
944
1060
  }
945
1061
  for (const entry of entries) {
946
1062
  if (entry.isDirectory()) {
947
- await scanCommandsRecursive(baseDir, join5(currentDir, entry.name), result);
1063
+ await scanCommandsRecursive(
1064
+ baseDir,
1065
+ join5(currentDir, entry.name),
1066
+ result
1067
+ );
948
1068
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
949
1069
  const name = entry.name.slice(0, -3);
950
1070
  const content = await readFile5(join5(currentDir, entry.name), "utf-8");
951
1071
  const relDir = currentDir.slice(baseDir.length + 1);
952
1072
  const category = relDir || null;
1073
+ const scope = extractScope(content, "command");
953
1074
  const key = compositeKey("command", name, category);
954
- result.set(key, { type: "command", name, category, content });
1075
+ result.set(key, { type: "command", name, category, content, scope });
955
1076
  }
956
1077
  }
957
1078
  }
@@ -967,8 +1088,15 @@ async function scanSubfolderType(dir, type, fileName, result) {
967
1088
  const filePath = join5(dir, entry.name, fileName);
968
1089
  try {
969
1090
  const content = await readFile5(filePath, "utf-8");
1091
+ const scope = extractScope(content, type);
970
1092
  const key = compositeKey(type, entry.name, null);
971
- result.set(key, { type, name: entry.name, category: null, content });
1093
+ result.set(key, {
1094
+ type,
1095
+ name: entry.name,
1096
+ category: null,
1097
+ content,
1098
+ scope
1099
+ });
972
1100
  } catch {
973
1101
  }
974
1102
  }
@@ -985,8 +1113,72 @@ async function scanFlatType(dir, type, ext, result) {
985
1113
  if (entry.isFile() && entry.name.endsWith(ext)) {
986
1114
  const name = entry.name.slice(0, -ext.length);
987
1115
  const content = await readFile5(join5(dir, entry.name), "utf-8");
1116
+ const scope = extractScope(content, type);
988
1117
  const key = compositeKey(type, name, null);
989
- result.set(key, { type, name, category: null, content });
1118
+ result.set(key, { type, name, category: null, content, scope });
1119
+ }
1120
+ }
1121
+ }
1122
+ async function scanCategorizedType(dir, type, ext, result) {
1123
+ let entries;
1124
+ try {
1125
+ entries = await readdir3(dir, { withFileTypes: true });
1126
+ } catch {
1127
+ return;
1128
+ }
1129
+ for (const entry of entries) {
1130
+ if (entry.isDirectory()) {
1131
+ const category = entry.name;
1132
+ let subEntries;
1133
+ try {
1134
+ subEntries = await readdir3(join5(dir, category), {
1135
+ withFileTypes: true
1136
+ });
1137
+ } catch {
1138
+ continue;
1139
+ }
1140
+ for (const sub of subEntries) {
1141
+ if (sub.isFile() && sub.name.endsWith(ext)) {
1142
+ const name = sub.name.slice(0, -ext.length);
1143
+ const content = await readFile5(
1144
+ join5(dir, category, sub.name),
1145
+ "utf-8"
1146
+ );
1147
+ const scope = extractScope(content, type);
1148
+ const key = compositeKey(type, name, category);
1149
+ result.set(key, { type, name, category, content, scope });
1150
+ }
1151
+ }
1152
+ } else if (entry.isFile() && entry.name.endsWith(ext)) {
1153
+ const name = entry.name.slice(0, -ext.length);
1154
+ const content = await readFile5(join5(dir, entry.name), "utf-8");
1155
+ const scope = extractScope(content, type);
1156
+ const key = compositeKey(type, name, null);
1157
+ result.set(key, { type, name, category: null, content, scope });
1158
+ }
1159
+ }
1160
+ }
1161
+ async function scanDocsRecursive(docsDir, result) {
1162
+ await scanDocsDir(docsDir, docsDir, result);
1163
+ }
1164
+ async function scanDocsDir(baseDir, currentDir, result) {
1165
+ let entries;
1166
+ try {
1167
+ entries = await readdir3(currentDir, { withFileTypes: true });
1168
+ } catch {
1169
+ return;
1170
+ }
1171
+ for (const entry of entries) {
1172
+ if (entry.isDirectory()) {
1173
+ await scanDocsDir(baseDir, join5(currentDir, entry.name), result);
1174
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
1175
+ const name = entry.name.slice(0, -3);
1176
+ const content = await readFile5(join5(currentDir, entry.name), "utf-8");
1177
+ const scope = extractScope(content, "docs");
1178
+ const relDir = currentDir.slice(baseDir.length + 1);
1179
+ const category = relDir || null;
1180
+ const key = compositeKey("docs", name, category);
1181
+ result.set(key, { type: "docs", name, category, content, scope });
990
1182
  }
991
1183
  }
992
1184
  }
@@ -1000,8 +1192,15 @@ async function scanTemplates(dir, result) {
1000
1192
  for (const entry of entries) {
1001
1193
  if (entry.isFile() && extname(entry.name)) {
1002
1194
  const content = await readFile5(join5(dir, entry.name), "utf-8");
1195
+ const scope = extractScope(content, "template");
1003
1196
  const key = compositeKey("template", entry.name, null);
1004
- result.set(key, { type: "template", name: entry.name, category: null, content });
1197
+ result.set(key, {
1198
+ type: "template",
1199
+ name: entry.name,
1200
+ category: null,
1201
+ content,
1202
+ scope
1203
+ });
1005
1204
  }
1006
1205
  }
1007
1206
  }
@@ -1035,7 +1234,13 @@ async function scanSettings(claudeDir, projectPath, result) {
1035
1234
  }
1036
1235
  const content = JSON.stringify(parsed, null, 2) + "\n";
1037
1236
  const key = compositeKey("settings", "settings", null);
1038
- result.set(key, { type: "settings", name: "settings", category: null, content });
1237
+ result.set(key, {
1238
+ type: "settings",
1239
+ name: "settings",
1240
+ category: null,
1241
+ content,
1242
+ scope: "shared"
1243
+ });
1039
1244
  }
1040
1245
  var init_fileMapper = __esm({
1041
1246
  "src/cli/fileMapper.ts"() {
@@ -1069,7 +1274,9 @@ async function confirmProceed(message) {
1069
1274
  const a = answer.trim().toLowerCase();
1070
1275
  if (a === "" || a === "y" || a === "yes") return true;
1071
1276
  if (a === "n" || a === "no") return false;
1072
- console.log(` Unknown option "${answer.trim()}". Valid: y/yes, n/no, or Enter for yes.`);
1277
+ console.log(
1278
+ ` Unknown option "${answer.trim()}". Valid: y/yes, n/no, or Enter for yes.`
1279
+ );
1073
1280
  }
1074
1281
  } catch (err) {
1075
1282
  if (isAbortError(err)) throw new SyncCancelledError();
@@ -1202,11 +1409,16 @@ async function promptReviewMode() {
1202
1409
  const rl = createInterface2({ input: stdin2, output: stdout2 });
1203
1410
  try {
1204
1411
  while (true) {
1205
- const answer = await rl.question(" Review [o]ne-by-one or [f]older-by-folder? ");
1412
+ const answer = await rl.question(
1413
+ " Review [o]ne-by-one or [f]older-by-folder? "
1414
+ );
1206
1415
  const a = answer.trim().toLowerCase();
1207
- if (a === "o" || a === "one-by-one" || a === "one" || a === "file") return "file";
1416
+ if (a === "o" || a === "one-by-one" || a === "one" || a === "file")
1417
+ return "file";
1208
1418
  if (a === "f" || a === "folder") return "folder";
1209
- console.log(` Unknown option "${answer.trim()}". Valid: o/one-by-one, f/folder`);
1419
+ console.log(
1420
+ ` Unknown option "${answer.trim()}". Valid: o/one-by-one, f/folder`
1421
+ );
1210
1422
  }
1211
1423
  } catch (err) {
1212
1424
  if (isAbortError(err)) throw new SyncCancelledError();
@@ -1245,7 +1457,9 @@ async function reviewFilesOneByOne(items, label, plannedAction, recommendedActio
1245
1457
  break;
1246
1458
  }
1247
1459
  if (result.action === null) {
1248
- console.log(` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(rec, hasContent, false)}`);
1460
+ console.log(
1461
+ ` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(rec, hasContent, false)}`
1462
+ );
1249
1463
  continue;
1250
1464
  }
1251
1465
  results.push(result.action);
@@ -1282,7 +1496,13 @@ async function reviewFolder(folderName, items, label, plannedAction, recommended
1282
1496
  const a = answer.trim().toLowerCase();
1283
1497
  if (a === "o" || a === "one-by-one") {
1284
1498
  rl.close();
1285
- return reviewFilesOneByOne(items, label, plannedAction, recommendedAction, content);
1499
+ return reviewFilesOneByOne(
1500
+ items,
1501
+ label,
1502
+ plannedAction,
1503
+ recommendedAction,
1504
+ content
1505
+ );
1286
1506
  }
1287
1507
  if (a === "r" || a === "recommended") {
1288
1508
  return items.map(
@@ -1303,11 +1523,13 @@ async function reviewFolder(folderName, items, label, plannedAction, recommended
1303
1523
  if (result.action !== null) {
1304
1524
  return items.map(() => result.action);
1305
1525
  }
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`);
1526
+ console.log(
1527
+ ` Unknown option "${answer.trim()}". Valid: ${formatActionPrompt(
1528
+ recommendedAction ? recommendedAction(items[0]) : plannedAction(items[0]),
1529
+ false,
1530
+ true
1531
+ )} [o]ne-by-one`
1532
+ );
1311
1533
  }
1312
1534
  } catch (err) {
1313
1535
  if (isAbortError(err)) throw new SyncCancelledError();
@@ -1893,7 +2115,10 @@ async function runSync() {
1893
2115
  if (!dryRun) {
1894
2116
  try {
1895
2117
  await apiDelete("/sync/lock", { repo_id: repoId });
1896
- } catch {
2118
+ } catch (err) {
2119
+ console.error(
2120
+ ` Warning: failed to release sync lock: ${err instanceof Error ? err.message : String(err)}`
2121
+ );
1897
2122
  }
1898
2123
  }
1899
2124
  }
@@ -1906,37 +2131,42 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1906
2131
  localFiles = await scanLocalFiles(claudeDir, projectPath);
1907
2132
  } catch {
1908
2133
  }
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
- ]);
2134
+ const [defaultsRes, repoSyncRes, repoRes, , fileReposRes] = await Promise.all(
2135
+ [
2136
+ apiGet("/sync/defaults"),
2137
+ apiGet("/sync/files", { repo_id: repoId }),
2138
+ apiGet(`/repos/${repoId}`),
2139
+ apiGet("/sync/state", {
2140
+ repo_id: repoId
2141
+ }),
2142
+ apiGet("/sync/file-repos", {
2143
+ repo_id: repoId
2144
+ })
2145
+ ]
2146
+ );
1920
2147
  const syncStartTime = Date.now();
1921
2148
  const repoData = repoRes.data;
1922
2149
  const remoteDefaults = flattenSyncData(defaultsRes.data);
1923
2150
  const remoteRepoFiles = flattenSyncData(repoSyncRes.data);
1924
- const syncState = syncStateRes.data;
1925
2151
  const fileRepoHashes = /* @__PURE__ */ new Map();
1926
2152
  const fileRepoByClaudeFileId = /* @__PURE__ */ new Map();
1927
2153
  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
2154
+ const baseKey = compositeKey(
2155
+ entry.file_type,
2156
+ entry.file_name,
2157
+ entry.file_category
2158
+ );
2159
+ const scopedKey = `${baseKey}:${entry.file_scope}`;
2160
+ fileRepoHashes.set(scopedKey, entry.last_synced_content_hash);
2161
+ if (!fileRepoHashes.has(baseKey)) {
2162
+ fileRepoHashes.set(baseKey, entry.last_synced_content_hash);
2163
+ }
2164
+ if (entry.claude_file_id) {
2165
+ fileRepoByClaudeFileId.set(
2166
+ entry.claude_file_id,
2167
+ entry.last_synced_content_hash
1933
2168
  );
1934
- fileRepoHashes.set(key, entry.last_synced_content_hash);
1935
2169
  }
1936
- fileRepoByClaudeFileId.set(
1937
- entry.claude_file_id,
1938
- entry.last_synced_content_hash
1939
- );
1940
2170
  }
1941
2171
  const remoteFiles = new Map([...remoteDefaults, ...remoteRepoFiles]);
1942
2172
  console.log(
@@ -1965,6 +2195,7 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1965
2195
  type: local.type,
1966
2196
  name: local.name,
1967
2197
  category: local.category,
2198
+ scope: local.scope,
1968
2199
  isHook: local.type === "hook",
1969
2200
  claudeFileId: null
1970
2201
  });
@@ -1984,6 +2215,7 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1984
2215
  type: remote.type,
1985
2216
  name: remote.name,
1986
2217
  category: remote.category ?? null,
2218
+ scope: remote.scope ?? "shared",
1987
2219
  isHook: remote.type === "hook",
1988
2220
  claudeFileId: remote.id ?? null
1989
2221
  });
@@ -1993,7 +2225,8 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
1993
2225
  continue;
1994
2226
  }
1995
2227
  const localHash = contentHash(local.content);
1996
- const lastSyncedHash = fileRepoHashes.get(key) ?? null;
2228
+ const scopedKey = `${key}:${local.scope}`;
2229
+ const lastSyncedHash = fileRepoHashes.get(scopedKey) ?? fileRepoHashes.get(key) ?? null;
1997
2230
  const localChanged = lastSyncedHash ? localHash !== lastSyncedHash : true;
1998
2231
  let action;
1999
2232
  if (force) {
@@ -2003,8 +2236,8 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2003
2236
  } else if (lastSyncedHash === null) {
2004
2237
  action = "conflict";
2005
2238
  } else {
2006
- const remoteDbHash = remote.content_hash ?? null;
2007
- const remoteChanged = remoteDbHash ? remoteDbHash !== lastSyncedHash : true;
2239
+ const remoteResolvedHash = contentHash(resolvedRemote);
2240
+ const remoteChanged = remoteResolvedHash !== lastSyncedHash;
2008
2241
  if (remoteChanged) {
2009
2242
  action = "conflict";
2010
2243
  } else {
@@ -2023,6 +2256,7 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2023
2256
  type: local.type,
2024
2257
  name: local.name,
2025
2258
  category: local.category,
2259
+ scope: local.scope,
2026
2260
  isHook: local.type === "hook",
2027
2261
  claudeFileId: remote.id ?? null
2028
2262
  });
@@ -2123,7 +2357,8 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2123
2357
  type: p.type,
2124
2358
  name: p.name,
2125
2359
  category: p.category,
2126
- content: p.pushContent
2360
+ content: p.pushContent,
2361
+ scope: p.scope
2127
2362
  }));
2128
2363
  if (toUpsert.length > 0) {
2129
2364
  await apiPost("/sync/files", {
@@ -2146,38 +2381,16 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2146
2381
  if (p.filePath) {
2147
2382
  try {
2148
2383
  await unlink2(p.filePath);
2149
- } catch {
2384
+ } catch (err) {
2385
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
2386
+ console.error(
2387
+ ` Warning: failed to delete ${p.filePath}: ${err.message}`
2388
+ );
2389
+ }
2150
2390
  }
2151
2391
  }
2152
2392
  }
2153
2393
  }
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
2394
  const syncDurationMs = Date.now() - syncStartTime;
2182
2395
  await apiPost("/sync/state", {
2183
2396
  repo_id: repoId,
@@ -2194,9 +2407,13 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2194
2407
  const syncTimestamp = (/* @__PURE__ */ new Date()).toISOString();
2195
2408
  const fileRepoUpdates = [];
2196
2409
  for (const p of toPull) {
2197
- if (p.claudeFileId && p.remoteContent !== null) {
2410
+ if (p.remoteContent !== null) {
2198
2411
  fileRepoUpdates.push({
2199
- claude_file_id: p.claudeFileId,
2412
+ claude_file_id: p.claudeFileId ?? void 0,
2413
+ file_type: p.type,
2414
+ file_name: p.name,
2415
+ file_category: p.category,
2416
+ file_scope: p.scope,
2200
2417
  last_synced_at: syncTimestamp,
2201
2418
  last_synced_content_hash: contentHash(p.remoteContent),
2202
2419
  sync_status: "synced"
@@ -2204,9 +2421,13 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2204
2421
  }
2205
2422
  }
2206
2423
  for (const p of toPush) {
2207
- if (p.claudeFileId && p.localContent !== null) {
2424
+ if (p.localContent !== null) {
2208
2425
  fileRepoUpdates.push({
2209
- claude_file_id: p.claudeFileId,
2426
+ claude_file_id: p.claudeFileId ?? void 0,
2427
+ file_type: p.type,
2428
+ file_name: p.name,
2429
+ file_category: p.category,
2430
+ file_scope: p.scope,
2210
2431
  last_synced_at: syncTimestamp,
2211
2432
  last_synced_content_hash: contentHash(p.localContent),
2212
2433
  sync_status: "synced"
@@ -2227,6 +2448,36 @@ async function runSyncInner(repoId, projectPath, dryRun, force, fix = false) {
2227
2448
  Applied: ${toPull.length} pulled, ${toPush.length} pushed, ${toDelete.length} deleted` + (skipped.length > 0 ? `, ${skipped.length} skipped` : "")
2228
2449
  );
2229
2450
  }
2451
+ const unresolvedConflicts = plan.filter(
2452
+ (p) => p.action === "conflict" || p.action === "skip" && p.localContent !== null && p.remoteContent !== null
2453
+ );
2454
+ if (unresolvedConflicts.length > 0) {
2455
+ let stored = 0;
2456
+ for (const p of unresolvedConflicts) {
2457
+ try {
2458
+ await apiPost("/sync/conflicts", {
2459
+ repo_id: repoId,
2460
+ claude_file_id: p.claudeFileId ?? void 0,
2461
+ file_type: p.type,
2462
+ file_name: p.name,
2463
+ file_category: p.category,
2464
+ file_scope: p.scope,
2465
+ conflict_type: "both_modified",
2466
+ local_content: p.localContent,
2467
+ remote_content: p.remoteContent
2468
+ });
2469
+ stored++;
2470
+ } catch (err) {
2471
+ console.error(`Failed to store conflict for ${p.displayPath}:`, err);
2472
+ }
2473
+ }
2474
+ if (stored > 0) {
2475
+ console.log(
2476
+ `
2477
+ ${stored} conflict(s) stored in DB for later resolution.`
2478
+ );
2479
+ }
2480
+ }
2230
2481
  } else if (dryRun) {
2231
2482
  console.log("\n (dry-run \u2014 no changes)");
2232
2483
  }
@@ -2486,7 +2737,10 @@ function groupByType(items) {
2486
2737
  rule: "Rules",
2487
2738
  hook: "Hooks",
2488
2739
  template: "Templates",
2489
- settings: "Settings"
2740
+ settings: "Settings",
2741
+ context: "Context",
2742
+ docs_stack: "Stack Docs",
2743
+ docs: "Docs"
2490
2744
  };
2491
2745
  for (const item of items) {
2492
2746
  const label = typeLabels[item.type] ?? item.type;
@@ -2504,6 +2758,9 @@ function getLocalFilePath(claudeDir, projectPath, remote) {
2504
2758
  rule: { dir: "rules", ext: ".md" },
2505
2759
  hook: { dir: "hooks", ext: ".sh" },
2506
2760
  template: { dir: "templates", ext: "" },
2761
+ context: { dir: "context", ext: ".md" },
2762
+ docs_stack: { dir: join7("docs", "stack"), ext: ".md" },
2763
+ docs: { dir: "docs", ext: ".md" },
2507
2764
  claude_md: { dir: "", ext: "" },
2508
2765
  settings: { dir: "", ext: "" }
2509
2766
  };
@@ -2517,11 +2774,13 @@ function getLocalFilePath(claudeDir, projectPath, remote) {
2517
2774
  if (remote.type === "command" && remote.category)
2518
2775
  return join7(typeDir, remote.category, `${remote.name}${cfg.ext}`);
2519
2776
  if (remote.type === "template") return join7(typeDir, remote.name);
2777
+ if (remote.category && (remote.type === "context" || remote.type === "docs_stack" || remote.type === "docs"))
2778
+ return join7(typeDir, remote.category, `${remote.name}${cfg.ext}`);
2520
2779
  return join7(typeDir, `${remote.name}${cfg.ext}`);
2521
2780
  }
2522
2781
  function getSyncVersion() {
2523
2782
  try {
2524
- return "1.0.0";
2783
+ return "1.2.0";
2525
2784
  } catch {
2526
2785
  return "unknown";
2527
2786
  }
@@ -2535,7 +2794,10 @@ function flattenSyncData(data) {
2535
2794
  rules: "rule",
2536
2795
  hooks: "hook",
2537
2796
  templates: "template",
2538
- settings: "settings"
2797
+ settings: "settings",
2798
+ contexts: "context",
2799
+ docs_stack: "docs_stack",
2800
+ docs: "docs"
2539
2801
  };
2540
2802
  for (const [syncKey, typeName] of Object.entries(typeMap)) {
2541
2803
  const files = data[syncKey] ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.0.0",
3
+ "version": "1.2.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
  }