codex-copilot-dx 0.6.21 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +142 -323
  2. package/bin/cli.mjs +26 -130
  3. package/package.json +2 -8
  4. package/scripts/benchmark.mjs +0 -114
  5. package/src/adapter.mjs +22 -107
  6. package/src/auth-profile.mjs +18 -130
  7. package/src/cli-animation.mjs +108 -0
  8. package/src/cli-auth.mjs +44 -398
  9. package/src/cli-models.mjs +21 -49
  10. package/src/cli-options.mjs +53 -75
  11. package/src/cli-status.mjs +6 -23
  12. package/src/config.mjs +2 -8
  13. package/src/copilot.mjs +2 -2
  14. package/src/doctor.mjs +44 -291
  15. package/src/model-cache.mjs +4 -11
  16. package/src/models.mjs +0 -184
  17. package/src/observability.mjs +1 -38
  18. package/src/profile-model-runtime.mjs +61 -189
  19. package/src/profile-routing.mjs +2 -102
  20. package/src/profile-runtime.mjs +15 -68
  21. package/src/responses-handler.mjs +1 -1
  22. package/src/responses-proxy.mjs +1 -1
  23. package/src/running-adapter.mjs +2 -4
  24. package/src/stream-errors.mjs +3 -6
  25. package/src/stream-performance.mjs +1 -1
  26. package/src/terminal-activity.mjs +40 -34
  27. package/src/terminal-animation.mjs +249 -0
  28. package/src/usage.mjs +0 -31
  29. package/src/user-settings.mjs +50 -0
  30. package/src/anthropic-handler.mjs +0 -195
  31. package/src/anthropic.mjs +0 -387
  32. package/src/claude-config.mjs +0 -57
  33. package/src/claude-desktop-config.mjs +0 -317
  34. package/src/cli-error.mjs +0 -50
  35. package/src/pm-studio-asar.mjs +0 -1244
  36. package/src/pm-studio-bundle-content.mjs +0 -165
  37. package/src/pm-studio-relay.mjs +0 -563
  38. package/src/pm-studio-setup-lock.mjs +0 -336
  39. package/src/pm-studio-setup.mjs +0 -1581
  40. package/src/pm-studio-status.mjs +0 -369
  41. package/src/token-counter-service.mjs +0 -211
  42. package/src/token-counter-worker.mjs +0 -16
package/src/adapter.mjs CHANGED
@@ -1,8 +1,6 @@
1
1
  import http from "node:http";
2
- import { createAnthropicCountTokensHandler, createAnthropicMessagesHandler } from "./anthropic-handler.mjs";
3
2
  import { defaultCopilotClient } from "./copilot.mjs";
4
3
  import { isValidModelList } from "./model-cache.mjs";
