memoir-cli 2.6.0 → 3.0.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.
package/bin/memoir.js CHANGED
@@ -62,6 +62,7 @@ if (process.argv.length <= 2) {
62
62
  chalk.cyan(' memoir resume ') + chalk.gray('— pick up where you left off') + '\n' +
63
63
  chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n' +
64
64
  chalk.cyan(' memoir profile ') + chalk.gray('— manage profiles (personal/work)') + '\n' +
65
+ chalk.cyan(' memoir encrypt ') + chalk.gray('— toggle E2E encryption') + '\n' +
65
66
  chalk.cyan(' memoir update ') + chalk.gray('— update to latest version') + '\n\n' +
66
67
  chalk.white.bold('Cloud (Pro):') + '\n' +
67
68
  chalk.cyan(' memoir login ') + chalk.gray('— sign in to memoir cloud') + '\n' +
@@ -238,9 +239,8 @@ program
238
239
  console.log('\n' + chalk.cyan(`Updating memoir ${VERSION} → ${chalk.green.bold(latest)}...`) + '\n');
239
240
 
240
241
  const { execSync } = await import('child_process');
241
- const execPath = process.argv[1] || '';
242
- const useBun = execPath.includes('.bun') || process.env.BUN_INSTALL;
243
- const cmd = useBun ? 'bun install -g memoir-cli' : 'npm install -g memoir-cli';
242
+ // Always use npm bun installs to a different location and can cause PATH conflicts
243
+ const cmd = 'npm install -g memoir-cli';
244
244
 
245
245
  execSync(cmd, { stdio: 'inherit' });
246
246
 
@@ -257,6 +257,44 @@ program
257
257
  }
258
258
  });
259
259
 
260
+ program
261
+ .command('encrypt')
262
+ .description('Toggle E2E encryption for your backups')
263
+ .action(async () => {
264
+ try {
265
+ const { getConfig, getRawConfig, saveConfig, migrateConfigToV2 } = await import('../src/config.js');
266
+ const config = await getConfig();
267
+ if (!config) {
268
+ console.error(chalk.red('\n✖ Not configured. Run memoir init first.'));
269
+ process.exit(1);
270
+ }
271
+ const current = config.encrypt || false;
272
+ console.log(chalk.white(`\n Encryption is currently: ${current ? chalk.green('ON') : chalk.red('OFF')}`));
273
+ const inquirer = (await import('inquirer')).default;
274
+ const { toggle } = await inquirer.prompt([{
275
+ type: 'confirm',
276
+ name: 'toggle',
277
+ message: current ? 'Disable encryption?' : 'Enable encryption?',
278
+ default: !current
279
+ }]);
280
+ if (toggle !== current) {
281
+ let raw = await getRawConfig();
282
+ if (!raw.version || raw.version < 2) raw = migrateConfigToV2(raw);
283
+ const profileName = raw.activeProfile || 'default';
284
+ if (raw.profiles?.[profileName]) {
285
+ raw.profiles[profileName].encrypt = !current;
286
+ } else {
287
+ raw.encrypt = !current;
288
+ }
289
+ await saveConfig(raw);
290
+ console.log(chalk.green(`\n ✔ Encryption ${!current ? 'enabled' : 'disabled'}. Next push will ${!current ? 'encrypt' : 'skip encryption'}.\n`));
291
+ }
292
+ } catch (err) {
293
+ console.error(chalk.red('\n✖ Error:'), err.message);
294
+ process.exit(1);
295
+ }
296
+ });
297
+
260
298
  program
261
299
  .command('migrate')
262
300
  .description('Translate memory between AI tools (Claude, Gemini, Codex, Cursor, etc.)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "2.6.0",
3
+ "version": "3.0.1",
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",
@@ -3,6 +3,7 @@ import nodeFs from 'node:fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import chalk from 'chalk';
6
+ import { shouldIgnoreProject } from '../context/capture.js';
6
7
 
7
8
  const home = os.homedir();
8
9
 
@@ -332,7 +333,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
332
333
  }
