memoir-cli 3.0.3 → 3.1.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/README.md +122 -55
- package/package.json +1 -1
- package/src/adapters/index.js +11 -4
- package/src/commands/push.js +33 -2
- package/src/commands/restore.js +42 -1
- package/src/context/capture.js +158 -7
- package/src/workspace/tracker.js +350 -0
package/README.md
CHANGED
|
@@ -2,43 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
# memoir
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Your AI remembers everything. On every machine.**
|
|
6
6
|
|
|
7
7
|
[](https://npmjs.org/package/memoir-cli)
|
|
8
8
|
[](https://npmjs.org/package/memoir-cli)
|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Close your laptop. Open another one. **Your AI picks up exactly where you left off.**
|
|
13
13
|
|
|
14
14
|
[Website](https://memoir.sh) • [npm](https://npmjs.org/package/memoir-cli) • [Blog](https://memoir.sh/blog)
|
|
15
15
|
|
|
16
16
|
<br />
|
|
17
17
|
|
|
18
|
-

|
|
19
|
-
|
|
20
18
|
</div>
|
|
21
19
|
|
|
22
|
-
##
|
|
20
|
+
## The Problem
|
|
23
21
|
|
|
24
|
-
You spend weeks teaching
|
|
22
|
+
You spend weeks teaching Claude how you code. Your projects are dialed in. Your AI knows your stack, your decisions, your preferences.
|
|
25
23
|
|
|
26
|
-
Then you
|
|
24
|
+
Then you switch machines. **Everything is gone.** Your AI has amnesia. Your projects aren't there. You start from zero.
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
## The Fix
|
|
29
27
|
|
|
30
28
|
```bash
|
|
29
|
+
# On your main machine
|
|
30
|
+
memoir push
|
|
31
|
+
|
|
32
|
+
# On any other machine
|
|
31
33
|
npm install -g memoir-cli
|
|
34
|
+
memoir restore -y
|
|
35
|
+
|
|
36
|
+
# Done. Everything's back:
|
|
37
|
+
# ✔ AI memory restored (Claude, Gemini, Cursor, 11 tools)
|
|
38
|
+
# ✔ 44 projects cloned & unpacked
|
|
39
|
+
# ✔ Uncommitted changes applied
|
|
40
|
+
# ✔ Session context injected — AI picks up mid-conversation
|
|
32
41
|
```
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
One command to save. One command to restore. That's it.
|
|
44
|
+
|
|
45
|
+
## What Gets Synced
|
|
46
|
+
|
|
47
|
+
memoir syncs three layers that no other tool connects:
|
|
48
|
+
|
|
49
|
+
### Layer 1: AI Memory
|
|
50
|
+
Your AI tool configs, preferences, and project knowledge across 11 tools.
|
|
35
51
|
|
|
36
52
|
| Tool | What gets synced |
|
|
37
53
|
|------|-----------------|
|
|
38
|
-
| **ChatGPT** | CHATGPT.md custom instructions |
|
|
39
54
|
| **Claude Code** | ~/.claude/ settings, memory, CLAUDE.md files |
|
|
40
55
|
| **Gemini CLI** | ~/.gemini/ config, GEMINI.md files |
|
|
41
|
-
| **
|
|
56
|
+
| **ChatGPT** | CHATGPT.md custom instructions |
|
|
57
|
+
| **OpenAI Codex** | ~/.codex/ config, AGENTS.md |
|
|
42
58
|
| **Cursor** | Settings, keybindings, .cursorrules |
|
|
43
59
|
| **GitHub Copilot** | Config, copilot-instructions.md |
|
|
44
60
|
| **Windsurf** | Settings, keybindings, .windsurfrules |
|
|
@@ -47,7 +63,37 @@ npm install -g memoir-cli
|
|
|
47
63
|
| **Continue.dev** | Config, .continuerules |
|
|
48
64
|
| **Aider** | .aider.conf.yml, system prompt |
|
|
49
65
|
|
|
50
|
-
|
|
66
|
+
### Layer 2: Session State
|
|
67
|
+
What you were **doing** — not just what your AI knows, but the active context.
|
|
68
|
+
|
|
69
|
+
- Last coding session captured automatically
|
|
70
|
+
- What files you changed, what errors you hit, what decisions you made
|
|
71
|
+
- Injected into your AI on restore so it picks up mid-conversation
|
|
72
|
+
- Secrets auto-redacted (API keys, tokens, passwords stripped before sync)
|
|
73
|
+
|
|
74
|
+
### Layer 3: Workspace (NEW in v3.1)
|
|
75
|
+
Your actual projects — code, files, everything.
|
|
76
|
+
|
|
77
|
+
- **Git projects:** Remote URLs saved, auto-cloned on restore
|
|
78
|
+
- **Non-git projects:** Bundled as compressed archives, unpacked on restore
|
|
79
|
+
- **Uncommitted work:** Saved as patches, applied after clone
|
|
80
|
+
- **Zero git commands needed** — memoir handles it all
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
memoir push on Mac:
|
|
84
|
+
✔ AI memory backed up
|
|
85
|
+
✔ Session context captured
|
|
86
|
+
✔ Workspace: 44 projects (17 git, 23 bundled)
|
|
87
|
+
🔒 E2E encrypted
|
|
88
|
+
|
|
89
|
+
memoir restore on Windows:
|
|
90
|
+
✔ AI memory restored
|
|
91
|
+
✔ stock-market-book → C:\Users\You\stock-market-book (cloned)
|
|
92
|
+
✔ socialslink → C:\Users\You\socialslink (cloned)
|
|
93
|
+
✔ btc-trader → C:\Users\You\btc-trader (unpacked)
|
|
94
|
+
✔ 41 more projects restored
|
|
95
|
+
📋 Session context injected — Claude picks up where you left off
|
|
96
|
+
```
|
|
51
97
|
|
|
52
98
|
## Quick Start
|
|
53
99
|
|
|
@@ -55,41 +101,52 @@ Plus **per-project configs** — memoir scans your filesystem for CLAUDE.md, GEM
|
|
|
55
101
|
# Install
|
|
56
102
|
npm install -g memoir-cli
|
|
57
103
|
|
|
58
|
-
# First-time setup
|
|
104
|
+
# First-time setup
|
|
59
105
|
memoir init
|
|
60
106
|
|
|
61
|
-
# Back up
|
|
107
|
+
# Back up everything
|
|
62
108
|
memoir push
|
|
63
109
|
|
|
64
|
-
# Restore on
|
|
110
|
+
# Restore on any machine
|
|
65
111
|
memoir restore
|
|
66
112
|
```
|
|
67
113
|
|
|
68
|
-
That's it. Every AI tool gets its memory back.
|
|
69
|
-
|
|
70
114
|
## Key Features
|
|
71
115
|
|
|
72
|
-
###
|
|
116
|
+
### Workspace sync
|
|
73
117
|
```bash
|
|
74
|
-
memoir
|
|
75
|
-
#
|
|
118
|
+
memoir push # scans all projects, saves git URLs + bundles non-git projects
|
|
119
|
+
memoir restore # auto-clones repos, unpacks bundles, applies uncommitted patches
|
|
76
120
|
```
|
|
77
121
|
|
|
78
|
-
|
|
122
|
+
No manual git commands. memoir detects your projects, tracks their remotes, and restores them anywhere.
|
|
79
123
|
|
|
124
|
+
### Translate between AI tools
|
|
80
125
|
```bash
|
|
126
|
+
memoir migrate --from chatgpt --to claude
|
|
127
|
+
# AI-powered — rewrites conventions, not copy-paste
|
|
128
|
+
|
|
81
129
|
memoir migrate --from chatgpt --to all
|
|
130
|
+
# Translate to every tool at once
|
|
82
131
|
```
|
|
83
132
|
|
|
84
133
|
### Session handoff
|
|
85
134
|
```bash
|
|
86
|
-
#
|
|
135
|
+
# Capture your session (automatic on push, or manual)
|
|
87
136
|
memoir snapshot
|
|
88
137
|
|
|
89
138
|
# Pick up on another machine
|
|
90
139
|
memoir resume --inject --to claude
|
|
91
140
|
```
|
|
92
141
|
|
|
142
|
+
### E2E Encryption
|
|
143
|
+
```bash
|
|
144
|
+
memoir encrypt # toggle encryption on/off
|
|
145
|
+
memoir push # prompted for passphrase, AES-256-GCM encrypted
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Your backup is encrypted before it leaves your machine. Even if your storage is compromised, your data is safe. Secret scanning auto-redacts API keys, tokens, and passwords.
|
|
149
|
+
|
|
93
150
|
### Profiles (personal / work)
|
|
94
151
|
```bash
|
|
95
152
|
memoir profile create work
|
|
@@ -107,23 +164,24 @@ memoir cloud restore # restore from any version
|
|
|
107
164
|
memoir history # view all backup versions
|
|
108
165
|
```
|
|
109
166
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
### Security
|
|
167
|
+
### Cross-platform (Mac / Windows / Linux)
|
|
113
168
|
```bash
|
|
114
|
-
|
|
115
|
-
|
|
169
|
+
# Push from Mac
|
|
170
|
+
memoir push
|
|
171
|
+
|
|
172
|
+
# Restore on Windows — paths remap automatically
|
|
173
|
+
memoir restore
|
|
116
174
|
```
|
|
117
175
|
|
|
118
|
-
|
|
176
|
+
Claude's memory paths are automatically remapped between platforms. Projects are cloned to the right locations. It just works.
|
|
119
177
|
|
|
120
178
|
## All Commands
|
|
121
179
|
|
|
122
180
|
| Command | What it does |
|
|
123
181
|
|---------|-------------|
|
|
124
182
|
| `memoir init` | Setup wizard — GitHub or local storage |
|
|
125
|
-
| `memoir push` | Back up
|
|
126
|
-
| `memoir restore` | Restore on a new machine |
|
|
183
|
+
| `memoir push` | Back up AI memory + workspace + session |
|
|
184
|
+
| `memoir restore` | Restore everything on a new machine |
|
|
127
185
|
| `memoir status` | Show detected AI tools |
|
|
128
186
|
| `memoir doctor` | Diagnose issues, scan for secrets |
|
|
129
187
|
| `memoir view` | Preview what's in your backup |
|
|
@@ -131,6 +189,7 @@ memoir **never** syncs credentials, API keys, .env files, or auth tokens.
|
|
|
131
189
|
| `memoir migrate` | Translate memory between tools via AI |
|
|
132
190
|
| `memoir snapshot` | Capture current coding session |
|
|
133
191
|
| `memoir resume` | Pick up where you left off |
|
|
192
|
+
| `memoir encrypt` | Toggle E2E encryption |
|
|
134
193
|
| `memoir profile` | Manage profiles (personal/work) |
|
|
135
194
|
| `memoir cloud push` | Back up to memoir cloud |
|
|
136
195
|
| `memoir cloud restore` | Restore from memoir cloud |
|
|
@@ -140,57 +199,65 @@ memoir **never** syncs credentials, API keys, .env files, or auth tokens.
|
|
|
140
199
|
|
|
141
200
|
## How memoir compares
|
|
142
201
|
|
|
143
|
-
| Feature | memoir |
|
|
144
|
-
|
|
145
|
-
|
|
|
202
|
+
| Feature | memoir | dotfiles managers | ai-rulez | memories.sh |
|
|
203
|
+
|---------|--------|-------------------|----------|-------------|
|
|
204
|
+
| AI memory sync | **11 tools** | No | 18 tools | 3 tools |
|
|
205
|
+
| Workspace sync | **Yes** | No | No | No |
|
|
206
|
+
| Session handoff | **Yes** | No | No | No |
|
|
146
207
|
| AI-powered translation | **Yes** | No | No | No |
|
|
147
|
-
|
|
|
208
|
+
| E2E encryption | **Yes** | No | No | No |
|
|
209
|
+
| Secret scanning | **Yes** | Some | No | No |
|
|
210
|
+
| Cross-platform remap | **Yes** | Some | No | No |
|
|
211
|
+
| Uncommitted work patches | **Yes** | No | No | No |
|
|
148
212
|
| Cloud backup | **Yes** | No | No | Yes ($15/mo) |
|
|
149
|
-
| Version history | **Yes** | No | No | No |
|
|
150
|
-
| Session handoff | **Yes** | No | No | No |
|
|
151
213
|
| Profiles | **Yes** | No | No | No |
|
|
152
|
-
| Secret scanning | **Yes** | Yes | No | No |
|
|
153
214
|
| Free & open source | **Yes** | Yes | Yes | No |
|
|
154
215
|
|
|
155
|
-
memoir is free and open source. Cloud sync starts at $0 (3 backups free).
|
|
156
|
-
|
|
157
216
|
## Common Workflows
|
|
158
217
|
|
|
159
|
-
### New
|
|
218
|
+
### New machine setup
|
|
160
219
|
```bash
|
|
161
220
|
# Old machine
|
|
162
221
|
memoir push
|
|
163
222
|
|
|
164
|
-
# New machine
|
|
165
|
-
npm install -g memoir-cli
|
|
166
|
-
memoir init # connect to same repo
|
|
167
|
-
memoir restore # all configs restored in seconds
|
|
223
|
+
# New machine — one command, everything's back
|
|
224
|
+
npm install -g memoir-cli && memoir init && memoir restore -y
|
|
168
225
|
```
|
|
169
226
|
|
|
170
|
-
###
|
|
227
|
+
### Daily sync between machines
|
|
171
228
|
```bash
|
|
172
|
-
memoir
|
|
173
|
-
#
|
|
229
|
+
memoir push # end of day on laptop
|
|
230
|
+
memoir restore # next morning on desktop — AI knows what you were doing
|
|
174
231
|
```
|
|
175
232
|
|
|
176
|
-
###
|
|
233
|
+
### Switching AI tools
|
|
177
234
|
```bash
|
|
178
|
-
memoir
|
|
179
|
-
|
|
235
|
+
memoir migrate --from chatgpt --to claude
|
|
236
|
+
# Your custom instructions become a proper CLAUDE.md
|
|
180
237
|
```
|
|
181
238
|
|
|
182
|
-
###
|
|
239
|
+
### Team onboarding
|
|
183
240
|
```bash
|
|
184
|
-
#
|
|
185
|
-
memoir push
|
|
241
|
+
# Senior dev pushes team config
|
|
242
|
+
memoir push --profile team
|
|
186
243
|
|
|
187
|
-
#
|
|
188
|
-
memoir restore
|
|
244
|
+
# New hire runs one command
|
|
245
|
+
memoir restore --profile team
|
|
246
|
+
# Every project cloned. Every AI tool configured. Day one productive.
|
|
189
247
|
```
|
|
190
248
|
|
|
249
|
+
## Security
|
|
250
|
+
|
|
251
|
+
- **E2E encryption** — AES-256-GCM with scrypt key derivation
|
|
252
|
+
- **Secret scanning** — 20+ patterns detect API keys, tokens, passwords, connection strings
|
|
253
|
+
- **Auto-redaction** — secrets stripped from session handoffs before sync
|
|
254
|
+
- **No credentials synced** — .env files, auth tokens, and API keys are never included
|
|
255
|
+
- **Passphrase verified** — wrong passphrase caught before decrypt attempt
|
|
256
|
+
|
|
191
257
|
## Requirements
|
|
192
258
|
|
|
193
259
|
- Node.js >= 18
|
|
260
|
+
- Git (for workspace sync)
|
|
194
261
|
- Works on macOS, Windows, Linux
|
|
195
262
|
|
|
196
263
|
## Contributing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.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
|
@@ -37,15 +37,22 @@ export const adapters = [
|
|
|
37
37
|
if (src === claudeDir) return true;
|
|
38
38
|
// Only allow these top-level dirs
|
|
39
39
|
const topDir = rel.split(path.sep)[0];
|
|
40
|
-
const allowedDirs = ['projects', 'settings'];
|
|
41
40
|
const allowedFiles = ['settings.json', 'settings.local.json'];
|
|
42
41
|
// Allow specific top-level config files
|
|
43
42
|
if (!rel.includes(path.sep) && allowedFiles.includes(basename)) return true;
|
|
44
43
|
// Allow projects dir (contains memory .md files)
|
|
45
44
|
if (topDir === 'projects') {
|
|
46
|
-
// Allow directory traversal
|
|
47
|
-
try {
|
|
48
|
-
|
|
45
|
+
// Allow directory traversal but skip dirs that only contain session data
|
|
46
|
+
try {
|
|
47
|
+
if (nodeFs.statSync(src).isDirectory()) {
|
|
48
|
+
// Skip subagents and UUID-named session dirs (contain large .jsonl files, no .md)
|
|
49
|
+
if (basename === 'subagents') return false;
|
|
50
|
+
// UUID pattern: 8-4-4-4-12 hex chars
|
|
51
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(basename)) return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
// Only sync memory markdown files — skip .jsonl session files
|
|
49
56
|
return basename.endsWith('.md');
|
|
50
57
|
}
|
|
51
58
|
// Allow settings dir
|
package/src/commands/push.js
CHANGED
|
@@ -9,10 +9,11 @@ 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, shouldIgnoreProject } from '../context/capture.js';
|
|
12
|
+
import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions } from '../context/capture.js';
|
|
13
13
|
import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
|
|
14
14
|
import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
|
|
15
15
|
import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
|
|
16
|
+
import { scanWorkspace } from '../workspace/tracker.js';
|
|
16
17
|
|
|
17
18
|
export async function pushCommand(options = {}) {
|
|
18
19
|
const config = await getConfig(options.profile);
|
|
@@ -76,10 +77,19 @@ export async function pushCommand(options = {}) {
|
|
|
76
77
|
await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
|
|
77
78
|
await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
|
|
78
79
|
|
|
80
|
+
// Persist decisions to Claude's memory so they survive across sessions
|
|
81
|
+
let decisionCount = 0;
|
|
82
|
+
if (parsed.decisions.length > 0) {
|
|
83
|
+
try {
|
|
84
|
+
decisionCount = persistDecisions(parsed.decisions);
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
contextCaptured = true;
|
|
80
89
|
sessionInfo = {
|
|
81
90
|
slug: parsed.slug,
|
|
82
91
|
filesModified: parsed.filesWritten.length,
|
|
92
|
+
decisions: decisionCount,
|
|
83
93
|
duration: parsed.firstTimestamp && parsed.lastTimestamp
|
|
84
94
|
? (() => {
|
|
85
95
|
const ms = new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp);
|
|
@@ -101,6 +111,15 @@ export async function pushCommand(options = {}) {
|
|
|
101
111
|
// Context capture is best-effort — don't fail the push
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
// Scan workspace for projects (git repos + unbacked projects)
|
|
115
|
+
let workspaceManifest = null;
|
|
116
|
+
spinner.text = chalk.gray('Scanning workspace...');
|
|
117
|
+
try {
|
|
118
|
+
workspaceManifest = await scanWorkspace(stagingDir, spinner);
|
|
119
|
+
} catch {
|
|
120
|
+
// Workspace scan is best-effort
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
// Count what was found
|
|
105
124
|
const found = [];
|
|
106
125
|
for (const adapter of adapters) {
|
|
@@ -216,13 +235,25 @@ export async function pushCommand(options = {}) {
|
|
|
216
235
|
if (sessionInfo.duration) parts.push(sessionInfo.duration);
|
|
217
236
|
if (sessionInfo.filesModified) parts.push(`${sessionInfo.filesModified} files changed`);
|
|
218
237
|
contextLine = '\n' + chalk.green(' ✔ Session Context') + chalk.gray(` (${parts.join(', ')})`) + '\n';
|
|
238
|
+
if (sessionInfo.decisions > 0) {
|
|
239
|
+
contextLine += chalk.green(` ✔ ${sessionInfo.decisions} decision(s) saved to persistent memory`) + '\n';
|
|
240
|
+
}
|
|
219
241
|
if (sessionInfo.secretsRedacted > 0) {
|
|
220
242
|
contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
|
|
221
243
|
}
|
|
222
244
|
}
|
|
245
|
+
let workspaceLine = '';
|
|
246
|
+
if (workspaceManifest && workspaceManifest.projects.length > 0) {
|
|
247
|
+
const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
|
|
248
|
+
const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
|
|
249
|
+
const parts = [];
|
|
250
|
+
if (gitCount > 0) parts.push(`${gitCount} git`);
|
|
251
|
+
if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
|
|
252
|
+
workspaceLine = '\n' + chalk.green(' ✔ Workspace') + chalk.gray(` (${workspaceManifest.projects.length} projects — ${parts.join(', ')})`) + '\n';
|
|
253
|
+
}
|
|
223
254
|
console.log('\n' + boxen(
|
|
224
255
|
gradient.pastel(' Backed up! ') + '\n\n' +
|
|
225
|
-
toolList + contextLine + '\n' +
|
|
256
|
+
toolList + contextLine + workspaceLine + '\n' +
|
|
226
257
|
chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
|
|
227
258
|
(encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
|
|
228
259
|
chalk.gray(`→ ${dest}`) + '\n\n' +
|
package/src/commands/restore.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getConfig } from '../config.js';
|
|
|
10
10
|
import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
|
|
11
11
|
import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
|
|
12
12
|
import { detectLocalHomeKey } from '../adapters/restore.js';
|
|
13
|
+
import { restoreWorkspace } from '../workspace/tracker.js';
|
|
13
14
|
|
|
14
15
|
const home = os.homedir();
|
|
15
16
|
|
|
@@ -169,6 +170,39 @@ export async function restoreCommand(options = {}) {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
// Restore workspace (clone git projects, unpack bundles)
|
|
174
|
+
let workspaceResults = null;
|
|
175
|
+
try {
|
|
176
|
+
spinner.start(chalk.gray('Checking workspace...'));
|
|
177
|
+
workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
|
|
178
|
+
spinner.stop();
|
|
179
|
+
|
|
180
|
+
if (workspaceResults) {
|
|
181
|
+
const { cloned, unpacked, patched, skipped } = workspaceResults;
|
|
182
|
+
if (cloned.length > 0 || unpacked.length > 0) {
|
|
183
|
+
console.log('\n' + chalk.cyan.bold(' 📁 Workspace restored:'));
|
|
184
|
+
for (const p of cloned) {
|
|
185
|
+
console.log(chalk.green(` ✔ ${p.name}`) + chalk.gray(` → ${p.path}`));
|
|
186
|
+
}
|
|
187
|
+
for (const p of unpacked) {
|
|
188
|
+
console.log(chalk.green(` ✔ ${p.name}`) + chalk.gray(` → ${p.path} (unpacked)`));
|
|
189
|
+
}
|
|
190
|
+
if (patched.length > 0) {
|
|
191
|
+
for (const p of patched) {
|
|
192
|
+
console.log(chalk.yellow(` ↻ ${p.name}`) + chalk.gray(` — uncommitted changes applied`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const existingCount = skipped.filter(s => s.reason === 'exists').length;
|
|
196
|
+
if (existingCount > 0) {
|
|
197
|
+
console.log(chalk.gray(` ⏭ ${existingCount} project(s) already on this machine`));
|
|
198
|
+
}
|
|
199
|
+
restored = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Workspace restore is best-effort
|
|
204
|
+
}
|
|
205
|
+
|
|
172
206
|
if (restored) {
|
|
173
207
|
let handoffMsg = '';
|
|
174
208
|
if (handoffInjected && handoffInfo) {
|
|
@@ -178,10 +212,17 @@ export async function restoreCommand(options = {}) {
|
|
|
178
212
|
(handoffInfo.duration ? '\n' + chalk.gray(` Duration: ${handoffInfo.duration}`) : '') + '\n' +
|
|
179
213
|
chalk.gray(' Your AI will pick up where you left off.');
|
|
180
214
|
}
|
|
215
|
+
let workspaceMsg = '';
|
|
216
|
+
if (workspaceResults) {
|
|
217
|
+
const total = workspaceResults.cloned.length + workspaceResults.unpacked.length;
|
|
218
|
+
if (total > 0) {
|
|
219
|
+
workspaceMsg = '\n' + chalk.cyan(`📁 ${total} project(s) restored to this machine`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
181
222
|
console.log(boxen(
|
|
182
223
|
gradient.pastel(' Done! ') + '\n\n' +
|
|
183
224
|
chalk.white('Your AI tools have their memories back.') +
|
|
184
|
-
handoffMsg + '\n' +
|
|
225
|
+
handoffMsg + workspaceMsg + '\n' +
|
|
185
226
|
chalk.gray(handoffInjected ? '' : 'Restart your AI tools to pick up the changes.'),
|
|
186
227
|
{ padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
187
228
|
) + '\n');
|
package/src/context/capture.js
CHANGED
|
@@ -42,12 +42,18 @@ export function findClaudeSessions() {
|
|
|
42
42
|
*/
|
|
43
43
|
export function parseSession(sessionPath, maxSizeMB = 10) {
|
|
44
44
|
const stat = fs.statSync(sessionPath);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
45
|
+
const TAIL_BYTES = 2 * 1024 * 1024; // Read last 2MB max
|
|
46
|
+
|
|
47
|
+
if (stat.size > TAIL_BYTES) {
|
|
48
|
+
// For large files, only read the tail to avoid loading 20MB+ into memory
|
|
49
|
+
const fd = fs.openSync(sessionPath, 'r');
|
|
50
|
+
const buf = Buffer.alloc(TAIL_BYTES);
|
|
51
|
+
fs.readSync(fd, buf, 0, TAIL_BYTES, stat.size - TAIL_BYTES);
|
|
52
|
+
fs.closeSync(fd);
|
|
53
|
+
const raw = buf.toString('utf8');
|
|
54
|
+
// Skip the first (partial) line since we likely cut mid-line
|
|
55
|
+
const lines = raw.split('\n').slice(1);
|
|
56
|
+
return parseLines(lines);
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
const raw = fs.readFileSync(sessionPath, 'utf8').trim();
|
|
@@ -55,6 +61,7 @@ export function parseSession(sessionPath, maxSizeMB = 10) {
|
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
function parseLines(lines) {
|
|
64
|
+
const assistantTexts = [];
|
|
58
65
|
const result = {
|
|
59
66
|
sessionId: null,
|
|
60
67
|
slug: null,
|
|
@@ -89,9 +96,14 @@ function parseLines(lines) {
|
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
// Tool uses from assistant
|
|
99
|
+
// Tool uses and text from assistant
|
|
93
100
|
if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
|
|
94
101
|
for (const block of obj.message.content) {
|
|
102
|
+
if (block.type === 'text' && block.text) {
|
|
103
|
+
// Capture assistant text for decision extraction (limit size)
|
|
104
|
+
if (block.text.length < 2000) assistantTexts.push(block.text);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
95
107
|
if (block.type !== 'tool_use') continue;
|
|
96
108
|
const name = block.name;
|
|
97
109
|
const input = block.input || {};
|
|
@@ -133,9 +145,148 @@ function parseLines(lines) {
|
|
|
133
145
|
result.filesRead = [...result.filesRead];
|
|
134
146
|
result.errors = [...new Set(result.errors)].slice(0, 10);
|
|
135
147
|
|
|
148
|
+
// Extract decisions from user + assistant messages
|
|
149
|
+
result.decisions = extractDecisions(result.userMessages, assistantTexts);
|
|
150
|
+
|
|
136
151
|
return result;
|
|
137
152
|
}
|
|
138
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Extract durable decisions from session conversation.
|
|
156
|
+
* These are things like renames, tech choices, preferences — stuff that should persist.
|
|
157
|
+
*/
|
|
158
|
+
function extractDecisions(userMessages, assistantTexts) {
|
|
159
|
+
const decisions = [];
|
|
160
|
+
const allText = [...userMessages, ...assistantTexts].join('\n');
|
|
161
|
+
|
|
162
|
+
// Patterns that indicate a decision was made
|
|
163
|
+
const patterns = [
|
|
164
|
+
// Renames / naming
|
|
165
|
+
{ regex: /(?:rename|call|name)\s+(?:it|this|the (?:project|app|tool|product))\s+(?:to\s+)?["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
|
|
166
|
+
{ regex: /(?:the\s+)?(?:new\s+)?name\s+(?:is|will be|should be)\s+["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
|
|
167
|
+
{ regex: /(?:rebrand|rebranding)\s+(?:to|as)\s+["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
|
|
168
|
+
// Tech choices
|
|
169
|
+
{ regex: /(?:let'?s|we(?:'ll| will| should)?|going to|decided to)\s+use\s+([A-Z][a-zA-Z0-9_./-]+)\s+(?:for|instead|as|to)/gi, type: 'tech' },
|
|
170
|
+
{ regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
|
|
171
|
+
// Architecture / design
|
|
172
|
+
{ regex: /(?:let'?s|we(?:'ll| will| should)?)\s+(?:go with|pick|choose)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'design' },
|
|
173
|
+
// Stack choices
|
|
174
|
+
{ regex: /(?:stack|framework|database|backend|frontend)\s+(?:is|will be|should be)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'stack' },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
for (const { regex, type } of patterns) {
|
|
178
|
+
let match;
|
|
179
|
+
while ((match = regex.exec(allText)) !== null) {
|
|
180
|
+
const value = match[1].trim().replace(/["']+$/, '');
|
|
181
|
+
if (value.length > 2 && value.length < 80) {
|
|
182
|
+
// Avoid duplicates
|
|
183
|
+
const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
|
|
184
|
+
if (!existing) {
|
|
185
|
+
decisions.push({ type, value, context: match[0].trim().slice(0, 120) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Look for explicit "remember this" instructions from the user
|
|
192
|
+
for (const msg of userMessages) {
|
|
193
|
+
// Only match when user is clearly asking to remember something
|
|
194
|
+
const rememberMatch = msg.match(/(?:remember (?:that|this)|note that|keep in mind that|from now on)[:\s]+(.{10,150})/i);
|
|
195
|
+
if (rememberMatch) {
|
|
196
|
+
decisions.push({ type: 'user-note', value: rememberMatch[1].trim(), context: msg.slice(0, 120) });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return decisions.slice(0, 20); // Cap at 20 decisions per session
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Write extracted decisions to Claude's persistent memory.
|
|
205
|
+
* This ensures decisions survive across sessions and machines.
|
|
206
|
+
*/
|
|
207
|
+
export function persistDecisions(decisions, claudeSource) {
|
|
208
|
+
if (!decisions || decisions.length === 0) return 0;
|
|
209
|
+
|
|
210
|
+
const claudeDir = claudeSource || path.join(home, '.claude');
|
|
211
|
+
const projectsDir = path.join(claudeDir, 'projects');
|
|
212
|
+
if (!fs.existsSync(projectsDir)) return 0;
|
|
213
|
+
|
|
214
|
+
// Find the HOME-level memory dir (not project-specific)
|
|
215
|
+
// This is the dir that matches the user's home path encoding
|
|
216
|
+
let homeKey;
|
|
217
|
+
if (process.platform === 'win32') {
|
|
218
|
+
homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
|
|
219
|
+
} else {
|
|
220
|
+
homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Try exact match first, then detect from existing dirs
|
|
224
|
+
let memDir = path.join(projectsDir, homeKey, 'memory');
|
|
225
|
+
if (!fs.existsSync(memDir)) {
|
|
226
|
+
// Fallback: find dirs with memory/ subfolder, pick shortest name (likely home-level)
|
|
227
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
228
|
+
.filter(e => e.isDirectory() && fs.existsSync(path.join(projectsDir, e.name, 'memory')));
|
|
229
|
+
if (entries.length === 0) return 0;
|
|
230
|
+
// Shortest dir name is most likely the home key (not a sub-project)
|
|
231
|
+
const homeEntry = entries.sort((a, b) => a.name.length - b.name.length)[0];
|
|
232
|
+
memDir = path.join(projectsDir, homeEntry.name, 'memory');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
236
|
+
const decisionsFile = path.join(memDir, 'session-decisions.md');
|
|
237
|
+
const memoryMdPath = path.join(memDir, 'MEMORY.md');
|
|
238
|
+
|
|
239
|
+
// Read existing decisions file or create new
|
|
240
|
+
let existing = '';
|
|
241
|
+
if (fs.existsSync(decisionsFile)) {
|
|
242
|
+
existing = fs.readFileSync(decisionsFile, 'utf8');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Format new decisions
|
|
246
|
+
const date = new Date().toISOString().split('T')[0];
|
|
247
|
+
const newEntries = decisions.map(d => {
|
|
248
|
+
if (d.type === 'rename') return `- **Renamed:** ${d.context}`;
|
|
249
|
+
if (d.type === 'tech') return `- **Tech choice:** ${d.context}`;
|
|
250
|
+
if (d.type === 'design') return `- **Decision:** ${d.context}`;
|
|
251
|
+
if (d.type === 'stack') return `- **Stack:** ${d.context}`;
|
|
252
|
+
if (d.type === 'user-note') return `- **Note:** ${d.value}`;
|
|
253
|
+
return `- ${d.context}`;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Check for duplicates against existing content
|
|
257
|
+
const fresh = newEntries.filter(entry => !existing.includes(entry));
|
|
258
|
+
if (fresh.length === 0) return 0;
|
|
259
|
+
|
|
260
|
+
const section = `\n### ${date}\n${fresh.join('\n')}\n`;
|
|
261
|
+
|
|
262
|
+
if (!existing) {
|
|
263
|
+
// Create new file with frontmatter
|
|
264
|
+
const content = `---
|
|
265
|
+
name: Session Decisions
|
|
266
|
+
description: Project decisions extracted from coding sessions — renames, tech choices, architecture
|
|
267
|
+
type: project
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
# Decisions from coding sessions
|
|
271
|
+
${section}`;
|
|
272
|
+
fs.writeFileSync(decisionsFile, content);
|
|
273
|
+
} else {
|
|
274
|
+
// Append to existing
|
|
275
|
+
fs.writeFileSync(decisionsFile, existing.trimEnd() + '\n' + section);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Ensure MEMORY.md references the decisions file
|
|
279
|
+
if (fs.existsSync(memoryMdPath)) {
|
|
280
|
+
const memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
|
|
281
|
+
if (!memoryMd.includes('session-decisions.md')) {
|
|
282
|
+
const addition = `\n- [Session Decisions](session-decisions.md) — project renames, tech choices, architecture decisions from coding sessions\n`;
|
|
283
|
+
fs.writeFileSync(memoryMdPath, memoryMd.trimEnd() + addition);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return fresh.length;
|
|
288
|
+
}
|
|
289
|
+
|
|
139
290
|
/**
|
|
140
291
|
* Generate a concise handoff markdown from parsed session
|
|
141
292
|
* This is what gets injected into the AI tool on the other machine
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const home = os.homedir();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Scan home directory for git projects and build a workspace manifest.
|
|
10
|
+
* Tracks: git remote URL, branch, last commit, uncommitted changes (as patch).
|
|
11
|
+
* For non-git projects with AI configs, bundles them as tar.gz.
|
|
12
|
+
*/
|
|
13
|
+
export async function scanWorkspace(stagingDir, spinner, opts = {}) {
|
|
14
|
+
const maxDepth = opts.maxDepth || 3;
|
|
15
|
+
const maxBundleSize = opts.maxBundleSize || 50 * 1024 * 1024; // 50MB
|
|
16
|
+
|
|
17
|
+
const skipDirs = new Set([
|
|
18
|
+
'node_modules', '.git', '.next', '.vercel', 'dist', 'build',
|
|
19
|
+
'__pycache__', '.venv', 'venv', '.cache', '.npm', '.bun',
|
|
20
|
+
'Library', '.Trash', 'Applications', 'Pictures', 'Music',
|
|
21
|
+
'Movies', 'Public', 'Downloads', '.local', '.cargo', '.rustup',
|
|
22
|
+
'.docker', '.ssh', '.config', '.claude', '.gemini'
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// Project markers — files that indicate "this is a project"
|
|
26
|
+
const projectMarkers = [
|
|
27
|
+
'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml',
|
|
28
|
+
'requirements.txt', 'Gemfile', 'pom.xml', 'build.gradle',
|
|
29
|
+
'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
|
|
30
|
+
'GEMINI.md', 'AGENTS.md', 'README.md',
|
|
31
|
+
// Also detect dirs with .git or multiple content files
|
|
32
|
+
'.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Also detect dirs with multiple markdown/code files as potential projects
|
|
36
|
+
const isContentProject = (entries) => {
|
|
37
|
+
const mdFiles = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md'));
|
|
38
|
+
return mdFiles.length >= 2; // 2+ markdown files = likely a writing project
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const projects = [];
|
|
42
|
+
|
|
43
|
+
const scanDir = async (dir, depth = 0) => {
|
|
44
|
+
if (depth > maxDepth) return;
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
48
|
+
} catch { return; }
|
|
49
|
+
|
|
50
|
+
// Check if this dir is a project
|
|
51
|
+
const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
|
|
52
|
+
const hasContent = isContentProject(entries);
|
|
53
|
+
|
|
54
|
+
if ((hasMarker || hasContent) && dir !== home) {
|
|
55
|
+
const info = await getProjectInfo(dir);
|
|
56
|
+
if (info) projects.push(info);
|
|
57
|
+
// Don't recurse into sub-projects deeper than this
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Recurse into subdirectories
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry.isDirectory()) continue;
|
|
64
|
+
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
65
|
+
if (skipDirs.has(entry.name)) continue;
|
|
66
|
+
await scanDir(path.join(dir, entry.name), depth + 1);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (spinner) spinner.text = 'Scanning workspace for projects...';
|
|
71
|
+
await scanDir(home);
|
|
72
|
+
|
|
73
|
+
// Build manifest
|
|
74
|
+
const manifest = {
|
|
75
|
+
version: 1,
|
|
76
|
+
machine: os.hostname(),
|
|
77
|
+
platform: process.platform,
|
|
78
|
+
home: home,
|
|
79
|
+
scannedAt: new Date().toISOString(),
|
|
80
|
+
projects: []
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const bundleDir = path.join(stagingDir, 'workspace-bundles');
|
|
84
|
+
|
|
85
|
+
for (const proj of projects) {
|
|
86
|
+
const entry = {
|
|
87
|
+
name: proj.name,
|
|
88
|
+
relativePath: proj.relativePath,
|
|
89
|
+
originalPath: proj.path,
|
|
90
|
+
type: proj.hasGit ? 'git' : 'bundle',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (proj.hasGit) {
|
|
94
|
+
entry.gitRemote = proj.gitRemote;
|
|
95
|
+
entry.branch = proj.branch;
|
|
96
|
+
entry.lastCommit = proj.lastCommit;
|
|
97
|
+
entry.lastCommitMessage = proj.lastCommitMessage;
|
|
98
|
+
|
|
99
|
+
// Save uncommitted changes as patch
|
|
100
|
+
if (proj.hasDirtyWork && proj.lastCommit) {
|
|
101
|
+
try {
|
|
102
|
+
const patchDir = path.join(stagingDir, 'workspace-patches');
|
|
103
|
+
await fs.ensureDir(patchDir);
|
|
104
|
+
const diff = execFileSync('git', ['diff', 'HEAD'], {
|
|
105
|
+
cwd: proj.path,
|
|
106
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
107
|
+
timeout: 10000
|
|
108
|
+
}).toString();
|
|
109
|
+
if (diff.trim()) {
|
|
110
|
+
const patchFile = `${proj.name}.patch`;
|
|
111
|
+
await fs.writeFile(path.join(patchDir, patchFile), diff);
|
|
112
|
+
entry.patchFile = patchFile;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Patch capture is best-effort
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Bundle non-git project (if small enough)
|
|
120
|
+
const size = await getDirSize(proj.path);
|
|
121
|
+
if (size <= maxBundleSize) {
|
|
122
|
+
try {
|
|
123
|
+
await fs.ensureDir(bundleDir);
|
|
124
|
+
const bundleName = `${proj.name}.tar.gz`;
|
|
125
|
+
execFileSync('tar', [
|
|
126
|
+
'czf', path.join(bundleDir, bundleName),
|
|
127
|
+
'-C', path.dirname(proj.path),
|
|
128
|
+
'--exclude', 'node_modules',
|
|
129
|
+
'--exclude', '.git',
|
|
130
|
+
'--exclude', '__pycache__',
|
|
131
|
+
'--exclude', '.venv',
|
|
132
|
+
'--exclude', 'dist',
|
|
133
|
+
'--exclude', 'build',
|
|
134
|
+
proj.name
|
|
135
|
+
], { stdio: 'ignore', timeout: 30000 });
|
|
136
|
+
entry.bundleFile = bundleName;
|
|
137
|
+
entry.bundleSize = (await fs.stat(path.join(bundleDir, bundleName))).size;
|
|
138
|
+
} catch {
|
|
139
|
+
// Bundle is best-effort
|
|
140
|
+
entry.bundleFailed = true;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
entry.tooLarge = true;
|
|
144
|
+
entry.size = size;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
manifest.projects.push(entry);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Save manifest
|
|
152
|
+
await fs.writeFile(
|
|
153
|
+
path.join(stagingDir, 'workspace.json'),
|
|
154
|
+
JSON.stringify(manifest, null, 2)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return manifest;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Restore workspace on this machine from a manifest.
|
|
162
|
+
* Clones git projects, unpacks bundles, applies patches.
|
|
163
|
+
*/
|
|
164
|
+
export async function restoreWorkspace(sourceDir, spinner, autoYes = false) {
|
|
165
|
+
const manifestPath = path.join(sourceDir, 'workspace.json');
|
|
166
|
+
if (!await fs.pathExists(manifestPath)) return null;
|
|
167
|
+
|
|
168
|
+
const manifest = await fs.readJson(manifestPath);
|
|
169
|
+
if (!manifest.projects || manifest.projects.length === 0) return null;
|
|
170
|
+
|
|
171
|
+
const results = { cloned: [], unpacked: [], patched: [], skipped: [] };
|
|
172
|
+
|
|
173
|
+
for (const proj of manifest.projects) {
|
|
174
|
+
// Determine where to put this project on the local machine
|
|
175
|
+
const localPath = resolveLocalPath(proj);
|
|
176
|
+
|
|
177
|
+
// Skip if project already exists locally
|
|
178
|
+
if (await fs.pathExists(localPath)) {
|
|
179
|
+
// But check if we should apply a patch
|
|
180
|
+
if (proj.patchFile) {
|
|
181
|
+
const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
|
|
182
|
+
if (await fs.pathExists(patchPath)) {
|
|
183
|
+
try {
|
|
184
|
+
execFileSync('git', ['apply', '--check', patchPath], {
|
|
185
|
+
cwd: localPath, stdio: 'ignore'
|
|
186
|
+
});
|
|
187
|
+
execFileSync('git', ['apply', patchPath], {
|
|
188
|
+
cwd: localPath, stdio: 'ignore'
|
|
189
|
+
});
|
|
190
|
+
results.patched.push({ name: proj.name, path: localPath });
|
|
191
|
+
} catch {
|
|
192
|
+
// Patch didn't apply cleanly — skip
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
results.skipped.push({ name: proj.name, path: localPath, reason: 'exists' });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (proj.type === 'git' && proj.gitRemote) {
|
|
201
|
+
// Clone the repo
|
|
202
|
+
if (spinner) spinner.text = `Cloning ${proj.name}...`;
|
|
203
|
+
try {
|
|
204
|
+
await fs.ensureDir(path.dirname(localPath));
|
|
205
|
+
execFileSync('git', ['clone', proj.gitRemote, localPath], {
|
|
206
|
+
stdio: 'ignore',
|
|
207
|
+
timeout: 120000
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Checkout the right branch
|
|
211
|
+
if (proj.branch && proj.branch !== 'main' && proj.branch !== 'master') {
|
|
212
|
+
try {
|
|
213
|
+
execFileSync('git', ['checkout', proj.branch], {
|
|
214
|
+
cwd: localPath, stdio: 'ignore'
|
|
215
|
+
});
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Apply patch if available
|
|
220
|
+
if (proj.patchFile) {
|
|
221
|
+
const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
|
|
222
|
+
if (await fs.pathExists(patchPath)) {
|
|
223
|
+
try {
|
|
224
|
+
execFileSync('git', ['apply', patchPath], {
|
|
225
|
+
cwd: localPath, stdio: 'ignore'
|
|
226
|
+
});
|
|
227
|
+
results.patched.push({ name: proj.name, path: localPath });
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
results.cloned.push({ name: proj.name, path: localPath, remote: proj.gitRemote });
|
|
233
|
+
} catch (err) {
|
|
234
|
+
results.skipped.push({ name: proj.name, reason: `clone failed: ${err.message}` });
|
|
235
|
+
}
|
|
236
|
+
} else if (proj.bundleFile) {
|
|
237
|
+
// Unpack bundle
|
|
238
|
+
const bundlePath = path.join(sourceDir, 'workspace-bundles', proj.bundleFile);
|
|
239
|
+
if (await fs.pathExists(bundlePath)) {
|
|
240
|
+
if (spinner) spinner.text = `Unpacking ${proj.name}...`;
|
|
241
|
+
try {
|
|
242
|
+
await fs.ensureDir(path.dirname(localPath));
|
|
243
|
+
execFileSync('tar', ['xzf', bundlePath, '-C', path.dirname(localPath)], {
|
|
244
|
+
stdio: 'ignore',
|
|
245
|
+
timeout: 60000
|
|
246
|
+
});
|
|
247
|
+
results.unpacked.push({ name: proj.name, path: localPath });
|
|
248
|
+
} catch (err) {
|
|
249
|
+
results.skipped.push({ name: proj.name, reason: `unpack failed: ${err.message}` });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
results.skipped.push({ name: proj.name, reason: proj.tooLarge ? 'too large' : 'no source' });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Figure out where a project should live on this machine.
|
|
262
|
+
*/
|
|
263
|
+
function resolveLocalPath(proj) {
|
|
264
|
+
// If the project had a relative path from home, use that
|
|
265
|
+
if (proj.relativePath) {
|
|
266
|
+
return path.join(home, proj.relativePath);
|
|
267
|
+
}
|
|
268
|
+
// Default: put it in home directory
|
|
269
|
+
return path.join(home, proj.name);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get info about a project directory.
|
|
274
|
+
*/
|
|
275
|
+
async function getProjectInfo(dir) {
|
|
276
|
+
const name = path.basename(dir);
|
|
277
|
+
const relativePath = path.relative(home, dir);
|
|
278
|
+
const info = {
|
|
279
|
+
name,
|
|
280
|
+
path: dir,
|
|
281
|
+
relativePath,
|
|
282
|
+
hasGit: false,
|
|
283
|
+
gitRemote: null,
|
|
284
|
+
branch: null,
|
|
285
|
+
lastCommit: null,
|
|
286
|
+
lastCommitMessage: null,
|
|
287
|
+
hasDirtyWork: false,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Check for git
|
|
291
|
+
const gitDir = path.join(dir, '.git');
|
|
292
|
+
if (await fs.pathExists(gitDir)) {
|
|
293
|
+
info.hasGit = true;
|
|
294
|
+
try {
|
|
295
|
+
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
296
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
297
|
+
}).toString().trim();
|
|
298
|
+
info.gitRemote = remote;
|
|
299
|
+
} catch {}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
info.branch = execFileSync('git', ['branch', '--show-current'], {
|
|
303
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
304
|
+
}).toString().trim();
|
|
305
|
+
} catch {}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
|
|
309
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
310
|
+
}).toString().trim();
|
|
311
|
+
info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
|
|
312
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
|
|
313
|
+
}).toString().trim();
|
|
314
|
+
} catch {}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
318
|
+
cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
|
|
319
|
+
}).toString().trim();
|
|
320
|
+
info.hasDirtyWork = status.length > 0;
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return info;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get total size of a directory (excluding common heavy dirs).
|
|
329
|
+
*/
|
|
330
|
+
async function getDirSize(dir) {
|
|
331
|
+
let size = 0;
|
|
332
|
+
const skip = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
|
|
333
|
+
|
|
334
|
+
const walk = async (d) => {
|
|
335
|
+
let entries;
|
|
336
|
+
try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
|
|
337
|
+
for (const e of entries) {
|
|
338
|
+
if (e.isDirectory()) {
|
|
339
|
+
if (!skip.has(e.name)) await walk(path.join(d, e.name));
|
|
340
|
+
} else {
|
|
341
|
+
try {
|
|
342
|
+
const stat = await fs.stat(path.join(d, e.name));
|
|
343
|
+
size += stat.size;
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
await walk(dir);
|
|
349
|
+
return size;
|
|
350
|
+
}
|