5
- import { claudeDesktopModelsResponse } from "./models.mjs";
6
4
  import {
7
5
  ADAPTER_STATUS_PATH,
8
6
  classifyAdapterRoute,
@@ -11,11 +9,9 @@ import {
11
9
  runtimeStatusPayload,
12
10
  } from "./observability.mjs";
13
11
  import { createRequestId, runWithRequestContext } from "./request-context.mjs";
14
- import { createPmStudioRelayHandler, PM_STUDIO_RELAY_PREFIX } from "./pm-studio-relay.mjs";
15
12
  import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
16
13
  import { status } from "./status.mjs";
17
14
  import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
18
- import { createTokenCounterService } from "./token-counter-service.mjs";
19
15
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
20
16
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
21
17
  import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
@@ -63,17 +59,19 @@ export function requestPath(reqUrl) {
63
59
  return new URL(reqUrl || "/", "http://localhost").pathname;
64
60
  }
65
61
 
66
- function bearerToken(headers = {}) {
67
- const value = headers.authorization || headers.Authorization || "";
68
- const match = /^Bearer\s+(.+)$/i.exec(String(value));
69
- return match?.[1]?.trim() || "";
70
- }
71
-
72
- export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
73
- if (!claudeDesktopApiKey || claudeDesktopApiKey === "dummy") return false;
74
- const token = bearerToken(req.headers);
75
- const xApiKey = req.headers?.["x-api-key"] || req.headers?.["X-Api-Key"] || req.headers?.["X-API-Key"] || "";
76
- return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
62
+ function sendRetiredClaudeSurface(res) {
63
+ res.writeHead(410, {
64
+ "Content-Type": "application/json",
65
+ "Cache-Control": "no-store",
66
+ });
67
+ res.end(JSON.stringify({
68
+ type: "error",
69
+ error: {
70
+ type: "not_found_error",
71
+ code: "ccdx_claude_retired",
72
+ message: "Claude App and Claude Code support was retired in ccdx 0.7.0.",
73
+ },
74
+ }));
77
75
  }
78
76
 
79
77
  function modelListCanUseLastKnownGood(statusCode) {
@@ -93,50 +91,18 @@ function sendLastKnownGoodModels(res, modelRegistry) {
93
91
  // Shared request handler. Keeping this separate from the listener makes the
94
92
  // complete HTTP routing layer testable without opening a local port.
95
93
  export function createAdapterHandler(options = {}) {
96
- const claudeDesktopApiKey = options.claudeDesktopApiKey
97
- || process.env.CCDX_CLAUDE_DESKTOP_API_KEY
98
- || process.env.CCDX_PROXY_API_KEY
99
- || "";
100
94
  const codexClient = options.codexClient || defaultCopilotClient;
101
- const hasExplicitClaudeMode = Object.hasOwn(options, "claudeMode");
102
- if (hasExplicitClaudeMode && !["inherited", "isolated"].includes(options.claudeMode)) {
103
- throw new Error(`Unsupported Claude mode: ${options.claudeMode}`);
104
- }
105
- if (options.claudeMode === "isolated"
106
- && (!options.claudeClient || options.claudeClient === codexClient)) {
107
- throw new Error("Isolated Claude mode requires a distinct Claude client");
108
- }
109
- if (options.claudeMode === "inherited"
110
- && options.claudeClient && options.claudeClient !== codexClient) {
111
- throw new Error("Inherited Claude mode requires the Codex client");
112
- }
113
- const claudeClient = options.claudeClient || codexClient;
114
- // chatCompletionsFn is retained as a compatibility injection and continues to
115
- // override both surfaces in existing tests and embedders. New dual-profile
116
- // callers should pass codexClient/claudeClient instead.
117
- const codexChatCompletionsFn = options.codexChatCompletionsFn
95
+ // Keep the established injection names because Responses may still use the
96
+ // internal Chat Completions compatibility path for upstream models that need it.
97
+ const chatCompletionsFn = options.codexChatCompletionsFn
118
98
  || options.chatCompletionsFn
119
99
  || codexClient.chatCompletions;
120
- const claudeChatCompletionsFn = options.claudeChatCompletionsFn
121
- || options.chatCompletionsFn
122
- || claudeClient.chatCompletions;
123
100
  const responsesFn = options.responsesFn || codexClient.responses;
124
101
  const responsesCompactFn = options.responsesCompactFn || codexClient.responsesCompact;
125
102
  const listModelsFn = options.listModelsFn || codexClient.listModels;
126
103
  const getCachedModelEndpointsFn = options.getCachedModelEndpointsFn
127
104
  || codexClient.getCachedModelEndpoints;
128
- const claudeMode = hasExplicitClaudeMode
129
- ? options.claudeMode
130
- : claudeClient === codexClient ? "inherited" : "isolated";
131
- if (claudeMode === "isolated" && options.chatCompletionsFn) {
132
- throw new Error("Isolated Claude mode cannot use the shared chatCompletionsFn override; pass codexChatCompletionsFn or claudeChatCompletionsFn explicitly");
133
- }
134
105
  const codexModelRegistry = options.codexModelRegistry || options.modelRegistry;
135
- const claudeModelRegistry = options.claudeModelRegistry || options.modelRegistry || codexModelRegistry;
136
- if (claudeMode === "isolated"
137
- && (!options.claudeModelRegistry || claudeModelRegistry === codexModelRegistry)) {
138
- throw new Error("Isolated Claude mode requires a distinct Claude model registry");
139
- }
140
106
  const openAIModelEnv = options.openAIModelEnv || process.env;
141
107
  const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, ADAPTER_RUNTIME_CONFIG.upstreamTimeoutMs);
142
108
  const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamHandshakeTimeoutMs);
@@ -146,19 +112,10 @@ export function createAdapterHandler(options = {}) {
146
112
  const requestMetrics = options.requestMetrics || createRequestMetrics();
147
113
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
148
114
  const imagePressure = options.imagePressure || createResponsesImagePressureController();
149
- const ownsTokenCounterService = !options.tokenCounterService;
150
- const tokenCounterService = options.tokenCounterService || createTokenCounterService({
151
- timeoutMs: options.countTokensTimeoutMs,
152
- maxQueued: options.countTokensMaxQueued,
153
- });
154
- const claudeDesktopModelOptions = () => {
155
- const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
156
- return Array.isArray(modelDefs) ? { modelDefs } : {};
157
- };
158
115
  const responsesHandler = createResponsesHandler({
159
116
  acquireRequest,
160
117
  autoReviewModelResolver: options.autoReviewModelResolver,
161
- chatCompletionsFn: codexChatCompletionsFn,
118
+ chatCompletionsFn,
162
119
  getCachedModelEndpointsFn,
163
120
  imagePressure,
164
121
  modelRegistry: codexModelRegistry,
@@ -184,34 +141,6 @@ export function createAdapterHandler(options = {}) {
184
141
  streamIdleTimeoutMs,
185
142
  upstreamTimeoutMs,
186
143
  });
187
- const anthropicCountTokensHandler = createAnthropicCountTokensHandler({
188
- acquireRequest,
189
- requestBodyTimeoutMs,
190
- countTokensFn: (body, countOptions) => tokenCounterService.count(body, countOptions),
191
- });
192
- const anthropicMessagesHandler = createAnthropicMessagesHandler({
193
- acquireRequest,
194
- chatCompletionsFn: claudeChatCompletionsFn,
195
- environment: process.env,
196
- modelOptions: claudeDesktopModelOptions,
197
- requestBodyTimeoutMs,
198
- streamHandshakeTimeoutMs,
199
- streamIdleTimeoutMs,
200
- upstreamTimeoutMs,
201
- });
202
- const pmStudioRelayHandler = createPmStudioRelayHandler({
203
- acquireRequest,
204
- claudeClient,
205
- claudeMode,
206
- claudeProfile: options.claudeProfile,
207
- claudeModelRegistry,
208
- fetchImpl: options.pmStudioFetchImpl,
209
- requestBodyTimeoutMs,
210
- streamHandshakeTimeoutMs,
211
- streamIdleTimeoutMs,
212
- upstreamTimeoutMs,
213
- });
214
-
215
144
  const dispatch = (req, res, pathname) => {
216
145
  if (req.method === "GET" && pathname === ADAPTER_STATUS_PATH) {
217
146
  if (!isLoopbackAddress(req.socket?.remoteAddress)) {
@@ -227,11 +156,7 @@ export function createAdapterHandler(options = {}) {
227
156
  imagePressure,
228
157
  modelRegistry: codexModelRegistry,
229
158
  codexClient,
230
- claudeClient,
231
159
  codexModelRegistry,
232
- claudeModelRegistry,
233
- claudeMode,
234
- isClaudeProfileCurrent: options.isClaudeProfileCurrent,
235
160
  })));
236
161
  return;
237
162
  }
@@ -242,10 +167,6 @@ export function createAdapterHandler(options = {}) {
242
167
  return;
243
168
  }
244
169
 
245
- if (pathname.startsWith(`${PM_STUDIO_RELAY_PREFIX}/`)) {
246
- return pmStudioRelayHandler(req, res, pathname);
247
- }
248
-
249
170
  if (req.method === "POST" && pathname === "/v1/responses") {
250
171
  return responsesHandler(req, res);
251
172
  }
@@ -255,11 +176,6 @@ export function createAdapterHandler(options = {}) {
255
176
  }
256
177
 
257
178
  if (req.method === "GET" && pathname === "/v1/models") {
258
- if (shouldServeClaudeDesktopModels(req, claudeDesktopApiKey)) {
259
- res.writeHead(200, { "Content-Type": "application/json" });
260
- res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions())));
261
- return;
262
- }
263
179
  const abort = createRequestAbort(req, res);
264
180
  abort.setTimeout(upstreamTimeoutMs);
265
181
  listModelsFn({ signal: abort.signal })
@@ -296,11 +212,11 @@ export function createAdapterHandler(options = {}) {
296
212
  }
297
213
 
298
214
  if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
299
- return anthropicCountTokensHandler(req, res);
215
+ return sendRetiredClaudeSurface(res);
300
216
  }