333
334
  }
334
335
 
335
- if (foundFiles.length > 0 && dir !== home) {
336
+ if (foundFiles.length > 0 && dir !== home && !shouldIgnoreProject(dir)) {
336
337
  // This is a project with AI configs
337
338
  const projectName = path.basename(dir);
338
339
  const projectDestDir = path.join(projectsDest, projectName);
@@ -9,7 +9,7 @@ import { adapters } from '../adapters/index.js';
9
9
  // on this machine, rather than trying to compute the encoding ourselves.
10
10
  // Claude's path encoding varies across platforms and versions, so detection
11
11
  // is the only reliable approach.
12
- function detectLocalHomeKey(adapterSource) {
12
+ export function detectLocalHomeKey(adapterSource) {
13
13
  const localProjectsDir = path.join(adapterSource, 'projects');
14
14
  if (!fs.existsSync(localProjectsDir)) return null;
15
15
 
@@ -65,7 +65,14 @@ function remapProjectPaths(backupDir, adapterSource) {
65
65
  if (!localHomeKey) {
66
66
  const home = os.homedir();
67
67
  // Use the same encoding Claude uses: path with separators → dashes
68
- localHomeKey = '-' + home.replace(/^\//, '').replace(/\\/g, '-').replace(/\//g, '-').replace(/:/g, '');
68
+ // Claude encodes paths: each separator (/ \ :) → dash
69
+ // macOS /Users/cam → -Users-cam (leading / stripped, prefixed with -)
70
+ // Windows C:\Users\X → C--Users-X (C + dash for colon + dash for backslash)
71
+ if (process.platform === 'win32') {
72
+ localHomeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
73
+ } else {
74
+ localHomeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
75
+ }
69
76
  }
70
77
 
71
78
  // Step 3: Identify foreign home keys in the backup
@@ -9,9 +9,10 @@ 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 } from '../context/capture.js';
12
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject } from '../context/capture.js';
13
13
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
14
  import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
+ import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
15
16
 
16
17
  export async function pushCommand(options = {}) {
17
18
  const config = await getConfig(options.profile);
@@ -113,10 +114,42 @@ export async function pushCommand(options = {}) {
113
114
  }
114
115
  }
115
116
 
116
- // Encrypt if enabled
117
+ // Encrypt if enabled (or ask on first push if not configured)
117
118
  let uploadDir = stagingDir;
118
119
  let encrypted = false;
119
- if (config.encrypt) {
120
+ let shouldEncrypt = config.encrypt;
121
+
122
+ if (shouldEncrypt === undefined) {
123
+ // First push since encryption was added — ask once and save preference
124
+ spinner.stop();
125
+ const { wantEncrypt } = await inquirer.prompt([{
126
+ type: 'confirm',
127
+ name: 'wantEncrypt',
128
+ message: 'Enable E2E encryption? (protects your backup even if compromised)',
129
+ default: true
130
+ }]);
131
+ shouldEncrypt = wantEncrypt;
132
+
133
+ // Save to config so we don't ask again
134
+ try {
135
+ let raw = await getRawConfig();
136
+ if (raw) {
137
+ if (!raw.version || raw.version < 2) {
138
+ raw = migrateConfigToV2(raw);
139
+ }
140
+ const profileName = options.profile || raw.activeProfile || 'default';
141
+ if (raw.profiles?.[profileName]) {
142
+ raw.profiles[profileName].encrypt = shouldEncrypt;
143
+ } else {
144
+ raw.encrypt = shouldEncrypt;
145
+ }
146
+ await saveConfig(raw);
147
+ }
148
+ } catch {}
149
+ spinner.start();
150
+ }
151
+
152
+ if (shouldEncrypt) {
120
153
  spinner.stop();
121
154
  const { passphrase } = await inquirer.prompt([{
122
155
  type: 'password',
@@ -199,7 +232,7 @@ export async function pushCommand(options = {}) {
199
232
  } finally {
200
233
  await fs.remove(stagingDir);
201
234
  // Clean up encrypted dir if it was created
202
- if (config.encrypt) {
235
+ if (true) {
203
236
  const encDirs = await fs.readdir(os.tmpdir());
204
237
  for (const d of encDirs) {
205
238
  if (d.startsWith('memoir-encrypted-')) {
@@ -9,6 +9,7 @@ import inquirer from 'inquirer';
9
9
  import { getConfig } from '../config.js';
10
10
  import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
11
11
  import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
12
+ import { detectLocalHomeKey } from '../adapters/restore.js';
12
13
 
13
14
  const home = os.homedir();
14
15
 
@@ -135,21 +136,31 @@ export async function restoreCommand(options = {}) {
135
136
  await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoffContent);
136
137
 
137
138
  // Inject into Claude's home-level memory so it's always loaded
138
- const cwdKey = '-' + home.replace(/^\//, '').replace(/\\/g, '-').replace(/\//g, '-').replace(/:/g, '');
139
- const claudeMemDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
140
- if (await fs.pathExists(path.join(home, '.claude'))) {
139
+ // Use detection (reads what Claude actually created) with corrected fallback
140
+ const claudeDir = path.join(home, '.claude');
141
+ if (await fs.pathExists(claudeDir)) {
142
+ let homeKey = detectLocalHomeKey(claudeDir);
143
+ if (!homeKey) {
144
+ // Fallback: compute key matching Claude's actual encoding
145
+ if (process.platform === 'win32') {
146
+ homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
147
+ } else {
148
+ homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
149
+ }
150
+ }
151
+ const claudeMemDir = path.join(claudeDir, 'projects', homeKey, 'memory');
141
152
  await fs.ensureDir(claudeMemDir);
142
153
  await fs.writeFile(path.join(claudeMemDir, 'handoff.md'), handoffContent);
143
154
  handoffInjected = true;
144
155
  }
145
156
 
146
- // Extract info for display
147
- const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/);
148
- const whenMatch = handoffContent.match(/\*\*When:\*\*\s*(.+)/);
149
- const durationMatch = handoffContent.match(/\*\*Duration:\*\*\s*(.+)/);
157
+ // Extract info for display — handles both old and new handoff formats
158
+ const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/) || handoffContent.match(/from \*\*(.+?)\*\*/);
159
+ const whenMatch = handoffContent.match(/\*\*When:\*\*\s*(.+)/) || handoffContent.match(/on (\d{4}-\d{2}-\d{2}) at (.+)/);
160
+ const durationMatch = handoffContent.match(/\*\*Duration:\*\*\s*(.+)/) || handoffContent.match(/Session: (\w+)/);
150
161
  handoffInfo = {
151
162
  from: fromMatch ? fromMatch[1] : 'another machine',
152
- when: whenMatch ? whenMatch[1] : 'recently',
163
+ when: whenMatch ? (whenMatch[2] ? `${whenMatch[1]} ${whenMatch[2]}` : whenMatch[1]) : 'recently',
153
164
  duration: durationMatch ? durationMatch[1] : null,
154
165
  };
155
166
  }
@@ -51,7 +51,13 @@ async function injectHandoff(content, tool) {
51
51
  claude: () => {
52
52
  // Write to Claude's project memory dir so it's auto-loaded
53
53
  const cwd = process.cwd();
54
- const cwdKey = '-' + cwd.replace(/^\//, '').replace(/\\/g, '-').replace(/\//g, '-').replace(/:/g, '');
54
+ // Match Claude's actual path encoding: each separator → dash
55
+ let cwdKey;
56
+ if (process.platform === 'win32') {
57
+ cwdKey = cwd.replace(/\\/g, '-').replace(/:/g, '-');
58
+ } else {
59
+ cwdKey = '-' + cwd.replace(/^\//, '').replace(/\//g, '-');
60
+ }
55
61
  const memDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
56
62
  return path.join(memDir, 'handoff.md');
57
63
  },
@@ -168,47 +168,41 @@ export function generateContextHandoff(parsed) {
168
168
  .filter(m => m.length > 10 && !/^(ok|yes|no|sure|yea|yeah|yep|nah|nope|thanks|ty|thx|good|great|nice|cool|done|hmm)$/i.test(m.trim()))
169
169
  .map(m => m.length > 150 ? m.slice(0, 150) + '...' : m);
170
170
 
171
+ // Build a concise, actionable handoff
171
172
  let md = `---
172
173
  name: Session Handoff
173
- description: Auto-generated context from last coding session for seamless cross-device continuity
174
+ description: Coding session context resume on any machine, any AI tool
174
175
  type: project
175
176
  ---
176
177
 
177
- # Session Handoff
178
+ # Continue where I left off
178
179
 
179
- **From:** ${hostname} (${platform})
180
- **When:** ${now.toISOString().split('T')[0]} ${now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
181
- **Duration:** ${duration}
182
- **Project:** ${cwd}
183
- **Branch:** ${parsed.gitBranch || 'unknown'}
180
+ > Handed off from **${hostname}** (${platform}) on ${now.toISOString().split('T')[0]} at ${now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
181
+ > Session: ${duration} | Branch: \`${parsed.gitBranch || 'unknown'}\` | Project: \`${cwd}\`
184
182
 
185
- ## What was being worked on
183
+ ## What I was working on
186
184
  ${meaningful.length > 0 ? meaningful.slice(0, 8).map(m => `- ${m}`).join('\n') : '_No significant messages captured_'}
187
185
 
188
- ## Files modified
186
+ ## Files I changed
189
187
  ${parsed.filesWritten.length > 0
190
188
  ? parsed.filesWritten.slice(0, 15).map(f => `- \`${shorten(f)}\``).join('\n')
191
189
  : '_None_'}
192
-
193
- ## Key files referenced
194
- ${parsed.filesRead.length > 0
195
- ? parsed.filesRead.filter(f => !parsed.filesWritten.includes(f)).slice(0, 10).map(f => `- \`${shorten(f)}\``).join('\n')
196
- : '_None_'}
197
190
  `;
198
191
 
199
- if (parsed.errors.length > 0) {
200
- md += `\n## Errors hit\n${parsed.errors.slice(0, 5).map(e => `- ${e}`).join('\n')}\n`;
192
+ // Only show referenced files that weren't also modified
193
+ const readOnly = parsed.filesRead.filter(f => !parsed.filesWritten.includes(f));
194
+ if (readOnly.length > 0) {
195
+ md += `\n## Files I was looking at\n${readOnly.slice(0, 10).map(f => `- \`${shorten(f)}\``).join('\n')}\n`;
201
196
  }
202
197
 
203
- md += `\n## Context for continuing
204
- This session ran for ${duration} on ${platform}. ${parsed.filesWritten.length} files were modified and ${parsed.bashCommands.length} commands were run.`;
198
+ if (parsed.errors.length > 0) {
199
+ md += `\n## Issues I ran into\n${parsed.errors.slice(0, 5).map(e => `- ${e}`).join('\n')}\n`;
200
+ }
205
201
 
206
202
  if (parsed.filesWritten.length > 0) {
207
- md += ` Start by reviewing: ${parsed.filesWritten.slice(0, 3).map(f => '`' + shorten(f) + '`').join(', ')}.`;
203
+ md += `\n## Pick up here\nStart by reviewing: ${parsed.filesWritten.slice(0, 3).map(f => '`' + shorten(f) + '`').join(', ')}. ${parsed.filesWritten.length} files were modified in total.\n`;
208
204
  }
209
205
 
210
- md += '\n';
211
-
212
206
  return md;
213
207
  }
214
208