@bakapiano/ccsm 0.22.3 → 0.22.4

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 (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +148 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +62 -15
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
package/lib/config.js CHANGED
@@ -1,274 +1,274 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs/promises');
4
- const fsSync = require('node:fs');
5
- const path = require('node:path');
6
- const os = require('node:os');
7
- const { atomicWriteJson, withFileLock } = require('./atomicJson');
8
-
9
- // Data dir lives under ~/.ccsm by default so config survives across upgrades
10
- // (incl. running from a new npx checkout). Override with CCSM_HOME if you
11
- // want a different location.
12
- const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
13
- const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
-
15
- const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
16
-
17
- // v1.0 — wt / system-terminal launch path removed. Sessions are always
18
- // in-page web terminals managed by ccsm. CLI is pluggable: configure one
19
- // or more entries under `clis` (claude, codex, custom wrappers), pick a
20
- // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
21
- // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
22
- // `snapshot*`) are silently dropped on load.
23
- const DEFAULT_CLIS = [
24
- {
25
- id: 'claude',
26
- name: 'Claude Code',
27
- command: 'claude',
28
- args: [],
29
- // Pre-assign the upstream session UUID when starting a NEW session.
30
- // ccsm picks the UUID, passes it via these args, and stores it on
31
- // the record immediately — no need to poll the transcript dir later.
32
- newSessionIdArgs: ['--session-id', '<id>'],
33
- resumeIdArgs: ['--resume', '<id>'],
34
- shell: 'direct',
35
- type: 'claude',
36
- builtin: true,
37
- },
38
- {
39
- id: 'codex',
40
- name: 'OpenAI Codex',
41
- command: 'codex',
42
- args: [],
43
- // codex has no "use this UUID for a new session" flag, but we work
44
- // around it: ccsm seeds a fake rollout file at the right path with
45
- // our chosen UUID (lib/codexSeed.js), then spawns `codex resume <id>`
46
- // so the first launch *is* a resume against our seed. From then on
47
- // codex appends to the same file and resume-by-id keeps working.
48
- newSessionIdArgs: ['resume', '<id>'],
49
- resumeIdArgs: ['resume', '<id>'],
50
- shell: 'direct',
51
- type: 'codex',
52
- builtin: true,
53
- },
54
- {
55
- id: 'copilot',
56
- name: 'GitHub Copilot',
57
- command: 'copilot',
58
- args: [],
59
- // copilot has no `--resume <id>` (space) flag — that parses as `--resume`
60
- // (valueless → session picker) plus a stray positional arg, which hangs on
61
- // a blank screen. `--session-id <id>` both creates AND resumes a session by
62
- // id (`copilot --help`: "Resume an existing session or task by ID"), so we
63
- // use it for both new and resume.
64
- newSessionIdArgs: ['--session-id', '<id>'],
65
- resumeIdArgs: ['--session-id', '<id>'],
66
- shell: 'direct',
67
- type: 'copilot',
68
- builtin: true,
69
- },
70
- ];
71
-
72
- const DEFAULTS = {
73
- port: 7777,
74
- workDir: path.join(os.homedir(), 'ccsm-workspaces'),
75
- // Repos available for cloning into a fresh workspace at launch time.
76
- // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
77
- repos: [],
78
- // Pluggable CLIs. Add custom wrappers or self-hosted
79
- // proxies by appending an entry. defaultCliId picks one for the
80
- // Launch button when the user doesn't override.
81
- clis: DEFAULT_CLIS,
82
- defaultCliId: 'claude',
83
- // External editor command for the "Open in editor" session action.
84
- // Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
85
- // Control panel doubles as the review-changes view once the folder's
86
- // open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
87
- editor: 'code',
88
- // Devtunnel state. tunnelId holds the persistent (named) tunnel
89
- // ccsm minted via `devtunnel create` on first Start. Reusing it
90
- // across host restarts keeps the public URL — and therefore the
91
- // remote browsers' approval records — stable. `devtunnel delete <id>`
92
- // is invoked when the user explicitly rotates via the Reset button.
93
- devtunnel: { tunnelId: null },
94
- // Provider-agnostic tunnel prefs. When autoStart is on, the backend
95
- // brings the tunnel up during its own startup (server.js boot hook) —
96
- // NOT an OS-level autostart. token is persisted so share URLs survive
97
- // a backend restart; it's written ONLY while autoStart is on and is
98
- // stripped from /api/config so remote devices can't read it. provider
99
- // is 'devtunnel' | 'cloudflared'.
100
- tunnel: { autoStart: false, provider: null, token: null },
101
- };
102
-
103
- function ensureDataDir() {
104
- if (!fsSync.existsSync(DATA_DIR)) {
105
- fsSync.mkdirSync(DATA_DIR, { recursive: true });
106
- }
107
- }
108
-
109
- // If we find a legacy <repo>/data dir from before the home-dir move AND
110
- // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
111
- // empty so existing users with both dirs aren't clobbered.
112
- function migrateLegacyDataIfNeeded() {
113
- if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
114
- if (LEGACY_DATA_DIR === DATA_DIR) return;
115
- ensureDataDir();
116
- const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
117
- if (!dataEmpty) return;
118
- try {
119
- fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
120
- console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
121
- } catch (e) {
122
- console.error('[ccsm] legacy migration failed:', e.message);
123
- }
124
- }
125
-
126
- migrateLegacyDataIfNeeded();
127
-
128
- // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
129
- // object so callers don't mutate DEFAULTS.
130
- function mergeWithDefaults(partial) {
131
- const out = { ...DEFAULTS, ...partial };
132
- // Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
133
- // just .autoStart) doesn't wipe sibling keys.
134
- out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
135
- out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
136
- // Drop v0.x keys that the new architecture doesn't use.
137
- delete out.terminal;
138
- delete out.commandShell;
139
- delete out.claudeCommand;
140
- delete out.defaultTerminalMode;
141
- delete out.autoFocusOnLaunch;
142
- delete out.focusMovesToCenter;
143
- delete out.snapshotIntervalMs;
144
- delete out.snapshotHistoryKeep;
145
- delete out.autoOpenBrowser;
146
- delete out.browserMode;
147
- delete out.finderPrompt;
148
-
149
- if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
150
- if (!Array.isArray(out.clis)) out.clis = [];
151
- if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
152
- // Always inject builtin CLIs (claude, codex) if they're missing or were
153
- // deleted from a saved config — they're managed by ccsm, the user can
154
- // tweak args/shell but can't remove them. Preserves any user
155
- // customisation on existing builtin entries.
156
- for (const def of DEFAULT_CLIS) {
157
- const existing = out.clis.find((c) => c.id === def.id);
158
- if (existing) {
159
- existing.builtin = true;
160
- // Backfill defaults from the built-in template for any field the
161
- // user's saved copy is missing OR has as an empty array. Empty
162
- // arrays matter because users upgrading from a pre-0.15 config
163
- // never wrote `newSessionIdArgs` (didn't exist), AND a partial
164
- // 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
165
- // the same: a builtin with no template means "use the canonical
166
- // one ccsm now knows about", since these fields are the
167
- // integration contract with the upstream CLI — not user knobs.
168
- const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
169
- if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
170
- if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
171
- // Heal the old broken copilot resume flag: `--resume <id>` (space form)
172
- // isn't valid copilot syntax — it parses as a valueless `--resume`
173
- // (session picker) plus a stray positional, hanging on a blank screen.
174
- // The builtin now uses `--session-id <id>`. Migrate configs still
175
- // carrying the old default; genuine user customisations are left alone.
176
- if (existing.id === 'copilot'
177
- && Array.isArray(existing.resumeIdArgs)
178
- && existing.resumeIdArgs.join(' ') === '--resume <id>') {
179
- existing.resumeIdArgs = def.resumeIdArgs;
180
- }
181
- // Drop the v0.x `resumeArgs` fallback — every builtin now has a
182
- // pre-assigned UUID (claude/copilot via flag, codex via seed) so
183
- // resumeIdArgs always applies on resume; the field is dead weight.
184
- delete existing.resumeArgs;
185
- if (!existing.type) existing.type = def.type;
186
- } else {
187
- out.clis.unshift({ ...def });
188
- }
189
- }
190
- // Normalize per-CLI fields.
191
- out.clis = out.clis.map((c) => {
192
- const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
193
- const normalized = {
194
- ...rest,
195
- args: Array.isArray(rest.args) ? rest.args : [],
196
- resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
197
- newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
198
- shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
199
- type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
200
- builtin: !!rest.builtin,
201
- };
202
- // Type-based fallback for non-builtin CLIs (wrappers that
203
- // just call claude under the hood). If user picked
204
- // type='claude' but left newSessionIdArgs / resumeIdArgs blank,
205
- // assume they want the same args claude / copilot / codex use
206
- // canonically — without this the wrapped CLI gets spawned with
207
- // no UUID and ccsm can never recapture the upstream session.
208
- // Builtins are already handled by the loop above with `def`.
209
- if (!normalized.builtin && normalized.type !== 'other') {
210
- const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
211
- if (template) {
212
- if (normalized.newSessionIdArgs.length === 0) {
213
- normalized.newSessionIdArgs = [...template.newSessionIdArgs];
214
- }
215
- if (normalized.resumeIdArgs.length === 0) {
216
- normalized.resumeIdArgs = [...template.resumeIdArgs];
217
- }
218
- }
219
- }
220
- return normalized;
221
- });
222
- // Make sure defaultCliId points at an actual CLI; fall back to first.
223
- if (!out.clis.find((c) => c.id === out.defaultCliId)) {
224
- out.defaultCliId = out.clis[0].id;
225
- }
226
- return out;
227
- }
228
-
229
- async function loadConfig() {
230
- ensureDataDir();
231
- try {
232
- const raw = await fs.readFile(CONFIG_PATH, 'utf8');
233
- return mergeWithDefaults(JSON.parse(raw));
234
- } catch (e) {
235
- if (e.code === 'ENOENT') {
236
- const cfg = { ...DEFAULTS };
237
- await atomicWriteJson(CONFIG_PATH, cfg);
238
- return cfg;
239
- }
240
- throw e;
241
- }
242
- }
243
-
244
- async function saveConfig(partial) {
245
- ensureDataDir();
246
- return withFileLock(CONFIG_PATH, async () => {
247
- const current = await loadConfig();
248
- // mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
249
- // against DEFAULTS only, so a partial save like
250
- // saveConfig({ tunnel: { autoStart: true } }) would reset the
251
- // sibling token/provider back to defaults. Pre-merge the nested
252
- // blocks against `current` so a partial update preserves siblings.
253
- const merged = { ...current, ...partial };
254
- if (partial && partial.devtunnel) {
255
- merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
256
- }
257
- if (partial && partial.tunnel) {
258
- merged.tunnel = { ...current.tunnel, ...partial.tunnel };
259
- }
260
- const next = mergeWithDefaults(merged);
261
- await atomicWriteJson(CONFIG_PATH, next);
262
- return next;
263
- });
264
- }
265
-
266
- module.exports = {
267
- loadConfig,
268
- saveConfig,
269
- DATA_DIR,
270
- CONFIG_PATH,
271
- LEGACY_DATA_DIR,
272
- DEFAULTS,
273
- DEFAULT_CLIS,
274
- };
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const fsSync = require('node:fs');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
8
+
9
+ // Data dir lives under ~/.ccsm by default so config survives across upgrades
10
+ // (incl. running from a new npx checkout). Override with CCSM_HOME if you
11
+ // want a different location.
12
+ const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
13
+ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
+
15
+ const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
16
+
17
+ // v1.0 — wt / system-terminal launch path removed. Sessions are always
18
+ // in-page web terminals managed by ccsm. CLI is pluggable: configure one
19
+ // or more entries under `clis` (claude, codex, custom wrappers), pick a
20
+ // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
21
+ // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
22
+ // `snapshot*`) are silently dropped on load.
23
+ const DEFAULT_CLIS = [
24
+ {
25
+ id: 'claude',
26
+ name: 'Claude Code',
27
+ command: 'claude',
28
+ args: [],
29
+ // Pre-assign the upstream session UUID when starting a NEW session.
30
+ // ccsm picks the UUID, passes it via these args, and stores it on
31
+ // the record immediately — no need to poll the transcript dir later.
32
+ newSessionIdArgs: ['--session-id', '<id>'],
33
+ resumeIdArgs: ['--resume', '<id>'],
34
+ shell: 'direct',
35
+ type: 'claude',
36
+ builtin: true,
37
+ },
38
+ {
39
+ id: 'codex',
40
+ name: 'OpenAI Codex',
41
+ command: 'codex',
42
+ args: [],
43
+ // codex has no "use this UUID for a new session" flag, but we work
44
+ // around it: ccsm seeds a fake rollout file at the right path with
45
+ // our chosen UUID (lib/codexSeed.js), then spawns `codex resume <id>`
46
+ // so the first launch *is* a resume against our seed. From then on
47
+ // codex appends to the same file and resume-by-id keeps working.
48
+ newSessionIdArgs: ['resume', '<id>'],
49
+ resumeIdArgs: ['resume', '<id>'],
50
+ shell: 'direct',
51
+ type: 'codex',
52
+ builtin: true,
53
+ },
54
+ {
55
+ id: 'copilot',
56
+ name: 'GitHub Copilot',
57
+ command: 'copilot',
58
+ args: [],
59
+ // copilot has no `--resume <id>` (space) flag — that parses as `--resume`
60
+ // (valueless → session picker) plus a stray positional arg, which hangs on
61
+ // a blank screen. `--session-id <id>` both creates AND resumes a session by
62
+ // id (`copilot --help`: "Resume an existing session or task by ID"), so we
63
+ // use it for both new and resume.
64
+ newSessionIdArgs: ['--session-id', '<id>'],
65
+ resumeIdArgs: ['--session-id', '<id>'],
66
+ shell: 'direct',
67
+ type: 'copilot',
68
+ builtin: true,
69
+ },
70
+ ];
71
+
72
+ const DEFAULTS = {
73
+ port: 7777,
74
+ workDir: path.join(os.homedir(), 'ccsm-workspaces'),
75
+ // Repos available for cloning into a fresh workspace at launch time.
76
+ // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
77
+ repos: [],
78
+ // Pluggable CLIs. Add custom wrappers or self-hosted
79
+ // proxies by appending an entry. defaultCliId picks one for the
80
+ // Launch button when the user doesn't override.
81
+ clis: DEFAULT_CLIS,
82
+ defaultCliId: 'claude',
83
+ // External editor command for the "Open in editor" session action.
84
+ // Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
85
+ // Control panel doubles as the review-changes view once the folder's
86
+ // open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
87
+ editor: 'code',
88
+ // Devtunnel state. tunnelId holds the persistent (named) tunnel
89
+ // ccsm minted via `devtunnel create` on first Start. Reusing it
90
+ // across host restarts keeps the public URL — and therefore the
91
+ // remote browsers' approval records — stable. `devtunnel delete <id>`
92
+ // is invoked when the user explicitly rotates via the Reset button.
93
+ devtunnel: { tunnelId: null },
94
+ // Provider-agnostic tunnel prefs. When autoStart is on, the backend
95
+ // brings the tunnel up during its own startup (server.js boot hook) —
96
+ // NOT an OS-level autostart. token is persisted so share URLs survive
97
+ // a backend restart; it's written ONLY while autoStart is on and is
98
+ // stripped from /api/config so remote devices can't read it. provider
99
+ // is 'devtunnel' | 'cloudflared'.
100
+ tunnel: { autoStart: false, provider: null, token: null },
101
+ };
102
+
103
+ function ensureDataDir() {
104
+ if (!fsSync.existsSync(DATA_DIR)) {
105
+ fsSync.mkdirSync(DATA_DIR, { recursive: true });
106
+ }
107
+ }
108
+
109
+ // If we find a legacy <repo>/data dir from before the home-dir move AND
110
+ // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
111
+ // empty so existing users with both dirs aren't clobbered.
112
+ function migrateLegacyDataIfNeeded() {
113
+ if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
114
+ if (LEGACY_DATA_DIR === DATA_DIR) return;
115
+ ensureDataDir();
116
+ const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
117
+ if (!dataEmpty) return;
118
+ try {
119
+ fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
120
+ console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
121
+ } catch (e) {
122
+ console.error('[ccsm] legacy migration failed:', e.message);
123
+ }
124
+ }
125
+
126
+ migrateLegacyDataIfNeeded();
127
+
128
+ // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
129
+ // object so callers don't mutate DEFAULTS.
130
+ function mergeWithDefaults(partial) {
131
+ const out = { ...DEFAULTS, ...partial };
132
+ // Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
133
+ // just .autoStart) doesn't wipe sibling keys.
134
+ out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
135
+ out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
136
+ // Drop v0.x keys that the new architecture doesn't use.
137
+ delete out.terminal;
138
+ delete out.commandShell;
139
+ delete out.claudeCommand;
140
+ delete out.defaultTerminalMode;
141
+ delete out.autoFocusOnLaunch;
142
+ delete out.focusMovesToCenter;
143
+ delete out.snapshotIntervalMs;
144
+ delete out.snapshotHistoryKeep;
145
+ delete out.autoOpenBrowser;
146
+ delete out.browserMode;
147
+ delete out.finderPrompt;
148
+
149
+ if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
150
+ if (!Array.isArray(out.clis)) out.clis = [];
151
+ if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
152
+ // Always inject builtin CLIs (claude, codex) if they're missing or were
153
+ // deleted from a saved config — they're managed by ccsm, the user can
154
+ // tweak args/shell but can't remove them. Preserves any user
155
+ // customisation on existing builtin entries.
156
+ for (const def of DEFAULT_CLIS) {
157
+ const existing = out.clis.find((c) => c.id === def.id);
158
+ if (existing) {
159
+ existing.builtin = true;
160
+ // Backfill defaults from the built-in template for any field the
161
+ // user's saved copy is missing OR has as an empty array. Empty
162
+ // arrays matter because users upgrading from a pre-0.15 config
163
+ // never wrote `newSessionIdArgs` (didn't exist), AND a partial
164
+ // 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
165
+ // the same: a builtin with no template means "use the canonical
166
+ // one ccsm now knows about", since these fields are the
167
+ // integration contract with the upstream CLI — not user knobs.
168
+ const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
169
+ if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
170
+ if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
171
+ // Heal the old broken copilot resume flag: `--resume <id>` (space form)
172
+ // isn't valid copilot syntax — it parses as a valueless `--resume`
173
+ // (session picker) plus a stray positional, hanging on a blank screen.
174
+ // The builtin now uses `--session-id <id>`. Migrate configs still
175
+ // carrying the old default; genuine user customisations are left alone.
176
+ if (existing.id === 'copilot'
177
+ && Array.isArray(existing.resumeIdArgs)
178
+ && existing.resumeIdArgs.join(' ') === '--resume <id>') {
179
+ existing.resumeIdArgs = def.resumeIdArgs;
180
+ }
181
+ // Drop the v0.x `resumeArgs` fallback — every builtin now has a
182
+ // pre-assigned UUID (claude/copilot via flag, codex via seed) so
183
+ // resumeIdArgs always applies on resume; the field is dead weight.
184
+ delete existing.resumeArgs;
185
+ if (!existing.type) existing.type = def.type;
186
+ } else {
187
+ out.clis.unshift({ ...def });
188
+ }
189
+ }
190
+ // Normalize per-CLI fields.
191
+ out.clis = out.clis.map((c) => {
192
+ const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
193
+ const normalized = {
194
+ ...rest,
195
+ args: Array.isArray(rest.args) ? rest.args : [],
196
+ resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
197
+ newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
198
+ shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
199
+ type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
200
+ builtin: !!rest.builtin,
201
+ };
202
+ // Type-based fallback for non-builtin CLIs (wrappers that
203
+ // just call claude under the hood). If user picked
204
+ // type='claude' but left newSessionIdArgs / resumeIdArgs blank,
205
+ // assume they want the same args claude / copilot / codex use
206
+ // canonically — without this the wrapped CLI gets spawned with
207
+ // no UUID and ccsm can never recapture the upstream session.
208
+ // Builtins are already handled by the loop above with `def`.
209
+ if (!normalized.builtin && normalized.type !== 'other') {
210
+ const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
211
+ if (template) {
212
+ if (normalized.newSessionIdArgs.length === 0) {
213
+ normalized.newSessionIdArgs = [...template.newSessionIdArgs];
214
+ }
215
+ if (normalized.resumeIdArgs.length === 0) {
216
+ normalized.resumeIdArgs = [...template.resumeIdArgs];
217
+ }
218
+ }
219
+ }
220
+ return normalized;
221
+ });
222
+ // Make sure defaultCliId points at an actual CLI; fall back to first.
223
+ if (!out.clis.find((c) => c.id === out.defaultCliId)) {
224
+ out.defaultCliId = out.clis[0].id;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ async function loadConfig() {
230
+ ensureDataDir();
231
+ try {
232
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
233
+ return mergeWithDefaults(JSON.parse(raw));
234
+ } catch (e) {
235
+ if (e.code === 'ENOENT') {
236
+ const cfg = { ...DEFAULTS };
237
+ await atomicWriteJson(CONFIG_PATH, cfg);
238
+ return cfg;
239
+ }
240
+ throw e;
241
+ }
242
+ }
243
+
244
+ async function saveConfig(partial) {
245
+ ensureDataDir();
246
+ return withFileLock(CONFIG_PATH, async () => {
247
+ const current = await loadConfig();
248
+ // mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
249
+ // against DEFAULTS only, so a partial save like
250
+ // saveConfig({ tunnel: { autoStart: true } }) would reset the
251
+ // sibling token/provider back to defaults. Pre-merge the nested
252
+ // blocks against `current` so a partial update preserves siblings.
253
+ const merged = { ...current, ...partial };
254
+ if (partial && partial.devtunnel) {
255
+ merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
256
+ }
257
+ if (partial && partial.tunnel) {
258
+ merged.tunnel = { ...current.tunnel, ...partial.tunnel };
259
+ }
260
+ const next = mergeWithDefaults(merged);
261
+ await atomicWriteJson(CONFIG_PATH, next);
262
+ return next;
263
+ });
264
+ }
265
+
266
+ module.exports = {
267
+ loadConfig,
268
+ saveConfig,
269
+ DATA_DIR,
270
+ CONFIG_PATH,
271
+ LEGACY_DATA_DIR,
272
+ DEFAULTS,
273
+ DEFAULT_CLIS,
274
+ };