memoir-cli 1.5.2 → 2.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.
@@ -0,0 +1,221 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import ora from 'ora';
6
+ import boxen from 'boxen';
7
+ import { execSync } from 'child_process';
8
+ import { getConfig } from '../config.js';
9
+ import { adapters } from '../adapters/index.js';
10
+
11
+ async function listFiles(dir, prefix = '') {
12
+ const entries = await fs.readdir(dir, { withFileTypes: true });
13
+ const files = [];
14
+ for (const entry of entries) {
15
+ if (entry.name === '.git') continue;
16
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
17
+ if (entry.isDirectory()) {
18
+ files.push(...await listFiles(path.join(dir, entry.name), rel));
19
+ } else {
20
+ files.push({ path: rel });
21
+ }
22
+ }
23
+ return files;
24
+ }
25
+
26
+ function isBinaryFile(filePath) {
27
+ const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.zip', '.tar', '.gz', '.db', '.sqlite'];
28
+ return binaryExts.includes(path.extname(filePath).toLowerCase());
29
+ }
30
+
31
+ function simpleDiff(oldText, newText) {
32
+ const oldLines = oldText.split('\n');
33
+ const newLines = newText.split('\n');
34
+ const output = [];
35
+
36
+ const oldSet = new Set(oldLines);
37
+ const newSet = new Set(newLines);
38
+
39
+ for (const line of oldLines) {
40
+ if (!newSet.has(line) && line.trim()) {
41
+ output.push(chalk.red(` - ${line}`));
42
+ }
43
+ }
44
+ for (const line of newLines) {
45
+ if (!oldSet.has(line) && line.trim()) {
46
+ output.push(chalk.green(` + ${line}`));
47
+ }
48
+ }
49
+
50
+ return output;
51
+ }
52
+
53
+ export async function diffCommand() {
54
+ const config = await getConfig();
55
+ if (!config) {
56
+ console.log(chalk.red('\n✖ Not configured yet. Run: memoir init\n'));
57
+ return;
58
+ }
59
+
60
+ const spinner = ora('Comparing local files against last backup...').start();
61
+ const stagingDir = path.join(os.tmpdir(), `memoir-diff-${Date.now()}`);
62
+ await fs.ensureDir(stagingDir);
63
+
64
+ try {
65
+ if (config.provider === 'git') {
66
+ execSync(`git clone --depth 1 ${config.gitRepo} .`, { cwd: stagingDir, stdio: 'ignore' });
67
+ } else {
68
+ const resolvedSource = config.localPath.replace(/^~/, os.homedir());
69
+ if (!(await fs.pathExists(resolvedSource))) {
70
+ spinner.fail('No backup found. Run: memoir push');
71
+ return;
72
+ }
73
+ await fs.copy(resolvedSource, stagingDir);
74
+ }
75
+
76
+ spinner.stop();
77
+
78
+ const summary = { added: [], modified: [], deleted: [], unchanged: 0 };
79
+ const details = [];
80
+
81
+ for (const adapter of adapters) {
82
+ const backupDir = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
83
+ const backupExists = await fs.pathExists(backupDir);
84
+
85
+ // Get local files for this adapter
86
+ const localFiles = new Set();
87
+ if (adapter.customExtract) {
88
+ for (const file of adapter.files) {
89
+ if (await fs.pathExists(path.join(adapter.source, file))) {
90
+ localFiles.add(file);
91
+ }
92
+ }
93
+ } else if (await fs.pathExists(adapter.source)) {
94
+ // Walk local source with the adapter's filter
95
+ const walk = async (dir, prefix = '') => {
96
+ const entries = await fs.readdir(dir, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ const fullPath = path.join(dir, entry.name);
99
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
100
+ if (adapter.filter && !adapter.filter(fullPath)) continue;
101
+ if (entry.isDirectory()) {
102
+ await walk(fullPath, rel);
103
+ } else {
104
+ localFiles.add(rel);
105
+ }
106
+ }
107
+ };
108
+ await walk(adapter.source);
109
+ }
110
+
111
+ // Get backup files
112
+ const backupFiles = new Set();
113
+ if (backupExists) {
114
+ const files = await listFiles(backupDir);
115
+ files.forEach(f => backupFiles.add(f.path));
116
+ }
117
+
118
+ // New files (local but not in backup)
119
+ for (const file of localFiles) {
120
+ if (!backupFiles.has(file)) {
121
+ if (!isBinaryFile(file)) {
122
+ summary.added.push({ tool: adapter.name, icon: adapter.icon, file });
123
+ }
124
+ }
125
+ }
126
+
127
+ // Deleted files (in backup but not local)
128
+ for (const file of backupFiles) {
129
+ if (!localFiles.has(file)) {
130
+ summary.deleted.push({ tool: adapter.name, icon: adapter.icon, file });
131
+ }
132
+ }
133
+
134
+ // Modified files (in both, content differs)
135
+ for (const file of localFiles) {
136
+ if (backupFiles.has(file) && !isBinaryFile(file)) {
137
+ const localPath = adapter.customExtract
138
+ ? path.join(adapter.source, file)
139
+ : path.join(adapter.source, file);
140
+ const backupPath = path.join(backupDir, file);
141
+
142
+ try {
143
+ const localContent = await fs.readFile(localPath, 'utf8');
144
+ const backupContent = await fs.readFile(backupPath, 'utf8');
145
+
146
+ if (localContent !== backupContent) {
147
+ const diffLines = simpleDiff(backupContent, localContent);
148
+ summary.modified.push({ tool: adapter.name, icon: adapter.icon, file });
149
+ details.push({ tool: adapter.name, icon: adapter.icon, file, lines: diffLines });
150
+ } else {
151
+ summary.unchanged++;
152
+ }
153
+ } catch {
154
+ summary.unchanged++;
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ // Print summary
161
+ const totalChanges = summary.added.length + summary.modified.length + summary.deleted.length;
162
+
163
+ if (totalChanges === 0) {
164
+ console.log('\n' + boxen(
165
+ chalk.green('Everything is in sync.') + '\n' +
166
+ chalk.gray(`${summary.unchanged} files unchanged since last backup.`),
167
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
168
+ ) + '\n');
169
+ return;
170
+ }
171
+
172
+ console.log('\n' + boxen(
173
+ chalk.cyan.bold('Changes since last backup'),
174
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan' }
175
+ ));
176
+
177
+ if (summary.added.length > 0) {
178
+ console.log(chalk.green.bold(`\n + ${summary.added.length} new file(s)`));
179
+ for (const f of summary.added) {
180
+ console.log(chalk.green(` ${f.icon} ${f.tool}/${f.file}`));
181
+ }
182
+ }
183
+
184
+ if (summary.modified.length > 0) {
185
+ console.log(chalk.yellow.bold(`\n ~ ${summary.modified.length} modified file(s)`));
186
+ for (const f of summary.modified) {
187
+ console.log(chalk.yellow(` ${f.icon} ${f.tool}/${f.file}`));
188
+ }
189
+ }
190
+
191
+ if (summary.deleted.length > 0) {
192
+ console.log(chalk.red.bold(`\n - ${summary.deleted.length} removed file(s)`));
193
+ for (const f of summary.deleted) {
194
+ console.log(chalk.red(` ${f.icon} ${f.tool}/${f.file}`));
195
+ }
196
+ }
197
+
198
+ if (summary.unchanged > 0) {
199
+ console.log(chalk.gray(`\n ${summary.unchanged} file(s) unchanged`));
200
+ }
201
+
202
+ // Show diffs for modified files
203
+ if (details.length > 0) {
204
+ console.log(chalk.gray('\n' + '─'.repeat(40)));
205
+ console.log(chalk.bold.white('\n Changes:\n'));
206
+
207
+ for (const d of details) {
208
+ console.log(` ${d.icon} ${chalk.cyan(d.tool + '/' + d.file)}`);
209
+ for (const line of d.lines) {
210
+ console.log(line);
211
+ }
212
+ console.log('');
213
+ }
214
+ }
215
+
216
+ console.log(chalk.gray(' Run ') + chalk.cyan('memoir push') + chalk.gray(' to back up these changes.\n'));
217
+
218
+ } finally {
219
+ await fs.remove(stagingDir);
220
+ }
221
+ }
@@ -31,10 +31,12 @@ export async function restoreCommand(options = {}) {
31
31
 
32
32
  const onlyFilter = options.only ? options.only.split(',').map(t => t.trim().toLowerCase()) : null;
33
33
 
34
+ const autoYes = options.yes || false;
35
+
34
36
  if (config.provider === 'local' || config.provider.includes('local')) {
35
- restored = await fetchFromLocal(config, stagingDir, spinner, onlyFilter);
37
+ restored = await fetchFromLocal(config, stagingDir, spinner, onlyFilter, autoYes);
36
38
  } else if (config.provider === 'git' || config.provider.includes('git')) {
37
- restored = await fetchFromGit(config, stagingDir, spinner, onlyFilter);
39
+ restored = await fetchFromGit(config, stagingDir, spinner, onlyFilter, autoYes);
38
40
  } else {
39
41
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
40
42
  return;
@@ -0,0 +1,166 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import ora from 'ora';
6
+ import boxen from 'boxen';
7
+ import gradient from 'gradient-string';
8
+ import { getConfig } from '../config.js';
9
+ import { execFileSync } from 'child_process';
10
+
11
+ const home = os.homedir();
12
+
13
+ // Fetch latest handoff from git backup
14
+ async function fetchLatestHandoff(config, spinner) {
15
+ const tmpDir = path.join(os.tmpdir(), `memoir-resume-${Date.now()}`);
16
+ await fs.ensureDir(tmpDir);
17
+
18
+ try {
19
+ if (config.provider === 'git' || config.provider.includes('git')) {
20
+ spinner.text = chalk.gray('Pulling latest handoff from GitHub...');
21
+ execFileSync('git', ['clone', '--depth', '1', config.gitRepo, '.'], { cwd: tmpDir, stdio: 'ignore' });
22
+ } else if (config.provider === 'local' || config.provider.includes('local')) {
23
+ const resolvedSource = config.localPath.replace(/^~/, home);
24
+ spinner.text = chalk.gray('Fetching handoff from local backup...');
25
+ await fs.copy(resolvedSource, tmpDir);
26
+ }
27
+
28
+ const handoffDir = path.join(tmpDir, 'handoffs');
29
+ if (!await fs.pathExists(handoffDir)) {
30
+ return null;
31
+ }
32
+
33
+ // Find the newest handoff file
34
+ const files = (await fs.readdir(handoffDir))
35
+ .filter(f => f.endsWith('.md') && f !== 'latest.md')
36
+ .sort()
37
+ .reverse();
38
+
39
+ if (files.length === 0) return null;
40
+
41
+ const content = await fs.readFile(path.join(handoffDir, files[0]), 'utf8');
42
+ return { filename: files[0], content };
43
+ } finally {
44
+ await fs.remove(tmpDir);
45
+ }
46
+ }
47
+
48
+ // Inject handoff into a tool's context location
49
+ async function injectHandoff(content, tool) {
50
+ const targets = {
51
+ claude: () => {
52
+ // Write to Claude's project memory dir so it's auto-loaded
53
+ const cwd = process.cwd();
54
+ const cwdKey = '-' + cwd.replace(/^\//, '').replace(/\\/g, '-').replace(/\//g, '-').replace(/:/g, '');
55
+ const memDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
56
+ return path.join(memDir, 'handoff.md');
57
+ },
58
+ gemini: () => {
59
+ return path.join(process.cwd(), 'GEMINI.md');
60
+ },
61
+ cursor: () => {
62
+ return path.join(process.cwd(), '.cursor', 'rules', 'handoff.mdc');
63
+ },
64
+ codex: () => {
65
+ return path.join(process.cwd(), 'AGENTS.md');
66
+ }
67
+ };
68
+
69
+ const getTarget = targets[tool];
70
+ if (!getTarget) {
71
+ throw new Error(`Unknown tool: ${tool}. Supported: claude, gemini, cursor, codex`);
72
+ }
73
+
74
+ const targetPath = getTarget();
75
+ await fs.ensureDir(path.dirname(targetPath));
76
+
77
+ if (tool === 'gemini' && await fs.pathExists(targetPath)) {
78
+ // Append to existing GEMINI.md
79
+ const existing = await fs.readFile(targetPath, 'utf8');
80
+ if (!existing.includes('# Session Handoff')) {
81
+ await fs.writeFile(targetPath, existing + '\n\n' + content);
82
+ } else {
83
+ // Replace existing handoff section
84
+ const before = existing.split('# Session Handoff')[0];
85
+ await fs.writeFile(targetPath, before + content);
86
+ }
87
+ } else {
88
+ await fs.writeFile(targetPath, content);
89
+ }
90
+
91
+ return targetPath;
92
+ }
93
+
94
+ export async function resumeCommand(options = {}) {
95
+ const config = await getConfig();
96
+
97
+ if (!config) {
98
+ console.log('\n' + boxen(
99
+ chalk.red('Not configured yet\n\n') +
100
+ chalk.white('Run ') + chalk.cyan.bold('memoir init') + chalk.white(' to get started.'),
101
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
102
+ ) + '\n');
103
+ return;
104
+ }
105
+
106
+ console.log();
107
+ const spinner = ora({ text: chalk.gray('Fetching latest handoff...'), spinner: 'dots' }).start();
108
+
109
+ // First check local cache
110
+ const localLatest = path.join(home, '.config', 'memoir', 'handoffs', 'latest.md');
111
+ let handoff;
112
+
113
+ // Try remote first
114
+ try {
115
+ handoff = await fetchLatestHandoff(config, spinner);
116
+ } catch (err) {
117
+ spinner.warn(chalk.yellow(`Remote fetch failed: ${err.message}`));
118
+ spinner.start();
119
+ }
120
+
121
+ // Fallback to local cache
122
+ if (!handoff && await fs.pathExists(localLatest)) {
123
+ handoff = { filename: 'latest.md', content: await fs.readFile(localLatest, 'utf8') };
124
+ }
125
+
126
+ if (!handoff) {
127
+ spinner.fail(chalk.red('No handoffs found.'));
128
+ console.log(chalk.gray('\n Run ') + chalk.cyan('memoir snapshot') + chalk.gray(' on another machine first.\n'));
129
+ return;
130
+ }
131
+
132
+ // Save locally
133
+ const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
134
+ await fs.ensureDir(localHandoffDir);
135
+ await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoff.content);
136
+
137
+ spinner.stop();
138
+
139
+ // Display the handoff
140
+ console.log(boxen(
141
+ gradient.pastel(' Session Handoff ') + '\n\n' +
142
+ handoff.content
143
+ .replace(/^---[\s\S]*?---\n/, '') // Strip YAML frontmatter for display
144
+ .trim(),
145
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
146
+ ));
147
+
148
+ // Inject if requested
149
+ if (options.inject) {
150
+ const tool = options.to || 'claude';
151
+ spinner.start(chalk.gray(`Injecting handoff for ${tool}...`));
152
+ try {
153
+ const targetPath = await injectHandoff(handoff.content, tool);
154
+ spinner.stop();
155
+ console.log('\n' + chalk.green(` Injected handoff → ${targetPath}`));
156
+ console.log(chalk.gray(` ${tool.charAt(0).toUpperCase() + tool.slice(1)} will read this on next session.\n`));
157
+ } catch (err) {
158
+ spinner.fail(chalk.red(`Inject failed: ${err.message}`));
159
+ }
160
+ } else {
161
+ console.log('\n' + chalk.gray(' To inject into your AI tool:'));
162
+ console.log(chalk.cyan(' memoir resume --inject') + chalk.gray(' (Claude)'));
163
+ console.log(chalk.cyan(' memoir resume --inject --to gemini'));
164
+ console.log(chalk.cyan(' memoir resume --inject --to cursor') + '\n');
165
+ }
166
+ }