memax-cli 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (127) hide show
  1. package/assets/skills/memax-memory/SKILL.md +154 -0
  2. package/dist/commands/agent-sessions.d.ts +32 -0
  3. package/dist/commands/agent-sessions.d.ts.map +1 -0
  4. package/dist/commands/agent-sessions.js +761 -0
  5. package/dist/commands/agent-sessions.js.map +1 -0
  6. package/dist/commands/agent-sessions.test.d.ts +2 -0
  7. package/dist/commands/agent-sessions.test.d.ts.map +1 -0
  8. package/dist/commands/agent-sessions.test.js +82 -0
  9. package/dist/commands/agent-sessions.test.js.map +1 -0
  10. package/dist/commands/auth.d.ts +1 -0
  11. package/dist/commands/auth.d.ts.map +1 -1
  12. package/dist/commands/auth.js +14 -7
  13. package/dist/commands/auth.js.map +1 -1
  14. package/dist/commands/capture.d.ts +17 -0
  15. package/dist/commands/capture.d.ts.map +1 -0
  16. package/dist/commands/capture.js +61 -0
  17. package/dist/commands/capture.js.map +1 -0
  18. package/dist/commands/delete.d.ts +1 -1
  19. package/dist/commands/delete.d.ts.map +1 -1
  20. package/dist/commands/delete.js +22 -5
  21. package/dist/commands/delete.js.map +1 -1
  22. package/dist/commands/hub.d.ts +4 -0
  23. package/dist/commands/hub.d.ts.map +1 -0
  24. package/dist/commands/hub.js +53 -0
  25. package/dist/commands/hub.js.map +1 -0
  26. package/dist/commands/list.d.ts +2 -1
  27. package/dist/commands/list.d.ts.map +1 -1
  28. package/dist/commands/list.js +35 -8
  29. package/dist/commands/list.js.map +1 -1
  30. package/dist/commands/login.d.ts.map +1 -1
  31. package/dist/commands/login.js +27 -5
  32. package/dist/commands/login.js.map +1 -1
  33. package/dist/commands/mcp.d.ts.map +1 -1
  34. package/dist/commands/mcp.js +275 -42
  35. package/dist/commands/mcp.js.map +1 -1
  36. package/dist/commands/push.d.ts +4 -1
  37. package/dist/commands/push.d.ts.map +1 -1
  38. package/dist/commands/push.js +51 -11
  39. package/dist/commands/push.js.map +1 -1
  40. package/dist/commands/recall.d.ts +7 -0
  41. package/dist/commands/recall.d.ts.map +1 -1
  42. package/dist/commands/recall.js +164 -39
  43. package/dist/commands/recall.js.map +1 -1
  44. package/dist/commands/recall.test.d.ts +2 -0
  45. package/dist/commands/recall.test.d.ts.map +1 -0
  46. package/dist/commands/recall.test.js +31 -0
  47. package/dist/commands/recall.test.js.map +1 -0
  48. package/dist/commands/setup-hooks.d.ts +12 -0
  49. package/dist/commands/setup-hooks.d.ts.map +1 -0
  50. package/dist/commands/setup-hooks.js +193 -0
  51. package/dist/commands/setup-hooks.js.map +1 -0
  52. package/dist/commands/setup-instructions.d.ts +21 -0
  53. package/dist/commands/setup-instructions.d.ts.map +1 -0
  54. package/dist/commands/setup-instructions.js +172 -0
  55. package/dist/commands/setup-instructions.js.map +1 -0
  56. package/dist/commands/setup-mcp.d.ts +14 -0
  57. package/dist/commands/setup-mcp.d.ts.map +1 -0
  58. package/dist/commands/setup-mcp.js +278 -0
  59. package/dist/commands/setup-mcp.js.map +1 -0
  60. package/dist/commands/setup-types.d.ts +20 -0
  61. package/dist/commands/setup-types.d.ts.map +1 -0
  62. package/dist/commands/setup-types.js +60 -0
  63. package/dist/commands/setup-types.js.map +1 -0
  64. package/dist/commands/setup.d.ts +6 -1
  65. package/dist/commands/setup.d.ts.map +1 -1
  66. package/dist/commands/setup.js +146 -306
  67. package/dist/commands/setup.js.map +1 -1
  68. package/dist/commands/show.d.ts.map +1 -1
  69. package/dist/commands/show.js +10 -13
  70. package/dist/commands/show.js.map +1 -1
  71. package/dist/commands/sync.d.ts +33 -2
  72. package/dist/commands/sync.d.ts.map +1 -1
  73. package/dist/commands/sync.js +1066 -119
  74. package/dist/commands/sync.js.map +1 -1
  75. package/dist/commands/sync.test.d.ts +2 -0
  76. package/dist/commands/sync.test.d.ts.map +1 -0
  77. package/dist/commands/sync.test.js +130 -0
  78. package/dist/commands/sync.test.js.map +1 -0
  79. package/dist/commands/topic.d.ts +20 -0
  80. package/dist/commands/topic.d.ts.map +1 -0
  81. package/dist/commands/topic.js +87 -0
  82. package/dist/commands/topic.js.map +1 -0
  83. package/dist/index.js +131 -14
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/client.d.ts +7 -0
  86. package/dist/lib/client.d.ts.map +1 -0
  87. package/dist/lib/client.js +72 -0
  88. package/dist/lib/client.js.map +1 -0
  89. package/dist/lib/config.d.ts +42 -0
  90. package/dist/lib/config.d.ts.map +1 -1
  91. package/dist/lib/config.js +64 -1
  92. package/dist/lib/config.js.map +1 -1
  93. package/dist/lib/project-context.d.ts +50 -0
  94. package/dist/lib/project-context.d.ts.map +1 -0
  95. package/dist/lib/project-context.js +201 -0
  96. package/dist/lib/project-context.js.map +1 -0
  97. package/dist/lib/project-context.test.d.ts +2 -0
  98. package/dist/lib/project-context.test.d.ts.map +1 -0
  99. package/dist/lib/project-context.test.js +75 -0
  100. package/dist/lib/project-context.test.js.map +1 -0
  101. package/dist/lib/prompt.d.ts +7 -0
  102. package/dist/lib/prompt.d.ts.map +1 -0
  103. package/dist/lib/prompt.js +41 -0
  104. package/dist/lib/prompt.js.map +1 -0
  105. package/package.json +17 -13
  106. package/.vscode/mcp.json +0 -8
  107. package/dist/lib/api.d.ts +0 -4
  108. package/dist/lib/api.d.ts.map +0 -1
  109. package/dist/lib/api.js +0 -95
  110. package/dist/lib/api.js.map +0 -1
  111. package/src/commands/auth.ts +0 -92
  112. package/src/commands/config.ts +0 -27
  113. package/src/commands/delete.ts +0 -20
  114. package/src/commands/hook.ts +0 -243
  115. package/src/commands/list.ts +0 -38
  116. package/src/commands/login.ts +0 -162
  117. package/src/commands/mcp.ts +0 -357
  118. package/src/commands/push.ts +0 -82
  119. package/src/commands/recall.ts +0 -163
  120. package/src/commands/setup.ts +0 -682
  121. package/src/commands/show.ts +0 -35
  122. package/src/commands/sync.ts +0 -403
  123. package/src/index.ts +0 -192
  124. package/src/lib/api.ts +0 -110
  125. package/src/lib/config.ts +0 -61
  126. package/src/lib/credentials.ts +0 -42
  127. package/tsconfig.json +0 -9
@@ -1,8 +1,12 @@
1
1
  import chalk from "chalk";
2
- import { readFileSync, readdirSync, statSync, watch, existsSync, } from "node:fs";
3
- import { join, relative, extname, resolve, basename } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, watch, existsSync, } from "node:fs";
4
+ import { join, relative, extname, resolve, dirname } from "node:path";
4
5
  import { homedir } from "node:os";
