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 +41 -3
- package/package.json +1 -1
- package/src/adapters/index.js +2 -1
- package/src/adapters/restore.js +9 -2
- package/src/commands/push.js +37 -4
- package/src/commands/restore.js +19 -8
- package/src/commands/resume.js +7 -1
- package/src/context/capture.js +15 -21
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
|
-
|
|
242
|
-
const
|
|
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": "
|
|
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",
|
package/src/adapters/index.js
CHANGED
|
@@ -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);
|
package/src/adapters/restore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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-')) {
|
package/src/commands/restore.js
CHANGED
|
@@ -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
|
-
|
|
139
|
-
const
|
|
140
|
-
if (await fs.pathExists(
|
|
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
|
}
|
package/src/commands/resume.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/src/context/capture.js
CHANGED
|
@@ -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:
|
|
174
|
+
description: Coding session context — resume on any machine, any AI tool
|
|
174
175
|
type: project
|
|
175
176
|
---
|
|
176
177
|
|
|
177
|
-
#
|
|
178
|
+
# Continue where I left off
|
|
178
179
|
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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 +=
|
|
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
|
|