@anraktech/sync 0.12.0 → 0.13.1

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 +285 -54
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -31,6 +31,36 @@ var log = {
31
31
  var CONFIG_DIR = join(homedir(), ".anrak-sync");
32
32
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
33
33
  var CACHE_FILE = join(CONFIG_DIR, "cache.json");
34
+ function getWatchFolders(config) {
35
+ const folders = /* @__PURE__ */ new Set();
36
+ if (config.watchFolder) folders.add(config.watchFolder);
37
+ if (config.watchFolders) {
38
+ for (const f of config.watchFolders) folders.add(f);
39
+ }
40
+ return [...folders];
41
+ }
42
+ function addWatchFolder(config, folder) {
43
+ const existing = getWatchFolders(config);
44
+ if (existing.includes(folder)) return false;
45
+ if (!config.watchFolders) config.watchFolders = [];
46
+ config.watchFolders.push(folder);
47
+ saveConfig(config);
48
+ return true;
49
+ }
50
+ function removeWatchFolder(config, folder) {
51
+ const all = getWatchFolders(config);
52
+ if (all.length <= 1) return { removed: false, error: "Cannot remove the only watch folder." };
53
+ if (!all.includes(folder)) return { removed: false, error: `Folder not found: ${folder}` };
54
+ if (config.watchFolder === folder) {
55
+ const remaining = all.filter((f) => f !== folder);
56
+ config.watchFolder = remaining[0];
57
+ config.watchFolders = remaining.slice(1);
58
+ } else {
59
+ config.watchFolders = (config.watchFolders || []).filter((f) => f !== folder);
60
+ }
61
+ saveConfig(config);
62
+ return { removed: true };
63
+ }
34
64
  function ensureDir() {
35
65
  if (!existsSync(CONFIG_DIR)) {
36
66
  mkdirSync(CONFIG_DIR, { recursive: true });
@@ -791,21 +821,47 @@ var TOOLS = [
791
821
  description: "Re-scan the watch folder for new or changed files and sync them.",
792
822
  parameters: { type: "object", properties: {} }
793
823
  }
824
+ },
825
+ {
826
+ type: "function",
827
+ function: {
828
+ name: "manage_watch_folders",
829
+ description: "Add, remove, or list watch folders. Use when user wants to watch a new folder, stop watching a folder, or see which folders are being watched.",
830
+ parameters: {
831
+ type: "object",
832
+ properties: {
833
+ action: {
834
+ type: "string",
835
+ enum: ["add", "remove", "list"],
836
+ description: "Action to perform: 'add' a new folder, 'remove' an existing one, or 'list' all watched folders."
837
+ },
838
+ folderPath: {
839
+ type: "string",
840
+ description: "Absolute path to the folder (required for add/remove). Use full absolute paths like /Users/name/Desktop."
841
+ }
842
+ },
843
+ required: ["action"]
844
+ }
845
+ }
794
846
  }
795
847
  ];
796
848
  function buildSystemPrompt(config) {
849
+ const folders = getWatchFolders(config);
850
+ const folderList = folders.map((f) => ` - ${f}`).join("\n");
797
851
  return `You are AnrakLegal Sync, a terminal assistant that helps lawyers sync local files to their case management system.
798
852
 
799
- Watch folder: ${config.watchFolder}
853
+ Watch folders:
854
+ ${folderList}
800
855
  Server: ${config.apiUrl}
801
856
  Home directory: ${HOME}
802
857
  Platform: ${IS_MAC ? "macOS" : platform()}
803
858
 
804
- You can browse local folders, scan & sync files, list cases, show sync status, and more.
859
+ You can browse local folders, scan & sync files, list cases, show sync status, manage watch folders, and more.
805
860
 
806
861
  Rules:
807
862
  - Be concise. This is a terminal \u2014 1-3 lines unless showing a list.
808
863
  - Do NOT use markdown. Plain text only.
864
+ - WATCH FOLDERS: When user asks to "watch", "add", or "also sync" a folder, use manage_watch_folders with action "add". When they say "stop watching" or "remove" a folder, use action "remove".
809
865
  - IMPORTANT: When user mentions a folder like "downloads" or "desktop", ALWAYS use the full absolute path. Examples:
810
866
  "downloads" or "my downloads" \u2192 ${join2(HOME, "Downloads")}
811
867
  "desktop" \u2192 ${join2(HOME, "Desktop")}
@@ -1043,6 +1099,46 @@ async function executeTool(name, args, ctx) {
1043
1099
  pending: stats.pending
1044
1100
  });
1045
1101
  }
1102
+ case "manage_watch_folders": {
1103
+ const action = args.action;
1104
+ const rawFolder = args.folderPath;
1105
+ if (action === "list") {
1106
+ const folders = getWatchFolders(ctx.config);
1107
+ return JSON.stringify({
1108
+ folders: folders.map((f, i) => ({
1109
+ path: f,
1110
+ primary: f === ctx.config.watchFolder,
1111
+ index: i + 1
1112
+ })),
1113
+ total: folders.length
1114
+ });
1115
+ }
1116
+ if (action === "add") {
1117
+ if (!rawFolder) return JSON.stringify({ error: "Missing folderPath" });
1118
+ const folderPath = normalizePath(rawFolder);
1119
+ if (!ctx.addWatchFolder) {
1120
+ return JSON.stringify({ error: "Watch folder management not available" });
1121
+ }
1122
+ const result = await ctx.addWatchFolder(folderPath);
1123
+ if (result.added) {
1124
+ return JSON.stringify({ success: true, added: result.path || folderPath, total: getWatchFolders(ctx.config).length });
1125
+ }
1126
+ return JSON.stringify({ error: result.error || "Could not add folder" });
1127
+ }
1128
+ if (action === "remove") {
1129
+ if (!rawFolder) return JSON.stringify({ error: "Missing folderPath" });
1130
+ const folderPath = normalizePath(rawFolder);
1131
+ if (!ctx.removeWatchFolder) {
1132
+ return JSON.stringify({ error: "Watch folder management not available" });
1133
+ }
1134
+ const result = ctx.removeWatchFolder(folderPath);
1135
+ if (result.removed) {
1136
+ return JSON.stringify({ success: true, removed: folderPath, total: getWatchFolders(ctx.config).length });
1137
+ }
1138
+ return JSON.stringify({ error: result.error || "Could not remove folder" });
1139
+ }
1140
+ return JSON.stringify({ error: `Unknown action: ${action}. Use add, remove, or list.` });
1141
+ }
1046
1142
  default:
1047
1143
  return JSON.stringify({ error: `Unknown tool: ${name}` });
1048
1144
  }
@@ -1146,7 +1242,10 @@ function startAIAgent(ctx) {
1146
1242
  ];
1147
1243
  const rl = createInterface({ input: stdin, output: stdout });
1148
1244
  const W = Math.min(process.stdout.columns || 60, 60);
1149
- const watchDisplay = ctx.config.watchFolder.startsWith(HOME) ? "~" + ctx.config.watchFolder.slice(HOME.length) : ctx.config.watchFolder;
1245
+ const allFolders = getWatchFolders(ctx.config);
1246
+ const folderDisplays = allFolders.map(
1247
+ (f) => f.startsWith(HOME) ? "~" + f.slice(HOME.length) : f
1248
+ );
1150
1249
  const stats = ctx.bootStats;
1151
1250
  const pad = (s, len) => {
1152
1251
  const visible = s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -1178,14 +1277,20 @@ function startAIAgent(ctx) {
1178
1277
  console.log("");
1179
1278
  console.log(top);
1180
1279
  console.log(empty);
1181
- console.log(row(`${chalk2.dim("Folder")} ${watchDisplay}`));
1280
+ if (folderDisplays.length === 1) {
1281
+ console.log(row(`${chalk2.dim("Folder")} ${folderDisplays[0]}`));
1282
+ } else {
1283
+ console.log(row(`${chalk2.dim("Folders")} ${chalk2.cyan(String(folderDisplays.length))} watched`));
1284
+ for (const fd of folderDisplays) {
1285
+ console.log(row(`${chalk2.dim(" \xB7")} ${fd}`));
1286
+ }
1287
+ }
1182
1288
  console.log(row(`${chalk2.dim("Server")} ${ctx.config.apiUrl}`));
1183
1289
  if (statusLine) {
1184
1290
  console.log(row(`${chalk2.dim("Status")} ${statusLine}`));
1185
1291
  }
1186
1292
  console.log(empty);
1187
- console.log(row(chalk2.dim("Type naturally, or use / commands.")));
1188
- console.log(row(chalk2.dim("Type /help to see all commands.")));
1293
+ console.log(row(chalk2.dim("Type naturally, or press / for commands.")));
1189
1294
  console.log(empty);
1190
1295
  console.log(bot);
1191
1296
  console.log("");
@@ -1246,10 +1351,80 @@ function startAIAgent(ctx) {
1246
1351
  }
1247
1352
  },
1248
1353
  folder: {
1249
- description: "Show watch folder path",
1250
- handler: () => {
1251
- console.log(` ${chalk2.dim("Watch folder")} ${ctx.config.watchFolder}`);
1252
- console.log(` ${chalk2.dim("Config dir")} ${getConfigDir()}`);
1354
+ description: "Manage watch folders (add/remove/list)",
1355
+ handler: async () => {
1356
+ const sub = currentSlashArgs.trim();
1357
+ if (!sub || sub === "list") {
1358
+ const folders = getWatchFolders(ctx.config);
1359
+ console.log("");
1360
+ console.log(chalk2.bold(` ${folders.length} watch folder${folders.length !== 1 ? "s" : ""}`));
1361
+ console.log(chalk2.dim(" " + "\u2500".repeat(40)));
1362
+ for (let i = 0; i < folders.length; i++) {
1363
+ const f = folders[i];
1364
+ const display = f.startsWith(HOME) ? "~" + f.slice(HOME.length) : f;
1365
+ const tag = f === ctx.config.watchFolder ? chalk2.cyan(" (primary)") : "";
1366
+ console.log(` ${chalk2.dim(`${i + 1}.`)} ${display}${tag}`);
1367
+ }
1368
+ console.log("");
1369
+ console.log(chalk2.dim(" /folder add <path> Add a new watch folder"));
1370
+ console.log(chalk2.dim(" /folder remove <path> Stop watching a folder"));
1371
+ console.log(chalk2.dim(" /folder set <path> Set primary watch folder"));
1372
+ return;
1373
+ }
1374
+ const parts = sub.split(/\s+/);
1375
+ const action = parts[0].toLowerCase();
1376
+ const folderArg = parts.slice(1).join(" ");
1377
+ if (action === "add") {
1378
+ if (!folderArg) {
1379
+ console.log(chalk2.red(" Usage: /folder add <path>"));
1380
+ return;
1381
+ }
1382
+ const absPath = normalizePath(folderArg);
1383
+ if (!ctx.addWatchFolder) {
1384
+ console.log(chalk2.red(" Watch folder management not available."));
1385
+ return;
1386
+ }
1387
+ const result = await ctx.addWatchFolder(absPath);
1388
+ if (result.added) {
1389
+ const display = (result.path || absPath).startsWith(HOME) ? "~" + (result.path || absPath).slice(HOME.length) : result.path || absPath;
1390
+ console.log(chalk2.green(` Added: ${display}`));
1391
+ } else {
1392
+ console.log(chalk2.yellow(` ${result.error || "Could not add folder"}`));
1393
+ }
1394
+ } else if (action === "remove" || action === "rm") {
1395
+ if (!folderArg) {
1396
+ console.log(chalk2.red(" Usage: /folder remove <path>"));
1397
+ return;
1398
+ }
1399
+ const absPath = normalizePath(folderArg);
1400
+ if (!ctx.removeWatchFolder) {
1401
+ console.log(chalk2.red(" Watch folder management not available."));
1402
+ return;
1403
+ }
1404
+ const result = ctx.removeWatchFolder(absPath);
1405
+ if (result.removed) {
1406
+ const display = absPath.startsWith(HOME) ? "~" + absPath.slice(HOME.length) : absPath;
1407
+ console.log(chalk2.green(` Removed: ${display}`));
1408
+ } else {
1409
+ console.log(chalk2.yellow(` ${result.error || "Could not remove folder"}`));
1410
+ }
1411
+ } else if (action === "set") {
1412
+ if (!folderArg) {
1413
+ console.log(chalk2.red(" Usage: /folder set <path>"));
1414
+ return;
1415
+ }
1416
+ const absPath = normalizePath(folderArg);
1417
+ if (!existsSync2(absPath) || !statSync(absPath).isDirectory()) {
1418
+ console.log(chalk2.red(` Not a valid directory: ${absPath}`));
1419
+ return;
1420
+ }
1421
+ ctx.config.watchFolder = absPath;
1422
+ saveConfig(ctx.config);
1423
+ const display = absPath.startsWith(HOME) ? "~" + absPath.slice(HOME.length) : absPath;
1424
+ console.log(chalk2.green(` Primary folder set to: ${display}`));
1425
+ } else {
1426
+ console.log(chalk2.dim(" Usage: /folder [list|add|remove|set] <path>"));
1427
+ }
1253
1428
  }
1254
1429
  },
1255
1430
  mappings: {
@@ -1330,6 +1505,7 @@ function startAIAgent(ctx) {
1330
1505
  }
1331
1506
  }
1332
1507
  };
1508
+ let currentSlashArgs = "";
1333
1509
  async function promptLoop() {
1334
1510
  while (true) {
1335
1511
  let input;
@@ -1341,9 +1517,27 @@ function startAIAgent(ctx) {
1341
1517
  const trimmed = input.trim();
1342
1518
  if (!trimmed) continue;
1343
1519
  if (trimmed.startsWith("/")) {
1344
- const cmd = trimmed.slice(1).toLowerCase().split(/\s+/)[0];
1520
+ const withoutSlash = trimmed.slice(1);
1521
+ const parts = withoutSlash.split(/\s+/);
1522
+ const cmd = (parts[0] || "").toLowerCase();
1523
+ if (!cmd) {
1524
+ console.log("");
1525
+ console.log(chalk2.bold(" Commands"));
1526
+ console.log(chalk2.dim(" " + "\u2500".repeat(Math.min(process.stdout.columns || 60, 60) - 4)));
1527
+ for (const [name, { description }] of Object.entries(slashCommands)) {
1528
+ const label = chalk2.cyan(`/${name}`);
1529
+ const dots = chalk2.dim(".".repeat(Math.max(2, 18 - name.length)));
1530
+ console.log(` ${label} ${dots} ${chalk2.dim(description)}`);
1531
+ }
1532
+ console.log("");
1533
+ console.log(chalk2.dim(" Or just type naturally \u2014 the AI understands plain English."));
1534
+ console.log("");
1535
+ continue;
1536
+ }
1345
1537
  if (slashCommands[cmd]) {
1538
+ currentSlashArgs = parts.slice(1).join(" ");
1346
1539
  await slashCommands[cmd].handler();
1540
+ currentSlashArgs = "";
1347
1541
  console.log("");
1348
1542
  continue;
1349
1543
  }
@@ -1351,7 +1545,7 @@ function startAIAgent(ctx) {
1351
1545
  if (matches.length > 0) {
1352
1546
  console.log(chalk2.dim(` Did you mean: ${matches.map((m) => chalk2.cyan(`/${m}`)).join(", ")}?`));
1353
1547
  } else {
1354
- console.log(chalk2.dim(` Unknown command. Type ${chalk2.cyan("/help")} for available commands.`));
1548
+ console.log(chalk2.dim(` Unknown command. Type ${chalk2.cyan("/")} for available commands.`));
1355
1549
  }
1356
1550
  console.log("");
1357
1551
  continue;
@@ -1381,10 +1575,9 @@ function startAIAgent(ctx) {
1381
1575
 
1382
1576
  // src/watcher.ts
1383
1577
  async function scanFolder(config) {
1384
- const folder = config.watchFolder;
1578
+ const folders = getWatchFolders(config);
1385
1579
  let scanned = 0;
1386
- let queued = 0;
1387
- function walk(dir) {
1580
+ function walk(dir, watchRoot) {
1388
1581
  let entries;
1389
1582
  try {
1390
1583
  entries = readdirSync2(dir);
@@ -1401,14 +1594,18 @@ async function scanFolder(config) {
1401
1594
  continue;
1402
1595
  }
1403
1596
  if (s.isDirectory()) {
1404
- walk(fullPath);
1597
+ walk(fullPath, watchRoot);
1405
1598
  } else if (s.isFile() && isSupportedFile(entry)) {
1406
1599
  scanned++;
1407
- void enqueue(fullPath, folder).then(() => queued++);
1600
+ void enqueue(fullPath, watchRoot);
1408
1601
  }
1409
1602
  }
1410
1603
  }
1411
- walk(folder);
1604
+ for (const folder of folders) {
1605
+ if (existsSync3(folder)) {
1606
+ walk(folder, folder);
1607
+ }
1608
+ }
1412
1609
  await new Promise((r) => setTimeout(r, 500));
1413
1610
  return { scanned, queued: queueSize() };
1414
1611
  }
@@ -1475,7 +1672,7 @@ async function scanExternalFolder(config, folderPath, cases) {
1475
1672
  }
1476
1673
  }
1477
1674
  async function startWatching(config) {
1478
- const folder = config.watchFolder;
1675
+ const folders = getWatchFolders(config);
1479
1676
  let cases = await listCases(config);
1480
1677
  const { scanned, queued } = await scanFolder(config);
1481
1678
  if (queued > 0) {
@@ -1490,19 +1687,7 @@ async function startWatching(config) {
1490
1687
  } catch {
1491
1688
  }
1492
1689
  }, 5 * 60 * 1e3);
1493
- const watcher = watch(folder, {
1494
- ignored: /(^|[\/\\])(\.|~\$|Thumbs\.db|desktop\.ini)/,
1495
- persistent: true,
1496
- ignoreInitial: true,
1497
- // We already scanned
1498
- awaitWriteFinish: {
1499
- stabilityThreshold: 2e3,
1500
- // Wait 2s after last change
1501
- pollInterval: 500
1502
- },
1503
- depth: 5
1504
- // Max folder depth
1505
- });
1690
+ const watchers = /* @__PURE__ */ new Map();
1506
1691
  let debounceTimer = null;
1507
1692
  function scheduleProcess() {
1508
1693
  if (debounceTimer) clearTimeout(debounceTimer);
@@ -1517,26 +1702,44 @@ async function startWatching(config) {
1517
1702
  }
1518
1703
  }, 3e3);
1519
1704
  }
1520
- watcher.on("add", async (path) => {
1521
- const filename = basename5(path);
1522
- if (!isSupportedFile(filename) || isIgnoredFile(filename)) return;
1523
- log.file("detected", relative2(folder, path));
1524
- await enqueue(path, folder);
1525
- scheduleProcess();
1526
- });
1527
- watcher.on("change", async (path) => {
1528
- const filename = basename5(path);
1529
- if (!isSupportedFile(filename) || isIgnoredFile(filename)) return;
1530
- log.file("changed", relative2(folder, path));
1531
- await enqueue(path, folder);
1532
- scheduleProcess();
1533
- });
1534
- watcher.on("unlink", (path) => {
1535
- log.file("deleted", relative2(folder, path));
1536
- });
1537
- watcher.on("error", (err) => {
1538
- log.error(`Watcher error: ${err instanceof Error ? err.message : String(err)}`);
1539
- });
1705
+ function createWatcher(folder) {
1706
+ if (watchers.has(folder)) return;
1707
+ const w = watch(folder, {
1708
+ ignored: /(^|[\/\\])(\.|~\$|Thumbs\.db|desktop\.ini)/,
1709
+ persistent: true,
1710
+ ignoreInitial: true,
1711
+ awaitWriteFinish: {
1712
+ stabilityThreshold: 2e3,
1713
+ pollInterval: 500
1714
+ },
1715
+ depth: 5
1716
+ });
1717
+ w.on("add", async (path) => {
1718
+ const filename = basename5(path);
1719
+ if (!isSupportedFile(filename) || isIgnoredFile(filename)) return;
1720
+ log.file("detected", relative2(folder, path));
1721
+ await enqueue(path, folder);
1722
+ scheduleProcess();
1723
+ });
1724
+ w.on("change", async (path) => {
1725
+ const filename = basename5(path);
1726
+ if (!isSupportedFile(filename) || isIgnoredFile(filename)) return;
1727
+ log.file("changed", relative2(folder, path));
1728
+ await enqueue(path, folder);
1729
+ scheduleProcess();
1730
+ });
1731
+ w.on("unlink", (path) => {
1732
+ log.file("deleted", relative2(folder, path));
1733
+ });
1734
+ w.on("error", (err) => {
1735
+ log.error(`Watcher error (${basename5(folder)}): ${err instanceof Error ? err.message : String(err)}`);
1736
+ });
1737
+ watchers.set(folder, w);
1738
+ log.debug(`Watching: ${folder}`);
1739
+ }
1740
+ for (const folder of folders) {
1741
+ createWatcher(folder);
1742
+ }
1540
1743
  startAIAgent({
1541
1744
  config,
1542
1745
  bootStats: { cases: cases.length, scanned, queued },
@@ -1545,7 +1748,8 @@ async function startWatching(config) {
1545
1748
  cases = await listCases(config);
1546
1749
  },
1547
1750
  triggerScan: async () => {
1548
- log.info(`Re-scanning ${folder}...`);
1751
+ const allFolders = getWatchFolders(config);
1752
+ log.info(`Re-scanning ${allFolders.length} folder(s)...`);
1549
1753
  const result = await scanFolder(config);
1550
1754
  log.info(`Scanned ${result.scanned} files, ${result.queued} need syncing`);
1551
1755
  if (result.queued > 0) {
@@ -1555,13 +1759,40 @@ async function startWatching(config) {
1555
1759
  },
1556
1760
  scanFolder: async (folderPath) => {
1557
1761
  await scanExternalFolder(config, folderPath, cases);
1762
+ },
1763
+ addWatchFolder: async (folderPath) => {
1764
+ const absPath = resolve2(folderPath);
1765
+ if (!existsSync3(absPath) || !statSync2(absPath).isDirectory()) {
1766
+ return { added: false, error: `Not a valid directory: ${absPath}` };
1767
+ }
1768
+ const added = addWatchFolder(config, absPath);
1769
+ if (!added) {
1770
+ return { added: false, error: "Folder already being watched" };
1771
+ }
1772
+ createWatcher(absPath);
1773
+ log.success(`Now watching: ${absPath}`);
1774
+ return { added: true, path: absPath };
1775
+ },
1776
+ removeWatchFolder: (folderPath) => {
1777
+ const absPath = resolve2(folderPath);
1778
+ const result = removeWatchFolder(config, absPath);
1779
+ if (result.removed) {
1780
+ const w = watchers.get(absPath);
1781
+ if (w) {
1782
+ void w.close();
1783
+ watchers.delete(absPath);
1784
+ }
1785
+ log.success(`Stopped watching: ${absPath}`);
1786
+ }
1787
+ return result;
1558
1788
  }
1559
1789
  });
1560
1790
  const shutdown = () => {
1561
1791
  log.info("Shutting down...");
1562
1792
  clearInterval(refreshInterval);
1563
1793
  if (debounceTimer) clearTimeout(debounceTimer);
1564
- watcher.close().then(() => {
1794
+ const closePromises = [...watchers.values()].map((w) => w.close());
1795
+ Promise.all(closePromises).then(() => {
1565
1796
  log.success("Stopped");
1566
1797
  process.exit(0);
1567
1798
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anraktech/sync",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "AnrakLegal desktop file sync agent — watches local folders and syncs to case management",
5
5
  "type": "module",
6
6
  "bin": {