memoir-cli 1.2.1 → 1.4.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
@@ -7,6 +7,7 @@ import { initCommand } from '../src/commands/init.js';
7
7
  import { pushCommand } from '../src/commands/push.js';
8
8
  import { restoreCommand } from '../src/commands/restore.js';
9
9
  import { statusCommand } from '../src/commands/status.js';
10
+ import { viewCommand } from '../src/commands/view.js';
10
11
 
11
12
  const VERSION = '1.2.0';
12
13
 
@@ -72,6 +73,19 @@ program
72
73
  }
73
74
  });
74
75
 
76
+ program
77
+ .command('view')
78
+ .alias('ls')
79
+ .description('Preview what files are in your backup')
80
+ .action(async () => {
81
+ try {
82
+ await viewCommand();
83
+ } catch (err) {
84
+ console.error(chalk.red('\n✖ Error:'), err.message);
85
+ process.exit(1);
86
+ }
87
+ });
88
+
75
89
  program
76
90
  .command('migrate')
77
91
  .description('Translate memory between AI providers')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Your AI remembers everything. Sync it everywhere.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -5,6 +5,26 @@ import os from 'os';
5
5
  import inquirer from 'inquirer';
6
6
  import { adapters } from '../adapters/index.js';
7
7
 
8
+ async function copyMissing(src, dest, changes) {
9
+ const entries = await fs.readdir(src, { withFileTypes: true });
10
+ for (const entry of entries) {
11
+ const srcPath = path.join(src, entry.name);
12
+ const destPath = path.join(dest, entry.name);
13
+
14
+ if (entry.isDirectory()) {
15
+ await fs.ensureDir(destPath);
16
+ await copyMissing(srcPath, destPath, changes);
17
+ } else {
18
+ if (await fs.pathExists(destPath)) {
19
+ changes.skipped.push(destPath);
20
+ } else {
21
+ await fs.copy(srcPath, destPath);
22
+ changes.added.push(destPath);
23
+ }
24
+ }
25
+ }
26
+ }
27
+
8
28
  export async function restoreMemories(sourceDir, spinner) {
9
29
  let restoredAny = false;
10
30
 
@@ -14,12 +34,12 @@ export async function restoreMemories(sourceDir, spinner) {
14
34
  if (await fs.pathExists(backupDir)) {
15
35
  spinner.stop();
16
36
 
17
- console.log('\\n' + chalk.yellow(`⚠ Found backup for ${chalk.bold(adapter.name)}.`));
37
+ console.log('\n' + chalk.yellow(`⚠ Found backup for ${chalk.bold(adapter.name)}.`));
18
38
  const { confirm } = await inquirer.prompt([
19
39
  {
20
40
  type: 'confirm',
21
41
  name: 'confirm',
22
- message: `Restore ${adapter.name} memory? This will overwrite existing configuration files!`,
42
+ message: `Restore ${adapter.name} memory? (only adds missing files, won't overwrite)`,
23
43
  default: false
24
44
  }
25
45
  ]);
@@ -27,18 +47,41 @@ export async function restoreMemories(sourceDir, spinner) {
27
47
  spinner.start();
28
48
 
29
49
  if (confirm) {
50
+ const changes = { added: [], skipped: [] };
51
+
30
52
  if (adapter.customExtract) {
31
- // Restore individual files back to their original locations
32
53
  const files = await fs.readdir(backupDir);
33
54
  for (const file of files) {
34
55
  const dest = path.join(adapter.source, file);
35
- await fs.copy(path.join(backupDir, file), dest, { overwrite: true });
56
+ if (await fs.pathExists(dest)) {
57
+ changes.skipped.push(dest);
58
+ } else {
59
+ await fs.copy(path.join(backupDir, file), dest);
60
+ changes.added.push(dest);
61
+ }
36
62
  }
37
63
  } else {
38
64
  spinner.text = `Restoring ${chalk.cyan(adapter.name)} memory to ${adapter.source}...`;
39
65
  await fs.ensureDir(adapter.source);
40
- await fs.copy(backupDir, adapter.source, { overwrite: true });
66
+ await copyMissing(backupDir, adapter.source, changes);
41
67
  }
68
+
69
+ // Show summary of changes
70
+ spinner.stop();
71
+ if (changes.added.length > 0) {
72
+ console.log(chalk.green.bold(`\n ✔ ${adapter.name} — ${changes.added.length} file(s) added:`));
73
+ for (const f of changes.added) {
74
+ console.log(chalk.green(` + ${f}`));
75
+ }
76
+ }
77
+ if (changes.skipped.length > 0) {
78
+ console.log(chalk.gray(` ⏭ ${changes.skipped.length} file(s) already existed (kept yours)`));
79
+ }
80
+ if (changes.added.length === 0 && changes.skipped.length > 0) {
81
+ console.log(chalk.gray(` Nothing new to add — you're already up to date.`));
82
+ }
83
+ spinner.start();
84
+
42
85
  restoredAny = true;
43
86
  } else {
44
87
  spinner.info(chalk.gray(`Skipped restoring ${adapter.name}.`));
@@ -4,78 +4,88 @@ import open from 'open';
4
4
  import boxen from 'boxen';
5
5
  import gradient from 'gradient-string';
6
6
  import { saveConfig } from '../config.js';
7
+ import { pushCommand } from './push.js';
8
+ import { restoreCommand } from './restore.js';
7
9
 
8
10
  export async function initCommand() {
9
11
  const title = gradient.pastel.multiline('memoir \\nYour AI Remembers Everything');
10
- console.log('\\n' + boxen(title, {
11
- padding: 1,
12
- margin: 1,
13
- borderStyle: 'round',
12
+ console.log('\\n' + boxen(title, {
13
+ padding: 1,
14
+ margin: 1,
15
+ borderStyle: 'round',
14
16
  borderColor: 'cyan',
15
17
  align: 'center'
16
18
  }));
17
19
 
18
- console.log(chalk.gray("Let's configure where your AI knowledge will be safely stored.\\n"));
20
+ console.log(chalk.gray("Let's get your AI memory set up.\\n"));
19
21
 
20
- const answers = await inquirer.prompt([
22
+ const { direction } = await inquirer.prompt([
21
23
  {
22
24
  type: 'list',
23
- name: 'provider',
24
- message: 'Choose your storage provider:',
25
+ name: 'direction',
26
+ message: 'What do you want to do?',
25
27
  choices: [
26
- { name: '☁️ Git Repository ' + chalk.gray('(GitHub, GitLab - Best for syncing across computers)'), value: 'git' },
27
- { name: '📂 Local Directory ' + chalk.gray('(Dropbox, iCloud - Best for local backups)'), value: 'local' }
28
+ { name: '⬆️ Upload ' + chalk.gray('(backup this machine\'s AI memory)'), value: 'upload' },
29
+ { name: '⬇️ Download ' + chalk.gray('(restore AI memory to this machine)'), value: 'download' }
28
30
  ]
29
- },
30
- {
31
- type: 'input',
32
- name: 'localPath',
33
- message: 'Enter the full path to your sync directory ' + chalk.gray('(e.g., ~/Dropbox/memoir):'),
34
- when: (answers) => answers.provider === 'local',
35
- validate: (input) => input.trim() !== '' ? true : chalk.red('✖ Path is required')
36
- },
31
+ }
32
+ ]);
33
+
34
+ const { provider } = await inquirer.prompt([
37
35
  {
38
- type: 'confirm',
39
- name: 'openBrowser',
40
- message: 'Need to create an empty GitHub repository right now?',
41
- when: (answers) => answers.provider === 'git',
42
- default: false
36
+ type: 'list',
37
+ name: 'provider',
38
+ message: 'Where do you want to store it?',
39
+ choices: [
40
+ { name: '☁️ GitHub ' + chalk.gray('(sync across computers)'), value: 'git' },
41
+ { name: '📂 Local Directory ' + chalk.gray('(Dropbox, iCloud, etc.)'), value: 'local' }
42
+ ]
43
43
  }
44
44
  ]);
45
45
 
46
- if (answers.openBrowser) {
47
- console.log(chalk.cyan('\\n↗ Opening GitHub... Create an empty private repository, then return here.\\n'));
48
- await open('https://github.com/new');
49
- }
46
+ let config = { provider };
50
47
 
51
- const finalAnswers = await inquirer.prompt([
52
- {
53
- type: 'input',
54
- name: 'gitRepo',
55
- message: 'Repository URL ' + chalk.gray('(e.g., git@github.com:username/ai-memory.git):'),
56
- when: () => answers.provider === 'git',
57
- validate: (input) => {
58
- if (input.trim() === '') return chalk.red('✖ Repo URL is required');
59
- if (!input.includes('github.com') && !input.includes('gitlab.com')) {
60
- return chalk.yellow('⚠ Warning: This does not look like a standard GitHub/GitLab URL. Please verify.');
48
+ if (provider === 'local') {
49
+ const { localPath } = await inquirer.prompt([
50
+ {
51
+ type: 'input',
52
+ name: 'localPath',
53
+ message: 'Path to sync directory ' + chalk.gray('(e.g., ~/Dropbox/memoir):'),
54
+ validate: (input) => input.trim() !== '' ? true : chalk.red('✖ Path is required')
55
+ }
56
+ ]);
57
+ config.localPath = localPath;
58
+ } else {
59
+ const { repoInput } = await inquirer.prompt([
60
+ {
61
+ type: 'input',
62
+ name: 'repoInput',
63
+ message: 'GitHub repo ' + chalk.gray('(e.g., camgitt/brain):'),
64
+ validate: (input) => {
65
+ if (input.trim() === '') return chalk.red('✖ Repo is required');
66
+ return true;
61
67
  }
62
- return true;
63
68
  }
64
- }
65
- ]);
69
+ ]);
66
70
 
67
- const config = {
68
- provider: answers.provider,
69
- localPath: answers.localPath,
70
- gitRepo: finalAnswers.gitRepo
71
- };
71
+ // Accept shorthand like "camgitt/brain" or full URLs
72
+ let gitRepo = repoInput.trim();
73
+ if (!gitRepo.includes('github.com') && !gitRepo.includes('gitlab.com')) {
74
+ gitRepo = `https://github.com/${gitRepo}.git`;
75
+ }
76
+ config.gitRepo = gitRepo;
77
+ }
72
78
 
73
79
  await saveConfig(config);
74
80
 
75
- console.log('\\n' + boxen(
76
- chalk.green('✔ Configuration saved successfully!') + '\\n\\n' +
77
- chalk.white('To backup your memory, run:') + '\\n' +
78
- chalk.cyan.bold('memoir push'),
79
- { padding: 1, borderStyle: 'single', borderColor: 'green' }
80
- ) + '\\n');
81
+ console.log('\\n' + chalk.green('✔ Configuration saved!'));
82
+
83
+ // Immediately run the chosen action
84
+ if (direction === 'upload') {
85
+ console.log(chalk.cyan('\\n↗ Uploading your AI memory...\\n'));
86
+ await pushCommand();
87
+ } else {
88
+ console.log(chalk.cyan('\\n↙ Downloading your AI memory...\\n'));
89
+ await restoreCommand();
90
+ }
81
91
  }
@@ -0,0 +1,176 @@
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 inquirer from 'inquirer';
8
+ import { execSync } from 'child_process';
9
+ import { loadConfig } from '../config.js';
10
+ import { adapters } from '../adapters/index.js';
11
+
12
+ async function listFiles(dir, prefix = '') {
13
+ const entries = await fs.readdir(dir, { withFileTypes: true });
14
+ const files = [];
15
+ for (const entry of entries) {
16
+ if (entry.name === '.git') continue;
17
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
18
+ if (entry.isDirectory()) {
19
+ files.push(...await listFiles(path.join(dir, entry.name), rel));
20
+ } else {
21
+ const stat = await fs.stat(path.join(dir, entry.name));
22
+ files.push({ path: rel, size: stat.size });
23
+ }
24
+ }
25
+ return files;
26
+ }
27
+
28
+ function formatSize(bytes) {
29
+ if (bytes < 1024) return `${bytes}B`;
30
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
31
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
32
+ }
33
+
34
+ function isBinaryFile(filePath) {
35
+ const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.zip', '.tar', '.gz', '.db', '.sqlite'];
36
+ return binaryExts.includes(path.extname(filePath).toLowerCase());
37
+ }
38
+
39
+ export async function viewCommand() {
40
+ const config = await loadConfig();
41
+ if (!config) {
42
+ console.log(chalk.red('\n✖ Not configured yet. Run: memoir init\n'));
43
+ return;
44
+ }
45
+
46
+ const spinner = ora('Fetching backup...').start();
47
+ const stagingDir = path.join(os.tmpdir(), `memoir-view-${Date.now()}`);
48
+ await fs.ensureDir(stagingDir);
49
+
50
+ try {
51
+ if (config.provider === 'git') {
52
+ execSync(`git clone --depth 1 ${config.gitRepo} .`, { cwd: stagingDir, stdio: 'ignore' });
53
+ } else {
54
+ const resolvedSource = config.localPath.replace(/^~/, os.homedir());
55
+ await fs.copy(resolvedSource, stagingDir);
56
+ }
57
+
58
+ spinner.stop();
59
+
60
+ console.log('\n' + boxen(
61
+ chalk.cyan.bold('Your Memoir Backup'),
62
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan' }
63
+ ));
64
+
65
+ let totalFiles = 0;
66
+ const viewableFiles = [];
67
+
68
+ for (const adapter of adapters) {
69
+ const adapterDir = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
70
+ if (await fs.pathExists(adapterDir)) {
71
+ const files = await listFiles(adapterDir);
72
+ totalFiles += files.length;
73
+
74
+ console.log('\n' + chalk.green.bold(` ${adapter.name}`) + chalk.gray(` (${files.length} files)`));
75
+
76
+ for (const file of files) {
77
+ const localPath = path.join(adapter.source, file.path);
78
+ const backupPath = path.join(adapterDir, file.path);
79
+ const existsLocally = await fs.pathExists(localPath);
80
+
81
+ let status;
82
+ let diffType;
83
+ if (!existsLocally) {
84
+ status = chalk.yellow(' (new — not on this machine)');
85
+ diffType = 'new';
86
+ } else if (isBinaryFile(file.path)) {
87
+ status = chalk.gray(' (binary)');
88
+ diffType = 'binary';
89
+ } else {
90
+ // Compare content
91
+ const localContent = await fs.readFile(localPath, 'utf8');
92
+ const backupContent = await fs.readFile(backupPath, 'utf8');
93
+ if (localContent === backupContent) {
94
+ status = chalk.gray(' (identical)');
95
+ diffType = 'same';
96
+ } else {
97
+ status = chalk.cyan(' (different)');
98
+ diffType = 'different';
99
+ }
100
+ }
101
+
102
+ console.log(chalk.white(` ${file.path}`) + chalk.gray(` ${formatSize(file.size)}`) + status);
103
+
104
+ if (diffType !== 'binary' && diffType !== 'same') {
105
+ viewableFiles.push({ file, adapter, adapterDir, localPath, diffType });
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ if (totalFiles === 0) {
112
+ console.log(chalk.yellow('\n No backed up files found.\n'));
113
+ return;
114
+ }
115
+
116
+ console.log(chalk.gray(`\n ${totalFiles} total files in backup\n`));
117
+
118
+ // Offer to view diffs
119
+ if (viewableFiles.length > 0) {
120
+ const { wantDiff } = await inquirer.prompt([
121
+ {
122
+ type: 'confirm',
123
+ name: 'wantDiff',
124
+ message: 'View file contents/diffs?',
125
+ default: true
126
+ }
127
+ ]);
128
+
129
+ if (wantDiff) {
130
+ const choices = viewableFiles.map(vf => ({
131
+ name: `${vf.adapter.name}/${vf.file.path}` + (vf.diffType === 'new' ? chalk.yellow(' (new)') : chalk.cyan(' (different)')),
132
+ value: vf
133
+ }));
134
+
135
+ const { filesToView } = await inquirer.prompt([
136
+ {
137
+ type: 'checkbox',
138
+ name: 'filesToView',
139
+ message: 'Select files to view:',
140
+ choices
141
+ }
142
+ ]);
143
+
144
+ for (const vf of filesToView) {
145
+ const backupPath = path.join(vf.adapterDir, vf.file.path);
146
+ const backupContent = await fs.readFile(backupPath, 'utf8');
147
+
148
+ console.log('\n' + boxen(
149
+ chalk.cyan.bold(`${vf.adapter.name}/${vf.file.path}`),
150
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'single', borderColor: 'cyan' }
151
+ ));
152
+
153
+ if (vf.diffType === 'new') {
154
+ console.log(chalk.yellow('\n This file only exists in backup:\n'));
155
+ console.log(chalk.green(backupContent.split('\n').map(l => ' + ' + l).join('\n')));
156
+ } else {
157
+ // Show side by side: local vs backup
158
+ const localContent = await fs.readFile(vf.localPath, 'utf8');
159
+ const localLines = localContent.split('\n');
160
+ const backupLines = backupContent.split('\n');
161
+
162
+ console.log(chalk.gray('\n ── Local (this machine) ──'));
163
+ console.log(localLines.map(l => ' ' + l).join('\n'));
164
+
165
+ console.log(chalk.cyan('\n ── Backup (remote) ──'));
166
+ console.log(backupLines.map(l => ' ' + l).join('\n'));
167
+ }
168
+ console.log('');
169
+ }
170
+ }
171
+ }
172
+
173
+ } finally {
174
+ await fs.remove(stagingDir);
175
+ }
176
+ }