memoir-cli 1.4.3 → 1.4.5
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/README.md +99 -8
- package/bin/memoir.js +15 -10
- package/package.json +33 -8
- package/src/adapters/index.js +74 -4
- package/src/commands/init.js +45 -16
- package/src/commands/migrate.js +241 -0
- package/src/config.js +20 -1
- package/src/migrate/profiles.js +2 -0
- package/src/migrate/translator.js +102 -0
- package/src/providers/index.js +24 -20
- package/src/providers/restore.js +7 -8
- package/src/tools/aider.js +31 -0
- package/src/tools/claude.js +51 -0
- package/src/tools/codex.js +24 -0
- package/src/tools/copilot.js +24 -0
- package/src/tools/cursor.js +24 -0
- package/src/tools/gemini.js +31 -0
- package/src/tools/index.js +26 -0
- package/src/tools/windsurf.js +24 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://npmjs.org/package/memoir-cli)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
|
-
*Never lose your AI's context again. Sync
|
|
9
|
+
*Never lose your AI's context again. Sync and translate your AI memory across every device and tool.*
|
|
10
10
|
|
|
11
11
|
</div>
|
|
12
12
|
|
|
@@ -22,9 +22,9 @@ Suddenly, you're starting from scratch. Your AI's "memory" is trapped in hidden
|
|
|
22
22
|
|
|
23
23
|
## 🚀 The Solution
|
|
24
24
|
|
|
25
|
-
`memoir` is a zero-friction CLI
|
|
25
|
+
`memoir` is a zero-friction CLI that extracts, backs up, restores, and **translates** your AI's memory across any computer and any tool. Bring your own storage (a private GitHub repo or an iCloud/Dropbox folder), and `memoir` handles the rest.
|
|
26
26
|
|
|
27
|
-
No locked-in SaaS, no lost context, no complex shell scripts.
|
|
27
|
+
No locked-in SaaS, no lost context, no complex shell scripts. Switch from Claude to Gemini in one command.
|
|
28
28
|
|
|
29
29
|
### Supported Integrations
|
|
30
30
|
- [x] **Gemini CLI**
|
|
@@ -72,6 +72,97 @@ memoir restore
|
|
|
72
72
|
memoir pull
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
### 4. Translate Between Tools
|
|
76
|
+
Switch AI tools without losing context. Memoir uses Gemini AI to intelligently rewrite your memory files for any supported tool:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
memoir migrate --from claude --to gemini
|
|
80
|
+
# or run interactively:
|
|
81
|
+
memoir migrate
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Your Claude instructions become a proper `GEMINI.md` — not a copy-paste, but a real translation that follows each tool's conventions.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 📖 All Commands
|
|
89
|
+
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
|---------|-------------|
|
|
92
|
+
| `memoir init` | Setup wizard — pick GitHub or local folder, upload or download |
|
|
93
|
+
| `memoir push` | Extract all AI tool configs, back up to GitHub/local |
|
|
94
|
+
| `memoir restore` | Pull backup down, restore missing files (non-destructive) |
|
|
95
|
+
| `memoir status` | Show which AI tools are detected on this machine |
|
|
96
|
+
| `memoir view` | Preview backup contents with diffs against local |
|
|
97
|
+
| `memoir migrate` | Translate memory between tools via Gemini AI |
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 🎯 Common Workflows
|
|
102
|
+
|
|
103
|
+
### New laptop setup
|
|
104
|
+
```bash
|
|
105
|
+
# Old machine — save everything
|
|
106
|
+
memoir init # → Upload → GitHub
|
|
107
|
+
|
|
108
|
+
# New machine — restore everything
|
|
109
|
+
memoir init # → Download → GitHub
|
|
110
|
+
# All your .claude/, .gemini/, .cursorrules configs restored in 30 seconds
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Switch from Claude to Gemini (or any tool)
|
|
114
|
+
```bash
|
|
115
|
+
memoir migrate --from claude --to gemini
|
|
116
|
+
```
|
|
117
|
+
Your CLAUDE.md + Claude memory files get intelligently rewritten as a proper GEMINI.md — not a copy-paste, but a real translation that follows Gemini's conventions.
|
|
118
|
+
|
|
119
|
+
### Keep your whole team in sync
|
|
120
|
+
```bash
|
|
121
|
+
# Team lead writes CLAUDE.md, then generates for everyone else:
|
|
122
|
+
memoir migrate --from claude --to cursor
|
|
123
|
+
memoir migrate --from claude --to copilot
|
|
124
|
+
memoir migrate --from claude --to codex
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Fan out to every tool at once
|
|
128
|
+
```bash
|
|
129
|
+
memoir migrate --from claude --to gemini
|
|
130
|
+
memoir migrate --from claude --to codex
|
|
131
|
+
memoir migrate --from claude --to cursor
|
|
132
|
+
memoir migrate --from claude --to windsurf
|
|
133
|
+
memoir migrate --from claude --to aider
|
|
134
|
+
```
|
|
135
|
+
Use one tool as the source of truth, propagate to all others.
|
|
136
|
+
|
|
137
|
+
### Preview before committing
|
|
138
|
+
```bash
|
|
139
|
+
memoir migrate --from gemini --to claude --dry-run
|
|
140
|
+
# Shows translated output but writes nothing
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Protect existing files
|
|
144
|
+
```bash
|
|
145
|
+
memoir migrate --from claude --to gemini
|
|
146
|
+
# → "GEMINI.md already exists."
|
|
147
|
+
# → Overwrite / Append / Skip
|
|
148
|
+
# Append adds a dated separator so you keep your existing instructions
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Daily sync across machines
|
|
152
|
+
```bash
|
|
153
|
+
# End of day
|
|
154
|
+
memoir push
|
|
155
|
+
|
|
156
|
+
# Next morning, different machine
|
|
157
|
+
memoir pull
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Check what's on this machine
|
|
161
|
+
```bash
|
|
162
|
+
memoir status
|
|
163
|
+
# Shows checkmarks for every detected AI tool and their config locations
|
|
164
|
+
```
|
|
165
|
+
|
|
75
166
|
---
|
|
76
167
|
|
|
77
168
|
## 🔒 Security First
|
|
@@ -82,12 +173,12 @@ Our specialized adapters intelligently filter your directories. We **only** sync
|
|
|
82
173
|
|
|
83
174
|
---
|
|
84
175
|
|
|
85
|
-
## 🗺️ Roadmap
|
|
86
|
-
|
|
87
|
-
We believe developers shouldn't be locked into a single AI ecosystem.
|
|
176
|
+
## 🗺️ Roadmap
|
|
88
177
|
|
|
89
|
-
**
|
|
90
|
-
|
|
178
|
+
**What's next:**
|
|
179
|
+
- Team sharing — sync a shared memory repo across your whole team
|
|
180
|
+
- Auto-detect new AI tools as they appear
|
|
181
|
+
- Two-way merge — combine memories from multiple tools into one
|
|
91
182
|
|
|
92
183
|
---
|
|
93
184
|
|
package/bin/memoir.js
CHANGED
|
@@ -8,8 +8,11 @@ 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
10
|
import { viewCommand } from '../src/commands/view.js';
|
|
11
|
+
import { migrateCommand } from '../src/commands/migrate.js';
|
|
12
|
+
import { createRequire } from 'module';
|
|
11
13
|
|
|
12
|
-
const
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { version: VERSION } = require('../package.json');
|
|
13
16
|
|
|
14
17
|
// Custom help banner
|
|
15
18
|
program.addHelpText('beforeAll', '\n' + boxen(
|
|
@@ -88,15 +91,17 @@ program
|
|
|
88
91
|
|
|
89
92
|
program
|
|
90
93
|
.command('migrate')
|
|
91
|
-
.description('Translate memory between AI
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
.description('Translate memory between AI tools (Claude, Gemini, Codex, Cursor, etc.)')
|
|
95
|
+
.option('--from <tool>', 'Source tool (claude, gemini, codex, cursor, copilot, windsurf, aider)')
|
|
96
|
+
.option('--to <tool>', 'Target tool (claude, gemini, codex, cursor, copilot, windsurf, aider, all)')
|
|
97
|
+
.option('--dry-run', 'Preview translation without writing files')
|
|
98
|
+
.action(async (options) => {
|
|
99
|
+
try {
|
|
100
|
+
await migrateCommand(options);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(chalk.red('\n✖ Error during migration:'), err.message);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
100
105
|
});
|
|
101
106
|
|
|
102
107
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.5",
|
|
4
|
+
"description": "Sync and translate AI memory across devices and tools. Back up Claude, Gemini, Codex, Cursor, Copilot, Windsurf, and Aider configs. Migrate instructions between AI coding assistants with one command.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"memoir": "bin/memoir.js"
|
|
9
9
|
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/camgitt/memoir.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/camgitt/memoir#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/camgitt/memoir/issues"
|
|
17
|
+
},
|
|
10
18
|
"scripts": {
|
|
11
19
|
"start": "node bin/memoir.js",
|
|
12
20
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
@@ -16,14 +24,30 @@
|
|
|
16
24
|
"cli",
|
|
17
25
|
"sync",
|
|
18
26
|
"memory",
|
|
19
|
-
"memoir",
|
|
20
|
-
"gemini",
|
|
21
|
-
"claude",
|
|
22
27
|
"backup",
|
|
23
|
-
"restore"
|
|
28
|
+
"restore",
|
|
29
|
+
"migrate",
|
|
30
|
+
"translate",
|
|
31
|
+
"claude",
|
|
32
|
+
"gemini",
|
|
33
|
+
"codex",
|
|
34
|
+
"cursor",
|
|
35
|
+
"copilot",
|
|
36
|
+
"windsurf",
|
|
37
|
+
"aider",
|
|
38
|
+
"ai-memory",
|
|
39
|
+
"ai-tools",
|
|
40
|
+
"dotfiles",
|
|
41
|
+
"developer-tools",
|
|
42
|
+
"claude-code",
|
|
43
|
+
"gemini-cli",
|
|
44
|
+
"openai",
|
|
45
|
+
"ai-assistant",
|
|
46
|
+
"coding-assistant",
|
|
47
|
+
"context-sync"
|
|
24
48
|
],
|
|
25
|
-
"author": "",
|
|
26
|
-
"license": "
|
|
49
|
+
"author": "camgitt",
|
|
50
|
+
"license": "MIT",
|
|
27
51
|
"dependencies": {
|
|
28
52
|
"boxen": "^7.1.1",
|
|
29
53
|
"chalk": "^5.3.0",
|
|
@@ -31,6 +55,7 @@
|
|
|
31
55
|
"fs-extra": "^11.2.0",
|
|
32
56
|
"gradient-string": "^3.0.0",
|
|
33
57
|
"inquirer": "^9.2.15",
|
|
58
|
+
"memoir-cli": "^1.4.4",
|
|
34
59
|
"open": "^11.0.0",
|
|
35
60
|
"ora": "^7.0.1"
|
|
36
61
|
}
|
package/src/adapters/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
|
11
11
|
export const adapters = [
|
|
12
12
|
{
|
|
13
13
|
name: 'Gemini CLI',
|
|
14
|
+
icon: '🔵',
|
|
14
15
|
source: path.join(home, '.gemini'),
|
|
15
16
|
filter: (src) => {
|
|
16
17
|
const basename = path.basename(src);
|
|
@@ -20,6 +21,7 @@ export const adapters = [
|
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
23
|
name: 'Claude CLI',
|
|
24
|
+
icon: '🟣',
|
|
23
25
|
source: path.join(home, '.claude'),
|
|
24
26
|
filter: (src) => {
|
|
25
27
|
const basename = path.basename(src);
|
|
@@ -28,6 +30,7 @@ export const adapters = [
|
|
|
28
30
|
},
|
|
29
31
|
{
|
|
30
32
|
name: 'OpenAI Codex',
|
|
33
|
+
icon: '🟢',
|
|
31
34
|
source: path.join(home, '.codex'),
|
|
32
35
|
filter: (src) => {
|
|
33
36
|
const basename = path.basename(src);
|
|
@@ -37,6 +40,7 @@ export const adapters = [
|
|
|
37
40
|
},
|
|
38
41
|
{
|
|
39
42
|
name: 'Cursor',
|
|
43
|
+
icon: '⚡',
|
|
40
44
|
source: isWin
|
|
41
45
|
? path.join(appData, 'Cursor', 'User')
|
|
42
46
|
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User'),
|
|
@@ -48,6 +52,7 @@ export const adapters = [
|
|
|
48
52
|
},
|
|
49
53
|
{
|
|
50
54
|
name: 'GitHub Copilot',
|
|
55
|
+
icon: '🐙',
|
|
51
56
|
source: isWin
|
|
52
57
|
? path.join(appData, 'GitHub Copilot')
|
|
53
58
|
: path.join(home, '.config', 'github-copilot'),
|
|
@@ -59,6 +64,7 @@ export const adapters = [
|
|
|
59
64
|
},
|
|
60
65
|
{
|
|
61
66
|
name: 'Windsurf',
|
|
67
|
+
icon: '🏄',
|
|
62
68
|
source: isWin
|
|
63
69
|
? path.join(appData, 'Windsurf', 'User')
|
|
64
70
|
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'),
|
|
@@ -70,6 +76,7 @@ export const adapters = [
|
|
|
70
76
|
},
|
|
71
77
|
{
|
|
72
78
|
name: 'Aider',
|
|
79
|
+
icon: '🔧',
|
|
73
80
|
source: home,
|
|
74
81
|
customExtract: true,
|
|
75
82
|
files: ['.aider.conf.yml', '.aider.system-prompt.md'],
|
|
@@ -77,33 +84,96 @@ export const adapters = [
|
|
|
77
84
|
}
|
|
78
85
|
];
|
|
79
86
|
|
|
87
|
+
async function countFiles(dir) {
|
|
88
|
+
let count = 0;
|
|
89
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
count += await countFiles(path.join(dir, entry.name));
|
|
93
|
+
} else {
|
|
94
|
+
count++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return count;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function dirSize(dir) {
|
|
101
|
+
let size = 0;
|
|
102
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const fullPath = path.join(dir, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
size += await dirSize(fullPath);
|
|
107
|
+
} else {
|
|
108
|
+
const stat = await fs.stat(fullPath);
|
|
109
|
+
size += stat.size;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return size;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatSize(bytes) {
|
|
116
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
117
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}kb`;
|
|
118
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}mb`;
|
|
119
|
+
}
|
|
120
|
+
|
|
80
121
|
export async function extractMemories(stagingDir, spinner) {
|
|
81
122
|
let foundAny = false;
|
|
123
|
+
const results = [];
|
|
82
124
|
|
|
83
125
|
for (const adapter of adapters) {
|
|
84
126
|
if (adapter.customExtract) {
|
|
85
|
-
// Handle tools with individual files (e.g. Aider)
|
|
86
127
|
const dest = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
|
|
87
128
|
let foundFile = false;
|
|
129
|
+
let fileCount = 0;
|
|
130
|
+
|
|
88
131
|
for (const file of adapter.files) {
|
|
89
132
|
const filePath = path.join(adapter.source, file);
|
|
90
133
|
if (await fs.pathExists(filePath)) {
|
|
91
134
|
if (!foundFile) {
|
|
92
|
-
spinner.text =
|
|
135
|
+
spinner.text = `${adapter.icon} Scanning ${chalk.cyan(adapter.name)}...`;
|
|
93
136
|
await fs.ensureDir(dest);
|
|
94
137
|
foundFile = true;
|
|
95
138
|
}
|
|
96
139
|
await fs.copy(filePath, path.join(dest, file));
|
|
140
|
+
fileCount++;
|
|
97
141
|
}
|
|
98
142
|
}
|
|
99
|
-
|
|
143
|
+
|
|
144
|
+
if (foundFile) {
|
|
145
|
+
foundAny = true;
|
|
146
|
+
results.push({ adapter, fileCount, size: await dirSize(dest) });
|
|
147
|
+
spinner.text = `${adapter.icon} ${chalk.green(adapter.name)} ${chalk.gray(`(${fileCount} files)`)}`;
|
|
148
|
+
}
|
|
100
149
|
} else if (await fs.pathExists(adapter.source)) {
|
|
101
|
-
spinner.text =
|
|
150
|
+
spinner.text = `${adapter.icon} Scanning ${chalk.cyan(adapter.name)}...`;
|
|
102
151
|
const dest = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
|
|
103
152
|
await fs.ensureDir(dest);
|
|
104
153
|
await fs.copy(adapter.source, dest, { filter: adapter.filter });
|
|
154
|
+
|
|
155
|
+
const fileCount = await countFiles(dest);
|
|
156
|
+
const size = await dirSize(dest);
|
|
105
157
|
foundAny = true;
|
|
158
|
+
results.push({ adapter, fileCount, size });
|
|
159
|
+
spinner.text = `${adapter.icon} ${chalk.green(adapter.name)} ${chalk.gray(`(${fileCount} files, ${formatSize(size)})`)}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Print tree after scanning
|
|
164
|
+
if (results.length > 0) {
|
|
165
|
+
spinner.stop();
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log(chalk.white.bold(' Detected AI tools:\n'));
|
|
168
|
+
for (let i = 0; i < results.length; i++) {
|
|
169
|
+
const r = results[i];
|
|
170
|
+
const isLast = i === results.length - 1;
|
|
171
|
+
const branch = isLast ? ' └─' : ' ├─';
|
|
172
|
+
const detail = chalk.gray(` ${r.fileCount} files, ${formatSize(r.size)}`);
|
|
173
|
+
console.log(`${branch} ${r.adapter.icon} ${chalk.cyan(r.adapter.name)}${detail}`);
|
|
106
174
|
}
|
|
175
|
+
console.log('');
|
|
176
|
+
spinner.start(chalk.gray('Uploading...'));
|
|
107
177
|
}
|
|
108
178
|
|
|
109
179
|
return foundAny;
|
package/src/commands/init.js
CHANGED
|
@@ -2,17 +2,27 @@ import inquirer from 'inquirer';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import boxen from 'boxen';
|
|
4
4
|
import gradient from 'gradient-string';
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
6
|
import { saveConfig } from '../config.js';
|
|
7
7
|
import { pushCommand } from './push.js';
|
|
8
8
|
import { restoreCommand } from './restore.js';
|
|
9
9
|
|
|
10
10
|
function getGitUsername() {
|
|
11
11
|
try {
|
|
12
|
-
return
|
|
12
|
+
return execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf8' }).trim();
|
|
13
13
|
} catch { return ''; }
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function getGitHubUsername() {
|
|
17
|
+
try {
|
|
18
|
+
// Try gh CLI first
|
|
19
|
+
return execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8' }).trim();
|
|
20
|
+
} catch {
|
|
21
|
+
// Fall back to git config
|
|
22
|
+
return getGitUsername();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export async function initCommand() {
|
|
17
27
|
console.log('');
|
|
18
28
|
console.log(boxen(
|
|
@@ -22,7 +32,7 @@ export async function initCommand() {
|
|
|
22
32
|
));
|
|
23
33
|
console.log('');
|
|
24
34
|
|
|
25
|
-
const
|
|
35
|
+
const detectedUser = getGitHubUsername();
|
|
26
36
|
|
|
27
37
|
const { direction, provider } = await inquirer.prompt([
|
|
28
38
|
{
|
|
@@ -57,27 +67,46 @@ export async function initCommand() {
|
|
|
57
67
|
}]);
|
|
58
68
|
config.localPath = localPath;
|
|
59
69
|
} else {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
},
|
|
67
|
-
{
|
|
70
|
+
// Pre-fill username if detected, just ask for repo name
|
|
71
|
+
const prompts = [];
|
|
72
|
+
|
|
73
|
+
if (detectedUser) {
|
|
74
|
+
console.log(chalk.gray(` GitHub user: ${chalk.cyan(detectedUser)}`));
|
|
75
|
+
prompts.push({
|
|
68
76
|
type: 'input',
|
|
69
77
|
name: 'repo',
|
|
70
|
-
message:
|
|
78
|
+
message: `Repo name (${detectedUser}/???):`,
|
|
71
79
|
default: 'ai-memory',
|
|
72
80
|
validate: (input) => input.trim() ? true : 'Required'
|
|
73
|
-
}
|
|
74
|
-
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
prompts.push(
|
|
84
|
+
{
|
|
85
|
+
type: 'input',
|
|
86
|
+
name: 'username',
|
|
87
|
+
message: 'GitHub username:',
|
|
88
|
+
validate: (input) => input.trim() ? true : 'Required'
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'input',
|
|
92
|
+
name: 'repo',
|
|
93
|
+
message: 'Repo name:',
|
|
94
|
+
default: 'ai-memory',
|
|
95
|
+
validate: (input) => input.trim() ? true : 'Required'
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const answers = await inquirer.prompt(prompts);
|
|
101
|
+
const username = (answers.username || detectedUser).trim();
|
|
102
|
+
const repo = answers.repo.trim();
|
|
75
103
|
|
|
76
|
-
config.gitRepo = `https://github.com/${username
|
|
104
|
+
config.gitRepo = `https://github.com/${username}/${repo}.git`;
|
|
105
|
+
console.log(chalk.gray(` → ${config.gitRepo}\n`));
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
await saveConfig(config);
|
|
80
|
-
console.log(chalk.green('Saved!\n'));
|
|
109
|
+
console.log(chalk.green('✔ Saved!\n'));
|
|
81
110
|
|
|
82
111
|
if (direction === 'upload') {
|
|
83
112
|
await pushCommand();
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import boxen from 'boxen';
|
|
8
|
+
import gradient from 'gradient-string';
|
|
9
|
+
import { getProfile, getProfileKeys, getProfileChoices } from '../tools/index.js';
|
|
10
|
+
import { resolveApiKey, translateMemory } from '../migrate/translator.js';
|
|
11
|
+
|
|
12
|
+
const TOOL_ICONS = {
|
|
13
|
+
claude: '🟣', gemini: '🔵', codex: '🟢', cursor: '⚡',
|
|
14
|
+
copilot: '🐙', windsurf: '🏄', aider: '🔧'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function toolLabel(key) {
|
|
18
|
+
return `${TOOL_ICONS[key] || '●'} ${getProfile(key)?.name || key}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function translateToTarget(targetKey, sourceFiles, sourceProfile, apiKey, dryRun) {
|
|
22
|
+
const targetProfile = getProfile(targetKey);
|
|
23
|
+
const spinner = ora();
|
|
24
|
+
const translated = [];
|
|
25
|
+
const failed = [];
|
|
26
|
+
|
|
27
|
+
for (const file of sourceFiles) {
|
|
28
|
+
const basename = path.basename(file.filePath);
|
|
29
|
+
spinner.start(chalk.cyan(` ${TOOL_ICONS[targetKey] || '●'} Translating ${basename} → ${targetProfile.name}...`));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const result = await translateMemory(file.content, sourceProfile, targetProfile, apiKey);
|
|
33
|
+
translated.push({ source: file, result });
|
|
34
|
+
spinner.succeed(chalk.green(` ${TOOL_ICONS[targetKey] || '✔'} ${basename} → ${targetProfile.name}`));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
failed.push({ source: file, error: err.message });
|
|
37
|
+
spinner.fail(chalk.red(` ✖ ${basename} → ${targetProfile.name}: ${err.message}`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (translated.length === 0) {
|
|
42
|
+
return { targetKey, translated: 0, failed: failed.length, written: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Combine content
|
|
46
|
+
let finalContent;
|
|
47
|
+
if (translated.length === 1) {
|
|
48
|
+
finalContent = translated[0].result;
|
|
49
|
+
} else {
|
|
50
|
+
finalContent = translated.map((t, i) => {
|
|
51
|
+
const header = `# From ${path.basename(t.source.filePath)}`;
|
|
52
|
+
return i === 0 ? `${header}\n\n${t.result}` : `\n\n${header}\n\n${t.result}`;
|
|
53
|
+
}).join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (dryRun) {
|
|
57
|
+
return { targetKey, translated: translated.length, failed: failed.length, written: false, content: finalContent };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Write output
|
|
61
|
+
const targetPath = targetProfile.targetPath();
|
|
62
|
+
let writeMode = 'write';
|
|
63
|
+
|
|
64
|
+
if (await fs.pathExists(targetPath)) {
|
|
65
|
+
const { action } = await inquirer.prompt([{
|
|
66
|
+
type: 'list',
|
|
67
|
+
name: 'action',
|
|
68
|
+
message: `${TOOL_ICONS[targetKey]} ${path.basename(targetPath)} already exists.`,
|
|
69
|
+
choices: [
|
|
70
|
+
{ name: 'Overwrite', value: 'overwrite' },
|
|
71
|
+
{ name: 'Append', value: 'append' },
|
|
72
|
+
{ name: 'Skip', value: 'skip' }
|
|
73
|
+
]
|
|
74
|
+
}]);
|
|
75
|
+
writeMode = action;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (writeMode === 'skip') {
|
|
79
|
+
return { targetKey, translated: translated.length, failed: failed.length, written: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await fs.ensureDir(path.dirname(targetPath));
|
|
83
|
+
|
|
84
|
+
if (writeMode === 'append') {
|
|
85
|
+
const existing = await fs.readFile(targetPath, 'utf-8');
|
|
86
|
+
const separator = `\n\n---\n<!-- Translated from ${sourceProfile.name} by memoir on ${new Date().toISOString().split('T')[0]} -->\n\n`;
|
|
87
|
+
await fs.writeFile(targetPath, existing + separator + finalContent);
|
|
88
|
+
} else {
|
|
89
|
+
await fs.writeFile(targetPath, finalContent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { targetKey, translated: translated.length, failed: failed.length, written: true, path: targetPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function migrateCommand(options = {}) {
|
|
96
|
+
let { from, to, dryRun } = options;
|
|
97
|
+
|
|
98
|
+
// 1. Pick source tool
|
|
99
|
+
if (!from) {
|
|
100
|
+
const answer = await inquirer.prompt([{
|
|
101
|
+
type: 'list',
|
|
102
|
+
name: 'from',
|
|
103
|
+
message: 'Translate from:',
|
|
104
|
+
choices: getProfileChoices().map(c => ({ ...c, name: `${TOOL_ICONS[c.value] || '●'} ${c.name}` }))
|
|
105
|
+
}]);
|
|
106
|
+
from = answer.from;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. Pick target tool(s)
|
|
110
|
+
let targets = [];
|
|
111
|
+
if (to === 'all') {
|
|
112
|
+
targets = getProfileKeys().filter(k => k !== from);
|
|
113
|
+
} else if (to) {
|
|
114
|
+
targets = [to];
|
|
115
|
+
} else {
|
|
116
|
+
const choices = [
|
|
117
|
+
{ name: '🌐 All tools', value: '_all' },
|
|
118
|
+
...getProfileChoices()
|
|
119
|
+
.filter(c => c.value !== from)
|
|
120
|
+
.map(c => ({ ...c, name: `${TOOL_ICONS[c.value] || '●'} ${c.name}` }))
|
|
121
|
+
];
|
|
122
|
+
const answer = await inquirer.prompt([{
|
|
123
|
+
type: 'list',
|
|
124
|
+
name: 'to',
|
|
125
|
+
message: 'Translate to:',
|
|
126
|
+
choices
|
|
127
|
+
}]);
|
|
128
|
+
targets = answer.to === '_all'
|
|
129
|
+
? getProfileKeys().filter(k => k !== from)
|
|
130
|
+
: [answer.to];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate
|
|
134
|
+
const sourceProfile = getProfile(from);
|
|
135
|
+
if (!sourceProfile) {
|
|
136
|
+
console.log(chalk.red(`\nUnknown source tool: ${from}`));
|
|
137
|
+
console.log(chalk.gray(`Available: ${getProfileKeys().join(', ')}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const t of targets) {
|
|
141
|
+
if (!getProfile(t)) {
|
|
142
|
+
console.log(chalk.red(`\nUnknown target tool: ${t}`));
|
|
143
|
+
console.log(chalk.gray(`Available: ${getProfileKeys().join(', ')}`));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (targets.includes(from)) {
|
|
148
|
+
console.log(chalk.yellow('\nSource and target are the same tool.'));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Resolve API key
|
|
153
|
+
const apiKey = await resolveApiKey(inquirer);
|
|
154
|
+
|
|
155
|
+
// 4. Discover source files
|
|
156
|
+
const sourceFiles = sourceProfile.discover();
|
|
157
|
+
|
|
158
|
+
if (sourceFiles.length === 0) {
|
|
159
|
+
console.log(chalk.yellow(`\nNo ${sourceProfile.name} memory files found.`));
|
|
160
|
+
console.log(chalk.gray('Make sure you\'re in the right directory or that the tool has been configured.'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 5. Show source files
|
|
165
|
+
console.log('');
|
|
166
|
+
console.log(boxen(
|
|
167
|
+
`${TOOL_ICONS[from]} ${chalk.bold(sourceProfile.name)} ${chalk.gray('→')} ${targets.map(t => TOOL_ICONS[t]).join(' ')}\n\n` +
|
|
168
|
+
sourceFiles.map(f => {
|
|
169
|
+
const display = f.filePath.replace(os.homedir(), '~');
|
|
170
|
+
const size = chalk.gray(`(${(f.content.length / 1024).toFixed(1)}kb)`);
|
|
171
|
+
return ` ${chalk.cyan('◆')} ${display} ${size}`;
|
|
172
|
+
}).join('\n'),
|
|
173
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
174
|
+
));
|
|
175
|
+
|
|
176
|
+
const { proceed } = await inquirer.prompt([{
|
|
177
|
+
type: 'confirm',
|
|
178
|
+
name: 'proceed',
|
|
179
|
+
message: `Translate ${sourceFiles.length} file${sourceFiles.length > 1 ? 's' : ''} to ${targets.length} tool${targets.length > 1 ? 's' : ''}?`,
|
|
180
|
+
default: true
|
|
181
|
+
}]);
|
|
182
|
+
|
|
183
|
+
if (!proceed) {
|
|
184
|
+
console.log(chalk.gray('\nCancelled.'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log('');
|
|
189
|
+
|
|
190
|
+
// 6. Translate to each target
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const targetKey of targets) {
|
|
193
|
+
const result = await translateToTarget(targetKey, sourceFiles, sourceProfile, apiKey, dryRun);
|
|
194
|
+
results.push(result);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 7. Summary
|
|
198
|
+
const succeeded = results.filter(r => r.translated > 0);
|
|
199
|
+
const written = results.filter(r => r.written);
|
|
200
|
+
const totalFailed = results.reduce((sum, r) => sum + r.failed, 0);
|
|
201
|
+
|
|
202
|
+
console.log('');
|
|
203
|
+
|
|
204
|
+
if (dryRun) {
|
|
205
|
+
// Show preview for dry run
|
|
206
|
+
for (const r of succeeded) {
|
|
207
|
+
if (r.content) {
|
|
208
|
+
const preview = r.content.split('\n').slice(0, 10).join('\n');
|
|
209
|
+
console.log(chalk.cyan(`--- ${getProfile(r.targetKey).name} preview ---`));
|
|
210
|
+
console.log(chalk.gray(preview));
|
|
211
|
+
const totalLines = r.content.split('\n').length;
|
|
212
|
+
if (totalLines > 10) console.log(chalk.gray(` ... (${totalLines - 10} more lines)`));
|
|
213
|
+
console.log('');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
console.log(chalk.yellow('Dry run — no files written.\n'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Build summary box
|
|
221
|
+
const summaryLines = results.map(r => {
|
|
222
|
+
const profile = getProfile(r.targetKey);
|
|
223
|
+
const icon = TOOL_ICONS[r.targetKey] || '●';
|
|
224
|
+
if (r.written) {
|
|
225
|
+
const display = r.path.replace(os.homedir(), '~');
|
|
226
|
+
return ` ${icon} ${chalk.green('✔')} ${profile.name} ${chalk.gray('→ ' + display)}`;
|
|
227
|
+
} else if (r.translated > 0) {
|
|
228
|
+
return ` ${icon} ${chalk.gray('⏭')} ${profile.name} ${chalk.gray('(skipped)')}`;
|
|
229
|
+
} else {
|
|
230
|
+
return ` ${icon} ${chalk.red('✖')} ${profile.name} ${chalk.gray('(failed)')}`;
|
|
231
|
+
}
|
|
232
|
+
}).join('\n');
|
|
233
|
+
|
|
234
|
+
console.log(boxen(
|
|
235
|
+
gradient.pastel(' Translated! ') + '\n\n' +
|
|
236
|
+
summaryLines + '\n\n' +
|
|
237
|
+
chalk.gray(`${written.length} written, ${succeeded.length - written.length} skipped${totalFailed > 0 ? `, ${totalFailed} failed` : ''}`),
|
|
238
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
239
|
+
));
|
|
240
|
+
console.log('');
|
|
241
|
+
}
|
package/src/config.js
CHANGED
|
@@ -9,7 +9,11 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
|
9
9
|
|
|
10
10
|
export async function getConfig() {
|
|
11
11
|
if (await fs.pathExists(CONFIG_FILE)) {
|
|
12
|
-
|
|
12
|
+
try {
|
|
13
|
+
return await fs.readJson(CONFIG_FILE);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
13
17
|
}
|
|
14
18
|
return null;
|
|
15
19
|
}
|
|
@@ -17,4 +21,19 @@ export async function getConfig() {
|
|
|
17
21
|
export async function saveConfig(config) {
|
|
18
22
|
await fs.ensureDir(CONFIG_DIR);
|
|
19
23
|
await fs.writeJson(CONFIG_FILE, config, { spaces: 2 });
|
|
24
|
+
// Restrict permissions — config may contain API keys
|
|
25
|
+
if (process.platform !== 'win32') {
|
|
26
|
+
await fs.chmod(CONFIG_FILE, 0o600);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getGeminiApiKey() {
|
|
31
|
+
const config = await getConfig();
|
|
32
|
+
return config?.geminiApiKey || process.env.GEMINI_API_KEY || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function saveGeminiApiKey(apiKey) {
|
|
36
|
+
const config = await getConfig() || {};
|
|
37
|
+
config.geminiApiKey = apiKey;
|
|
38
|
+
await saveConfig(config);
|
|
20
39
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { getConfig, saveConfig } from '../config.js';
|
|
2
|
+
|
|
3
|
+
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
|
4
|
+
const RETRYABLE_CODES = [429, 500, 502, 503];
|
|
5
|
+
|
|
6
|
+
export async function resolveApiKey(inquirer) {
|
|
7
|
+
// 1. Check env var
|
|
8
|
+
if (process.env.GEMINI_API_KEY) {
|
|
9
|
+
return process.env.GEMINI_API_KEY;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 2. Check memoir config
|
|
13
|
+
const config = await getConfig() || {};
|
|
14
|
+
if (config.geminiApiKey) {
|
|
15
|
+
return config.geminiApiKey;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 3. Prompt user
|
|
19
|
+
const { apiKey } = await inquirer.prompt([{
|
|
20
|
+
type: 'password',
|
|
21
|
+
name: 'apiKey',
|
|
22
|
+
message: 'Gemini API key (free at aistudio.google.com):',
|
|
23
|
+
mask: '*',
|
|
24
|
+
validate: (v) => v.length > 10 || 'Please enter a valid API key'
|
|
25
|
+
}]);
|
|
26
|
+
|
|
27
|
+
// Save for next time
|
|
28
|
+
config.geminiApiKey = apiKey;
|
|
29
|
+
await saveConfig(config);
|
|
30
|
+
|
|
31
|
+
return apiKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function callGeminiApi(prompt, apiKey) {
|
|
35
|
+
const response = await fetch(GEMINI_API_URL, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'x-goog-api-key': apiKey
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
43
|
+
generationConfig: {
|
|
44
|
+
temperature: 0.3,
|
|
45
|
+
maxOutputTokens: 8192
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const err = await response.text();
|
|
52
|
+
if (response.status === 400 || response.status === 403) {
|
|
53
|
+
throw new Error('Invalid Gemini API key. Get a free key at https://aistudio.google.com');
|
|
54
|
+
}
|
|
55
|
+
if (RETRYABLE_CODES.includes(response.status)) {
|
|
56
|
+
const error = new Error(`Gemini API error (${response.status}): ${err}`);
|
|
57
|
+
error.retryable = true;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Gemini API error (${response.status}): ${err}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = await response.json();
|
|
64
|
+
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
65
|
+
|
|
66
|
+
if (!text) {
|
|
67
|
+
throw new Error('Empty response from Gemini API');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return text.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function translateMemory(content, sourceProfile, targetProfile, apiKey) {
|
|
74
|
+
const prompt = `You are an expert at translating AI coding assistant memory/instruction files between different tools.
|
|
75
|
+
|
|
76
|
+
SOURCE TOOL: ${sourceProfile.name}
|
|
77
|
+
SOURCE FORMAT: ${sourceProfile.format}
|
|
78
|
+
|
|
79
|
+
TARGET TOOL: ${targetProfile.name}
|
|
80
|
+
TARGET FORMAT: ${targetProfile.format}
|
|
81
|
+
|
|
82
|
+
INSTRUCTIONS:
|
|
83
|
+
- Translate the content below so it works perfectly as a ${targetProfile.name} instruction file.
|
|
84
|
+
- Preserve ALL information, preferences, conventions, and context from the source.
|
|
85
|
+
- Adapt the structure and phrasing to match ${targetProfile.name}'s conventions.
|
|
86
|
+
- Remove any tool-specific references that don't apply to ${targetProfile.name}.
|
|
87
|
+
- Keep the tone direct and instructional.
|
|
88
|
+
- Output ONLY the translated content, no explanations or wrapping.
|
|
89
|
+
|
|
90
|
+
SOURCE CONTENT:
|
|
91
|
+
${content}`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
return await callGeminiApi(prompt, apiKey);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err.retryable) {
|
|
97
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
98
|
+
return await callGeminiApi(prompt, apiKey);
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -2,66 +2,70 @@ import fs from 'fs-extra';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
function sanitizeUrl(url) {
|
|
8
|
+
// Reject URLs with shell metacharacters
|
|
9
|
+
if (/[`$;|&()<>!]/.test(url)) {
|
|
10
|
+
throw new Error('Repository URL contains invalid characters.');
|
|
11
|
+
}
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
export async function syncToLocal(config, stagingDir, spinner) {
|
|
8
16
|
const destDir = config.localPath;
|
|
9
17
|
if (!destDir) throw new Error('Local path is not configured.');
|
|
10
|
-
|
|
11
|
-
// Expand tilde if user used it
|
|
18
|
+
|
|
12
19
|
const resolvedDest = destDir.replace(/^~/, os.homedir());
|
|
13
|
-
|
|
20
|
+
|
|
14
21
|
spinner.text = `Syncing files to local directory: ${chalk.cyan(resolvedDest)}`;
|
|
15
22
|
await fs.ensureDir(resolvedDest);
|
|
16
|
-
|
|
23
|
+
|
|
17
24
|
await fs.copy(stagingDir, resolvedDest);
|
|
18
25
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray(`(Saved to ${resolvedDest})`));
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
export async function syncToGit(config, stagingDir, spinner) {
|
|
22
|
-
const repoUrl = config.gitRepo;
|
|
29
|
+
const repoUrl = sanitizeUrl(config.gitRepo);
|
|
23
30
|
if (!repoUrl) throw new Error('Git repository is not configured.');
|
|
24
|
-
|
|
31
|
+
|
|
25
32
|
spinner.text = `Authenticating and syncing with Git remote: ${chalk.cyan(repoUrl)}`;
|
|
26
|
-
|
|
27
|
-
// Clone existing repo to preserve history, then replace contents
|
|
33
|
+
|
|
28
34
|
const gitDir = path.join(os.tmpdir(), `memoir-git-${Date.now()}`);
|
|
29
35
|
await fs.ensureDir(gitDir);
|
|
30
36
|
|
|
31
37
|
try {
|
|
32
38
|
try {
|
|
33
|
-
|
|
34
|
-
// Remove old files so deleted configs don't persist
|
|
39
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: gitDir, stdio: 'ignore' });
|
|
35
40
|
const files = await fs.readdir(gitDir);
|
|
36
41
|
for (const f of files) {
|
|
37
42
|
if (f !== '.git') await fs.remove(path.join(gitDir, f));
|
|
38
43
|
}
|
|
39
44
|
} catch {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
execSync('git branch -m main', { cwd: gitDir, stdio: 'ignore' });
|
|
45
|
+
execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
|
|
46
|
+
execFileSync('git', ['branch', '-m', 'main'], { cwd: gitDir, stdio: 'ignore' });
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
// Copy staged memories into the git dir
|
|
46
49
|
await fs.copy(stagingDir, gitDir);
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
execFileSync('git', ['add', '-A'], { cwd: gitDir, stdio: 'ignore' });
|
|
52
|
+
execFileSync('git', ['config', 'user.name', 'memoir'], { cwd: gitDir, stdio: 'ignore' });
|
|
53
|
+
execFileSync('git', ['config', 'user.email', 'bot@memoir.dev'], { cwd: gitDir, stdio: 'ignore' });
|
|
51
54
|
|
|
52
55
|
const timestamp = new Date().toISOString().split('T')[0];
|
|
53
56
|
try {
|
|
54
|
-
|
|
57
|
+
execFileSync('git', ['commit', '-m', `memoir backup ${timestamp}`], { cwd: gitDir, stdio: 'ignore' });
|
|
55
58
|
} catch {
|
|
56
59
|
spinner.succeed(chalk.green('Already up to date! ') + chalk.gray('No changes to push.'));
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
spinner.text = `Pushing data to ${chalk.cyan(repoUrl)}...`;
|
|
61
|
-
|
|
64
|
+
execFileSync('git', ['push', repoUrl, 'main'], { cwd: gitDir, stdio: 'ignore' });
|
|
62
65
|
|
|
63
66
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)'));
|
|
64
67
|
} catch (err) {
|
|
68
|
+
if (err.message.includes('invalid characters')) throw err;
|
|
65
69
|
throw new Error('Failed to push to git repository. Ensure your credentials are configured and the repository exists.');
|
|
66
70
|
} finally {
|
|
67
71
|
await fs.remove(gitDir);
|
package/src/providers/restore.js
CHANGED
|
@@ -2,34 +2,33 @@ import chalk from 'chalk';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
6
|
import { restoreMemories } from '../adapters/restore.js';
|
|
7
7
|
|
|
8
8
|
export async function fetchFromLocal(config, stagingDir, spinner) {
|
|
9
9
|
const sourceDir = config.localPath;
|
|
10
10
|
if (!sourceDir) throw new Error('Local path is not configured.');
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
const resolvedSource = sourceDir.replace(/^~/, os.homedir());
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
if (!(await fs.pathExists(resolvedSource))) {
|
|
15
15
|
throw new Error(`The backup directory does not exist: ${resolvedSource}`);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
spinner.text = `Fetching data from local directory: ${chalk.cyan(resolvedSource)}`;
|
|
19
19
|
await fs.copy(resolvedSource, stagingDir);
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
return await restoreMemories(stagingDir, spinner);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function fetchFromGit(config, stagingDir, spinner) {
|
|
25
25
|
const repoUrl = config.gitRepo;
|
|
26
26
|
if (!repoUrl) throw new Error('Git repository is not configured.');
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
spinner.text = `Cloning memory from Git remote: ${chalk.cyan(repoUrl)}`;
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
try {
|
|
31
|
-
|
|
32
|
-
execSync(`git clone --depth 1 ${repoUrl} .`, { cwd: stagingDir, stdio: 'ignore' });
|
|
31
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: stagingDir, stdio: 'ignore' });
|
|
33
32
|
} catch (err) {
|
|
34
33
|
throw new Error('Failed to pull from git repository. Ensure your SSH keys are configured and the repository is accessible.');
|
|
35
34
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const home = os.homedir();
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
key: 'aider',
|
|
10
|
+
name: 'Aider',
|
|
11
|
+
icon: '🔧',
|
|
12
|
+
format: 'Markdown system prompt in .aider.system-prompt.md. Contains instructions that get injected into Aider\'s system prompt. Supports coding style, conventions, and project context.',
|
|
13
|
+
|
|
14
|
+
discover() {
|
|
15
|
+
const files = [];
|
|
16
|
+
const projectFile = path.join(cwd, '.aider.system-prompt.md');
|
|
17
|
+
if (fs.existsSync(projectFile)) {
|
|
18
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
19
|
+
}
|
|
20
|
+
const homeFile = path.join(home, '.aider.system-prompt.md');
|
|
21
|
+
const alreadyFound = files.some(f => path.resolve(f.filePath) === path.resolve(homeFile));
|
|
22
|
+
if (fs.existsSync(homeFile) && !alreadyFound) {
|
|
23
|
+
files.push({ filePath: homeFile, content: fs.readFileSync(homeFile, 'utf-8'), scope: 'user' });
|
|
24
|
+
}
|
|
25
|
+
return files;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
targetPath() {
|
|
29
|
+
return path.join(cwd, '.aider.system-prompt.md');
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const home = os.homedir();
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
key: 'claude',
|
|
10
|
+
name: 'Claude',
|
|
11
|
+
icon: '🟣',
|
|
12
|
+
format: 'Markdown instructions in CLAUDE.md. Supports sections for project context, coding conventions, tool preferences, and workflow rules. Written as direct instructions to Claude.',
|
|
13
|
+
|
|
14
|
+
discover() {
|
|
15
|
+
const files = [];
|
|
16
|
+
|
|
17
|
+
const projectFile = path.join(cwd, 'CLAUDE.md');
|
|
18
|
+
if (fs.existsSync(projectFile)) {
|
|
19
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const memoryBase = path.join(home, '.claude', 'projects');
|
|
23
|
+
if (fs.existsSync(memoryBase)) {
|
|
24
|
+
const cwdEncoded = cwd.replace(/\//g, '-');
|
|
25
|
+
const projectDirs = fs.readdirSync(memoryBase).filter(d => {
|
|
26
|
+
if (!fs.statSync(path.join(memoryBase, d)).isDirectory()) return false;
|
|
27
|
+
return d === cwdEncoded || cwdEncoded.startsWith(d) || d.startsWith(cwdEncoded);
|
|
28
|
+
});
|
|
29
|
+
for (const dir of projectDirs) {
|
|
30
|
+
const memoryDir = path.join(memoryBase, dir, 'memory');
|
|
31
|
+
if (fs.existsSync(memoryDir)) {
|
|
32
|
+
const mdFiles = fs.readdirSync(memoryDir).filter(f => f.endsWith('.md'));
|
|
33
|
+
for (const f of mdFiles) {
|
|
34
|
+
const filePath = path.join(memoryDir, f);
|
|
35
|
+
files.push({ filePath, content: fs.readFileSync(filePath, 'utf-8'), scope: 'user' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const claudeMd = path.join(memoryBase, dir, 'CLAUDE.md');
|
|
39
|
+
if (fs.existsSync(claudeMd)) {
|
|
40
|
+
files.push({ filePath: claudeMd, content: fs.readFileSync(claudeMd, 'utf-8'), scope: 'user' });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return files;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
targetPath() {
|
|
49
|
+
return path.join(cwd, 'CLAUDE.md');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const cwd = process.cwd();
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
key: 'codex',
|
|
8
|
+
name: 'Codex',
|
|
9
|
+
icon: '🟢',
|
|
10
|
+
format: 'Markdown instructions in AGENTS.md. Written as instructions for OpenAI Codex agent. Supports project context, coding conventions, and task guidance.',
|
|
11
|
+
|
|
12
|
+
discover() {
|
|
13
|
+
const files = [];
|
|
14
|
+
const projectFile = path.join(cwd, 'AGENTS.md');
|
|
15
|
+
if (fs.existsSync(projectFile)) {
|
|
16
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
targetPath() {
|
|
22
|
+
return path.join(cwd, 'AGENTS.md');
|
|
23
|
+
}
|
|
24
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const cwd = process.cwd();
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
key: 'copilot',
|
|
8
|
+
name: 'GitHub Copilot',
|
|
9
|
+
icon: '🐙',
|
|
10
|
+
format: 'Markdown instructions in .github/copilot-instructions.md. Written as instructions for GitHub Copilot. Supports coding style, project context, and language preferences.',
|
|
11
|
+
|
|
12
|
+
discover() {
|
|
13
|
+
const files = [];
|
|
14
|
+
const projectFile = path.join(cwd, '.github', 'copilot-instructions.md');
|
|
15
|
+
if (fs.existsSync(projectFile)) {
|
|
16
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
targetPath() {
|
|
22
|
+
return path.join(cwd, '.github', 'copilot-instructions.md');
|
|
23
|
+
}
|
|
24
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const cwd = process.cwd();
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
key: 'cursor',
|
|
8
|
+
name: 'Cursor',
|
|
9
|
+
icon: '⚡',
|
|
10
|
+
format: 'Plain text or markdown rules in .cursorrules. Contains coding conventions, style preferences, and project-specific instructions for Cursor AI.',
|
|
11
|
+
|
|
12
|
+
discover() {
|
|
13
|
+
const files = [];
|
|
14
|
+
const projectFile = path.join(cwd, '.cursorrules');
|
|
15
|
+
if (fs.existsSync(projectFile)) {
|
|
16
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
targetPath() {
|
|
22
|
+
return path.join(cwd, '.cursorrules');
|
|
23
|
+
}
|
|
24
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const home = os.homedir();
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
key: 'gemini',
|
|
10
|
+
name: 'Gemini',
|
|
11
|
+
icon: '🔵',
|
|
12
|
+
format: 'Markdown instructions in GEMINI.md. Written as direct instructions to Gemini. Supports project context, coding style, preferences, and behavioral rules.',
|
|
13
|
+
|
|
14
|
+
discover() {
|
|
15
|
+
const files = [];
|
|
16
|
+
const projectFile = path.join(cwd, 'GEMINI.md');
|
|
17
|
+
if (fs.existsSync(projectFile)) {
|
|
18
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
19
|
+
}
|
|
20
|
+
const homeFile = path.join(home, 'GEMINI.md');
|
|
21
|
+
const alreadyFound = files.some(f => path.resolve(f.filePath) === path.resolve(homeFile));
|
|
22
|
+
if (fs.existsSync(homeFile) && !alreadyFound) {
|
|
23
|
+
files.push({ filePath: homeFile, content: fs.readFileSync(homeFile, 'utf-8'), scope: 'user' });
|
|
24
|
+
}
|
|
25
|
+
return files;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
targetPath() {
|
|
29
|
+
return path.join(cwd, 'GEMINI.md');
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import claude from './claude.js';
|
|
2
|
+
import gemini from './gemini.js';
|
|
3
|
+
import codex from './codex.js';
|
|
4
|
+
import cursor from './cursor.js';
|
|
5
|
+
import copilot from './copilot.js';
|
|
6
|
+
import windsurf from './windsurf.js';
|
|
7
|
+
import aider from './aider.js';
|
|
8
|
+
|
|
9
|
+
const registry = {};
|
|
10
|
+
for (const tool of [claude, gemini, codex, cursor, copilot, windsurf, aider]) {
|
|
11
|
+
registry[tool.key] = tool;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getProfile(key) {
|
|
15
|
+
return registry[key] || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getProfileKeys() {
|
|
19
|
+
return Object.keys(registry);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getProfileChoices() {
|
|
23
|
+
return Object.values(registry).map(t => ({ name: t.name, value: t.key }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { registry as profiles };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const cwd = process.cwd();
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
key: 'windsurf',
|
|
8
|
+
name: 'Windsurf',
|
|
9
|
+
icon: '🏄',
|
|
10
|
+
format: 'Plain text or markdown rules in .windsurfrules. Contains coding conventions, style preferences, and project-specific instructions for Windsurf AI.',
|
|
11
|
+
|
|
12
|
+
discover() {
|
|
13
|
+
const files = [];
|
|
14
|
+
const projectFile = path.join(cwd, '.windsurfrules');
|
|
15
|
+
if (fs.existsSync(projectFile)) {
|
|
16
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
targetPath() {
|
|
22
|
+
return path.join(cwd, '.windsurfrules');
|
|
23
|
+
}
|
|
24
|
+
};
|