memoir-cli 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/memoir.js CHANGED
@@ -16,7 +16,10 @@ import { resumeCommand } from '../src/commands/resume.js';
16
16
  import { profileListCommand, profileCreateCommand, profileSwitchCommand, profileDeleteCommand } from '../src/commands/profile.js';
17
17
  import { loginCommand, logoutCommand } from '../src/commands/login.js';
18
18
  import { cloudPushCommand, cloudRestoreCommand } from '../src/commands/cloud.js';
19
+ import { shareCommand } from '../src/commands/share.js';
19
20
  import { historyCommand } from '../src/commands/history.js';
21
+ import { projectsListCommand, projectsTodoCommand } from '../src/commands/projects.js';
22
+ import { upgradeCommand } from '../src/commands/upgrade.js';
20
23
  import { createRequire } from 'module';
21
24
 
22
25
  const require = createRequire(import.meta.url);
@@ -62,12 +65,15 @@ if (process.argv.length <= 2) {
62
65
  chalk.cyan(' memoir resume ') + chalk.gray('— pick up where you left off') + '\n' +
63
66
  chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n' +
64
67
  chalk.cyan(' memoir profile ') + chalk.gray('— manage profiles (personal/work)') + '\n' +
68
+ chalk.cyan(' memoir projects ') + chalk.gray('— see all your projects at a glance') + '\n' +
65
69
  chalk.cyan(' memoir encrypt ') + chalk.gray('— toggle E2E encryption') + '\n' +
66
- chalk.cyan(' memoir update ') + chalk.gray('— update to latest version') + '\n\n' +
70
+ chalk.cyan(' memoir update ') + chalk.gray('— update to latest version') + '\n' +
71
+ chalk.cyan(' memoir upgrade ') + chalk.gray('— view plans & upgrade') + '\n\n' +
67
72
  chalk.white.bold('Cloud (Pro):') + '\n' +
68
73
  chalk.cyan(' memoir login ') + chalk.gray('— sign in to memoir cloud') + '\n' +
69
74
  chalk.cyan(' memoir cloud push ') + chalk.gray('— back up to the cloud') + '\n' +
70
75
  chalk.cyan(' memoir cloud restore ') + chalk.gray('— restore from cloud') + '\n' +
76
+ chalk.cyan(' memoir share ') + chalk.gray('— share memory via secure link') + '\n' +
71
77
  chalk.cyan(' memoir history ') + chalk.gray('— view backup versions') + '\n\n' +
72
78
  chalk.gray(' Tip: use --profile work to sync a specific profile') + '\n\n' +
73
79
  chalk.gray(`v${VERSION}`),
@@ -120,8 +126,9 @@ program
120
126
  .alias('pull')
121
127
  .description('Restore your AI memory on this machine')
122
128
  .option('--only <tools>', 'Only restore specific tools (comma-separated)')
123
- .option('-y, --yes', 'Skip confirmation prompts (restore all)')
129
+ .option('-i, --interactive', 'Confirm each tool before restoring')
124
130
  .option('-p, --profile <name>', 'Use a specific profile')
131
+ .option('--from <token>', 'Restore from a share link token')
125
132
  .action(async (options) => {
126
133
  try {
127
134
  await restoreCommand(options);
@@ -131,6 +138,21 @@ program
131
138
  }
132
139
  });
133
140
 
141
+ program
142
+ .command('share')
143
+ .description('Share your AI memory via a secure link')
144
+ .option('--only <tools>', 'Only share specific tools (comma-separated)')
145
+ .option('--expires <hours>', 'Link expiry in hours (default: 24)')
146
+ .option('--uses <number>', 'Max number of uses (default: 5)')
147
+ .action(async (options) => {
148
+ try {
149
+ await shareCommand(options);
150
+ } catch (err) {
151
+ console.error(chalk.red('\n✖ Error during share:'), err.message);
152
+ process.exit(1);
153
+ }
154
+ });
155
+
134
156
  program
135
157
  .command('status')
136
158
  .description('See what AI tools are on this machine')
@@ -219,7 +241,6 @@ program
219
241
 
220
242
  program
221
243
  .command('update')
222
- .alias('upgrade')
223
244
  .description('Update memoir to the latest version')
224
245
  .action(async () => {
225
246
  try {
@@ -257,6 +278,19 @@ program
257
278
  }
258
279
  });
259
280
 
281
+ program
282
+ .command('upgrade')
283
+ .alias('pro')
284
+ .description('View plans and upgrade your memoir subscription')
285
+ .action(async () => {
286
+ try {
287
+ await upgradeCommand();
288
+ } catch (err) {
289
+ console.error(chalk.red('\n✖ Error:'), err.message);
290
+ process.exit(1);
291
+ }
292
+ });
293
+
260
294
  program
261
295
  .command('encrypt')
262
296
  .description('Toggle E2E encryption for your backups')
@@ -432,6 +466,47 @@ profile
432
466
  }
