@gricha/perry 0.2.3 → 0.2.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 +3 -0
- package/dist/agent/file-watcher.js +2 -6
- package/dist/agent/router.js +116 -16
- package/dist/agent/run.js +15 -1
- package/dist/agent/web/assets/index-0UMxrAK_.js +104 -0
- package/dist/agent/web/assets/index-BwItLEFi.css +1 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/agents/__tests__/claude-code.test.js +125 -0
- package/dist/agents/__tests__/codex.test.js +64 -0
- package/dist/agents/__tests__/opencode.test.js +130 -0
- package/dist/agents/__tests__/sync.test.js +272 -0
- package/dist/agents/index.js +177 -0
- package/dist/agents/sync/claude-code.js +84 -0
- package/dist/agents/sync/codex.js +29 -0
- package/dist/agents/sync/copier.js +89 -0
- package/dist/agents/sync/opencode.js +51 -0
- package/dist/agents/sync/types.js +1 -0
- package/dist/agents/types.js +1 -0
- package/dist/chat/base-chat-websocket.js +1 -1
- package/dist/chat/opencode-websocket.js +1 -1
- package/dist/chat/websocket.js +2 -2
- package/dist/client/api.js +25 -0
- package/dist/config/loader.js +20 -2
- package/dist/docker/eager-pull.js +19 -3
- package/dist/docker/index.js +27 -0
- package/dist/index.js +83 -12
- package/dist/perry-worker +0 -0
- package/dist/workspace/manager.js +178 -115
- package/package.json +1 -1
- package/dist/agent/web/assets/index-BF-4SpMu.js +0 -104
- package/dist/agent/web/assets/index-DIOWcVH-.css +0 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { syncAgent, syncAllAgents, getCredentialFilePaths, createSyncContext } from '../index';
|
|
3
|
+
import { createMockFileCopier } from '../sync/copier';
|
|
4
|
+
function createMockContext(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
containerName: 'test-container',
|
|
7
|
+
agentConfig: {
|
|
8
|
+
port: 7777,
|
|
9
|
+
credentials: { env: {}, files: {} },
|
|
10
|
+
scripts: {},
|
|
11
|
+
},
|
|
12
|
+
hostFileExists: async () => false,
|
|
13
|
+
hostDirExists: async () => false,
|
|
14
|
+
readHostFile: async () => null,
|
|
15
|
+
readContainerFile: async () => null,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('syncAgent', () => {
|
|
20
|
+
it('creates required directories', async () => {
|
|
21
|
+
const provider = {
|
|
22
|
+
getRequiredDirs: () => ['/home/workspace/.test'],
|
|
23
|
+
getFilesToSync: async () => [],
|
|
24
|
+
getDirectoriesToSync: async () => [],
|
|
25
|
+
getGeneratedConfigs: async () => [],
|
|
26
|
+
};
|
|
27
|
+
const context = createMockContext();
|
|
28
|
+
const copier = createMockFileCopier();
|
|
29
|
+
await syncAgent(provider, context, copier);
|
|
30
|
+
expect(copier.calls).toContainEqual({
|
|
31
|
+
method: 'ensureDir',
|
|
32
|
+
args: ['test-container', '/home/workspace/.test'],
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
it('copies files that exist', async () => {
|
|
36
|
+
const provider = {
|
|
37
|
+
getRequiredDirs: () => [],
|
|
38
|
+
getFilesToSync: async () => [
|
|
39
|
+
{
|
|
40
|
+
source: '~/.test/file.json',
|
|
41
|
+
dest: '/home/workspace/.test/file.json',
|
|
42
|
+
category: 'credential',
|
|
43
|
+
permissions: '600',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
getDirectoriesToSync: async () => [],
|
|
47
|
+
getGeneratedConfigs: async () => [],
|
|
48
|
+
};
|
|
49
|
+
const context = createMockContext({
|
|
50
|
+
hostFileExists: async (path) => path === '~/.test/file.json',
|
|
51
|
+
});
|
|
52
|
+
const copier = createMockFileCopier();
|
|
53
|
+
const result = await syncAgent(provider, context, copier);
|
|
54
|
+
expect(copier.calls).toContainEqual({
|
|
55
|
+
method: 'copyFile',
|
|
56
|
+
args: [
|
|
57
|
+
'test-container',
|
|
58
|
+
{
|
|
59
|
+
source: '~/.test/file.json',
|
|
60
|
+
dest: '/home/workspace/.test/file.json',
|
|
61
|
+
category: 'credential',
|
|
62
|
+
permissions: '600',
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
expect(result.copied).toContain('~/.test/file.json');
|
|
67
|
+
});
|
|
68
|
+
it('skips optional files that do not exist', async () => {
|
|
69
|
+
const provider = {
|
|
70
|
+
getRequiredDirs: () => [],
|
|
71
|
+
getFilesToSync: async () => [
|
|
72
|
+
{
|
|
73
|
+
source: '~/.test/optional.json',
|
|
74
|
+
dest: '/home/workspace/.test/optional.json',
|
|
75
|
+
category: 'preference',
|
|
76
|
+
optional: true,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
getDirectoriesToSync: async () => [],
|
|
80
|
+
getGeneratedConfigs: async () => [],
|
|
81
|
+
};
|
|
82
|
+
const context = createMockContext({
|
|
83
|
+
hostFileExists: async () => false,
|
|
84
|
+
});
|
|
85
|
+
const copier = createMockFileCopier();
|
|
86
|
+
const result = await syncAgent(provider, context, copier);
|
|
87
|
+
expect(result.skipped).toContain('~/.test/optional.json');
|
|
88
|
+
expect(result.errors).toHaveLength(0);
|
|
89
|
+
expect(copier.calls.filter((c) => c.method === 'copyFile')).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
it('reports errors for missing required files', async () => {
|
|
92
|
+
const provider = {
|
|
93
|
+
getRequiredDirs: () => [],
|
|
94
|
+
getFilesToSync: async () => [
|
|
95
|
+
{
|
|
96
|
+
source: '~/.test/required.json',
|
|
97
|
+
dest: '/home/workspace/.test/required.json',
|
|
98
|
+
category: 'credential',
|
|
99
|
+
optional: false,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
getDirectoriesToSync: async () => [],
|
|
103
|
+
getGeneratedConfigs: async () => [],
|
|
104
|
+
};
|
|
105
|
+
const context = createMockContext({
|
|
106
|
+
hostFileExists: async () => false,
|
|
107
|
+
});
|
|
108
|
+
const copier = createMockFileCopier();
|
|
109
|
+
const result = await syncAgent(provider, context, copier);
|
|
110
|
+
expect(result.errors).toContainEqual({
|
|
111
|
+
path: '~/.test/required.json',
|
|
112
|
+
error: 'File not found',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
it('copies directories that exist', async () => {
|
|
116
|
+
const provider = {
|
|
117
|
+
getRequiredDirs: () => [],
|
|
118
|
+
getFilesToSync: async () => [],
|
|
119
|
+
getDirectoriesToSync: async () => [
|
|
120
|
+
{
|
|
121
|
+
source: '~/.test/dir',
|
|
122
|
+
dest: '/home/workspace/.test/dir',
|
|
123
|
+
category: 'preference',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
getGeneratedConfigs: async () => [],
|
|
127
|
+
};
|
|
128
|
+
const context = createMockContext({
|
|
129
|
+
hostDirExists: async (path) => path === '~/.test/dir',
|
|
130
|
+
});
|
|
131
|
+
const copier = createMockFileCopier();
|
|
132
|
+
const result = await syncAgent(provider, context, copier);
|
|
133
|
+
expect(copier.calls).toContainEqual({
|
|
134
|
+
method: 'copyDirectory',
|
|
135
|
+
args: [
|
|
136
|
+
'test-container',
|
|
137
|
+
{
|
|
138
|
+
source: '~/.test/dir',
|
|
139
|
+
dest: '/home/workspace/.test/dir',
|
|
140
|
+
category: 'preference',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect(result.copied).toContain('~/.test/dir');
|
|
145
|
+
});
|
|
146
|
+
it('generates configs', async () => {
|
|
147
|
+
const provider = {
|
|
148
|
+
getRequiredDirs: () => [],
|
|
149
|
+
getFilesToSync: async () => [],
|
|
150
|
+
getDirectoriesToSync: async () => [],
|
|
151
|
+
getGeneratedConfigs: async () => [
|
|
152
|
+
{
|
|
153
|
+
dest: '/home/workspace/.test/config.json',
|
|
154
|
+
content: '{"key": "value"}',
|
|
155
|
+
permissions: '644',
|
|
156
|
+
category: 'preference',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
const context = createMockContext();
|
|
161
|
+
const copier = createMockFileCopier();
|
|
162
|
+
const result = await syncAgent(provider, context, copier);
|
|
163
|
+
expect(copier.calls).toContainEqual({
|
|
164
|
+
method: 'writeConfig',
|
|
165
|
+
args: [
|
|
166
|
+
'test-container',
|
|
167
|
+
{
|
|
168
|
+
dest: '/home/workspace/.test/config.json',
|
|
169
|
+
content: '{"key": "value"}',
|
|
170
|
+
permissions: '644',
|
|
171
|
+
category: 'preference',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
expect(result.generated).toContain('/home/workspace/.test/config.json');
|
|
176
|
+
});
|
|
177
|
+
it('tracks all results correctly', async () => {
|
|
178
|
+
const provider = {
|
|
179
|
+
getRequiredDirs: () => ['/home/workspace/.test'],
|
|
180
|
+
getFilesToSync: async () => [
|
|
181
|
+
{
|
|
182
|
+
source: '~/.test/exists.json',
|
|
183
|
+
dest: '/home/workspace/.test/exists.json',
|
|
184
|
+
category: 'credential',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
source: '~/.test/optional.json',
|
|
188
|
+
dest: '/home/workspace/.test/optional.json',
|
|
189
|
+
category: 'preference',
|
|
190
|
+
optional: true,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
getDirectoriesToSync: async () => [],
|
|
194
|
+
getGeneratedConfigs: async () => [
|
|
195
|
+
{
|
|
196
|
+
dest: '/home/workspace/.test/generated.json',
|
|
197
|
+
content: '{}',
|
|
198
|
+
category: 'preference',
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
const context = createMockContext({
|
|
203
|
+
hostFileExists: async (path) => path === '~/.test/exists.json',
|
|
204
|
+
});
|
|
205
|
+
const copier = createMockFileCopier();
|
|
206
|
+
const result = await syncAgent(provider, context, copier);
|
|
207
|
+
expect(result.copied).toEqual(['~/.test/exists.json']);
|
|
208
|
+
expect(result.skipped).toEqual(['~/.test/optional.json']);
|
|
209
|
+
expect(result.generated).toEqual(['/home/workspace/.test/generated.json']);
|
|
210
|
+
expect(result.errors).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
describe('syncAllAgents', () => {
|
|
214
|
+
it('syncs all three agents', async () => {
|
|
215
|
+
const copier = createMockFileCopier();
|
|
216
|
+
const results = await syncAllAgents('test-container', {
|
|
217
|
+
port: 7777,
|
|
218
|
+
credentials: { env: {}, files: {} },
|
|
219
|
+
scripts: {},
|
|
220
|
+
}, copier);
|
|
221
|
+
expect(results['claude-code']).toBeDefined();
|
|
222
|
+
expect(results['opencode']).toBeDefined();
|
|
223
|
+
expect(results['codex']).toBeDefined();
|
|
224
|
+
});
|
|
225
|
+
it('returns results per agent', async () => {
|
|
226
|
+
const copier = createMockFileCopier();
|
|
227
|
+
const results = await syncAllAgents('test-container', {
|
|
228
|
+
port: 7777,
|
|
229
|
+
credentials: { env: {}, files: {} },
|
|
230
|
+
scripts: {},
|
|
231
|
+
}, copier);
|
|
232
|
+
for (const agentType of ['claude-code', 'opencode', 'codex']) {
|
|
233
|
+
const result = results[agentType];
|
|
234
|
+
expect(result).toHaveProperty('copied');
|
|
235
|
+
expect(result).toHaveProperty('generated');
|
|
236
|
+
expect(result).toHaveProperty('skipped');
|
|
237
|
+
expect(result).toHaveProperty('errors');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
describe('getCredentialFilePaths', () => {
|
|
242
|
+
it('returns credential files from providers', () => {
|
|
243
|
+
const paths = getCredentialFilePaths();
|
|
244
|
+
expect(paths).toContain('~/.claude/.credentials.json');
|
|
245
|
+
expect(paths).toContain('~/.codex/auth.json');
|
|
246
|
+
});
|
|
247
|
+
it('does not include preference-only files', () => {
|
|
248
|
+
const paths = getCredentialFilePaths();
|
|
249
|
+
expect(paths).not.toContain('~/.claude/settings.json');
|
|
250
|
+
expect(paths).not.toContain('~/.codex/config.toml');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('createSyncContext', () => {
|
|
254
|
+
it('creates context with correct container name', () => {
|
|
255
|
+
const context = createSyncContext('my-container', {
|
|
256
|
+
port: 7777,
|
|
257
|
+
credentials: { env: {}, files: {} },
|
|
258
|
+
scripts: {},
|
|
259
|
+
});
|
|
260
|
+
expect(context.containerName).toBe('my-container');
|
|
261
|
+
});
|
|
262
|
+
it('creates context with agent config', () => {
|
|
263
|
+
const config = {
|
|
264
|
+
port: 7777,
|
|
265
|
+
credentials: { env: {}, files: {} },
|
|
266
|
+
scripts: {},
|
|
267
|
+
agents: { opencode: { zen_token: 'test' } },
|
|
268
|
+
};
|
|
269
|
+
const context = createSyncContext('my-container', config);
|
|
270
|
+
expect(context.agentConfig).toBe(config);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { claudeProvider, opencodeProvider, codexProvider } from '../sessions/agents';
|
|
3
|
+
import { claudeCodeSync } from './sync/claude-code';
|
|
4
|
+
import { opencodeSync } from './sync/opencode';
|
|
5
|
+
import { codexSync } from './sync/codex';
|
|
6
|
+
import { createDockerFileCopier } from './sync/copier';
|
|
7
|
+
import { expandPath } from '../config/loader';
|
|
8
|
+
import * as docker from '../docker';
|
|
9
|
+
export const agents = {
|
|
10
|
+
'claude-code': {
|
|
11
|
+
agentType: 'claude-code',
|
|
12
|
+
sync: claudeCodeSync,
|
|
13
|
+
sessions: claudeProvider,
|
|
14
|
+
},
|
|
15
|
+
opencode: {
|
|
16
|
+
agentType: 'opencode',
|
|
17
|
+
sync: opencodeSync,
|
|
18
|
+
sessions: opencodeProvider,
|
|
19
|
+
},
|
|
20
|
+
codex: {
|
|
21
|
+
agentType: 'codex',
|
|
22
|
+
sync: codexSync,
|
|
23
|
+
sessions: codexProvider,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export function createSyncContext(containerName, agentConfig) {
|
|
27
|
+
return {
|
|
28
|
+
containerName,
|
|
29
|
+
agentConfig,
|
|
30
|
+
async hostFileExists(filePath) {
|
|
31
|
+
try {
|
|
32
|
+
const expanded = expandPath(filePath);
|
|
33
|
+
const stat = await fs.stat(expanded);
|
|
34
|
+
return stat.isFile();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async hostDirExists(dirPath) {
|
|
41
|
+
try {
|
|
42
|
+
const expanded = expandPath(dirPath);
|
|
43
|
+
const stat = await fs.stat(expanded);
|
|
44
|
+
return stat.isDirectory();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
async readHostFile(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
const expanded = expandPath(filePath);
|
|
53
|
+
return await fs.readFile(expanded, 'utf-8');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async readContainerFile(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
const result = await docker.execInContainer(containerName, ['cat', filePath], {
|
|
62
|
+
user: 'workspace',
|
|
63
|
+
});
|
|
64
|
+
if (result.exitCode !== 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return result.stdout;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export async function syncAgent(provider, context, copier) {
|
|
76
|
+
const result = {
|
|
77
|
+
copied: [],
|
|
78
|
+
generated: [],
|
|
79
|
+
skipped: [],
|
|
80
|
+
errors: [],
|
|
81
|
+
};
|
|
82
|
+
for (const dir of provider.getRequiredDirs()) {
|
|
83
|
+
try {
|
|
84
|
+
await copier.ensureDir(context.containerName, dir);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
result.errors.push({ path: dir, error: String(err) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const files = await provider.getFilesToSync(context);
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const exists = await context.hostFileExists(file.source);
|
|
93
|
+
if (!exists) {
|
|
94
|
+
if (file.optional) {
|
|
95
|
+
result.skipped.push(file.source);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
result.errors.push({ path: file.source, error: 'File not found' });
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await copier.copyFile(context.containerName, file);
|
|
104
|
+
result.copied.push(file.source);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
result.errors.push({ path: file.source, error: String(err) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const directories = await provider.getDirectoriesToSync(context);
|
|
111
|
+
for (const dir of directories) {
|
|
112
|
+
const exists = await context.hostDirExists(dir.source);
|
|
113
|
+
if (!exists) {
|
|
114
|
+
if (dir.optional) {
|
|
115
|
+
result.skipped.push(dir.source);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
result.errors.push({ path: dir.source, error: 'Directory not found' });
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await copier.copyDirectory(context.containerName, dir);
|
|
124
|
+
result.copied.push(dir.source);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
result.errors.push({ path: dir.source, error: String(err) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const configs = await provider.getGeneratedConfigs(context);
|
|
131
|
+
for (const config of configs) {
|
|
132
|
+
try {
|
|
133
|
+
await copier.writeConfig(context.containerName, config);
|
|
134
|
+
result.generated.push(config.dest);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
result.errors.push({ path: config.dest, error: String(err) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
export async function syncAllAgents(containerName, agentConfig, copier) {
|
|
143
|
+
const actualCopier = copier || createDockerFileCopier();
|
|
144
|
+
const context = createSyncContext(containerName, agentConfig);
|
|
145
|
+
const results = {
|
|
146
|
+
'claude-code': { copied: [], generated: [], skipped: [], errors: [] },
|
|
147
|
+
opencode: { copied: [], generated: [], skipped: [], errors: [] },
|
|
148
|
+
codex: { copied: [], generated: [], skipped: [], errors: [] },
|
|
149
|
+
};
|
|
150
|
+
for (const [agentType, agent] of Object.entries(agents)) {
|
|
151
|
+
results[agentType] = await syncAgent(agent.sync, context, actualCopier);
|
|
152
|
+
}
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
export function getCredentialFilePaths() {
|
|
156
|
+
const paths = [];
|
|
157
|
+
for (const agent of Object.values(agents)) {
|
|
158
|
+
const dummyContext = {
|
|
159
|
+
containerName: '',
|
|
160
|
+
agentConfig: { port: 0, credentials: { env: {}, files: {} }, scripts: {} },
|
|
161
|
+
hostFileExists: async () => false,
|
|
162
|
+
hostDirExists: async () => false,
|
|
163
|
+
readHostFile: async () => null,
|
|
164
|
+
readContainerFile: async () => null,
|
|
165
|
+
};
|
|
166
|
+
const filesPromise = agent.sync.getFilesToSync(dummyContext);
|
|
167
|
+
filesPromise.then((files) => {
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
if (file.category === 'credential') {
|
|
170
|
+
paths.push(file.source);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return ['~/.claude/.credentials.json', '~/.codex/auth.json'];
|
|
176
|
+
}
|
|
177
|
+
export { createDockerFileCopier, createMockFileCopier } from './sync/copier';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const claudeCodeSync = {
|
|
2
|
+
getRequiredDirs() {
|
|
3
|
+
return ['/home/workspace/.claude'];
|
|
4
|
+
},
|
|
5
|
+
async getFilesToSync(_context) {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
source: '~/.claude/.credentials.json',
|
|
9
|
+
dest: '/home/workspace/.claude/.credentials.json',
|
|
10
|
+
category: 'credential',
|
|
11
|
+
permissions: '600',
|
|
12
|
+
optional: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source: '~/.claude/settings.json',
|
|
16
|
+
dest: '/home/workspace/.claude/settings.json',
|
|
17
|
+
category: 'preference',
|
|
18
|
+
permissions: '644',
|
|
19
|
+
optional: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
source: '~/.claude/CLAUDE.md',
|
|
23
|
+
dest: '/home/workspace/.claude/CLAUDE.md',
|
|
24
|
+
category: 'preference',
|
|
25
|
+
permissions: '644',
|
|
26
|
+
optional: true,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
},
|
|
30
|
+
async getDirectoriesToSync(context) {
|
|
31
|
+
const agentsDirExists = await context.hostDirExists('~/.claude/agents');
|
|
32
|
+
if (!agentsDirExists) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
source: '~/.claude/agents',
|
|
38
|
+
dest: '/home/workspace/.claude/agents',
|
|
39
|
+
category: 'preference',
|
|
40
|
+
optional: true,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
},
|
|
44
|
+
async getGeneratedConfigs(context) {
|
|
45
|
+
const hostConfigContent = await context.readHostFile('~/.claude.json');
|
|
46
|
+
const containerConfigContent = await context.readContainerFile('/home/workspace/.claude.json');
|
|
47
|
+
let hostMcpServers = {};
|
|
48
|
+
if (hostConfigContent) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(hostConfigContent);
|
|
51
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
|
52
|
+
hostMcpServers = parsed.mcpServers;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Invalid JSON, ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let containerConfig = {};
|
|
60
|
+
if (containerConfigContent) {
|
|
61
|
+
try {
|
|
62
|
+
containerConfig = JSON.parse(containerConfigContent);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Invalid JSON, start fresh
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
containerConfig.hasCompletedOnboarding = true;
|
|
69
|
+
if (Object.keys(hostMcpServers).length > 0) {
|
|
70
|
+
const existingMcp = containerConfig.mcpServers && typeof containerConfig.mcpServers === 'object'
|
|
71
|
+
? containerConfig.mcpServers
|
|
72
|
+
: {};
|
|
73
|
+
containerConfig.mcpServers = { ...existingMcp, ...hostMcpServers };
|
|
74
|
+
}
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
dest: '/home/workspace/.claude.json',
|
|
78
|
+
content: JSON.stringify(containerConfig, null, 2),
|
|
79
|
+
permissions: '644',
|
|
80
|
+
category: 'preference',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const codexSync = {
|
|
2
|
+
getRequiredDirs() {
|
|
3
|
+
return ['/home/workspace/.codex'];
|
|
4
|
+
},
|
|
5
|
+
async getFilesToSync(_context) {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
source: '~/.codex/auth.json',
|
|
9
|
+
dest: '/home/workspace/.codex/auth.json',
|
|
10
|
+
category: 'credential',
|
|
11
|
+
permissions: '600',
|
|
12
|
+
optional: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source: '~/.codex/config.toml',
|
|
16
|
+
dest: '/home/workspace/.codex/config.toml',
|
|
17
|
+
category: 'preference',
|
|
18
|
+
permissions: '600',
|
|
19
|
+
optional: true,
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
},
|
|
23
|
+
async getDirectoriesToSync(_context) {
|
|
24
|
+
return [];
|
|
25
|
+
},
|
|
26
|
+
async getGeneratedConfigs(_context) {
|
|
27
|
+
return [];
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import * as docker from '../../docker';
|
|
5
|
+
import { expandPath } from '../../config/loader';
|
|
6
|
+
export function createDockerFileCopier() {
|
|
7
|
+
return {
|
|
8
|
+
async ensureDir(containerName, dir) {
|
|
9
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', dir], {
|
|
10
|
+
user: 'workspace',
|
|
11
|
+
});
|
|
12
|
+
},
|
|
13
|
+
async copyFile(containerName, file) {
|
|
14
|
+
const expandedSource = expandPath(file.source);
|
|
15
|
+
const permissions = file.permissions || '644';
|
|
16
|
+
const destDir = path.dirname(file.dest);
|
|
17
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', destDir], {
|
|
18
|
+
user: 'workspace',
|
|
19
|
+
});
|
|
20
|
+
await docker.copyToContainer(containerName, expandedSource, file.dest);
|
|
21
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', file.dest], {
|
|
22
|
+
user: 'root',
|
|
23
|
+
});
|
|
24
|
+
await docker.execInContainer(containerName, ['chmod', permissions, file.dest], {
|
|
25
|
+
user: 'workspace',
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
async copyDirectory(containerName, dir) {
|
|
29
|
+
const expandedSource = expandPath(dir.source);
|
|
30
|
+
const tempTar = path.join(os.tmpdir(), `agent-sync-${Date.now()}.tar`);
|
|
31
|
+
try {
|
|
32
|
+
const { execSync } = await import('child_process');
|
|
33
|
+
execSync(`tar -cf "${tempTar}" -C "${expandedSource}" .`, { stdio: 'pipe' });
|
|
34
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', dir.dest], {
|
|
35
|
+
user: 'workspace',
|
|
36
|
+
});
|
|
37
|
+
await docker.copyToContainer(containerName, tempTar, '/tmp/agent-sync.tar');
|
|
38
|
+
await docker.execInContainer(containerName, ['tar', '-xf', '/tmp/agent-sync.tar', '-C', dir.dest], { user: 'workspace' });
|
|
39
|
+
await docker.execInContainer(containerName, ['rm', '/tmp/agent-sync.tar'], {
|
|
40
|
+
user: 'workspace',
|
|
41
|
+
});
|
|
42
|
+
await docker.execInContainer(containerName, ['find', dir.dest, '-type', 'f', '-exec', 'chmod', '644', '{}', '+'], { user: 'workspace' });
|
|
43
|
+
await docker.execInContainer(containerName, ['find', dir.dest, '-type', 'd', '-exec', 'chmod', '755', '{}', '+'], { user: 'workspace' });
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await fs.unlink(tempTar).catch(() => { });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async writeConfig(containerName, config) {
|
|
50
|
+
const tempFile = path.join(os.tmpdir(), `agent-config-${Date.now()}.json`);
|
|
51
|
+
const permissions = config.permissions || '644';
|
|
52
|
+
await fs.writeFile(tempFile, config.content, 'utf-8');
|
|
53
|
+
try {
|
|
54
|
+
const destDir = path.dirname(config.dest);
|
|
55
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', destDir], {
|
|
56
|
+
user: 'workspace',
|
|
57
|
+
});
|
|
58
|
+
await docker.copyToContainer(containerName, tempFile, config.dest);
|
|
59
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', config.dest], {
|
|
60
|
+
user: 'root',
|
|
61
|
+
});
|
|
62
|
+
await docker.execInContainer(containerName, ['chmod', permissions, config.dest], {
|
|
63
|
+
user: 'workspace',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await fs.unlink(tempFile).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function createMockFileCopier() {
|
|
73
|
+
const calls = [];
|
|
74
|
+
return {
|
|
75
|
+
calls,
|
|
76
|
+
async ensureDir(containerName, dir) {
|
|
77
|
+
calls.push({ method: 'ensureDir', args: [containerName, dir] });
|
|
78
|
+
},
|
|
79
|
+
async copyFile(containerName, file) {
|
|
80
|
+
calls.push({ method: 'copyFile', args: [containerName, file] });
|
|
81
|
+
},
|
|
82
|
+
async copyDirectory(containerName, dir) {
|
|
83
|
+
calls.push({ method: 'copyDirectory', args: [containerName, dir] });
|
|
84
|
+
},
|
|
85
|
+
async writeConfig(containerName, config) {
|
|
86
|
+
calls.push({ method: 'writeConfig', args: [containerName, config] });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|