@myclaw163/clawclaw-cli 0.6.70 → 0.6.74

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.
Files changed (212) hide show
  1. package/README.md +377 -427
  2. package/package.json +48 -48
  3. package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
  4. package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
  5. package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
  6. package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
  7. package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
  8. package/scripts/check-skill-command-surface.mjs +116 -0
  9. package/scripts/find-hide-spots.py +157 -157
  10. package/scripts/postinstall.mjs +20 -20
  11. package/scripts/sync-bundled-skill.mjs +244 -244
  12. package/scripts/sync-bundled-skill.test.mjs +152 -152
  13. package/skills/clawclaw/SKILL.md +246 -244
  14. package/skills/clawclaw/references/CHATTERBOX.md +141 -142
  15. package/skills/clawclaw/references/COMMANDS.md +155 -148
  16. package/skills/clawclaw/references/GAME-MECHANICS.md +188 -188
  17. package/skills/clawclaw/references/HUB.md +48 -48
  18. package/skills/clawclaw/references/KNOWLEDGE.md +42 -45
  19. package/skills/clawclaw/references/STRATEGIES.md +59 -59
  20. package/skills/clawclaw/references/STREAM.md +93 -91
  21. package/skills/clawclaw/references/TACTICS.md +65 -65
  22. package/src/assets/clawclaw-ascii-map.txt +40 -40
  23. package/src/cli.ts +110 -110
  24. package/src/commands/_schema.ts +124 -109
  25. package/src/commands/account.ts +209 -209
  26. package/src/commands/config.ts +30 -30
  27. package/src/commands/do.test.ts +84 -73
  28. package/src/commands/do.ts +130 -126
  29. package/src/commands/events.test.ts +71 -71
  30. package/src/commands/events.ts +221 -155
  31. package/src/commands/game-map.test.ts +28 -28
  32. package/src/commands/game-start-plan.test.ts +84 -84
  33. package/src/commands/game.ts +1113 -1042
  34. package/src/commands/history-player.test.ts +102 -102
  35. package/src/commands/history.ts +573 -573
  36. package/src/commands/hub.test.ts +96 -96
  37. package/src/commands/hub.ts +234 -234
  38. package/src/commands/knowledge.test.ts +13 -13
  39. package/src/commands/knowledge.ts +139 -139
  40. package/src/commands/load.test.ts +51 -51
  41. package/src/commands/load.ts +13 -13
  42. package/src/commands/meeting-history.test.ts +106 -106
  43. package/src/commands/memory.ts +40 -40
  44. package/src/commands/peek.ts +45 -45
  45. package/src/commands/persona.ts +57 -57
  46. package/src/commands/setup/codex.ts +266 -266
  47. package/src/commands/setup/hermes.test.ts +96 -96
  48. package/src/commands/setup/hermes.ts +76 -76
  49. package/src/commands/setup/index.ts +13 -13
  50. package/src/commands/setup/openclaw.test.ts +114 -114
  51. package/src/commands/setup/openclaw.ts +147 -147
  52. package/src/commands/skill.ts +128 -128
  53. package/src/commands/state.ts +46 -46
  54. package/src/commands/strategy.test.ts +145 -145
  55. package/src/commands/strategy.ts +181 -181
  56. package/src/commands/tts.ts +128 -128
  57. package/src/commands/upgrade.test.ts +82 -82
  58. package/src/commands/upgrade.ts +148 -148
  59. package/src/commands/watch.test.ts +999 -977
  60. package/src/commands/watch.ts +660 -658
  61. package/src/lib/auth.test.ts +74 -74
  62. package/src/lib/auth.ts +186 -186
  63. package/src/lib/command-meta.ts +37 -37
  64. package/src/lib/game-client.ts +403 -391
  65. package/src/lib/game-context.ts +92 -0
  66. package/src/lib/host-config-patcher.test.ts +130 -130
  67. package/src/lib/host-config-patcher.ts +151 -151
  68. package/src/lib/http-keepalive.ts +15 -15
  69. package/src/lib/http-transport.test.ts +42 -42
  70. package/src/lib/http-transport.ts +113 -113
  71. package/src/lib/hub-client.test.ts +56 -56
  72. package/src/lib/hub-client.ts +88 -88
  73. package/src/lib/hub-install.test.ts +98 -98
  74. package/src/lib/hub-install.ts +121 -121
  75. package/src/lib/hub-reminder.ts +56 -56
  76. package/src/lib/hub-unzip.test.ts +69 -69
  77. package/src/lib/hub-unzip.ts +62 -62
  78. package/src/lib/init-command.test.ts +75 -75
  79. package/src/lib/init-command.ts +120 -120
  80. package/src/lib/knowledge-store.test.ts +170 -170
  81. package/src/lib/knowledge-store.ts +369 -369
  82. package/src/lib/load-context.test.ts +52 -52
  83. package/src/lib/load-context.ts +52 -52
  84. package/src/lib/match-state.test.ts +134 -134
  85. package/src/lib/match-state.ts +94 -94
  86. package/src/lib/netease-tts.ts +83 -83
  87. package/src/lib/normalize.ts +42 -42
  88. package/src/lib/persona.test.ts +41 -41
  89. package/src/lib/persona.ts +72 -72
  90. package/src/lib/server-registry.ts +152 -152
  91. package/src/lib/skill-version.test.ts +48 -48
  92. package/src/lib/skill-version.ts +19 -19
  93. package/src/lib/strategy-export.test.ts +232 -232
  94. package/src/lib/strategy-export.ts +242 -242
  95. package/src/lib/tts-keys.ts +7 -7
  96. package/src/lib/tts-speech.test.ts +63 -63
  97. package/src/lib/tts-speech.ts +76 -76
  98. package/src/lib/workspace-argv.test.ts +49 -49
  99. package/src/lib/workspace-argv.ts +44 -44
  100. package/src/perception/player-history-store.test.ts +87 -87
  101. package/src/perception/player-history-store.ts +194 -194
  102. package/src/pipeline/event-format.test.ts +243 -215
  103. package/src/pipeline/event-format.ts +501 -485
  104. package/src/pipeline/event-hints.ts +195 -190
  105. package/src/pipeline/event-store.test.ts +28 -28
  106. package/src/pipeline/event-store.ts +193 -193
  107. package/src/pipeline/pipeline.ts +35 -35
  108. package/src/pipeline/player-projection.test.ts +119 -0
  109. package/src/pipeline/player-projection.ts +380 -0
  110. package/src/runtime/auto-upgrade.test.ts +66 -66
  111. package/src/runtime/auto-upgrade.ts +31 -31
  112. package/src/runtime/event-daemon.test.ts +209 -141
  113. package/src/runtime/event-daemon.ts +519 -457
  114. package/src/runtime/owner-control.ts +150 -150
  115. package/src/runtime/raw-ws-log.test.ts +33 -33
  116. package/src/runtime/raw-ws-log.ts +32 -32
  117. package/src/runtime/runtime-logger.ts +107 -107
  118. package/src/runtime/ws-client.test.ts +125 -104
  119. package/src/runtime/ws-client.ts +287 -272
  120. package/src/sdk/action.ts +166 -166
  121. package/src/sdk/index.ts +110 -110
  122. package/src/sdk/types.ts +161 -161
  123. package/src/strategies/avoid-lone.ts +12 -12
  124. package/src/strategies/avoid-players.knowledge.md +19 -19
  125. package/src/strategies/avoid-players.ts +16 -16
  126. package/src/strategies/corpse-patrol.ts +23 -23
  127. package/src/strategies/crab-sabotage.ts +22 -22
  128. package/src/strategies/custom-module.test.ts +270 -270
  129. package/src/strategies/find-player.ts +17 -17
  130. package/src/strategies/game-utils.test.ts +242 -242
  131. package/src/strategies/game-utils.ts +846 -846
  132. package/src/strategies/goals/anchor-linger.ts +77 -77
  133. package/src/strategies/goals/avoid-lone-top.ts +168 -168
  134. package/src/strategies/goals/avoid-players-top.test.ts +83 -83
  135. package/src/strategies/goals/avoid-players-top.ts +121 -121
  136. package/src/strategies/goals/conversation-goal.ts +51 -51
  137. package/src/strategies/goals/corpse-patrol-top.ts +113 -113
  138. package/src/strategies/goals/crab-octopus-reflexes.ts +101 -101
  139. package/src/strategies/goals/crab-sabotage-top.ts +197 -197
  140. package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
  141. package/src/strategies/goals/find-player-top.ts +93 -93
  142. package/src/strategies/goals/flee-players-goal.ts +53 -53
  143. package/src/strategies/goals/follow-companion-goal.ts +106 -106
  144. package/src/strategies/goals/goal-manager.ts +41 -41
  145. package/src/strategies/goals/goal-root-strategy.ts +49 -49
  146. package/src/strategies/goals/goal.ts +28 -28
  147. package/src/strategies/goals/hide-top.ts +197 -197
  148. package/src/strategies/goals/keep-away-goal.ts +221 -221
  149. package/src/strategies/goals/kill-frenzy-top.ts +80 -80
  150. package/src/strategies/goals/kill-lone-top.ts +160 -160
  151. package/src/strategies/goals/kill-target-goal.ts +59 -59
  152. package/src/strategies/goals/kill-target-top.ts +109 -109
  153. package/src/strategies/goals/leaf-goal.ts +27 -27
  154. package/src/strategies/goals/linger-corpse-goal.ts +35 -35
  155. package/src/strategies/goals/lone-kill-core.ts +82 -82
  156. package/src/strategies/goals/lone-kill-goal.ts +24 -24
  157. package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
  158. package/src/strategies/goals/lone-kill-task-top.ts +133 -133
  159. package/src/strategies/goals/move-room-goal.ts +60 -60
  160. package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
  161. package/src/strategies/goals/normal-shrimp-top.ts +242 -242
  162. package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
  163. package/src/strategies/goals/paradise-fish-top.ts +224 -224
  164. package/src/strategies/goals/patrol-top.ts +57 -57
  165. package/src/strategies/goals/report-patrol-top.ts +80 -80
  166. package/src/strategies/goals/safe-task-goal.ts +102 -102
  167. package/src/strategies/goals/social-task-top.ts +161 -161
  168. package/src/strategies/goals/task-kill-report-top.ts +163 -163
  169. package/src/strategies/goals/task-only-top.ts +57 -57
  170. package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
  171. package/src/strategies/goals/task-report-top.ts +57 -57
  172. package/src/strategies/goals/wander-task-goal.ts +33 -33
  173. package/src/strategies/goals/warrior-shrimp-top.test.ts +87 -87
  174. package/src/strategies/goals/warrior-shrimp-top.ts +267 -267
  175. package/src/strategies/greeting.ts +53 -53
  176. package/src/strategies/hide-spots.ts +59 -59
  177. package/src/strategies/hide.ts +24 -24
  178. package/src/strategies/kill-frenzy.ts +13 -13
  179. package/src/strategies/kill-lone.knowledge.md +17 -17
  180. package/src/strategies/kill-lone.ts +14 -14
  181. package/src/strategies/kill-target.ts +19 -19
  182. package/src/strategies/loader.test.ts +678 -678
  183. package/src/strategies/loader.ts +179 -179
  184. package/src/strategies/lone-kill-task.ts +22 -22
  185. package/src/strategies/meeting-gate.test.ts +59 -59
  186. package/src/strategies/meeting-gate.ts +23 -23
  187. package/src/strategies/move-room.ts +16 -16
  188. package/src/strategies/new-events-backfill.ts +98 -98
  189. package/src/strategies/off-route-points.ts +105 -105
  190. package/src/strategies/paradise-fish.knowledge.md +19 -19
  191. package/src/strategies/paradise-fish.ts +26 -26
  192. package/src/strategies/pathfind/distance-field.ts +150 -150
  193. package/src/strategies/pathfind/escape-planner.test.ts +197 -197
  194. package/src/strategies/pathfind/escape-planner.ts +355 -355
  195. package/src/strategies/pathfind/walkable-grid.ts +117 -117
  196. package/src/strategies/patrol.ts +12 -12
  197. package/src/strategies/player-targets.ts +13 -13
  198. package/src/strategies/report-patrol.ts +12 -12
  199. package/src/strategies/shrimp-memory.knowledge.md +19 -19
  200. package/src/strategies/shrimp-memory.ts +26 -26
  201. package/src/strategies/social-task.test.ts +28 -28
  202. package/src/strategies/social-task.ts +50 -50
  203. package/src/strategies/spawn.ts +82 -82
  204. package/src/strategies/speech-module.ts +123 -123
  205. package/src/strategies/strategy-loop.test.ts +15 -0
  206. package/src/strategies/strategy-loop.ts +776 -771
  207. package/src/strategies/task-kill-report.ts +18 -18
  208. package/src/strategies/task-only.ts +12 -12
  209. package/src/strategies/task-report.ts +23 -23
  210. package/src/strategies/types.ts +109 -109
  211. package/src/strategies/warrior-memory.knowledge.md +21 -21
  212. package/src/strategies/warrior-memory.ts +17 -17
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from 'fs';
2
+
3
+ function finiteNumber(value: unknown): number | undefined {
4
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
5
+ if (typeof value === 'string' && value.trim() !== '') {
6
+ const parsed = Number(value);
7
+ if (Number.isFinite(parsed)) return parsed;
8
+ }
9
+ return undefined;
10
+ }
11
+
12
+ function nonEmptyString(value: unknown): string | undefined {
13
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
14
+ }
15
+
16
+ function cleanObject<T extends Record<string, any>>(obj: T): T {
17
+ for (const key of Object.keys(obj)) {
18
+ if (obj[key] === undefined) delete obj[key];
19
+ }
20
+ return obj;
21
+ }
22
+
23
+ export function briefGameMap(mapData: any): any {
24
+ if (!mapData) return null;
25
+ return {
26
+ rooms: mapData.rooms?.map((r: any) => ({
27
+ name: r.name,
28
+ polygon: r.polygon,
29
+ })),
30
+ };
31
+ }
32
+
33
+ export function taskLocations(mapData: any): { lobster: any[]; crab: any[] } {
34
+ const output = { lobster: [] as any[], crab: [] as any[] };
35
+ for (const task of Array.isArray(mapData?.all_task_locations) ? mapData.all_task_locations : []) {
36
+ const item = cleanObject({
37
+ name: task.name,
38
+ room: task.room,
39
+ x: task.x,
40
+ y: task.y,
41
+ });
42
+ if (task.faction === 'crab') output.crab.push(item);
43
+ else output.lobster.push(item);
44
+ }
45
+ return output;
46
+ }
47
+
48
+ export function loadAsciiMapAsset(): string | null {
49
+ try {
50
+ return readFileSync(new URL('../assets/clawclaw-ascii-map.txt', import.meta.url), 'utf8').trimEnd();
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function summarizeGameMap(mapData: any, opts?: { ascii?: boolean }): any {
57
+ const output: Record<string, any> = {
58
+ rooms: mapData?.rooms?.map((r: any) => ({
59
+ name: r.name,
60
+ polygon: r.polygon,
61
+ })) ?? [],
62
+ task_locations: taskLocations(mapData),
63
+ };
64
+ if (opts?.ascii) output.ascii_map = loadAsciiMapAsset();
65
+ return output;
66
+ }
67
+
68
+ export function playersFromMap(mapData: any): Array<{ name: string; seat: number }> {
69
+ const players: Array<{ name: string; seat: number }> = [];
70
+ for (const player of Array.isArray(mapData?.all_players) ? mapData.all_players : []) {
71
+ const name = nonEmptyString(player?.name ?? player?.agent_name);
72
+ const seat = finiteNumber(player?.seat);
73
+ if (!name || seat === undefined) continue;
74
+ players.push({ name, seat });
75
+ }
76
+ return players;
77
+ }
78
+
79
+ export function seatMapFromPlayers(players: Array<{ name: string; seat: number }>): Record<string, number> {
80
+ const seats: Record<string, number> = {};
81
+ for (const player of players) seats[player.name] = player.seat;
82
+ return seats;
83
+ }
84
+
85
+ export function buildGameStartedEnrichment(mapData: any): Record<string, any> {
86
+ const players = playersFromMap(mapData);
87
+ return cleanObject({
88
+ players: players.length > 0 ? players : undefined,
89
+ all_seats: players.length > 0 ? seatMapFromPlayers(players) : undefined,
90
+ map: summarizeGameMap(mapData, { ascii: true }),
91
+ });
92
+ }
@@ -1,130 +1,130 @@
1
- import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
2
- import { tmpdir } from 'os';
3
- import { dirname, join } from 'path';
4
- import { describe, expect, it } from 'vitest';
5
- import { runHostConfigSetup, unionArrayField, type HostConfigPatcher } from './host-config-patcher.js';
6
-
7
- function tmpConfig(initial: unknown): string {
8
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-'));
9
- const p = join(dir, 'config.json');
10
- writeFileSync(p, JSON.stringify(initial, null, 2));
11
- return p;
12
- }
13
-
14
- const dummyHost: HostConfigPatcher<{ value: string }> = {
15
- hostName: 'dummy',
16
- resolveConfigPath: () => '/tmp/never-used',
17
- computePatch: (current, opts) => {
18
- const cur = (current as Record<string, unknown> | null) ?? {};
19
- const before = (cur.values as string[] | undefined) ?? [];
20
- if (before.includes(opts.value)) return { next: cur, changes: [], warnings: [] };
21
- return {
22
- next: { ...cur, values: [...before, opts.value] },
23
- changes: [`add ${opts.value} to values`],
24
- warnings: [],
25
- };
26
- },
27
- };
28
-
29
- describe('runHostConfigSetup', () => {
30
- it('prints recommended patch in --print mode without touching disk', () => {
31
- const r = runHostConfigSetup(dummyHost, { value: 'x' }, { print: true });
32
- expect(r.exitCode).toBe(0);
33
- expect(r.output.join('\n')).toContain('"values"');
34
- });
35
-
36
- it('reports missing config file gracefully', () => {
37
- const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: '/no/such/file.json' });
38
- expect(r.exitCode).toBe(0);
39
- expect(r.output.some((l) => l.includes('not found'))).toBe(true);
40
- });
41
-
42
- it('reports malformed JSON with exit 1', () => {
43
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-bad-'));
44
- const p = join(dir, 'config.json');
45
- writeFileSync(p, '{ not: valid json');
46
- const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: p });
47
- expect(r.exitCode).toBe(1);
48
- expect(r.output.join('\n')).toMatch(/Failed to parse/);
49
- });
50
-
51
- it('returns exit 2 with diff in dry-run when changes pending', () => {
52
- const p = tmpConfig({ values: ['a'] });
53
- const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p });
54
- expect(r.exitCode).toBe(2);
55
- const txt = r.output.join('\n');
56
- expect(txt).toMatch(/Pending changes/);
57
- expect(txt).toMatch(/Re-run with -y to apply/);
58
- // file unchanged
59
- expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a'] });
60
- });
61
-
62
- it('returns exit 0 no-op when already up to date', () => {
63
- const p = tmpConfig({ values: ['a'] });
64
- const r = runHostConfigSetup(dummyHost, { value: 'a' }, { configPath: p });
65
- expect(r.exitCode).toBe(0);
66
- expect(r.output.join('\n')).toMatch(/already up to date/);
67
- });
68
-
69
- it('applies changes atomically with backup when -y', () => {
70
- const p = tmpConfig({ values: ['a'] });
71
- const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true });
72
- expect(r.exitCode).toBe(0);
73
- expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a', 'b'] });
74
- const files = readdirSync(dirname(p));
75
- expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(true);
76
- });
77
-
78
- it('--no-backup skips backup file', () => {
79
- const p = tmpConfig({ values: ['a'] });
80
- const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
81
- expect(r.exitCode).toBe(0);
82
- const files = readdirSync(dirname(p));
83
- expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(false);
84
- });
85
-
86
- it('does not leave .tmp file behind on success', () => {
87
- const p = tmpConfig({ values: ['a'] });
88
- runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
89
- const files = readdirSync(dirname(p));
90
- expect(files.some((f) => f.includes('.tmp.'))).toBe(false);
91
- });
92
-
93
- it('preserves CRLF line endings when original file uses CRLF', () => {
94
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-crlf-'));
95
- const p = join(dir, 'config.json');
96
- writeFileSync(p, '{\r\n "values": [\r\n "a"\r\n ]\r\n}\r\n');
97
- runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
98
- const after = readFileSync(p, 'utf-8');
99
- expect(after.includes('\r\n')).toBe(true);
100
- expect(after.replace(/\r\n/g, '<EOL>').includes('\n')).toBe(false);
101
- });
102
-
103
- it('preserves LF line endings when original file uses LF', () => {
104
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-lf-'));
105
- const p = join(dir, 'config.json');
106
- writeFileSync(p, '{\n "values": [\n "a"\n ]\n}\n');
107
- runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
108
- const after = readFileSync(p, 'utf-8');
109
- expect(after.includes('\r\n')).toBe(false);
110
- });
111
- });
112
-
113
- describe('unionArrayField', () => {
114
- it('creates new array when field missing', () => {
115
- expect(unionArrayField(undefined, 'x')).toEqual({ next: ['x'], added: true });
116
- });
117
-
118
- it('returns warning when existing value is not an array', () => {
119
- const r = unionArrayField('not-an-array', 'x');
120
- expect(r).toEqual({ warning: 'existing value is not an array, refusing to overwrite' });
121
- });
122
-
123
- it('returns added=false when entry already present', () => {
124
- expect(unionArrayField(['a', 'b'], 'a')).toEqual({ next: ['a', 'b'], added: false });
125
- });
126
-
127
- it('appends entry when missing from array', () => {
128
- expect(unionArrayField(['a'], 'b')).toEqual({ next: ['a', 'b'], added: true });
129
- });
130
- });
1
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { dirname, join } from 'path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { runHostConfigSetup, unionArrayField, type HostConfigPatcher } from './host-config-patcher.js';
6
+
7
+ function tmpConfig(initial: unknown): string {
8
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-'));
9
+ const p = join(dir, 'config.json');
10
+ writeFileSync(p, JSON.stringify(initial, null, 2));
11
+ return p;
12
+ }
13
+
14
+ const dummyHost: HostConfigPatcher<{ value: string }> = {
15
+ hostName: 'dummy',
16
+ resolveConfigPath: () => '/tmp/never-used',
17
+ computePatch: (current, opts) => {
18
+ const cur = (current as Record<string, unknown> | null) ?? {};
19
+ const before = (cur.values as string[] | undefined) ?? [];
20
+ if (before.includes(opts.value)) return { next: cur, changes: [], warnings: [] };
21
+ return {
22
+ next: { ...cur, values: [...before, opts.value] },
23
+ changes: [`add ${opts.value} to values`],
24
+ warnings: [],
25
+ };
26
+ },
27
+ };
28
+
29
+ describe('runHostConfigSetup', () => {
30
+ it('prints recommended patch in --print mode without touching disk', () => {
31
+ const r = runHostConfigSetup(dummyHost, { value: 'x' }, { print: true });
32
+ expect(r.exitCode).toBe(0);
33
+ expect(r.output.join('\n')).toContain('"values"');
34
+ });
35
+
36
+ it('reports missing config file gracefully', () => {
37
+ const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: '/no/such/file.json' });
38
+ expect(r.exitCode).toBe(0);
39
+ expect(r.output.some((l) => l.includes('not found'))).toBe(true);
40
+ });
41
+
42
+ it('reports malformed JSON with exit 1', () => {
43
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-bad-'));
44
+ const p = join(dir, 'config.json');
45
+ writeFileSync(p, '{ not: valid json');
46
+ const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: p });
47
+ expect(r.exitCode).toBe(1);
48
+ expect(r.output.join('\n')).toMatch(/Failed to parse/);
49
+ });
50
+
51
+ it('returns exit 2 with diff in dry-run when changes pending', () => {
52
+ const p = tmpConfig({ values: ['a'] });
53
+ const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p });
54
+ expect(r.exitCode).toBe(2);
55
+ const txt = r.output.join('\n');
56
+ expect(txt).toMatch(/Pending changes/);
57
+ expect(txt).toMatch(/Re-run with -y to apply/);
58
+ // file unchanged
59
+ expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a'] });
60
+ });
61
+
62
+ it('returns exit 0 no-op when already up to date', () => {
63
+ const p = tmpConfig({ values: ['a'] });
64
+ const r = runHostConfigSetup(dummyHost, { value: 'a' }, { configPath: p });
65
+ expect(r.exitCode).toBe(0);
66
+ expect(r.output.join('\n')).toMatch(/already up to date/);
67
+ });
68
+
69
+ it('applies changes atomically with backup when -y', () => {
70
+ const p = tmpConfig({ values: ['a'] });
71
+ const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true });
72
+ expect(r.exitCode).toBe(0);
73
+ expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a', 'b'] });
74
+ const files = readdirSync(dirname(p));
75
+ expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(true);
76
+ });
77
+
78
+ it('--no-backup skips backup file', () => {
79
+ const p = tmpConfig({ values: ['a'] });
80
+ const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
81
+ expect(r.exitCode).toBe(0);
82
+ const files = readdirSync(dirname(p));
83
+ expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(false);
84
+ });
85
+
86
+ it('does not leave .tmp file behind on success', () => {
87
+ const p = tmpConfig({ values: ['a'] });
88
+ runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
89
+ const files = readdirSync(dirname(p));
90
+ expect(files.some((f) => f.includes('.tmp.'))).toBe(false);
91
+ });
92
+
93
+ it('preserves CRLF line endings when original file uses CRLF', () => {
94
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-crlf-'));
95
+ const p = join(dir, 'config.json');
96
+ writeFileSync(p, '{\r\n "values": [\r\n "a"\r\n ]\r\n}\r\n');
97
+ runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
98
+ const after = readFileSync(p, 'utf-8');
99
+ expect(after.includes('\r\n')).toBe(true);
100
+ expect(after.replace(/\r\n/g, '<EOL>').includes('\n')).toBe(false);
101
+ });
102
+
103
+ it('preserves LF line endings when original file uses LF', () => {
104
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-lf-'));
105
+ const p = join(dir, 'config.json');
106
+ writeFileSync(p, '{\n "values": [\n "a"\n ]\n}\n');
107
+ runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
108
+ const after = readFileSync(p, 'utf-8');
109
+ expect(after.includes('\r\n')).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('unionArrayField', () => {
114
+ it('creates new array when field missing', () => {
115
+ expect(unionArrayField(undefined, 'x')).toEqual({ next: ['x'], added: true });
116
+ });
117
+
118
+ it('returns warning when existing value is not an array', () => {
119
+ const r = unionArrayField('not-an-array', 'x');
120
+ expect(r).toEqual({ warning: 'existing value is not an array, refusing to overwrite' });
121
+ });
122
+
123
+ it('returns added=false when entry already present', () => {
124
+ expect(unionArrayField(['a', 'b'], 'a')).toEqual({ next: ['a', 'b'], added: false });
125
+ });
126
+
127
+ it('appends entry when missing from array', () => {
128
+ expect(unionArrayField(['a'], 'b')).toEqual({ next: ['a', 'b'], added: true });
129
+ });
130
+ });
@@ -1,151 +1,151 @@
1
- /**
2
- * Generic JSON-config setup runner shared by `ccl setup <host>` subcommands.
3
- *
4
- * Each host adapter only supplies:
5
- * - resolveConfigPath(): where the host stores its global JSON config
6
- * - computePatch(current, opts): pure function returning the desired next config + change/warning notes
7
- *
8
- * The runner handles dry-run, diff, atomic write, and timestamped backup uniformly,
9
- * so adding a new host (gemini, claude-code, ...) is ~30 lines of glue.
10
- */
11
-
12
- import { existsSync, readFileSync, renameSync, copyFileSync, writeFileSync } from 'fs';
13
-
14
- export interface HostConfigPatcher<Opts extends object = Record<string, never>> {
15
- hostName: string;
16
- resolveConfigPath: () => string;
17
- computePatch: (current: unknown, opts: Opts) => HostPatchResult;
18
- }
19
-
20
- export interface HostPatchResult {
21
- next: unknown;
22
- changes: string[];
23
- warnings: string[];
24
- }
25
-
26
- export interface RunHostSetupFlags {
27
- yes?: boolean;
28
- print?: boolean;
29
- configPath?: string;
30
- backup?: boolean;
31
- }
32
-
33
- export interface RunHostSetupResult {
34
- exitCode: number;
35
- output: string[];
36
- }
37
-
38
- export function runHostConfigSetup<Opts extends object>(
39
- patcher: HostConfigPatcher<Opts>,
40
- hostOpts: Opts,
41
- flags: RunHostSetupFlags,
42
- ): RunHostSetupResult {
43
- const out: string[] = [];
44
- const configPath = flags.configPath ?? patcher.resolveConfigPath();
45
-
46
- if (flags.print) {
47
- const sample = patcher.computePatch({}, hostOpts);
48
- out.push(`# Recommended ${patcher.hostName} config patch:`);
49
- out.push(JSON.stringify(sample.next, null, 2));
50
- return { exitCode: 0, output: out };
51
- }
52
-
53
- if (!existsSync(configPath)) {
54
- out.push(`${patcher.hostName} config not found at: ${configPath}`);
55
- out.push(`Initialize it first (e.g. \`openclaw doctor\` for OpenClaw), then re-run.`);
56
- return { exitCode: 0, output: out };
57
- }
58
-
59
- let raw: string;
60
- try {
61
- raw = readFileSync(configPath, 'utf-8');
62
- } catch (err: any) {
63
- out.push(`Failed to read ${configPath}: ${err?.message ?? err}`);
64
- return { exitCode: 1, output: out };
65
- }
66
-
67
- let current: unknown;
68
- try {
69
- current = JSON.parse(raw);
70
- } catch (err: any) {
71
- out.push(`Failed to parse JSON at ${configPath}: ${err?.message ?? err}`);
72
- out.push(`Fix the JSON manually first; this tool does not edit malformed files.`);
73
- return { exitCode: 1, output: out };
74
- }
75
-
76
- const { next, changes, warnings } = patcher.computePatch(current, hostOpts);
77
-
78
- for (const w of warnings) out.push(`warning: ${w}`);
79
-
80
- if (changes.length === 0) {
81
- out.push(`${patcher.hostName} config already up to date at ${configPath}.`);
82
- return { exitCode: 0, output: out };
83
- }
84
-
85
- if (!flags.yes) {
86
- out.push(`Pending changes to ${configPath}:`);
87
- for (const c of changes) out.push(` - ${c}`);
88
- out.push(``);
89
- out.push(diffJson(current, next));
90
- out.push(``);
91
- out.push(`Dry-run only. Re-run with -y to apply.`);
92
- return { exitCode: 2, output: out };
93
- }
94
-
95
- let backupPath: string | undefined;
96
- if (flags.backup !== false) {
97
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
98
- backupPath = `${configPath}.bak.${ts}`;
99
- try {
100
- copyFileSync(configPath, backupPath);
101
- } catch (err: any) {
102
- out.push(`Failed to back up ${configPath} → ${backupPath}: ${err?.message ?? err}`);
103
- return { exitCode: 1, output: out };
104
- }
105
- }
106
-
107
- const tmpPath = `${configPath}.tmp.${process.pid}`;
108
- try {
109
- const eol = raw.includes('\r\n') ? '\r\n' : '\n';
110
- const serialized = JSON.stringify(next, null, 2).replace(/\n/g, eol) + eol;
111
- writeFileSync(tmpPath, serialized, 'utf-8');
112
- renameSync(tmpPath, configPath);
113
- } catch (err: any) {
114
- out.push(`Failed to write ${configPath}: ${err?.message ?? err}`);
115
- return { exitCode: 1, output: out };
116
- }
117
-
118
- out.push(`Updated ${configPath}:`);
119
- for (const c of changes) out.push(` - ${c}`);
120
- if (backupPath) out.push(`Backup: ${backupPath}`);
121
- return { exitCode: 0, output: out };
122
- }
123
-
124
- /** Minimal line-level JSON diff for the dry-run preview. Self-contained, zero deps. */
125
- export function diffJson(before: unknown, after: unknown): string {
126
- const a = JSON.stringify(before, null, 2).split('\n');
127
- const b = JSON.stringify(after, null, 2).split('\n');
128
- const aSet = new Set(a);
129
- const bSet = new Set(b);
130
- const lines: string[] = [];
131
- const max = Math.max(a.length, b.length);
132
- for (let i = 0; i < max; i++) {
133
- const ax = a[i];
134
- const bx = b[i];
135
- if (ax === bx) {
136
- lines.push(` ${ax ?? ''}`);
137
- } else {
138
- if (ax !== undefined && !bSet.has(ax)) lines.push(`- ${ax}`);
139
- if (bx !== undefined && !aSet.has(bx)) lines.push(`+ ${bx}`);
140
- }
141
- }
142
- return lines.join('\n');
143
- }
144
-
145
- /** Shared helper for hosts: add an entry to an array field, returning a new array (Set union). */
146
- export function unionArrayField(arr: unknown, entry: string): { next: string[]; added: boolean } | { warning: string } {
147
- if (arr === undefined) return { next: [entry], added: true };
148
- if (!Array.isArray(arr)) return { warning: `existing value is not an array, refusing to overwrite` };
149
- if (arr.includes(entry)) return { next: arr.slice() as string[], added: false };
150
- return { next: [...(arr as string[]), entry], added: true };
151
- }
1
+ /**
2
+ * Generic JSON-config setup runner shared by `ccl setup <host>` subcommands.
3
+ *
4
+ * Each host adapter only supplies:
5
+ * - resolveConfigPath(): where the host stores its global JSON config
6
+ * - computePatch(current, opts): pure function returning the desired next config + change/warning notes
7
+ *
8
+ * The runner handles dry-run, diff, atomic write, and timestamped backup uniformly,
9
+ * so adding a new host (gemini, claude-code, ...) is ~30 lines of glue.
10
+ */
11
+
12
+ import { existsSync, readFileSync, renameSync, copyFileSync, writeFileSync } from 'fs';
13
+
14
+ export interface HostConfigPatcher<Opts extends object = Record<string, never>> {
15
+ hostName: string;
16
+ resolveConfigPath: () => string;
17
+ computePatch: (current: unknown, opts: Opts) => HostPatchResult;
18
+ }
19
+
20
+ export interface HostPatchResult {
21
+ next: unknown;
22
+ changes: string[];
23
+ warnings: string[];
24
+ }
25
+
26
+ export interface RunHostSetupFlags {
27
+ yes?: boolean;
28
+ print?: boolean;
29
+ configPath?: string;
30
+ backup?: boolean;
31
+ }
32
+
33
+ export interface RunHostSetupResult {
34
+ exitCode: number;
35
+ output: string[];
36
+ }
37
+
38
+ export function runHostConfigSetup<Opts extends object>(
39
+ patcher: HostConfigPatcher<Opts>,
40
+ hostOpts: Opts,
41
+ flags: RunHostSetupFlags,
42
+ ): RunHostSetupResult {
43
+ const out: string[] = [];
44
+ const configPath = flags.configPath ?? patcher.resolveConfigPath();
45
+
46
+ if (flags.print) {
47
+ const sample = patcher.computePatch({}, hostOpts);
48
+ out.push(`# Recommended ${patcher.hostName} config patch:`);
49
+ out.push(JSON.stringify(sample.next, null, 2));
50
+ return { exitCode: 0, output: out };
51
+ }
52
+
53
+ if (!existsSync(configPath)) {
54
+ out.push(`${patcher.hostName} config not found at: ${configPath}`);
55
+ out.push(`Initialize it first (e.g. \`openclaw doctor\` for OpenClaw), then re-run.`);
56
+ return { exitCode: 0, output: out };
57
+ }
58
+
59
+ let raw: string;
60
+ try {
61
+ raw = readFileSync(configPath, 'utf-8');
62
+ } catch (err: any) {
63
+ out.push(`Failed to read ${configPath}: ${err?.message ?? err}`);
64
+ return { exitCode: 1, output: out };
65
+ }
66
+
67
+ let current: unknown;
68
+ try {
69
+ current = JSON.parse(raw);
70
+ } catch (err: any) {
71
+ out.push(`Failed to parse JSON at ${configPath}: ${err?.message ?? err}`);
72
+ out.push(`Fix the JSON manually first; this tool does not edit malformed files.`);
73
+ return { exitCode: 1, output: out };
74
+ }
75
+
76
+ const { next, changes, warnings } = patcher.computePatch(current, hostOpts);
77
+
78
+ for (const w of warnings) out.push(`warning: ${w}`);
79
+
80
+ if (changes.length === 0) {
81
+ out.push(`${patcher.hostName} config already up to date at ${configPath}.`);
82
+ return { exitCode: 0, output: out };
83
+ }
84
+
85
+ if (!flags.yes) {
86
+ out.push(`Pending changes to ${configPath}:`);
87
+ for (const c of changes) out.push(` - ${c}`);
88
+ out.push(``);
89
+ out.push(diffJson(current, next));
90
+ out.push(``);
91
+ out.push(`Dry-run only. Re-run with -y to apply.`);
92
+ return { exitCode: 2, output: out };
93
+ }
94
+
95
+ let backupPath: string | undefined;
96
+ if (flags.backup !== false) {
97
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
98
+ backupPath = `${configPath}.bak.${ts}`;
99
+ try {
100
+ copyFileSync(configPath, backupPath);
101
+ } catch (err: any) {
102
+ out.push(`Failed to back up ${configPath} → ${backupPath}: ${err?.message ?? err}`);
103
+ return { exitCode: 1, output: out };
104
+ }
105
+ }
106
+
107
+ const tmpPath = `${configPath}.tmp.${process.pid}`;
108
+ try {
109
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
110
+ const serialized = JSON.stringify(next, null, 2).replace(/\n/g, eol) + eol;
111
+ writeFileSync(tmpPath, serialized, 'utf-8');
112
+ renameSync(tmpPath, configPath);
113
+ } catch (err: any) {
114
+ out.push(`Failed to write ${configPath}: ${err?.message ?? err}`);
115
+ return { exitCode: 1, output: out };
116
+ }
117
+
118
+ out.push(`Updated ${configPath}:`);
119
+ for (const c of changes) out.push(` - ${c}`);
120
+ if (backupPath) out.push(`Backup: ${backupPath}`);
121
+ return { exitCode: 0, output: out };
122
+ }
123
+
124
+ /** Minimal line-level JSON diff for the dry-run preview. Self-contained, zero deps. */
125
+ export function diffJson(before: unknown, after: unknown): string {
126
+ const a = JSON.stringify(before, null, 2).split('\n');
127
+ const b = JSON.stringify(after, null, 2).split('\n');
128
+ const aSet = new Set(a);
129
+ const bSet = new Set(b);
130
+ const lines: string[] = [];
131
+ const max = Math.max(a.length, b.length);
132
+ for (let i = 0; i < max; i++) {
133
+ const ax = a[i];
134
+ const bx = b[i];
135
+ if (ax === bx) {
136
+ lines.push(` ${ax ?? ''}`);
137
+ } else {
138
+ if (ax !== undefined && !bSet.has(ax)) lines.push(`- ${ax}`);
139
+ if (bx !== undefined && !aSet.has(bx)) lines.push(`+ ${bx}`);
140
+ }
141
+ }
142
+ return lines.join('\n');
143
+ }
144
+
145
+ /** Shared helper for hosts: add an entry to an array field, returning a new array (Set union). */
146
+ export function unionArrayField(arr: unknown, entry: string): { next: string[]; added: boolean } | { warning: string } {
147
+ if (arr === undefined) return { next: [entry], added: true };
148
+ if (!Array.isArray(arr)) return { warning: `existing value is not an array, refusing to overwrite` };
149
+ if (arr.includes(entry)) return { next: arr.slice() as string[], added: false };
150
+ return { next: [...(arr as string[]), entry], added: true };
151
+ }