@myclaw163/clawclaw-cli 0.6.74 → 0.6.76

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.
@@ -4,15 +4,21 @@ import { dirname, extname, join } from 'path';
4
4
  import { getWorkspaceDir } from './init-command.js';
5
5
  import type { ResourceType, ResourceDetail } from './hub-client.js';
6
6
  import { unzip } from './hub-unzip.js';
7
+ import {
8
+ ensureUserDataLayout,
9
+ getUserDataDir,
10
+ getUserDataHubStrategyLockfile,
11
+ getUserDataStrategiesDir,
12
+ } from './user-data.js';
7
13
 
8
14
  export interface InstalledEntry { type: ResourceType; id: string; title: string; path: string; installedAt: string; }
9
15
  export type Lockfile = Record<string, InstalledEntry>; // keyed by "<type>/<id>"
10
16
 
11
17
  export function hubDir(): string { return join(getWorkspaceDir(), 'hub'); }
12
18
  export function lockfilePath(): string { return join(hubDir(), 'installed.json'); }
19
+ export function strategyLockfilePath(): string { return getUserDataHubStrategyLockfile(getWorkspaceDir()); }
13
20
 
14
- export function readLockfile(): Lockfile {
15
- const p = lockfilePath();
21
+ function readJsonLockfile(p: string): Lockfile {
16
22
  if (!existsSync(p)) return {};
17
23
  try {
18
24
  return JSON.parse(readFileSync(p, 'utf8')) as Lockfile;
@@ -22,11 +28,39 @@ export function readLockfile(): Lockfile {
22
28
  }
23
29
  }
24
30
 
25
- export function writeLockfile(lock: Lockfile): void {
31
+ export function readSkillLockfile(): Lockfile {
32
+ return Object.fromEntries(
33
+ Object.entries(readJsonLockfile(lockfilePath())).filter(([, entry]) => entry.type === 'skill'),
34
+ );
35
+ }
36
+
37
+ export function readStrategyLockfile(): Lockfile {
38
+ ensureUserDataLayout(getWorkspaceDir());
39
+ return Object.fromEntries(
40
+ Object.entries(readJsonLockfile(strategyLockfilePath())).filter(([, entry]) => entry.type === 'strategy'),
41
+ );
42
+ }
43
+
44
+ export function readLockfile(): Lockfile {
45
+ return { ...readSkillLockfile(), ...readStrategyLockfile() };
46
+ }
47
+
48
+ function writeSkillLockfile(lock: Lockfile): void {
26
49
  mkdirSync(hubDir(), { recursive: true });
27
50
  writeFileSync(lockfilePath(), JSON.stringify(lock, null, 2) + '\n', 'utf8');
28
51
  }
29
52
 
53
+ function writeStrategyLockfile(lock: Lockfile): void {
54
+ const p = strategyLockfilePath();
55
+ mkdirSync(dirname(p), { recursive: true });
56
+ writeFileSync(p, JSON.stringify(lock, null, 2) + '\n', 'utf8');
57
+ }
58
+
59
+ export function writeLockfile(lock: Lockfile): void {
60
+ writeSkillLockfile(Object.fromEntries(Object.entries(lock).filter(([, entry]) => entry.type === 'skill')));
61
+ writeStrategyLockfile(Object.fromEntries(Object.entries(lock).filter(([, entry]) => entry.type === 'strategy')));
62
+ }
63
+
30
64
  function assertSafeSegment(label: string, s: string): void {
31
65
  if (!s || s === '.' || s === '..' || /[\\/:\0]/.test(s)) {
32
66
  throw new Error(`unsafe ${label} segment: '${s}'`);
@@ -39,7 +73,7 @@ export function slugify(title: string): string {
39
73
 
40
74
  const STRATEGY_EXTS = new Set(['.ts', '.tsx', '.js']);
41
75
 
42
- /** Workspace-relative install path, decided by resource type. */
76
+ /** Install path relative to user-data for strategies and workspace for skills. */
43
77
  export function placementRelPath(detail: { type: ResourceType; id: string; title: string; fileName?: string }): string {
44
78
  assertSafeSegment('id', detail.id);
45
79
  if (detail.type === 'strategy') {
@@ -60,31 +94,31 @@ export interface InstallResult { ref: string; type: ResourceType; id: string; ti
60
94
 
61
95
  export function installResource(detail: ResourceDetail, artifact: Buffer, opts: { force?: boolean } = {}): InstallResult {
62
96
  const ws = getWorkspaceDir();
97
+ ensureUserDataLayout(ws);
63
98
  const ref = makeRef(detail.type, detail.id);
64
99
  const relPath = placementRelPath(detail);
65
- const absPath = join(ws, relPath);
66
- const lock = readLockfile();
100
+ const rootDir = detail.type === 'strategy' ? getUserDataDir(ws) : ws;
101
+ const absPath = join(rootDir, relPath);
102
+ const lock = detail.type === 'strategy' ? readStrategyLockfile() : readSkillLockfile();
67
103
  const tracked = lock[ref] !== undefined;
68
104
 
69
105
  if (detail.type === 'strategy') {
70
106
  if (existsSync(absPath) && !tracked && !opts.force) {
71
107
  throw new Error(`${relPath} already exists and is not tracked by hub; re-run with --force to overwrite`);
72
108
  }
73
- mkdirSync(join(ws, 'strategies'), { recursive: true });
109
+ mkdirSync(getUserDataStrategiesDir(ws), { recursive: true });
74
110
  writeFileSync(absPath, artifact);
75
111
  } else {
76
112
  if (existsSync(absPath) && !tracked && !opts.force) {
77
113
  throw new Error(`skills/${slugify(detail.title) || detail.id} already exists and is not tracked by hub; re-run with --force to overwrite`);
78
114
  }
79
115
  const entries = unzip(artifact);
80
- // Validate ALL entry paths before any filesystem mutation (no partial/unsafe writes).
81
116
  for (const e of entries) {
82
117
  if (e.path.includes('..') || e.path.startsWith('/') || /^[A-Za-z]:/.test(e.path)) {
83
118
  throw new Error(`unsafe path in skill archive: ${e.path}`);
84
119
  }
85
120
  }
86
- // NOTE (v1): non-atomic entries are decoded+validated in memory above, but a disk
87
- // error mid-write could leave a partial skill dir. Acceptable for v1.
121
+ // Non-atomic by design: entries are decoded and validated before writing.
88
122
  if (existsSync(absPath)) rmSync(absPath, { recursive: true, force: true });
89
123
  mkdirSync(absPath, { recursive: true });
90
124
  for (const e of entries) {
@@ -101,18 +135,23 @@ export function installResource(detail: ResourceDetail, artifact: Buffer, opts:
101
135
  path: relPath.split('\\').join('/'),
102
136
  installedAt: new Date().toISOString(),
103
137
  };
104
- writeLockfile(lock);
138
+ if (detail.type === 'strategy') writeStrategyLockfile(lock);
139
+ else writeSkillLockfile(lock);
105
140
  return { ref, type: detail.type, id: detail.id, title: detail.title, installedPath: absPath };
106
141
  }
107
142
 
108
143
  export function uninstallResource(ref: string): { removed: boolean; path?: string } {
109
- const lock = readLockfile();
144
+ const type = ref.split('/')[0] as ResourceType;
145
+ const lock = type === 'strategy' ? readStrategyLockfile() : readSkillLockfile();
110
146
  const entry = lock[ref];
111
147
  if (!entry) return { removed: false };
112
- const abs = join(getWorkspaceDir(), entry.path);
148
+ const ws = getWorkspaceDir();
149
+ const rootDir = type === 'strategy' ? getUserDataDir(ws) : ws;
150
+ const abs = join(rootDir, entry.path);
113
151
  if (existsSync(abs)) rmSync(abs, { recursive: true, force: true });
114
152
  delete lock[ref];
115
- writeLockfile(lock);
153
+ if (type === 'strategy') writeStrategyLockfile(lock);
154
+ else writeSkillLockfile(lock);
116
155
  return { removed: true, path: abs };
117
156
  }
118
157
 
@@ -7,19 +7,22 @@ import { join } from 'path';
7
7
  import { getWorkspaceDir, getProfileStateDir } from './init-command.js';
8
8
  import { listInstalled } from './hub-install.js';
9
9
  import { AuthStore } from './auth.js';
10
+ import { ensureUserDataLayout, getUserDataStrategiesDir } from './user-data.js';
10
11
 
11
12
  export const HUB_URL = 'https://myclaw.163.com/hub';
12
13
 
13
14
  /**
14
15
  * True when this account has only ever run built-in default strategies: no
15
- * hub-installed strategy and no user strategy file in `<workspace>/strategies`.
16
+ * hub-installed strategy and no user strategy file in `<workspace>/user-data/strategies`.
16
17
  */
17
18
  export function usesOnlyBuiltinStrategies(): boolean {
18
19
  try {
19
20
  if (listInstalled().some((e) => e.type === 'strategy')) return false;
20
21
  } catch {}
21
22
  try {
22
- const dir = join(getWorkspaceDir(), 'strategies');
23
+ const workspaceDir = getWorkspaceDir();
24
+ ensureUserDataLayout(workspaceDir);
25
+ const dir = getUserDataStrategiesDir(workspaceDir);
23
26
  if (existsSync(dir)) {
24
27
  const hasUserStrategy = readdirSync(dir).some((f) => {
25
28
  if (f.startsWith('_')) return false;
@@ -2,6 +2,13 @@ import { existsSync, mkdirSync, writeFileSync } from 'fs';
2
2
  import { isAbsolute, join, resolve, win32 } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import type { AuthProfile } from './auth.js';
5
+ import {
6
+ ensureUserDataLayout,
7
+ getRuntimeDir,
8
+ getUserDataAccountDir,
9
+ getUserDataMemoryFile,
10
+ getUserDataPersonaFile,
11
+ } from './user-data.js';
5
12
 
6
13
  const ACCOUNT_DIRS = ['state', 'logs', 'games'];
7
14
 
@@ -52,6 +59,7 @@ export function cmdInit(force = false): { created: boolean; path: string } {
52
59
 
53
60
  mkdirSync(workspaceDir, { recursive: true });
54
61
  mkdirSync(join(workspaceDir, 'accounts'), { recursive: true });
62
+ ensureUserDataLayout(workspaceDir);
55
63
 
56
64
  if (!alreadyExists) {
57
65
  writeFileSync(
@@ -80,6 +88,8 @@ export function getAccountId(profile: Pick<AuthProfile, 'apiKey'>): string {
80
88
 
81
89
  export function createProfileDirs(profile: Pick<AuthProfile, 'apiKey'>): void {
82
90
  createAccountDirs(getAccountId(profile));
91
+ mkdirSync(getUserDataAccountDir(getWorkspaceDir(), profile), { recursive: true });
92
+ mkdirSync(getRuntimeDir(getWorkspaceDir()), { recursive: true });
83
93
  }
84
94
 
85
95
  export function getStateDir(accountId: string): string {
@@ -111,10 +121,10 @@ export function getProfileGamesDir(profile: Pick<AuthProfile, 'apiKey'>): string
111
121
 
112
122
  export function getProfileMemoryFile(profile: Pick<AuthProfile, 'apiKey'>): string {
113
123
  createProfileDirs(profile);
114
- return join(getAccountDir(getAccountId(profile)), 'memory.md');
124
+ return getUserDataMemoryFile(getWorkspaceDir(), profile);
115
125
  }
116
126
 
117
127
  export function getProfilePersonaFile(profile: Pick<AuthProfile, 'apiKey'>): string {
118
128
  createProfileDirs(profile);
119
- return join(getAccountDir(getAccountId(profile)), 'persona.md');
129
+ return getUserDataPersonaFile(getWorkspaceDir(), profile);
120
130
  }
@@ -40,13 +40,13 @@ describe('loadContext', () => {
40
40
  });
41
41
 
42
42
  it('returns persona and memory snapshots for the active account', () => {
43
- const store = new AuthStore(authFile);
43
+ const store = new AuthStore();
44
44
  const snapshot = loadContext(store);
45
45
  expect(snapshot.persona.content).toBe('voice');
46
46
  expect(snapshot.persona.exists).toBe(true);
47
47
  expect(snapshot.memory.content).toBe('notes');
48
48
  expect(snapshot.memory.exists).toBe(true);
49
- expect(snapshot.persona.path).toContain('persona.md');
50
- expect(snapshot.memory.path).toContain('memory.md');
49
+ expect(snapshot.persona.path.split('\\').join('/')).toContain('user-data/accounts/');
50
+ expect(snapshot.memory.path.split('\\').join('/')).toContain('user-data/accounts/');
51
51
  });
52
52
  });
@@ -142,7 +142,7 @@ export class ServerRegistry {
142
142
  }
143
143
  }
144
144
 
145
- /** Persist gameServerUrl to auth profile (best-effort). */
145
+ /** Persist gameServerUrl to runtime auth state (best-effort). */
146
146
  private persistGameServerUrl(): void {
147
147
  if (!this.agentName) return;
148
148
  try {
@@ -30,6 +30,14 @@ describe('strategy-export', () => {
30
30
  rmSync(testWorkspace, { recursive: true, force: true });
31
31
  });
32
32
 
33
+ function userStrategiesDir(): string {
34
+ return join(testWorkspace, 'user-data', 'strategies');
35
+ }
36
+
37
+ function officialManifestFile(): string {
38
+ return join(testWorkspace, 'runtime', 'official-strategies', 'manifest.json');
39
+ }
40
+
33
41
  describe('rewriteBuiltinImports', () => {
34
42
  it('should rewrite ALL relative imports to @myclaw163/clawclaw-cli', () => {
35
43
  const source = `
@@ -75,7 +83,7 @@ export const strategy = { id: 'test', description: 'test', create: () => ({ name
75
83
  it('should export a formerly goal-based strategy with its knowledge sidecar', async () => {
76
84
  await exportStrategy('kill-lone');
77
85
 
78
- const strategiesDir = join(testWorkspace, 'strategies');
86
+ const strategiesDir = userStrategiesDir();
79
87
  expect(existsSync(join(strategiesDir, 'kill-lone.ts'))).toBe(true);
80
88
  expect(existsSync(join(strategiesDir, 'kill-lone.knowledge.md'))).toBe(true);
81
89
  });
@@ -83,8 +91,8 @@ export const strategy = { id: 'test', description: 'test', create: () => ({ name
83
91
  it('should export a valid strategy', async () => {
84
92
  await exportStrategy('task-only');
85
93
 
86
- const strategyFile = join(testWorkspace, 'strategies', 'task-only.ts');
87
- const manifestFile = join(testWorkspace, 'strategies', '.official', 'manifest.json');
94
+ const strategyFile = join(userStrategiesDir(), 'task-only.ts');
95
+ const manifestFile = officialManifestFile();
88
96
 
89
97
  expect(existsSync(strategyFile)).toBe(true);
90
98
  expect(existsSync(manifestFile)).toBe(true);
@@ -130,7 +138,7 @@ export const strategy = {
130
138
  });
131
139
 
132
140
  it('should refuse to overwrite untracked files without --force', async () => {
133
- const strategiesDir = join(testWorkspace, 'strategies');
141
+ const strategiesDir = userStrategiesDir();
134
142
  mkdirSync(strategiesDir, { recursive: true });
135
143
  writeFileSync(join(strategiesDir, 'task-only.ts'), '// user file', 'utf8');
136
144
 
@@ -141,7 +149,7 @@ export const strategy = {
141
149
  });
142
150
 
143
151
  it('should overwrite untracked files with --force', async () => {
144
- const strategiesDir = join(testWorkspace, 'strategies');
152
+ const strategiesDir = userStrategiesDir();
145
153
  mkdirSync(strategiesDir, { recursive: true });
146
154
  writeFileSync(join(strategiesDir, 'task-only.ts'), '// user file', 'utf8');
147
155
 
@@ -155,7 +163,7 @@ export const strategy = {
155
163
  // First export
156
164
  await exportStrategy('task-only');
157
165
 
158
- const strategiesDir = join(testWorkspace, 'strategies');
166
+ const strategiesDir = userStrategiesDir();
159
167
  const strategyFile = join(strategiesDir, 'task-only.ts');
160
168
 
161
169
  // Modify the file
@@ -189,7 +197,7 @@ export const strategy = {
189
197
  await exportStrategy('task-only');
190
198
 
191
199
  // Manually modify the manifest to simulate a stale hash
192
- const manifestFile = join(testWorkspace, 'strategies', '.official', 'manifest.json');
200
+ const manifestFile = officialManifestFile();
193
201
  const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
194
202
  manifest['task-only'].sourceHash = 'stale-hash-value';
195
203
  writeFileSync(manifestFile, JSON.stringify(manifest, null, 2), 'utf8');
@@ -202,7 +210,7 @@ export const strategy = {
202
210
  await exportStrategy('kill-lone');
203
211
 
204
212
  // Make both stale
205
- const manifestFile = join(testWorkspace, 'strategies', '.official', 'manifest.json');
213
+ const manifestFile = officialManifestFile();
206
214
  const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
207
215
  manifest['task-only'].sourceHash = 'stale-1';
208
216
  manifest['kill-lone'].sourceHash = 'stale-2';
@@ -218,7 +226,7 @@ export const strategy = {
218
226
  await exportStrategy('kill-lone');
219
227
 
220
228
  // Verify kill-lone has a sidecar hash (it has a .knowledge.md)
221
- const manifestFile = join(testWorkspace, 'strategies', '.official', 'manifest.json');
229
+ const manifestFile = officialManifestFile();
222
230
  const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
223
231
  expect(manifest['kill-lone'].sidecarHash).toBeDefined();
224
232
 
@@ -6,12 +6,15 @@ import { tmpdir } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { createHash } from 'crypto';
8
8
  import { getWorkspaceDir } from './init-command.js';
9
+ import {
10
+ ensureUserDataLayout,
11
+ getRuntimeOfficialStrategiesDir,
12
+ getRuntimeOfficialStrategiesManifestFile,
13
+ getUserDataStrategiesDir,
14
+ } from './user-data.js';
9
15
  import { importUserFile } from '../strategies/loader.js';
10
16
 
11
17
  // ── Constants ──────────────────────────────────────────────────────
12
- const OFFICIAL_DIR = '.official';
13
- const MANIFEST_FILE = 'manifest.json';
14
-
15
18
  // ── Types ──────────────────────────────────────────────────────────
16
19
  export interface ManifestEntry {
17
20
  sourceHash: string;
@@ -22,15 +25,17 @@ export type Manifest = Record<string, ManifestEntry>;
22
25
 
23
26
  // ── Helpers ────────────────────────────────────────────────────────
24
27
  function strategiesDir(): string {
25
- return join(getWorkspaceDir(), 'strategies');
28
+ const workspaceDir = getWorkspaceDir();
29
+ ensureUserDataLayout(workspaceDir);
30
+ return getUserDataStrategiesDir(workspaceDir);
26
31
  }
27
32
 
28
33
  function officialDir(): string {
29
- return join(strategiesDir(), OFFICIAL_DIR);
34
+ return getRuntimeOfficialStrategiesDir(getWorkspaceDir());
30
35
  }
31
36
 
32
37
  function manifestPath(): string {
33
- return join(officialDir(), MANIFEST_FILE);
38
+ return getRuntimeOfficialStrategiesManifestFile(getWorkspaceDir());
34
39
  }
35
40
 
36
41
  export function readManifest(): Manifest {
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import {
6
+ ensureUserDataLayout,
7
+ getRuntimeAuthStateFile,
8
+ getUserDataAccountHash,
9
+ getUserDataAuthFile,
10
+ getUserDataHubStrategyLockfile,
11
+ getUserDataMemoryFile,
12
+ getUserDataPersonaFile,
13
+ getUserDataStrategiesDir,
14
+ } from './user-data.js';
15
+
16
+ describe('user-data layout migration', () => {
17
+ let ws: string;
18
+
19
+ beforeEach(() => {
20
+ ws = mkdtempSync(join(tmpdir(), 'ccl-user-data-'));
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(ws, { recursive: true, force: true });
25
+ });
26
+
27
+ it('migrates only portable user data from the legacy workspace layout', () => {
28
+ const apiKey = 'claw_secret_123';
29
+ writeFileSync(join(ws, '.auth.json'), JSON.stringify({
30
+ activeProfile: 'lobster-1',
31
+ profiles: {
32
+ 'lobster-1': {
33
+ agentName: 'lobster-1',
34
+ apiKey,
35
+ serverUrl: 'https://example.com/claw',
36
+ gameServerUrl: 'http://game.example.com',
37
+ },
38
+ },
39
+ neteaseTtsKey: 'tts-secret',
40
+ }, null, 2));
41
+
42
+ mkdirSync(join(ws, 'accounts', apiKey), { recursive: true });
43
+ writeFileSync(join(ws, 'accounts', apiKey, 'persona.md'), 'persona old', 'utf8');
44
+ writeFileSync(join(ws, 'accounts', apiKey, 'memory.md'), 'memory old', 'utf8');
45
+
46
+ mkdirSync(join(ws, 'strategies', '.official'), { recursive: true });
47
+ writeFileSync(join(ws, 'strategies', 'custom.ts'), 'export const strategy = {};', 'utf8');
48
+ writeFileSync(join(ws, 'strategies', '.official', 'manifest.json'), JSON.stringify({ 'task-only': { sourceHash: 'x' } }), 'utf8');
49
+
50
+ mkdirSync(join(ws, 'hub'), { recursive: true });
51
+ writeFileSync(join(ws, 'hub', 'installed.json'), JSON.stringify({
52
+ 'strategy/abc': { type: 'strategy', id: 'abc', title: 'Strategy', path: 'strategies/custom.ts', installedAt: 'now' },
53
+ 'skill/def': { type: 'skill', id: 'def', title: 'Skill', path: 'skills/helper', installedAt: 'now' },
54
+ }, null, 2), 'utf8');
55
+
56
+ ensureUserDataLayout(ws);
57
+
58
+ const auth = JSON.parse(readFileSync(getUserDataAuthFile(ws), 'utf8'));
59
+ expect(auth.activeProfile).toBe('lobster-1');
60
+ expect(auth.profiles['lobster-1'].apiKey).toBe(apiKey);
61
+ expect(auth.profiles['lobster-1'].gameServerUrl).toBeUndefined();
62
+ expect(auth.profiles['lobster-1'].tts.keys.leihuo).toBe('tts-secret');
63
+
64
+ const runtimeAuth = JSON.parse(readFileSync(getRuntimeAuthStateFile(ws), 'utf8'));
65
+ expect(runtimeAuth.profiles['lobster-1'].gameServerUrl).toBe('http://game.example.com');
66
+
67
+ expect(getUserDataAccountHash(apiKey)).toHaveLength(16);
68
+ expect(readFileSync(getUserDataPersonaFile(ws, { apiKey }), 'utf8')).toBe('persona old');
69
+ expect(readFileSync(getUserDataMemoryFile(ws, { apiKey }), 'utf8')).toBe('memory old');
70
+
71
+ expect(readFileSync(join(getUserDataStrategiesDir(ws), 'custom.ts'), 'utf8')).toContain('strategy');
72
+ expect(existsSync(join(getUserDataStrategiesDir(ws), '.official'))).toBe(false);
73
+
74
+ const strategyLock = JSON.parse(readFileSync(getUserDataHubStrategyLockfile(ws), 'utf8'));
75
+ expect(Object.keys(strategyLock)).toEqual(['strategy/abc']);
76
+ expect(strategyLock['skill/def']).toBeUndefined();
77
+ });
78
+
79
+ it('does not overwrite existing user-data account files', () => {
80
+ const apiKey = 'claw_keep_new';
81
+ writeFileSync(join(ws, '.auth.json'), JSON.stringify({
82
+ activeProfile: 'lobster-1',
83
+ profiles: {
84
+ 'lobster-1': { agentName: 'lobster-1', apiKey, serverUrl: 'https://example.com/claw' },
85
+ },
86
+ }), 'utf8');
87
+ mkdirSync(join(ws, 'accounts', apiKey), { recursive: true });
88
+ writeFileSync(join(ws, 'accounts', apiKey, 'persona.md'), 'old persona', 'utf8');
89
+ mkdirSync(join(ws, 'user-data', 'accounts', getUserDataAccountHash(apiKey)), { recursive: true });
90
+ writeFileSync(getUserDataPersonaFile(ws, { apiKey }), 'new persona', 'utf8');
91
+
92
+ ensureUserDataLayout(ws);
93
+
94
+ expect(readFileSync(getUserDataPersonaFile(ws, { apiKey }), 'utf8')).toBe('new persona');
95
+ });
96
+ });