5
- import { apiPost } from "../lib/api.js";
6
+ import { getClient } from "../lib/client.js";
7
+ import { getProjectScope, resolveProjectScope, resolveClaudeProjectFolder, normalizeFilePath, readMemaxYmlConfig, detectProjectContext, } from "../lib/project-context.js";
8
+ import { getOrCreateDeviceID, listSyncSources, updateSyncSourceRun, } from "../lib/config.js";
9
+ import { confirm, ask, confirmDefault } from "../lib/prompt.js";
6
10
  const DEFAULT_IGNORE = new Set([
7
11
  "node_modules",
8
12
  ".git",
@@ -37,47 +41,412 @@ const SUPPORTED_EXTENSIONS = new Set([
37
41
  ".proto",
38
42
  ".dockerfile",
39
43
  ]);
40
- export async function syncAgentMemoryCommand() {
41
- await syncAgentMemory();
44
+ export async function syncAgentMemoryCommand(options = {}) {
45
+ await syncAgentMemory(options);
42
46
  }
43
- export async function syncCommand(directory, options) {
44
- if (options.agentMemory || directory === "agents") {
45
- await syncAgentMemory();
47
+ export async function listAgentConfigsCommand() {
48
+ let configs;
49
+ try {
50
+ const result = await getClient().configs.list();
51
+ configs = result.configs;
52
+ }
53
+ catch (err) {
54
+ console.error(chalk.red(` Failed to fetch configs: ${err.message}\n`));
55
+ return;
56
+ }
57
+ if (!configs || configs.length === 0) {
58
+ console.log(chalk.yellow(" No synced configs. Run: memax agent-configs sync\n"));
59
+ return;
60
+ }
61
+ // Group by agent
62
+ const byAgent = new Map();
63
+ for (const c of configs) {
64
+ const list = byAgent.get(c.agent) ?? [];
65
+ list.push({
66
+ id: c.id,
67
+ filePath: c.file_path,
68
+ scope: c.scope,
69
+ updatedAt: c.updated_at,
70
+ });
71
+ byAgent.set(c.agent, list);
72
+ }
73
+ console.log();
74
+ for (const [agent, files] of byAgent) {
75
+ console.log(` ${chalk.cyan(agent)}`);
76
+ for (const f of files) {
77
+ const scopeTag = f.scope === "global"
78
+ ? chalk.dim("global")
79
+ : chalk.dim(f.scope.replace("project:", ""));
80
+ const age = formatAge(f.updatedAt);
81
+ console.log(` ${f.filePath} ${scopeTag} ${chalk.dim(age)} ${chalk.dim(f.id.slice(0, 8))}`);
82
+ }
83
+ console.log();
84
+ }
85
+ console.log(chalk.gray(` ${configs.length} config${configs.length > 1 ? "s" : ""} synced to cloud.\n`));
86
+ }
87
+ export function classifyAgentConfigPlacement(agent, filePath, scope, options = {}) {
88
+ const key = `${agent}|${normalizeFilePath(filePath)}|${scope}`;
89
+ const existing = options.localByKey?.get(key);
90
+ if (existing) {
91
+ return {
92
+ kind: "present",
93
+ path: existing.path,
94
+ reason: "present locally",
95
+ };
96
+ }
97
+ const cwd = options.cwd ?? process.cwd();
98
+ const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
99
+ if (scope.startsWith("project:") &&
100
+ scope !== "project" &&
101
+ scope !== currentProjectScope) {
102
+ return {
103
+ kind: "different_project",
104
+ reason: `belongs to ${scope.replace(/^project:/, "")}`,
105
+ };
106
+ }
107
+ const path = resolveAgentConfigWritePath(agent, filePath, scope, options);
108
+ if (path) {
109
+ return {
110
+ kind: "restorable",
111
+ path,
112
+ reason: "safe restore path available",
113
+ };
114
+ }
115
+ return {
116
+ kind: "unresolved",
117
+ reason: "no safe restore path on this machine",
118
+ };
119
+ }
120
+ export async function doctorAgentConfigsCommand() {
121
+ const cwd = process.cwd();
122
+ const home = homedir();
123
+ const deviceID = getOrCreateDeviceID();
124
+ const project = resolveProjectScope(cwd);
125
+ const memaxYml = readMemaxYmlConfig(cwd);
126
+ const locations = discoverAgentConfigs();
127
+ const localConfigs = locations.filter((loc) => {
128
+ if (!existsSync(loc.path))
129
+ return false;
130
+ try {
131
+ const stat = statSync(loc.path);
132
+ return stat.isFile() && stat.size > 0;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ });
138
+ const localByKey = new Map();
139
+ for (const loc of localConfigs) {
140
+ localByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
141
+ }
142
+ let cloudConfigs = null;
143
+ try {
144
+ cloudConfigs = await getClient().configs.list();
145
+ }
146
+ catch (err) {
147
+ console.error(chalk.red(` Failed to fetch cloud configs: ${err.message}\n`));
148
+ }
149
+ const scopeSource = project.source === "memax_yml"
150
+ ? ".memax.yml project_id"
151
+ : project.source === "git_remote"
152
+ ? "git origin"
153
+ : "no canonical project identity";
154
+ console.log(chalk.bold("\n Memax Agent Config Doctor\n"));
155
+ console.log(chalk.white(" Device"));
156
+ console.log(` ID ${chalk.bold(deviceID)}`);
157
+ console.log(` Home ${chalk.gray(home)}`);
158
+ console.log();
159
+ console.log(chalk.white(" Project"));
160
+ console.log(` CWD ${chalk.gray(cwd)}`);
161
+ console.log(` Scope ${chalk.bold(project.scope)}`);
162
+ console.log(` Source ${chalk.gray(scopeSource)}`);
163
+ if (memaxYml?.hub) {
164
+ console.log(` Hub ${chalk.gray(memaxYml.hub)}`);
165
+ }
166
+ if (memaxYml?.project_id) {
167
+ console.log(` project_id ${chalk.gray(memaxYml.project_id)}`);
168
+ }
169
+ if (project.warning) {
170
+ console.log(` Warning ${chalk.yellow(project.warning)}`);
171
+ }
172
+ if (project.scope === "project") {
173
+ console.log(` Note ${chalk.yellow("project-scoped cross-device restore is disabled until git origin or .memax.yml project_id is available")}`);
174
+ }
175
+ console.log();
176
+ const byAgent = new Map();
177
+ for (const loc of localConfigs) {
178
+ const group = byAgent.get(loc.agent) ?? [];
179
+ group.push(loc);
180
+ byAgent.set(loc.agent, group);
181
+ }
182
+ console.log(chalk.white(" Local Discovery"));
183
+ if (localConfigs.length === 0) {
184
+ console.log(` ${chalk.gray("No local agent configs discovered.")}`);
185
+ }
186
+ else {
187
+ for (const [agent, group] of byAgent) {
188
+ console.log(` ${chalk.cyan(formatAgentName(agent))}`);
189
+ for (const loc of group) {
190
+ const scopeLabel = loc.scope === "global"
191
+ ? "global"
192
+ : loc.scope.replace(/^project:/, "");
193
+ console.log(` • ${loc.filePath} ${chalk.gray(scopeLabel)} ${chalk.gray(loc.path)}`);
194
+ }
195
+ }
196
+ }
197
+ console.log();
198
+ if (!cloudConfigs) {
199
+ console.log(chalk.gray(" Cloud inspection skipped because cloud config fetch failed.\n"));
200
+ return;
201
+ }
202
+ const placements = cloudConfigs.configs.map((config) => ({
203
+ config,
204
+ placement: classifyAgentConfigPlacement(config.agent, config.file_path, config.scope, {
205
+ cwd,
206
+ home,
207
+ currentProjectScope: project.scope,
208
+ localByKey,
209
+ findClaudeProjectDir,
210
+ }),
211
+ }));
212
+ const present = placements.filter((p) => p.placement.kind === "present");
213
+ const restorable = placements.filter((p) => p.placement.kind === "restorable");
214
+ const differentProject = placements.filter((p) => p.placement.kind === "different_project");
215
+ const unresolved = placements.filter((p) => p.placement.kind === "unresolved");
216
+ console.log(chalk.white(" Cloud Coverage"));
217
+ console.log(` Present locally ${chalk.bold(String(present.length))}`);
218
+ console.log(` Restorable here ${chalk.bold(String(restorable.length))}`);
219
+ console.log(` Other project ${chalk.bold(String(differentProject.length))}`);
220
+ console.log(` Unresolved here ${chalk.bold(String(unresolved.length))}`);
221
+ console.log();
222
+ printPlacementSection(" Restorable Here", chalk.cyan, restorable, (item) => [
223
+ item.config.agent,
224
+ item.config.file_path,
225
+ item.placement.path ?? "",
226
+ ]);
227
+ printPlacementSection(" Different Project", chalk.yellow, differentProject, (item) => [item.config.agent, item.config.file_path, item.placement.reason]);
228
+ printPlacementSection(" Unresolved", chalk.magenta, unresolved, (item) => [
229
+ item.config.agent,
230
+ item.config.file_path,
231
+ item.placement.reason,
232
+ ]);
233
+ console.log(chalk.gray(" Use this command to verify what sync can restore safely on this machine.\n"));
234
+ }
235
+ export async function deleteAgentConfigsCommand() {
236
+ let configs;
237
+ try {
238
+ const result = await getClient().configs.list();
239
+ configs = result.configs;
240
+ }
241
+ catch (err) {
242
+ console.error(chalk.red(` Failed to fetch configs: ${err.message}\n`));
243
+ return;
244
+ }
245
+ if (!configs || configs.length === 0) {
246
+ console.log(chalk.yellow(" No synced configs to delete.\n"));
247
+ return;
248
+ }
249
+ // Display numbered list grouped by agent
250
+ const items = [];
251
+ let currentAgent = "";
252
+ for (const c of configs) {
253
+ if (c.agent !== currentAgent) {
254
+ if (currentAgent)
255
+ console.log();
256
+ console.log(` ${chalk.cyan(c.agent)}`);
257
+ currentAgent = c.agent;
258
+ }
259
+ items.push({
260
+ id: c.id,
261
+ agent: c.agent,
262
+ filePath: c.file_path,
263
+ scope: c.scope,
264
+ version: c.version,
265
+ });
266
+ const idx = chalk.dim(`${items.length}.`);
267
+ const scopeTag = c.scope === "global"
268
+ ? chalk.dim("global")
269
+ : chalk.dim(c.scope.replace("project:", ""));
270
+ console.log(` ${idx} ${c.file_path} ${scopeTag}`);
271
+ }
272
+ console.log();
273
+ const answer = await ask(" Select configs to delete (comma-separated numbers, or 'q' to quit): ");
274
+ if (!answer || answer.trim().toLowerCase() === "q") {
275
+ console.log(chalk.gray(" Cancelled.\n"));
276
+ return;
277
+ }
278
+ const indices = answer
279
+ .split(",")
280
+ .map((s) => parseInt(s.trim(), 10))
281
+ .filter((n) => !isNaN(n) && n >= 1 && n <= items.length);
282
+ if (indices.length === 0) {
283
+ console.log(chalk.gray(" No valid selections.\n"));
284
+ return;
285
+ }
286
+ const modeAnswer = await ask(" Delete from [l] this device only, [e] everywhere, or [s] skip? ");
287
+ const mode = modeAnswer.trim().toLowerCase();
288
+ if (mode !== "l" && mode !== "e") {
289
+ console.log(chalk.gray(" Cancelled.\n"));
290
+ return;
291
+ }
292
+ console.log();
293
+ for (const i of indices) {
294
+ const item = items[i - 1];
295
+ console.log(chalk.yellow(` ${item.agent}/${item.filePath}`));
296
+ }
297
+ const ok = await confirm(mode === "e"
298
+ ? `\n Delete ${indices.length} config${indices.length > 1 ? "s" : ""} everywhere? (y/N) `
299
+ : `\n Remove ${indices.length} config${indices.length > 1 ? "s" : ""} from this device only? (y/N) `);
300
+ if (!ok) {
301
+ console.log(chalk.gray(" Cancelled.\n"));
46
302
  return;
47
303
  }
304
+ const deviceID = getOrCreateDeviceID();
305
+ const cwd = process.cwd();
306
+ const currentProjectScope = getProjectScope(cwd);
307
+ const locations = discoverAgentConfigs();
308
+ const locByKey = new Map();
309
+ for (const loc of locations) {
310
+ locByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
311
+ }
312
+ const resolveDeletePath = (agent, filePath, scope) => {
313
+ const loc = locByKey.get(`${agent}|${filePath}|${scope}`);
314
+ if (loc)
315
+ return loc.path;
316
+ return resolveAgentConfigWritePath(agent, filePath, scope, {
317
+ cwd,
318
+ home: homedir(),
319
+ currentProjectScope,
320
+ });
321
+ };
322
+ let deleted = 0;
323
+ for (const i of indices) {
324
+ const item = items[i - 1];
325
+ try {
326
+ const localPath = resolveDeletePath(item.agent, item.filePath, item.scope);
327
+ if (mode === "l") {
328
+ await getClient().configs.localDelete({
329
+ device_id: deviceID,
330
+ agent: item.agent,
331
+ file_path: item.filePath,
332
+ scope: item.scope,
333
+ local_path: localPath ?? undefined,
334
+ });
335
+ if (localPath && existsSync(localPath)) {
336
+ rmSync(localPath);
337
+ }
338
+ console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("removed from this device"));
339
+ }
340
+ else {
341
+ await getClient().configs.delete(item.id);
342
+ if (localPath && existsSync(localPath)) {
343
+ rmSync(localPath);
344
+ }
345
+ await getClient().configs.ack({
346
+ device_id: deviceID,
347
+ configs: [
348
+ {
349
+ agent: item.agent,
350
+ file_path: item.filePath,
351
+ scope: item.scope,
352
+ version: item.version + 1,
353
+ local_path: localPath ?? undefined,
354
+ deleted: true,
355
+ },
356
+ ],
357
+ });
358
+ console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("deleted everywhere"));
359
+ }
360
+ deleted++;
361
+ }
362
+ catch (err) {
363
+ console.log(chalk.red(` \u2717 ${item.agent}/${item.filePath}`), chalk.gray(err.message));
364
+ }
365
+ }
366
+ console.log(chalk.gray(mode === "e"
367
+ ? `\n ${deleted} config${deleted > 1 ? "s" : ""} deleted everywhere.\n`
368
+ : `\n ${deleted} config${deleted > 1 ? "s" : ""} removed from this device.\n`));
369
+ }
370
+ function formatAge(dateStr) {
371
+ const ms = Date.now() - new Date(dateStr).getTime();
372
+ const mins = Math.floor(ms / 60000);
373
+ if (mins < 60)
374
+ return `${mins}m ago`;
375
+ const hours = Math.floor(mins / 60);
376
+ if (hours < 24)
377
+ return `${hours}h ago`;
378
+ const days = Math.floor(hours / 24);
379
+ return `${days}d ago`;
380
+ }
381
+ export async function syncCommand(directory, options) {
48
382
  const dir = directory ?? ".";
383
+ const syncRoot = resolve(dir);
49
384
  const customIgnore = options.ignore
50
385
  ? new Set(options.ignore.split(",").map((s) => s.trim()))
51
386
  : new Set();
52
387
  const ignoreSet = new Set([...DEFAULT_IGNORE, ...customIgnore]);
388
+ const ignorePatterns = [...customIgnore].sort();
389
+ const projectContext = detectProjectContext(syncRoot);
53
390
  console.log(chalk.blue("Scanning"), dir);
54
- const files = walkDir(dir, ignoreSet);
391
+ const files = walkDir(syncRoot, ignoreSet);
55
392
  if (files.length === 0) {
56
393
  console.log(chalk.yellow("No supported files found."));
394
+ updateSyncSourceRun(syncRoot, {
395
+ ignorePatterns,
396
+ defaultCategory: options.category,
397
+ defaultBoundary: options.boundary,
398
+ mode: options.watch ? "watch" : "manual",
399
+ scanCount: 0,
400
+ pushed: 0,
401
+ skipped: 0,
402
+ errors: 0,
403
+ });
57
404
  return;
58
405
  }
59
406
  console.log(chalk.gray(`Found ${files.length} files to sync`));
407
+ // Confirm if many files (>10) unless -y is passed
408
+ if (files.length > 10 && !options.yes) {
409
+ console.log(chalk.yellow(`\n This will push ${files.length} files. Continue? (y/N) `));
410
+ const confirmed = await confirm(" ");
411
+ if (!confirmed) {
412
+ console.log(chalk.gray(" Cancelled.\n"));
413
+ return;
414
+ }
415
+ }
60
416
  console.log();
61
417
  let pushed = 0;
418
+ let skipped = 0;
62
419
  let errors = 0;
63
420
  for (const file of files) {
64
- const result = await pushFile(file, options);
421
+ const result = await pushFile(file, syncRoot, projectContext, options);
65
422
  if (result === "pushed")
66
423
  pushed++;
424
+ else if (result === "skipped")
425
+ skipped++;
67
426
  else if (result === "error")
68
427
  errors++;
69
428
  }
429
+ updateSyncSourceRun(syncRoot, {
430
+ ignorePatterns,
431
+ defaultCategory: options.category,
432
+ defaultBoundary: options.boundary,
433
+ mode: options.watch ? "watch" : "manual",
434
+ scanCount: files.length,
435
+ pushed,
436
+ skipped,
437
+ errors,
438
+ });
70
439
  console.log();
71
- console.log(chalk.blue(`Synced ${pushed} files`), errors > 0 ? chalk.red(`(${errors} errors)`) : "");
440
+ console.log(chalk.blue(`Synced ${pushed} files`), skipped > 0 ? chalk.gray(`(${skipped} skipped)`) : "", errors > 0 ? chalk.red(`(${errors} errors)`) : "");
441
+ console.log(chalk.gray(" Missing local files are retained in Memax until removed explicitly."));
72
442
  if (options.watch) {
73
- const resolvedDir = resolve(dir);
74
443
  console.log(chalk.cyan(`\nWatching ${dir} for changes... (Ctrl+C to stop)`));
75
444
  let debounceTimer = null;
76
445
  const pendingChanges = new Set();
77
- watch(resolvedDir, { recursive: true }, (_eventType, filename) => {
446
+ watch(syncRoot, { recursive: true }, (_eventType, filename) => {
78
447
  if (!filename)
79
448
  return;
80
- const fullPath = join(resolvedDir, filename);
449
+ const fullPath = join(syncRoot, filename);
81
450
  if (!isSupportedFile(filename) || isIgnored(filename, ignoreSet))
82
451
  return;
83
452
  pendingChanges.add(fullPath);
@@ -86,37 +455,37 @@ export async function syncCommand(directory, options) {
86
455
  debounceTimer = setTimeout(async () => {
87
456
  for (const file of pendingChanges) {
88
457
  if (!existsSync(file)) {
89
- console.log(chalk.gray(" ~"), relative(process.cwd(), file), chalk.gray("[deleted, skipped]"));
458
+ console.log(chalk.gray(" ~"), buildSyncSourcePath(syncRoot, file), chalk.gray("[deleted locally, retained in Memax]"));
90
459
  continue;
91
460
  }
92
- await pushFile(file, options);
461
+ await pushFile(file, syncRoot, projectContext, options);
93
462
  }
94
463
  pendingChanges.clear();
95
464
  }, 500);
96
465
  });
97
466
  }
98
467
  }
99
- async function pushFile(file, options) {
468
+ async function pushFile(file, syncRoot, projectContext, options) {
100
469
  try {
101
470
  const content = readFileSync(file, "utf-8");
102
471
  if (!content.trim())
103
472
  return "skipped";
104
- const relPath = relative(process.cwd(), file);
473
+ const relPath = buildSyncSourcePath(syncRoot, file);
105
474
  const ext = extname(file);
106
475
  const contentType = ext === ".md"
107
476
  ? "markdown"
108
477
  : ext === ".json" || ext === ".yaml" || ext === ".yml"
109
478
  ? "structured"
110
479
  : "code";
111
- const note = await apiPost("/v1/notes", {
112
- content,
480
+ const memory = await getClient().push(content, {
113
481
  title: relPath,
114
482
  category: options.category ?? guessCategory(relPath),
115
483
  source: "sync",
116
- source_path: relPath,
117
- content_type: contentType,
484
+ sourcePath: relPath,
485
+ contentType,
486
+ projectContext,
118
487
  });
119
- console.log(chalk.green(" +"), relPath, chalk.gray(`[${note.category}]`));
488
+ console.log(chalk.green(" +"), relPath, chalk.gray(`[${memory.category}]`));
120
489
  return "pushed";
121
490
  }
122
491
  catch (err) {
@@ -124,6 +493,46 @@ async function pushFile(file, options) {
124
493
  return "error";
125
494
  }
126
495
  }
496
+ export function buildSyncSourcePath(syncRoot, file) {
497
+ const relativePath = relative(syncRoot, file);
498
+ if (!relativePath ||
499
+ relativePath.startsWith("..") ||
500
+ relativePath === "." ||
501
+ resolve(syncRoot, relativePath) !== resolve(file)) {
502
+ throw new Error("file is outside sync root");
503
+ }
504
+ return normalizeFilePath(relativePath);
505
+ }
506
+ export function syncStatusCommand() {
507
+ const sources = listSyncSources();
508
+ console.log(chalk.bold("\n Memax Sync Status\n"));
509
+ if (sources.length === 0) {
510
+ console.log(chalk.gray(" No sync sources recorded yet."));
511
+ console.log(chalk.gray(" Run: memax sync <dir>\n"));
512
+ return;
513
+ }
514
+ for (const source of sources) {
515
+ console.log(` ${chalk.cyan(source.root_path)}`);
516
+ console.log(` kind ${chalk.gray(source.kind)}`);
517
+ console.log(` deletion policy ${chalk.gray("retain missing files in Memax")}`);
518
+ if (source.default_category) {
519
+ console.log(` category ${chalk.gray(source.default_category)}`);
520
+ }
521
+ if (source.default_boundary) {
522
+ console.log(` boundary ${chalk.gray(source.default_boundary)}`);
523
+ }
524
+ if (source.ignore_patterns.length > 0) {
525
+ console.log(` ignore ${chalk.gray(source.ignore_patterns.join(", "))}`);
526
+ }
527
+ if (source.last_sync_at) {
528
+ console.log(` last sync ${chalk.gray(formatAge(source.last_sync_at))} (${chalk.gray(source.last_mode ?? "manual")})`);
529
+ }
530
+ if (source.last_scan_count !== undefined) {
531
+ console.log(` last run ${chalk.gray(`${source.last_scan_count} scanned, ${source.last_pushed ?? 0} pushed, ${source.last_skipped ?? 0} skipped, ${source.last_errors ?? 0} errors`)}`);
532
+ }
533
+ console.log();
534
+ }
535
+ }
127
536
  function isSupportedFile(filename) {
128
537
  return SUPPORTED_EXTENSIONS.has(extname(filename));
129
538
  }
@@ -179,143 +588,681 @@ function guessCategory(path) {
179
588
  return "reference/config";
180
589
  return "daily/note";
181
590
  }
182
- function discoverAgentMemoryFiles() {
591
+ function findClaudeProjectDir(scope) {
592
+ const home = homedir();
593
+ const claudeProjectsDir = join(home, ".claude", "projects");
594
+ if (!existsSync(claudeProjectsDir))
595
+ return null;
596
+ try {
597
+ for (const project of readdirSync(claudeProjectsDir)) {
598
+ const repoUrl = resolveClaudeProjectFolder(project);
599
+ if (repoUrl && scope === `project:${repoUrl}`) {
600
+ return join(claudeProjectsDir, project);
601
+ }
602
+ }
603
+ }
604
+ catch {
605
+ // Permission denied — skip
606
+ }
607
+ return null;
608
+ }
609
+ function printPlacementSection(title, color, items, format) {
610
+ if (items.length === 0)
611
+ return;
612
+ console.log(chalk.white(title));
613
+ for (const item of items) {
614
+ const [agent, filePath, detail] = format(item);
615
+ console.log(` ${color(formatAgentName(agent))} ${filePath}`);
616
+ console.log(` ${chalk.gray(detail)}`);
617
+ }
618
+ console.log();
619
+ }
620
+ export function resolveAgentConfigWritePath(agent, filePath, scope, options = {}) {
621
+ const cwd = options.cwd ?? process.cwd();
622
+ const home = options.home ?? homedir();
623
+ const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
624
+ const normalizedFilePath = normalizeFilePath(filePath);
625
+ if (scope === "global") {
626
+ switch (agent) {
627
+ case "claude-code":
628
+ return join(home, ".claude", normalizedFilePath);
629
+ case "codex":
630
+ return join(home, ".codex", normalizedFilePath);
631
+ case "gemini":
632
+ return join(home, ".gemini", normalizedFilePath);
633
+ case "openclaw":
634
+ return join(home, ".openclaw", normalizedFilePath);
635
+ case "opencode":
636
+ return join(home, ".opencode", normalizedFilePath);
637
+ default:
638
+ return null;
639
+ }
640
+ }
641
+ if (!scope.startsWith("project:") || scope !== currentProjectScope) {
642
+ return null;
643
+ }
644
+ switch (agent) {
645
+ case "claude-code":
646
+ if (normalizedFilePath === "CLAUDE.md" ||
647
+ normalizedFilePath === "MEMORY.md") {
648
+ return join(cwd, ".claude", normalizedFilePath);
649
+ }
650
+ if (normalizedFilePath.startsWith(".claude/")) {
651
+ return join(cwd, normalizedFilePath);
652
+ }
653
+ if (normalizedFilePath.startsWith("memory/")) {
654
+ const projectDir = options.findClaudeProjectDir?.(scope);
655
+ if (projectDir) {
656
+ return join(projectDir, normalizedFilePath);
657
+ }
658
+ const mangledCwd = cwd.replace(/\//g, "-");
659
+ return join(home, ".claude", "projects", mangledCwd, normalizedFilePath);
660
+ }
661
+ return null;
662
+ case "cursor":
663
+ if (normalizedFilePath === ".cursorrules" ||
664
+ normalizedFilePath.startsWith(".cursor/")) {
665
+ return join(cwd, normalizedFilePath);
666
+ }
667
+ return null;
668
+ case "codex":
669
+ if (normalizedFilePath === "instructions.md") {
670
+ return join(cwd, ".codex", "instructions.md");
671
+ }
672
+ if (normalizedFilePath.startsWith(".codex/")) {
673
+ return join(cwd, normalizedFilePath);
674
+ }
675
+ return null;
676
+ case "gemini":
677
+ if (normalizedFilePath === "GEMINI.md") {
678
+ return join(cwd, "GEMINI.md");
679
+ }
680
+ return null;
681
+ case "copilot":
682
+ if (normalizedFilePath === "copilot-instructions.md") {
683
+ return join(cwd, ".github", "copilot-instructions.md");
684
+ }
685
+ if (normalizedFilePath.startsWith(".github/")) {
686
+ return join(cwd, normalizedFilePath);
687
+ }
688
+ return null;
689
+ case "windsurf":
690
+ if (normalizedFilePath === ".windsurfrules" ||
691
+ normalizedFilePath.startsWith(".windsurf/")) {
692
+ return join(cwd, normalizedFilePath);
693
+ }
694
+ return null;
695
+ case "opencode":
696
+ if (normalizedFilePath.startsWith(".opencode/")) {
697
+ return join(cwd, normalizedFilePath);
698
+ }
699
+ return join(cwd, ".opencode", normalizedFilePath);
700
+ case "generic":
701
+ if (normalizedFilePath === "AGENTS.md" ||
702
+ normalizedFilePath === "CLAUDE.md" ||
703
+ normalizedFilePath === "GEMINI.md") {
704
+ return join(cwd, normalizedFilePath);
705
+ }
706
+ return null;
707
+ default:
708
+ return null;
709
+ }
710
+ }
711
+ function discoverAgentConfigs() {
183
712
  const home = homedir();
184
713
  const cwd = process.cwd();
714
+ const projectScope = getProjectScope(cwd);
715
+ const inProject = projectScope !== "project"; // has actual repo identity
185
716
  const locations = [];
186
- // Claude Code global memory
187
- const claudeMemory = join(home, ".claude", "MEMORY.md");
188
- locations.push({ label: "~/.claude/MEMORY.md", path: claudeMemory });
189
- // Claude Code per-project memories: ~/.claude/projects/*/memory/*.md
717
+ const add = (agent, label, path, filePath, scope = "global") => locations.push({
718
+ agent,
719
+ label,
720
+ path,
721
+ filePath: normalizeFilePath(filePath),
722
+ scope,
723
+ });
724
+ // Claude Code — global
725
+ add("claude-code", "~/.claude/CLAUDE.md", join(home, ".claude", "CLAUDE.md"), "CLAUDE.md");
726
+ add("claude-code", "~/.claude/MEMORY.md", join(home, ".claude", "MEMORY.md"), "MEMORY.md");
727
+ // Claude Code — per-project memories: ~/.claude/projects/*/memory/*.md
728
+ // The folder name is the absolute project path with "/" replaced by "-"
729
+ // (e.g., "-workspaces-memax"). We resolve it to a git repo URL so the
730
+ // same project's memories match across machines regardless of clone path.
190
731
  const claudeProjectsDir = join(home, ".claude", "projects");
191
732
  if (existsSync(claudeProjectsDir)) {
192
733
  try {
193
734
  for (const project of readdirSync(claudeProjectsDir)) {
194
735
  const memoryDir = join(claudeProjectsDir, project, "memory");
195
- if (existsSync(memoryDir)) {
196
- try {
197
- for (const file of readdirSync(memoryDir)) {
198
- if (file.endsWith(".md")) {
199
- locations.push({
200
- label: `~/.claude/projects/${project}/memory/${file}`,
201
- path: join(memoryDir, file),
202
- });
203
- }
736
+ if (!existsSync(memoryDir))
737
+ continue;
738
+ // Try to resolve mangled folder → git repo → canonical scope
739
+ const repoUrl = resolveClaudeProjectFolder(project);
740
+ const memoryScope = repoUrl ? `project:${repoUrl}` : undefined;
741
+ try {
742
+ for (const file of readdirSync(memoryDir)) {
743
+ if (!file.endsWith(".md"))
744
+ continue;
745
+ if (memoryScope) {
746
+ // Canonical: filePath is just "memory/<file>", scope identifies the project
747
+ add("claude-code", `~/.claude/projects/${project}/memory/${file}`, join(memoryDir, file), `memory/${file}`, memoryScope);
748
+ }
749
+ else {
750
+ // Fallback: can't resolve project → keep legacy format with folder name
751
+ add("claude-code", `~/.claude/projects/${project}/memory/${file}`, join(memoryDir, file), `projects/${project}/memory/${file}`);
204
752
  }
205
- }
206
- catch {
207
- // Permission denied or other read error — skip
208
753
  }
209
754
  }
755
+ catch {
756
+ // Permission denied — skip
757
+ }
210
758
  }
211
759
  }
212
760
  catch {
213
- // Permission denied or other read error — skip
761
+ // Permission denied — skip
762
+ }
763
+ }
764
+ // --- Project-scoped configs (only when inside a git repo) ---
765
+ if (inProject) {
766
+ // Claude Code — project-level
767
+ add("claude-code", "./.claude/CLAUDE.md", join(cwd, ".claude", "CLAUDE.md"), "CLAUDE.md", projectScope);
768
+ // Cursor (project-level)
769
+ add("cursor", "./.cursorrules", join(cwd, ".cursorrules"), ".cursorrules", projectScope);
770
+ const cursorRulesDir = join(cwd, ".cursor", "rules");
771
+ if (existsSync(cursorRulesDir)) {
772
+ try {
773
+ for (const file of readdirSync(cursorRulesDir)) {
774
+ if (file.endsWith(".mdc")) {
775
+ add("cursor", `./.cursor/rules/${file}`, join(cursorRulesDir, file), `.cursor/rules/${file}`, projectScope);
776
+ }
777
+ }
778
+ }
779
+ catch {
780
+ /* skip */
781
+ }
782
+ }
783
+ // Codex (project-level)
784
+ add("codex", "./.codex/instructions.md", join(cwd, ".codex", "instructions.md"), "instructions.md", projectScope);
785
+ }
786
+ add("codex", "~/.codex/AGENTS.md", join(home, ".codex", "AGENTS.md"), "AGENTS.md");
787
+ // Gemini CLI — global
788
+ add("gemini", "~/.gemini/GEMINI.md", join(home, ".gemini", "GEMINI.md"), "GEMINI.md");
789
+ if (inProject) {
790
+ // Gemini CLI — project-level
791
+ add("gemini", "./GEMINI.md", join(cwd, "GEMINI.md"), "GEMINI.md", projectScope);
792
+ // GitHub Copilot
793
+ add("copilot", "./.github/copilot-instructions.md", join(cwd, ".github", "copilot-instructions.md"), "copilot-instructions.md", projectScope);
794
+ // Windsurf
795
+ add("windsurf", "./.windsurfrules", join(cwd, ".windsurfrules"), ".windsurfrules", projectScope);
796
+ const windsurfRulesDir = join(cwd, ".windsurf", "rules");
797
+ if (existsSync(windsurfRulesDir)) {
798
+ try {
799
+ for (const file of readdirSync(windsurfRulesDir)) {
800
+ if (file.endsWith(".md")) {
801
+ add("windsurf", `./.windsurf/rules/${file}`, join(windsurfRulesDir, file), `.windsurf/rules/${file}`, projectScope);
802
+ }
803
+ }
804
+ }
805
+ catch {
806
+ /* skip */
807
+ }
214
808
  }
215
809
  }
216
- // Cursor rules (project-level)
217
- locations.push({ label: "./.cursorrules", path: join(cwd, ".cursorrules") });
218
- // Cursor scoped rules: .cursor/rules/*.mdc
219
- const cursorRulesDir = join(cwd, ".cursor", "rules");
220
- if (existsSync(cursorRulesDir)) {
810
+ // OpenClaw
811
+ const openclawMemoryDir = join(home, ".openclaw", "memory");
812
+ if (existsSync(openclawMemoryDir)) {
221
813
  try {
222
- for (const file of readdirSync(cursorRulesDir)) {
223
- if (file.endsWith(".mdc")) {
224
- locations.push({
225
- label: `./.cursor/rules/${file}`,
226
- path: join(cursorRulesDir, file),
227
- });
814
+ for (const file of readdirSync(openclawMemoryDir)) {
815
+ if (file.endsWith(".md") || file.endsWith(".json")) {
816
+ add("openclaw", `~/.openclaw/memory/${file}`, join(openclawMemoryDir, file), `memory/${file}`);
228
817
  }
229
818
  }
230
819
  }
231
820
  catch {
232
- // Skip on error
821
+ /* skip */
233
822
  }
234
823
  }
235
- // Codex instructions
236
- locations.push({
237
- label: "./.codex/instructions.md",
238
- path: join(cwd, ".codex", "instructions.md"),
239
- });
240
- // Generic agent config files in current directory
241
- locations.push({ label: "./AGENTS.md", path: join(cwd, "AGENTS.md") });
242
- locations.push({ label: "./CLAUDE.md", path: join(cwd, "CLAUDE.md") });
243
- return locations;
244
- }
245
- function formatFileSize(bytes) {
246
- if (bytes < 1024)
247
- return `${bytes} B`;
248
- return `${(bytes / 1024).toFixed(1)} KB`;
249
- }
250
- async function syncAgentMemory() {
251
- console.log(chalk.blue("Scanning for agent memory files..."));
252
- console.log();
253
- const locations = discoverAgentMemoryFiles();
254
- const found = [];
255
- const notFound = [];
256
- for (const loc of locations) {
257
- if (existsSync(loc.path)) {
824
+ if (inProject) {
825
+ // OpenCode (project-level)
826
+ const opencodePath = join(cwd, ".opencode");
827
+ if (existsSync(opencodePath)) {
258
828
  try {
259
- const stat = statSync(loc.path);
260
- if (stat.isFile() && stat.size > 0) {
261
- found.push({ label: loc.label, path: loc.path, size: stat.size });
262
- }
263
- else {
264
- notFound.push(loc.label);
829
+ for (const file of readdirSync(opencodePath)) {
830
+ if (file.endsWith(".md")) {
831
+ add("opencode", `./.opencode/${file}`, join(opencodePath, file), file, projectScope);
832
+ }
265
833
  }
266
834
  }
267
835
  catch {
268
- notFound.push(loc.label);
836
+ /* skip */
269
837
  }
270
838
  }
271
- else {
272
- notFound.push(loc.label);
839
+ // Generic project-level agent files
840
+ add("generic", "./AGENTS.md", join(cwd, "AGENTS.md"), "AGENTS.md", projectScope);
841
+ add("generic", "./CLAUDE.md", join(cwd, "CLAUDE.md"), "CLAUDE.md", projectScope);
842
+ }
843
+ return locations;
844
+ }
845
+ async function syncAgentMemory(options = {}) {
846
+ console.log(chalk.bold("\n Memax Config Sync\n"));
847
+ const projectScopeResolution = resolveProjectScope();
848
+ if (projectScopeResolution.warning) {
849
+ console.log(chalk.yellow(` Warning: ${projectScopeResolution.warning}`));
850
+ console.log(chalk.gray(" Using .memax.yml project_id as the canonical project identity.\n"));
851
+ }
852
+ // Discover local config files
853
+ const locations = discoverAgentConfigs();
854
+ const localConfigs = [];
855
+ for (const loc of locations) {
856
+ if (!existsSync(loc.path))
857
+ continue;
858
+ try {
859
+ const stat = statSync(loc.path);
860
+ if (!stat.isFile() || stat.size === 0)
861
+ continue;
862
+ const content = readFileSync(loc.path, "utf-8");
863
+ if (!content.trim())
864
+ continue;
865
+ const hash = createHash("sha256").update(content).digest("hex");
866
+ localConfigs.push({
867
+ loc,
868
+ content,
869
+ hash,
870
+ updatedAt: stat.mtime.toISOString(),
871
+ });
872
+ }
873
+ catch {
874
+ // Skip unreadable files
273
875
  }
274
876
  }
275
- if (found.length === 0) {
276
- console.log(chalk.yellow("No agent memory files found."));
877
+ const isBootstrap = localConfigs.length === 0;
878
+ const deviceID = getOrCreateDeviceID();
879
+ if (isBootstrap) {
880
+ console.log(chalk.gray(" No local agent configs found. Checking cloud for backups...\n"));
881
+ }
882
+ else {
883
+ console.log(chalk.gray(` Found ${localConfigs.length} local config${localConfigs.length > 1 ? "s" : ""}. Syncing with cloud...\n`));
884
+ }
885
+ // Build manifest — may be empty on a new device (that's fine, we'll pull from cloud)
886
+ const manifest = localConfigs.map((c) => ({
887
+ agent: c.loc.agent,
888
+ file_path: c.loc.filePath,
889
+ scope: c.loc.scope,
890
+ content_hash: c.hash,
891
+ updated_at: c.updatedAt,
892
+ local_path: c.loc.path,
893
+ }));
894
+ let actions;
895
+ try {
896
+ const plan = await getClient().configs.sync({
897
+ device_id: deviceID,
898
+ configs: manifest,
899
+ });
900
+ actions = plan.actions;
901
+ }
902
+ catch (err) {
903
+ console.error(chalk.red(` Sync failed: ${err.message}\n`));
277
904
  return;
278
905
  }
279
- console.log("Found:");
280
- for (const f of found) {
281
- console.log(chalk.green(" \u2713"), f.label, chalk.gray(`(${formatFileSize(f.size)})`));
906
+ // Force modes: resolve conflicts in one direction.
907
+ // Only convert "conflict" actions — one-sided actions (local_only push,
908
+ // cloud_only pull) stay as-is since converting them is invalid:
909
+ // push→pull with no config_id = nothing to download
910
+ // pull→push with no local file = nothing to upload
911
+ if (options.push) {
912
+ actions = actions.map((a) => a.action === "conflict" ? { ...a, action: "push" } : a);
282
913
  }
283
- for (const label of notFound) {
284
- console.log(chalk.gray(" \u2717"), chalk.gray(label), chalk.gray("(not found)"));
914
+ else if (options.pull) {
915
+ actions = actions.map((a) => a.action === "conflict" ? { ...a, action: "pull" } : a);
285
916
  }
286
- console.log();
287
- let synced = 0;
288
- let unchanged = 0;
917
+ // Filter out project-scoped cloud-only configs that don't belong to the
918
+ // current project. Without this, running `memax sync agents` from ~/
919
+ // would dump project configs (like .cursorrules from repo X) into the
920
+ // home directory.
921
+ const currentProjectScope = projectScopeResolution.scope;
922
+ actions = actions.filter((a) => {
923
+ if (!a.scope.startsWith("project:"))
924
+ return true; // global → always sync
925
+ if (a.scope === "project")
926
+ return true; // legacy → keep for compat
927
+ // Project-scoped: only sync if it matches the current project
928
+ if (a.scope === currentProjectScope)
929
+ return true;
930
+ // Cloud-only configs for other projects → skip silently
931
+ if (a.action === "pull" && a.reason === "cloud_only")
932
+ return false;
933
+ // Conflict/push for configs we have locally → keep (user is in the project)
934
+ return true;
935
+ });
936
+ // Index local configs by (agent, file_path, scope) for quick lookup
937
+ const localByKey = new Map();
938
+ for (const c of localConfigs) {
939
+ localByKey.set(`${c.loc.agent}|${c.loc.filePath}|${c.loc.scope}`, c);
940
+ }
941
+ // Index locations by (agent, file_path, scope) for pull path resolution
942
+ const locByKey = new Map();
943
+ for (const loc of locations) {
944
+ locByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
945
+ }
946
+ // Resolve a local write path for any config — even ones not discovered locally.
947
+ // This enables pulling configs to a brand-new device where agent dirs don't exist yet.
948
+ const resolveWritePath = (agent, filePath, scope) => {
949
+ const loc = locByKey.get(`${agent}|${filePath}|${scope}`);
950
+ if (loc)
951
+ return loc.path;
952
+ return resolveAgentConfigWritePath(agent, filePath, scope, {
953
+ cwd: process.cwd(),
954
+ home: homedir(),
955
+ currentProjectScope,
956
+ findClaudeProjectDir,
957
+ });
958
+ };
959
+ // Execute sync plan
960
+ let pushed = 0;
961
+ let pulled = 0;
962
+ let deletedLocal = 0;
963
+ let unchangedCount = 0;
964
+ let skipped = 0;
289
965
  let errors = 0;
290
- for (const f of found) {
291
- try {
292
- const content = readFileSync(f.path, "utf-8");
293
- if (!content.trim())
966
+ const ackConfigs = [];
967
+ // Group actions by agent for display
968
+ const byAgent = new Map();
969
+ for (const action of actions) {
970
+ const group = byAgent.get(action.agent) ?? [];
971
+ group.push(action);
972
+ byAgent.set(action.agent, group);
973
+ }
974
+ for (const [agent, agentActions] of byAgent) {
975
+ console.log(chalk.white(` ${formatAgentName(agent)}`));
976
+ for (const action of agentActions) {
977
+ const key = `${action.agent}|${action.file_path}|${action.scope}`;
978
+ if (action.action === "unchanged") {
979
+ const local = localByKey.get(key);
980
+ console.log(chalk.gray(` = ${action.file_path}`), chalk.gray("unchanged"));
981
+ if (local && action.version) {
982
+ ackConfigs.push({
983
+ agent: action.agent,
984
+ file_path: action.file_path,
985
+ scope: action.scope,
986
+ content_hash: local.hash,
987
+ version: action.version,
988
+ local_path: local.loc.path,
989
+ });
990
+ }
991
+ unchangedCount++;
294
992
  continue;
295
- const title = basename(f.path);
296
- await apiPost("/v1/notes", {
297
- content,
298
- title,
299
- source: "sync",
300
- source_path: f.path,
301
- content_type: "markdown",
302
- category: "",
993
+ }
994
+ if (action.action === "push") {
995
+ const local = localByKey.get(key);
996
+ if (!local) {
997
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray("local file not found for push"));
998
+ errors++;
999
+ continue;
1000
+ }
1001
+ try {
1002
+ await getClient().configs.upsert({
1003
+ agent: action.agent,
1004
+ file_path: action.file_path,
1005
+ scope: action.scope,
1006
+ content: local.content,
1007
+ device_id: deviceID,
1008
+ local_path: local.loc.path,
1009
+ });
1010
+ console.log(chalk.green(` \u2191 ${action.file_path}`), chalk.gray(action.reason === "local_only"
1011
+ ? "pushing (new)"
1012
+ : "pushing (local newer)"));
1013
+ pushed++;
1014
+ }
1015
+ catch (err) {
1016
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1017
+ errors++;
1018
+ }
1019
+ continue;
1020
+ }
1021
+ if (action.action === "pull") {
1022
+ if (!action.config_id) {
1023
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray("missing config ID from server"));
1024
+ errors++;
1025
+ continue;
1026
+ }
1027
+ try {
1028
+ const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
1029
+ if (!writePath) {
1030
+ console.log(chalk.yellow(` ? ${action.file_path}`), chalk.gray(action.scope !== "global" &&
1031
+ action.scope !== currentProjectScope
1032
+ ? "different project \u2014 skipped"
1033
+ : "unknown agent \u2014 skipped"));
1034
+ skipped++;
1035
+ continue;
1036
+ }
1037
+ // For new files (not updates), ask user before writing
1038
+ const isNewLocally = action.reason === "cloud_only" && !existsSync(writePath);
1039
+ if (isNewLocally && !options.pull) {
1040
+ console.log(chalk.cyan(` New file: ${action.file_path}`));
1041
+ console.log(chalk.gray(` → ${writePath}`));
1042
+ const accept = await confirmDefault(` Download? [Y/n] `);
1043
+ if (!accept) {
1044
+ console.log(chalk.gray(` - ${action.file_path}`), chalk.gray("skipped"));
1045
+ skipped++;
1046
+ continue;
1047
+ }
1048
+ }
1049
+ const config = await getClient().configs.get(action.config_id);
1050
+ mkdirSync(dirname(writePath), { recursive: true });
1051
+ writeFileSync(writePath, config.content);
1052
+ if (action.version) {
1053
+ ackConfigs.push({
1054
+ agent: action.agent,
1055
+ file_path: action.file_path,
1056
+ scope: action.scope,
1057
+ content_hash: config.content_hash,
1058
+ version: action.version,
1059
+ local_path: writePath,
1060
+ });
1061
+ }
1062
+ console.log(chalk.cyan(` \u2193 ${action.file_path}`), chalk.gray(isNewLocally ? "restored" : "pulling (cloud newer)"));
1063
+ pulled++;
1064
+ }
1065
+ catch (err) {
1066
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1067
+ errors++;
1068
+ }
1069
+ continue;
1070
+ }
1071
+ if (action.action === "delete_local") {
1072
+ try {
1073
+ const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
1074
+ if (writePath && existsSync(writePath)) {
1075
+ rmSync(writePath);
1076
+ }
1077
+ if (action.version) {
1078
+ ackConfigs.push({
1079
+ agent: action.agent,
1080
+ file_path: action.file_path,
1081
+ scope: action.scope,
1082
+ version: action.version,
1083
+ local_path: writePath ?? undefined,
1084
+ deleted: true,
1085
+ });
1086
+ }
1087
+ console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("deleted locally (cloud removed everywhere)"));
1088
+ deletedLocal++;
1089
+ }
1090
+ catch (err) {
1091
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1092
+ errors++;
1093
+ }
1094
+ continue;
1095
+ }
1096
+ if (action.action === "conflict") {
1097
+ const resolution = await promptConflict(agent, action.file_path);
1098
+ if (resolution === "local") {
1099
+ const local = localByKey.get(key);
1100
+ if (local) {
1101
+ try {
1102
+ await getClient().configs.upsert({
1103
+ agent: action.agent,
1104
+ file_path: action.file_path,
1105
+ scope: action.scope,
1106
+ content: local.content,
1107
+ device_id: deviceID,
1108
+ local_path: local.loc.path,
1109
+ });
1110
+ console.log(chalk.green(` \u2191 ${action.file_path}`), chalk.gray("kept local"));
1111
+ pushed++;
1112
+ }
1113
+ catch (err) {
1114
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1115
+ errors++;
1116
+ }
1117
+ }
1118
+ }
1119
+ else if (resolution === "cloud" && action.config_id) {
1120
+ try {
1121
+ const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
1122
+ if (writePath) {
1123
+ const config = await getClient().configs.get(action.config_id);
1124
+ mkdirSync(dirname(writePath), { recursive: true });
1125
+ writeFileSync(writePath, config.content);
1126
+ if (action.version) {
1127
+ ackConfigs.push({
1128
+ agent: action.agent,
1129
+ file_path: action.file_path,
1130
+ scope: action.scope,
1131
+ content_hash: config.content_hash,
1132
+ version: action.version,
1133
+ local_path: writePath,
1134
+ });
1135
+ }
1136
+ console.log(chalk.cyan(` \u2193 ${action.file_path}`), chalk.gray("used cloud"));
1137
+ pulled++;
1138
+ }
1139
+ }
1140
+ catch (err) {
1141
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1142
+ errors++;
1143
+ }
1144
+ }
1145
+ else if (resolution === "merge" && action.config_id) {
1146
+ const local = localByKey.get(key);
1147
+ if (local) {
1148
+ try {
1149
+ const cloudConfig = await getClient().configs.get(action.config_id);
1150
+ console.log(chalk.gray(` Merging with LLM...`));
1151
+ const merged = await mergeConfigs(action.agent, action.file_path, local.content, cloudConfig.content);
1152
+ if (merged) {
1153
+ // Write merged to local
1154
+ const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
1155
+ if (writePath) {
1156
+ mkdirSync(dirname(writePath), { recursive: true });
1157
+ writeFileSync(writePath, merged);
1158
+ }
1159
+ // Push merged to cloud
1160
+ await getClient().configs.upsert({
1161
+ agent: action.agent,
1162
+ file_path: action.file_path,
1163
+ scope: action.scope,
1164
+ content: merged,
1165
+ device_id: deviceID,
1166
+ local_path: writePath ?? undefined,
1167
+ });
1168
+ console.log(chalk.magenta(` \u2194 ${action.file_path}`), chalk.gray("merged (LLM)"));
1169
+ pushed++;
1170
+ }
1171
+ else {
1172
+ skipped++;
1173
+ }
1174
+ }
1175
+ catch (err) {
1176
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1177
+ errors++;
1178
+ }
1179
+ }
1180
+ }
1181
+ else {
1182
+ console.log(chalk.gray(` - ${action.file_path}`), chalk.gray("skipped"));
1183
+ skipped++;
1184
+ }
1185
+ }
1186
+ }
1187
+ }
1188
+ if (ackConfigs.length > 0) {
1189
+ try {
1190
+ await getClient().configs.ack({
1191
+ device_id: deviceID,
1192
+ configs: ackConfigs,
303
1193
  });
304
- synced++;
305
1194
  }
306
1195
  catch (err) {
307
- const msg = err.message;
308
- if (msg.includes("unchanged") ||
309
- msg.includes("duplicate") ||
310
- msg.includes("exists")) {
311
- unchanged++;
312
- }
313
- else {
314
- errors++;
315
- console.log(chalk.red(" Error syncing"), f.label, chalk.gray(msg));
316
- }
1196
+ console.log(chalk.yellow("\n Warning: failed to persist sync state"), chalk.gray(err.message));
317
1197
  }
318
1198
  }
319
- console.log(chalk.blue(`Synced ${found.length} files to Memax`), chalk.gray(`(${synced} new, ${unchanged} unchanged${errors > 0 ? `, ${errors} errors` : ""})`));
1199
+ // Summary
1200
+ if (pushed === 0 &&
1201
+ pulled === 0 &&
1202
+ unchangedCount === 0 &&
1203
+ skipped === 0 &&
1204
+ errors === 0) {
1205
+ console.log(chalk.gray(" No configs in cloud yet. Push some first from a device that has them.\n"));
1206
+ return;
1207
+ }
1208
+ const parts = [];
1209
+ if (pushed > 0)
1210
+ parts.push(`${pushed} pushed`);
1211
+ if (pulled > 0)
1212
+ parts.push(`${pulled} restored`);
1213
+ if (deletedLocal > 0)
1214
+ parts.push(`${deletedLocal} deleted locally`);
1215
+ if (unchangedCount > 0)
1216
+ parts.push(`${unchangedCount} unchanged`);
1217
+ if (skipped > 0)
1218
+ parts.push(`${skipped} skipped`);
1219
+ if (errors > 0)
1220
+ parts.push(`${errors} errors`);
1221
+ console.log(chalk.bold(`\n Done: ${parts.join(", ")}`));
1222
+ if (pulled > 0) {
1223
+ console.log(chalk.gray(" Restart your agents for restored configs to take effect."));
1224
+ }
1225
+ console.log();
1226
+ }
1227
+ function formatAgentName(id) {
1228
+ const names = {
1229
+ "claude-code": "Claude Code",
1230
+ cursor: "Cursor",
1231
+ codex: "Codex",
1232
+ gemini: "Gemini CLI",
1233
+ copilot: "GitHub Copilot",
1234
+ windsurf: "Windsurf",
1235
+ openclaw: "OpenClaw",
1236
+ opencode: "OpenCode",
1237
+ generic: "Generic",
1238
+ };
1239
+ return names[id] ?? id;
1240
+ }
1241
+ async function promptConflict(_agent, filePath) {
1242
+ const answer = await ask(chalk.yellow(`\n ${filePath} has changes on both sides.\n` +
1243
+ ` [l] Keep local [c] Use cloud [m] Merge (LLM) [s] Skip: `));
1244
+ const a = answer.toLowerCase();
1245
+ if (a === "l")
1246
+ return "local";
1247
+ if (a === "c")
1248
+ return "cloud";
1249
+ if (a === "m")
1250
+ return "merge";
1251
+ return "skip";
1252
+ }
1253
+ async function mergeConfigs(agent, filePath, localContent, cloudContent) {
1254
+ try {
1255
+ const result = await getClient().configs.merge({
1256
+ local_content: localContent,
1257
+ cloud_content: cloudContent,
1258
+ file_path: filePath,
1259
+ agent,
1260
+ });
1261
+ return result.merged_content;
1262
+ }
1263
+ catch (err) {
1264
+ console.log(chalk.red(` Merge failed: ${err.message}`));
1265
+ return null;
1266
+ }
320
1267
  }
321
1268
  //# sourceMappingURL=sync.js.map