301
217
 
302
218
  if (req.method === "POST" && pathname === "/v1/messages") {
303
- return anthropicMessagesHandler(req, res);
219
+ return sendRetiredClaudeSurface(res);
304
220
  }
305
221
 
306
222
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -388,9 +304,6 @@ export function createAdapterHandler(options = {}) {
388
304
  containUnexpectedError(error);
389
305
  }
390
306
  };
391
- handler.cleanup = () => {
392
- if (ownsTokenCounterService) tokenCounterService.close();
393
- };
394
307
  return handler;
395
308
  }
396
309
 
@@ -398,7 +311,9 @@ export function createAdapterHandler(options = {}) {
398
311
 
399
312
  export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
400
313
  const ownsTerminalActivity = !Object.hasOwn(options, "terminalActivity");
401
- const terminalActivity = ownsTerminalActivity ? createTerminalActivityIndicator() : options.terminalActivity;
314
+ const terminalActivity = ownsTerminalActivity
315
+ ? createTerminalActivityIndicator({ theme: options.terminalAnimationTheme })
316
+ : options.terminalActivity;
402
317
  const handler = createAdapterHandler({ ...options, terminalActivity });
403
318
  const server = http.createServer(handler);
404
319
  const cleanupTerminalActivity = () => {
@@ -1,7 +1,5 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
- import path from "node:path";
4
- import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
5
3
  import {
6
4
  githubTokenLockPath,
7
5
  githubTokenMetadataPath,
@@ -11,172 +9,62 @@ import {
11
9
  githubTokenFingerprint,
12
10
  normalizeGithubIdentity,
13
11
  } from "./github-identity.mjs";
14
- import { withFileLock } from "./lock.mjs";
15
- import { parseSafePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
16
12
 
17
13
  export const AUTH_PROFILE_CODEX = "codex";
18
- export const AUTH_PROFILE_CLAUDE = "claude";
19
-
20
- const AUTH_PROFILES = new Set([AUTH_PROFILE_CODEX, AUTH_PROFILE_CLAUDE]);
21
14
 
22
15
  function checkedProfile(profile) {
23
16
  const value = String(profile || "").trim().toLowerCase();
24
- if (!AUTH_PROFILES.has(value)) throw new Error(`Unsupported authentication profile: ${profile}`);
17
+ if (value !== AUTH_PROFILE_CODEX) {
18
+ throw new Error(`Unsupported authentication profile: ${profile}`);
19
+ }
25
20
  return value;
26
21
  }
27
22
 
28
23
  export function authProfilePaths(profile, { home = os.homedir() } = {}) {
29
24
  const name = checkedProfile(profile);
30
- if (name === AUTH_PROFILE_CODEX) {
31
- return {
32
- profile: name,
33
- tokenPath: githubTokenPath(home),
34
- metadataPath: githubTokenMetadataPath(home),
35
- lockPath: githubTokenLockPath(home),
36
- };
37
- }
38
-
39
- const directory = path.join(home, ".local", "share", "copilot-api", "profiles", AUTH_PROFILE_CLAUDE);
40
- const tokenPath = path.join(directory, "github_token");
41
25
  return {
42
26
  profile: name,
43
- tokenPath,
44
- metadataPath: `${tokenPath}.account.json`,
45
- lockPath: `${tokenPath}.lock`,
27
+ tokenPath: githubTokenPath(home),
28
+ metadataPath: githubTokenMetadataPath(home),
29
+ lockPath: githubTokenLockPath(home),
46
30
  };
47
31
  }
48
32
 
49
33
  function readOptionalFile(filePath) {
50
34
  try {
51
- return { exists: true, data: fs.readFileSync(filePath), mode: fs.statSync(filePath).mode & 0o777 };
35
+ return { exists: true, data: fs.readFileSync(filePath) };
52
36
  } catch (error) {
53
- if (error?.code === "ENOENT") return { exists: false, data: null, mode: 0o600 };
37
+ if (error?.code === "ENOENT") return { exists: false, data: null };
54
38
  throw error;
55
39
  }
56
40
  }
57
41
 
58
42
  function parsedMetadata(snapshot) {
59
- if (!snapshot.exists) return { value: null, malformed: false };
43
+ if (!snapshot.exists) return null;
60
44
  try {
61
- return { value: JSON.parse(snapshot.data.toString("utf8")), malformed: false };
45
+ return JSON.parse(snapshot.data.toString("utf8"));
62
46
  } catch {
63
- return { value: null, malformed: true };
47
+ return null;
64
48
  }
65
49
  }
66
50
 
67
51
  export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}) {
68
52
  const paths = authProfilePaths(profile, { home });
69
53
  const tokenSnapshot = readOptionalFile(paths.tokenPath);
70
- const metadataSnapshot = readOptionalFile(paths.metadataPath);
71
54
  const token = tokenSnapshot.exists ? tokenSnapshot.data.toString("utf8").trim() : "";
72
- const metadataResult = parsedMetadata(metadataSnapshot);
73
- const metadataFingerprint = metadataResult.value?.token_fingerprint;
55
+ const metadata = parsedMetadata(readOptionalFile(paths.metadataPath));
74
56
  const metadataMatchesToken = Boolean(token)
75
- && Boolean(metadataFingerprint)
76
- && metadataFingerprint === githubTokenFingerprint(token);
77
- const metadataIdentity = normalizeGithubIdentity(metadataResult.value);
78
- const identity = metadataMatchesToken ? metadataIdentity : null;
79
-
80
- if (paths.profile === AUTH_PROFILE_CODEX) {
81
- const configured = tokenSnapshot.exists;
82
- return {
83
- profile: paths.profile,
84
- configured,
85
- valid: configured && Boolean(token),
86
- reason: !configured ? "unconfigured" : token ? "" : "empty_token",
87
- token,
88
- identity,
89
- metadata: metadataResult.value,
90
- paths,
91
- };
92
- }
93
-
94
- // Metadata is written last, but any credential artifact reserves the Claude
95
- // profile. A partial write must fail closed instead of borrowing Codex.
96
- const configured = metadataSnapshot.exists || tokenSnapshot.exists;
97
- let reason = "";
98
- if (!configured) reason = "unconfigured";
99
- else if (!metadataSnapshot.exists) reason = "missing_metadata";
100
- else if (metadataResult.malformed) reason = "metadata_malformed";
101
- else if (!tokenSnapshot.exists) reason = "missing_token";
102
- else if (!token) reason = "empty_token";
103
- else if (!metadataIdentity) reason = "metadata_identity_missing";
104
- else if (!metadataFingerprint || metadataFingerprint !== githubTokenFingerprint(token)) reason = "token_metadata_mismatch";
57
+ && Boolean(metadata?.token_fingerprint)
58
+ && metadata.token_fingerprint === githubTokenFingerprint(token);
105
59
 
106
60
  return {
107
61
  profile: paths.profile,
108
- configured,
109
- valid: configured && !reason,
110
- reason,
62
+ configured: tokenSnapshot.exists,
63
+ valid: tokenSnapshot.exists && Boolean(token),
64
+ reason: !tokenSnapshot.exists ? "unconfigured" : token ? "" : "empty_token",
111
65
  token,
112
- identity,
113
- metadata: metadataResult.value,
114
- paths,
115
- };
116
- }
117
-
118
- function lockOptions(env = process.env) {
119
- return {
120
- timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
- staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
122
- };
123
- }
124
-
125
- export function withAuthProfileLock(profile, fn, {
126
- home = os.homedir(),
127
- env = process.env,
128
- ...options
129
- } = {}) {
130
- const { lockPath } = authProfilePaths(profile, { home });
131
- return withFileLock(lockPath, fn, { ...lockOptions(env), ...options });
132
- }
133
-
134
- export function writeClaudeAuthProfile(token, identity, {
135
- home = os.homedir(),
136
- now = () => new Date(),
137
- writeFile = atomicWriteFileSync,
138
- unlinkFile = fs.unlinkSync,
139
- } = {}) {
140
- const cleanToken = String(token || "").trim();
141
- const normalizedIdentity = normalizeGithubIdentity(identity);
142
- if (!cleanToken) throw new Error("Claude GitHub token is empty");
143
- if (!normalizedIdentity) throw new Error("Claude GitHub account identity is missing");
144
-
145
- const paths = authProfilePaths(AUTH_PROFILE_CLAUDE, { home });
146
- const directory = path.dirname(paths.tokenPath);
147
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
148
- fs.chmodSync(directory, 0o700);
149
-
150
- const timestamp = now();
151
- const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
152
- const metadata = `${JSON.stringify({
153
- profile: AUTH_PROFILE_CLAUDE,
154
- ...normalizedIdentity,
155
- token_fingerprint: githubTokenFingerprint(cleanToken),
156
- updated_at: updatedAt,
157
- }, null, 2)}\n`;
158
-
159
- // Commit the activation marker last. Readers never observe a configured
160
- // Claude profile unless the token and its fingerprint agree.
161
- atomicWriteFilePairSync(
162
- paths.tokenPath,
163
- cleanToken,
164
- paths.metadataPath,
66
+ identity: metadataMatchesToken ? normalizeGithubIdentity(metadata) : null,
165
67
  metadata,
166
- { mode: 0o600, writeFile, unlinkFile },
167
- );
168
-
169
- return {
170
- profile: AUTH_PROFILE_CLAUDE,
171
- identity: normalizedIdentity,
172
68
  paths,
173
69
  };
174
70
  }
175
-
176
- export function profileReauthMessage(profile, { home = os.homedir() } = {}) {
177
- const name = checkedProfile(profile);
178
- if (name === AUTH_PROFILE_CLAUDE) {
179
- return "Claude authentication is unavailable. Run `ccdx auth login claude --reauth`, then restart ccdx.";
180
- }
181
- return `Codex authentication is unavailable at ${authProfilePaths(name, { home }).tokenPath}. Run ccdx again to log in.`;
182
- }
@@ -0,0 +1,108 @@
1
+ import os from "node:os";
2
+ import { createInterface } from "node:readline/promises";
3
+ import {
4
+ DEFAULT_TERMINAL_ANIMATION_THEME,
5
+ playTerminalAnimationPreview,
6
+ renderTerminalAnimationFrame,
7
+ TERMINAL_ANIMATION_THEMES,
8
+ } from "./terminal-animation.mjs";
9
+ import {
10
+ readUserSettings,
11
+ terminalAnimationPreference,
12
+ writeTerminalAnimationTheme,
13
+ } from "./user-settings.mjs";
14
+
15
+ const DISABLED_ENV_VALUES = new Set(["0", "false", "no", "off"]);
16
+
17
+ function markers(theme, currentTheme) {
18
+ const values = [];
19
+ if (theme.id === DEFAULT_TERMINAL_ANIMATION_THEME) values.push("default");
20
+ if (theme.id === currentTheme) values.push("current");
21
+ return values.length ? ` [${values.join(", ")}]` : "";
22
+ }
23
+
24
+ function disabledEnvironmentValue(env) {
25
+ const value = String(env.CCDX_TERMINAL_ANIMATION || "").trim();
26
+ return DISABLED_ENV_VALUES.has(value.toLowerCase()) ? value : "";
27
+ }
28
+
29
+ export async function runAnimationCommand({
30
+ commandName = "ccdx",
31
+ env = process.env,
32
+ home = os.homedir(),
33
+ input = process.stdin,
34
+ output = process.stdout,
35
+ prompt,
36
+ preview = playTerminalAnimationPreview,
37
+ } = {}) {
38
+ if (!prompt && (!input.isTTY || !output.isTTY)) {
39
+ throw new Error(`${commandName} animation requires an interactive terminal`);
40
+ }
41
+
42
+ readUserSettings({ env, home, strict: true });
43
+ const current = terminalAnimationPreference({ env, home });
44
+ const currentIndex = TERMINAL_ANIMATION_THEMES.findIndex(({ id }) => id === current.theme);
45
+
46
+ output.write(`${commandName} animation\n`);
47
+ output.write(`Current: ${TERMINAL_ANIMATION_THEMES[currentIndex].label} (${current.source})\n\n`);
48
+ TERMINAL_ANIMATION_THEMES.forEach((theme, index) => {
49
+ const staticPreview = theme.id === DEFAULT_TERMINAL_ANIMATION_THEME
50
+ ? ` ${renderTerminalAnimationFrame(theme.id, 10)}`
51
+ : "";
52
+ output.write(` ${index + 1}. ${theme.label}${markers(theme, current.theme)}${staticPreview}\n`);
53
+ });
54
+
55
+ let readline;
56
+ const ask = prompt || (async (question) => {
57
+ readline ||= createInterface({ input, output });
58
+ return readline.question(question);
59
+ });
60
+
61
+ let selectedTheme = current.theme;
62
+ let keepCurrent = false;
63
+ try {
64
+ while (true) {
65
+ const answer = String(await ask(`Select [${currentIndex + 1}], or q to cancel: `) || "").trim();
66
+ if (answer.toLowerCase() === "q") {
67
+ output.write("No changes made.\n");
68
+ return { changed: false, cancelled: true, theme: current.theme };
69
+ }
70
+ if (answer === "") {
71
+ keepCurrent = true;
72
+ break;
73
+ }
74
+ const selectedIndex = Number(answer) - 1;
75
+ if (Number.isInteger(selectedIndex)
76
+ && selectedIndex >= 0
77
+ && selectedIndex < TERMINAL_ANIMATION_THEMES.length) {
78
+ selectedTheme = TERMINAL_ANIMATION_THEMES[selectedIndex].id;
79
+ break;
80
+ }
81
+ output.write(`Enter a number from 1 to ${TERMINAL_ANIMATION_THEMES.length}, or q to cancel.\n`);
82
+ }
83
+ } finally {
84
+ readline?.close();
85
+ }
86
+
87
+ const result = keepCurrent
88
+ ? { changed: false, theme: current.theme }
89
+ : writeTerminalAnimationTheme(selectedTheme, { env, home });
90
+ const disabledValue = disabledEnvironmentValue(env);
91
+ if (!disabledValue) {
92
+ try {
93
+ await preview(selectedTheme, { output });
94
+ } catch {
95
+ output.write("[WARN] Preview unavailable; the terminal animation setting was still applied.\n");
96
+ }
97
+ }
98
+
99
+ const selected = TERMINAL_ANIMATION_THEMES.find(({ id }) => id === selectedTheme);
100
+ output.write(`${result.changed ? "Saved" : "Kept"} terminal animation: ${selected.label}\n`);
101
+ if (disabledValue) {
102
+ output.write(`CCDX_TERMINAL_ANIMATION=${disabledValue} remains effective until it is unset; terminal animation stays disabled.\n`);
103
+ output.write("The saved animation will be used on the next ccdx start after the override is unset.\n");
104
+ } else {
105
+ output.write("The next ccdx start will use this animation.\n");
106
+ }
107
+ return { ...result, cancelled: false, theme: selectedTheme };
108
+ }