433
467
  });
434
468
 
469
+ // Project tracker
470
+ const projects = program.command('projects').alias('p').description('Track and manage your projects');
471
+
472
+ projects
473
+ .command('list', { isDefault: true })
474
+ .alias('ls')
475
+ .description('List all projects with recent activity')
476
+ .option('--all', 'Show all projects (default: top 15)')
477
+ .option('-v, --verbose', 'Show more commits and todos')
478
+ .option('--json', 'Output as JSON')
479
+ .action(async (options) => {
480
+ try {
481
+ await projectsListCommand(options);
482
+ } catch (err) {
483
+ console.error(chalk.red('\n✖ Error:'), err.message);
484
+ process.exit(1);
485
+ }
486
+ });
487
+
488
+ projects
489
+ .command('todo <project> [text]')
490
+ .description('Add or manage todos for a project')
491
+ .option('--done <index>', 'Mark a todo as done by number')
492
+ .option('--clear', 'Clear all todos for this project')
493
+ .action(async (project, text, options) => {
494
+ try {
495
+ await projectsTodoCommand(project, text, options);
496
+ } catch (err) {
497
+ console.error(chalk.red('\n✖ Error:'), err.message);
498
+ process.exit(1);
499
+ }
500
+ });
501
+
502
+ program
503
+ .command('mcp')
504
+ .description('Start the MCP server (for Claude Code, Cursor, VS Code integration)')
505
+ .action(async () => {
506
+ // Import and run the MCP server directly
507
+ await import('../src/mcp.js');
508
+ });
509
+
435
510
  program.hook('postAction', async () => {
436
511
  await checkForUpdate();
437
512
  });
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Sync AI memory across devices. Back up and restore Claude, Gemini, Codex, Cursor, Copilot, Windsurf configs. Snapshot coding sessions and resume on another machine. Migrate instructions between AI assistants.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
- "memoir": "bin/memoir.js"
8
+ "memoir": "bin/memoir.js",
9
+ "memoir-mcp": "src/mcp.js"
9
10
  },
10
11
  "repository": {
11
12
  "type": "git",
@@ -52,17 +53,21 @@
52
53
  "context-sync",
53
54
  "session-handoff",
54
55
  "snapshot",
55
- "resume"
56
+ "resume",
57
+ "mcp",
58
+ "mcp-server",
59
+ "model-context-protocol"
56
60
  ],
57
61
  "author": "camgitt",
58
62
  "license": "MIT",
59
63
  "dependencies": {
64
+ "@modelcontextprotocol/sdk": "^1.28.0",
60
65
  "boxen": "^7.1.1",
61
66
  "chalk": "^5.3.0",
62
67
  "commander": "^12.0.0",
63
68
  "fs-extra": "^11.2.0",
64
69
  "gradient-string": "^3.0.0",
65
70
  "inquirer": "^9.2.15",
66
- "ora": "^7.0.1"
71
+ "ora": "^7.0.1"
67
72
  }
68
73
  }
