memoir-cli 2.6.0 → 3.0.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 +39 -0
- package/package.json +1 -1
- package/src/adapters/index.js +2 -1
- package/src/commands/push.js +37 -4
- package/src/commands/restore.js +5 -5
- 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' +
|
|
@@ -257,6 +258,44 @@ program
|
|
|
257
258
|
}
|
|
258
259
|
});
|
|
259
260
|
|
|
261
|
+
program
|
|
262
|
+
.command('encrypt')
|
|
263
|
+
.description('Toggle E2E encryption for your backups')
|
|
264
|
+
.action(async () => {
|
|
265
|
+
try {
|
|
266
|
+
const { getConfig, getRawConfig, saveConfig, migrateConfigToV2 } = await import('../src/config.js');
|
|
267
|
+
const config = await getConfig();
|
|
268
|
+
if (!config) {
|
|
269
|
+
console.error(chalk.red('\n✖ Not configured. Run memoir init first.'));
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
const current = config.encrypt || false;
|
|
273
|
+
console.log(chalk.white(`\n Encryption is currently: ${current ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
274
|
+
const inquirer = (await import('inquirer')).default;
|
|
275
|
+
const { toggle } = await inquirer.prompt([{
|
|
276
|
+
type: 'confirm',
|
|
277
|
+
name: 'toggle',
|
|
278
|
+
message: current ? 'Disable encryption?' : 'Enable encryption?',
|
|
279
|
+
default: !current
|
|
280
|
+
}]);
|
|
281
|
+
if (toggle !== current) {
|
|
282
|
+
let raw = await getRawConfig();
|
|
283
|
+
if (!raw.version || raw.version < 2) raw = migrateConfigToV2(raw);
|
|
284
|
+
const profileName = raw.activeProfile || 'default';
|
|
285
|
+
if (raw.profiles?.[profileName]) {
|
|
286
|
+
raw.profiles[profileName].encrypt = !current;
|
|
287
|
+
} else {
|
|
288
|
+
raw.encrypt = !current;
|
|
289
|
+
}
|
|
290
|
+
await saveConfig(raw);
|
|
291
|
+
console.log(chalk.green(`\n ✔ Encryption ${!current ? 'enabled' : 'disabled'}. Next push will ${!current ? 'encrypt' : 'skip encryption'}.\n`));
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(chalk.red('\n✖ Error:'), err.message);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
260
299
|
program
|
|
261
300
|
.command('migrate')
|
|
262
301
|
.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.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",
|
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/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
|
@@ -143,13 +143,13 @@ export async function restoreCommand(options = {}) {
|
|
|
143
143
|
handoffInjected = true;
|
|
144
144
|
}
|
|
145
145
|
|
|
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*(.+)/);
|
|
146
|
+
// Extract info for display — handles both old and new handoff formats
|
|
147
|
+
const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/) || handoffContent.match(/from \*\*(.+?)\*\*/);
|
|
148
|
+
const whenMatch = handoffContent.match(/\*\*When:\*\*\s*(.+)/) || handoffContent.match(/on (\d{4}-\d{2}-\d{2}) at (.+)/);
|
|
149
|
+
const durationMatch = handoffContent.match(/\*\*Duration:\*\*\s*(.+)/) || handoffContent.match(/Session: (\w+)/);
|
|
150
150
|
handoffInfo = {
|
|
151
151
|
from: fromMatch ? fromMatch[1] : 'another machine',
|
|
152
|
-
when: whenMatch ? whenMatch[1] : 'recently',
|
|
152
|
+
when: whenMatch ? (whenMatch[2] ? `${whenMatch[1]} ${whenMatch[2]}` : whenMatch[1]) : 'recently',
|
|
153
153
|
duration: durationMatch ? durationMatch[1] : null,
|
|
154
154
|
};
|
|
155
155
|
}
|
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
|
|