@@ -0,0 +1,240 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import chalk from 'chalk';
5
+ import boxen from 'boxen';
6
+ import { execFileSync } from 'child_process';
7
+
8
+ const home = os.homedir();
9
+ const TODOS_PATH = path.join(home, '.config', 'memoir', 'project-todos.json');
10
+
11
+ // ─── Helpers ───
12
+
13
+ async function loadTodos() {
14
+ try {
15
+ return await fs.readJson(TODOS_PATH);
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ async function saveTodos(todos) {
22
+ await fs.ensureDir(path.dirname(TODOS_PATH));
23
+ await fs.writeJson(TODOS_PATH, todos, { spaces: 2 });
24
+ }
25
+
26
+ function git(args, cwd) {
27
+ try {
28
+ return execFileSync('git', args, {
29
+ cwd,
30
+ stdio: ['pipe', 'pipe', 'ignore'],
31
+ timeout: 5000,
32
+ }).toString().trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function timeAgo(dateStr) {
39
+ const diff = Date.now() - new Date(dateStr).getTime();
40
+ const mins = Math.floor(diff / 60000);
41
+ if (mins < 60) return `${mins}m ago`;
42
+ const hrs = Math.floor(mins / 60);
43
+ if (hrs < 24) return `${hrs}h ago`;
44
+ const days = Math.floor(hrs / 24);
45
+ if (days < 30) return `${days}d ago`;
46
+ return `${Math.floor(days / 30)}mo ago`;
47
+ }
48
+
49
+ // ─── Scan ───
50
+
51
+ async function discoverProjects() {
52
+ const maxDepth = 3;
53
+ const skipDirs = new Set([
54
+ 'node_modules', '.git', '.next', '.vercel', 'dist', 'build',
55
+ '__pycache__', '.venv', 'venv', '.cache', '.npm', '.bun',
56
+ 'Library', '.Trash', 'Applications', 'Pictures', 'Music',
57
+ 'Movies', 'Public', 'Downloads', '.local', '.cargo', '.rustup',
58
+ '.docker', '.ssh', '.config', '.claude', '.gemini',
59
+ ]);
60
+
61
+ const projectMarkers = [
62
+ 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml',
63
+ 'requirements.txt', 'Gemfile', 'pom.xml', 'build.gradle',
64
+ 'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
65
+ 'GEMINI.md', 'AGENTS.md',
66
+ '.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
67
+ ];
68
+
69
+ const projects = [];
70
+
71
+ const scanDir = async (dir, depth = 0) => {
72
+ if (depth > maxDepth) return;
73
+ let entries;
74
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
75
+
76
+ const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
77
+ if (hasMarker && dir !== home) {
78
+ projects.push(dir);
79
+ return;
80
+ }
81
+
82
+ for (const entry of entries) {
83
+ if (!entry.isDirectory()) continue;
84
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue;
85
+ if (skipDirs.has(entry.name)) continue;
86
+ await scanDir(path.join(dir, entry.name), depth + 1);
87
+ }
88
+ };
89
+
90
+ await scanDir(home);
91
+ return projects;
92
+ }
93
+
94
+ function getProjectStatus(dir) {
95
+ const name = path.basename(dir);
96
+ const hasGit = fs.pathExistsSync(path.join(dir, '.git'));
97
+
98
+ const info = { name, path: dir, hasGit, branch: null, dirty: false, logs: [] };
99
+
100
+ if (!hasGit) return info;
101
+
102
+ info.branch = git(['branch', '--show-current'], dir) || 'unknown';
103
+
104
+ const status = git(['status', '--porcelain'], dir);
105
+ info.dirty = status ? status.length > 0 : false;
106
+
107
+ const logRaw = git(['log', '-5', '--format=%ad|%s', '--date=format:%b %d, %H:%M'], dir);
108
+ if (logRaw) {
109
+ info.logs = logRaw.split('\n').filter(Boolean).map(line => {
110
+ const sep = line.indexOf('|');
111
+ return { date: line.slice(0, sep), msg: line.slice(sep + 1) };
112
+ });
113
+ }
114
+
115
+ const lastDate = git(['log', '-1', '--format=%aI'], dir);
116
+ if (lastDate) info.lastActivity = lastDate;
117
+
118
+ return info;
119
+ }
120
+
121
+ // ─── Commands ───
122
+
123
+ export async function projectsListCommand(options) {
124
+ const dirs = await discoverProjects();
125
+ const todos = await loadTodos();
126
+
127
+ // Gather status for all projects
128
+ const projects = dirs.map(d => {
129
+ const s = getProjectStatus(d);
130
+ s.todos = todos[s.name] || [];
131
+ return s;
132
+ });
133
+
134
+ // Sort by last activity (most recent first)
135
+ projects.sort((a, b) => {
136
+ if (!a.lastActivity && !b.lastActivity) return 0;
137
+ if (!a.lastActivity) return 1;
138
+ if (!b.lastActivity) return -1;
139
+ return new Date(b.lastActivity) - new Date(a.lastActivity);
140
+ });
141
+
142
+ if (options.json) {
143
+ console.log(JSON.stringify(projects, null, 2));
144
+ return;
145
+ }
146
+
147
+ console.log('\n' + boxen(
148
+ chalk.white.bold(' projects ') + chalk.gray(` ${projects.length} found`),
149
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
150
+ ));
151
+
152
+ const limit = options.all ? projects.length : Math.min(projects.length, 15);
153
+
154
+ for (let i = 0; i < limit; i++) {
155
+ const p = projects[i];
156
+ const dot = p.hasGit
157
+ ? (p.dirty ? chalk.yellow('●') : chalk.green('●'))
158
+ : chalk.gray('●');
159
+
160
+ const age = p.lastActivity ? chalk.gray(` ${timeAgo(p.lastActivity)}`) : '';
161
+ const branchTag = p.branch && p.branch !== 'main' && p.branch !== 'master'
162
+ ? chalk.magenta(` [${p.branch}]`)
163
+ : '';
164
+ const dirtyTag = p.dirty ? chalk.yellow(' *') : '';
165
+ const todoTag = p.todos.length > 0
166
+ ? chalk.yellow(` (${p.todos.length} todo${p.todos.length > 1 ? 's' : ''})`)
167
+ : '';
168
+
169
+ console.log(`\n ${dot} ${chalk.white.bold(p.name)}${branchTag}${dirtyTag}${todoTag}${age}`);
170
+
171
+ // Show last few commits
172
+ const logCount = options.verbose ? 5 : 2;
173
+ for (let j = 0; j < Math.min(p.logs.length, logCount); j++) {
174
+ const log = p.logs[j];
175
+ console.log(` ${chalk.gray(log.date)} ${chalk.dim(log.msg)}`);
176
+ }
177
+
178
+ // Show todos inline
179
+ if (p.todos.length > 0 && options.verbose) {
180
+ for (const t of p.todos) {
181
+ console.log(` ${chalk.yellow('□')} ${chalk.yellow(t)}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ if (!options.all && projects.length > 15) {
187
+ console.log(chalk.gray(`\n ... and ${projects.length - 15} more (use --all to show all)`));
188
+ }
189
+
190
+ console.log(chalk.gray(`\n ${chalk.green('●')} clean ${chalk.yellow('●')} dirty ${chalk.gray('●')} no git\n`));
191
+ }
192
+
193
+ export async function projectsTodoCommand(projectName, text, options) {
194
+ const todos = await loadTodos();
195
+
196
+ // List todos for a project
197
+ if (!text && !options.done && !options.clear) {
198
+ const items = todos[projectName] || [];
199
+ if (items.length === 0) {
200
+ console.log(chalk.gray(`\n No todos for ${projectName}\n`));
201
+ return;
202
+ }
203
+ console.log(chalk.white.bold(`\n ${projectName} todos:\n`));
204
+ items.forEach((t, i) => {
205
+ console.log(` ${chalk.gray(`${i + 1}.`)} ${chalk.yellow('□')} ${t}`);
206
+ });
207
+ console.log('');
208
+ return;
209
+ }
210
+
211
+ // Mark done
212
+ if (options.done !== undefined) {
213
+ const idx = parseInt(options.done, 10) - 1;
214
+ const items = todos[projectName] || [];
215
+ if (idx < 0 || idx >= items.length) {
216
+ console.error(chalk.red(`\n ✖ Invalid index. ${projectName} has ${items.length} todo(s).\n`));
217
+ return;
218
+ }
219
+ const removed = items.splice(idx, 1)[0];
220
+ todos[projectName] = items;
221
+ if (items.length === 0) delete todos[projectName];
222
+ await saveTodos(todos);
223
+ console.log(chalk.green(`\n ✔ Done: ${removed}\n`));
224
+ return;
225
+ }
226
+
227
+ // Clear all
228
+ if (options.clear) {
229
+ delete todos[projectName];
230
+ await saveTodos(todos);
231
+ console.log(chalk.green(`\n ✔ Cleared all todos for ${projectName}\n`));
232
+ return;
233
+ }
234
+
235
+ // Add todo
236
+ if (!todos[projectName]) todos[projectName] = [];
237
+ todos[projectName].push(text);
238
+ await saveTodos(todos);
239
+ console.log(chalk.green(`\n ✔ Added to ${projectName}: ${text}\n`));
240
+ }
@@ -9,7 +9,7 @@ import { getConfig } from '../config.js';
9
9
  import { extractMemories, adapters } from '../adapters/index.js';
10
10
  import { syncToLocal, syncToGit } from '../providers/index.js';
11
11
  import inquirer from 'inquirer';
12
- import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject } from '../context/capture.js';
12
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions } from '../context/capture.js';
13
13
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
14
  import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
15
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
@@ -77,10 +77,19 @@ export async function pushCommand(options = {}) {
77
77
  await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
78
78
  await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
79
79
 
80
+ // Persist decisions to Claude's memory so they survive across sessions
81
+ let decisionCount = 0;
82
+ if (parsed.decisions.length > 0) {
83
+ try {
84
+ decisionCount = persistDecisions(parsed.decisions);
85
+ } catch {}
86
+ }
87
+
80
88
  contextCaptured = true;
81
89
  sessionInfo = {
82
90
  slug: parsed.slug,
83
91
  filesModified: parsed.filesWritten.length,
92
+ decisions: decisionCount,
84
93
  duration: parsed.firstTimestamp && parsed.lastTimestamp
85
94
  ? (() => {
86
95
  const ms = new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp);
@@ -170,14 +179,16 @@ export async function pushCommand(options = {}) {
170
179
  mask: '*',
171
180
  validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
172
181
  }]);
173
- spinner.start(chalk.gray('Encrypting...'));
182
+ spinner.start(chalk.gray('Deriving encryption key...'));
174
183
 
175
184
  encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
176
185
  await fs.ensureDir(encryptedDir);
177
- await encryptDirectory(stagingDir, encryptedDir, passphrase);
186
+ const encryptedCount = await encryptDirectory(stagingDir, encryptedDir, passphrase, spinner);
187
+ spinner.succeed(chalk.green(spinner.text));
188
+ spinner.start();
178
189
 
179
190
  // Save verify token so restore can check passphrase before decrypting
180
- const token = createVerifyToken(passphrase);
191
+ const token = await createVerifyToken(passphrase);
181
192
  await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
182
193
 
183
194
  uploadDir = encryptedDir;
@@ -226,6 +237,9 @@ export async function pushCommand(options = {}) {
226
237
  if (sessionInfo.duration) parts.push(sessionInfo.duration);
227
238
  if (sessionInfo.filesModified) parts.push(`${sessionInfo.filesModified} files changed`);
228
239
  contextLine = '\n' + chalk.green(' ✔ Session Context') + chalk.gray(` (${parts.join(', ')})`) + '\n';
240
+ if (sessionInfo.decisions > 0) {
241
+ contextLine += chalk.green(` ✔ ${sessionInfo.decisions} decision(s) saved to persistent memory`) + '\n';
242
+ }
229
243
  if (sessionInfo.secretsRedacted > 0) {
230
244
  contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
231
245
  }