@minniexcode/codex-switch 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.AI.md +8 -3
  2. package/README.md +160 -91
  3. package/dist/app/add-provider.js +32 -1
  4. package/dist/app/edit-provider.js +137 -0
  5. package/dist/app/get-status.js +9 -2
  6. package/dist/app/import-providers.js +47 -2
  7. package/dist/app/list-backups.js +17 -0
  8. package/dist/app/list-config-profiles.js +29 -0
  9. package/dist/app/remove-provider.js +34 -2
  10. package/dist/app/rollback-backup.js +30 -0
  11. package/dist/app/run-doctor.js +22 -21
  12. package/dist/app/setup-codex.js +155 -0
  13. package/dist/app/show-config.js +34 -0
  14. package/dist/app/show-provider.js +22 -0
  15. package/dist/app/switch-provider.js +5 -2
  16. package/dist/cli/add-interactive.js +25 -31
  17. package/dist/cli/args.js +19 -5
  18. package/dist/cli/help.js +109 -14
  19. package/dist/cli/interactive.js +123 -8
  20. package/dist/cli/output.js +56 -1
  21. package/dist/cli/prompt.js +19 -2
  22. package/dist/cli.js +250 -13
  23. package/dist/domain/backups.js +103 -0
  24. package/dist/domain/config.js +471 -39
  25. package/dist/domain/errors.js +3 -3
  26. package/dist/domain/providers.js +10 -0
  27. package/dist/domain/setup.js +30 -0
  28. package/dist/infra/backup-repo.js +65 -6
  29. package/dist/infra/codex-cli.js +79 -2
  30. package/dist/infra/codex-discovery.js +10 -0
  31. package/dist/infra/codex-paths.js +14 -1
  32. package/dist/infra/config-repo.js +102 -9
  33. package/dist/infra/providers-repo.js +29 -0
  34. package/docs/Design/codex-switch-v0.0.4-design.md +874 -0
  35. package/docs/Design/codex-switch-v0.0.5-design.md +922 -0
  36. package/docs/PRD/codex-switch-prd-v0.0.5-to-v0.1.0.md +308 -0
  37. package/docs/PRD/codex-switch-prd-v0.1.0.md +343 -0
  38. package/docs/{codex-switch-prd.md → PRD/codex-switch-prd.md} +9 -5
  39. package/docs/cli-usage.md +580 -0
  40. package/docs/codex-switch-command-design.md +1 -1
  41. package/docs/codex-switch-product-overview.md +1 -1
  42. package/docs/codex-switch-product-research.md +2 -2
  43. package/docs/codex-switch-technical-architecture.md +1 -1
  44. package/package.json +1 -1
@@ -37,8 +37,11 @@ exports.createBackup = createBackup;
37
37
  exports.restoreManifest = restoreManifest;
38
38
  exports.saveLatestManifest = saveLatestManifest;
39
39
  exports.loadLatestManifest = loadLatestManifest;
40
+ exports.loadManifestById = loadManifestById;
41
+ exports.listBackups = listBackups;
40
42
  const fs = __importStar(require("node:fs"));
41
43
  const path = __importStar(require("node:path"));
44
+ const backups_1 = require("../domain/backups");
42
45
  const errors_1 = require("../domain/errors");
43
46
  const fs_utils_1 = require("./fs-utils");
44
47
  /**
@@ -115,16 +118,12 @@ function saveLatestManifest(latestBackupPath, manifest) {
115
118
  */
116
119
  function loadLatestManifest(latestBackupPath) {
117
120
  if (!fs.existsSync(latestBackupPath)) {
118
- throw (0, errors_1.cliError)("ROLLBACK_FAILED", "No rollback backup is available.", {
121
+ throw (0, errors_1.cliError)("BACKUP_NOT_FOUND", "No rollback backup is available.", {
119
122
  file: latestBackupPath,
120
123
  });
121
124
  }
122
125
  try {
123
- const manifest = JSON.parse(fs.readFileSync(latestBackupPath, "utf8"));
124
- if (!manifest || typeof manifest !== "object" || !Array.isArray(manifest.files)) {
125
- throw new Error("Invalid latest backup manifest.");
126
- }
127
- return manifest;
126
+ return (0, backups_1.validateBackupManifest)(JSON.parse(fs.readFileSync(latestBackupPath, "utf8")));
128
127
  }
129
128
  catch (error) {
130
129
  throw (0, errors_1.cliError)("ROLLBACK_FAILED", "Failed to read latest backup manifest.", {
@@ -133,6 +132,66 @@ function loadLatestManifest(latestBackupPath) {
133
132
  });
134
133
  }
135
134
  }
135
+ /**
136
+ * Loads a backup manifest by its explicit backup id.
137
+ */
138
+ function loadManifestById(backupsDir, backupId) {
139
+ const manifestPath = path.join(backupsDir, backupId, "manifest.json");
140
+ if (!fs.existsSync(manifestPath)) {
141
+ throw (0, errors_1.cliError)("BACKUP_NOT_FOUND", `Backup "${backupId}" was not found.`, {
142
+ backupId,
143
+ file: manifestPath,
144
+ });
145
+ }
146
+ try {
147
+ return (0, backups_1.validateBackupManifest)(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
148
+ }
149
+ catch (error) {
150
+ throw (0, errors_1.cliError)("ROLLBACK_FAILED", `Failed to read backup manifest "${backupId}".`, {
151
+ backupId,
152
+ file: manifestPath,
153
+ cause: (0, errors_1.normalizeError)(error).message,
154
+ });
155
+ }
156
+ }
157
+ /**
158
+ * Lists valid backup manifests under backups/, newest first, while skipping corrupt entries with warnings.
159
+ */
160
+ function listBackups(backupsDir) {
161
+ if (!fs.existsSync(backupsDir)) {
162
+ throw (0, errors_1.cliError)("BACKUP_NOT_FOUND", "No backups directory exists.", {
163
+ directory: backupsDir,
164
+ });
165
+ }
166
+ const entries = fs.readdirSync(backupsDir, { withFileTypes: true });
167
+ const backups = [];
168
+ const warnings = [];
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory() || entry.name === "latest.json") {
171
+ continue;
172
+ }
173
+ const manifestPath = path.join(backupsDir, entry.name, "manifest.json");
174
+ if (!fs.existsSync(manifestPath)) {
175
+ warnings.push(`Skipped backup "${entry.name}" because manifest.json is missing.`);
176
+ continue;
177
+ }
178
+ try {
179
+ backups.push((0, backups_1.toBackupListItem)((0, backups_1.validateBackupManifest)(JSON.parse(fs.readFileSync(manifestPath, "utf8")))));
180
+ }
181
+ catch (error) {
182
+ warnings.push(`Skipped backup "${entry.name}" because manifest.json is invalid: ${(0, errors_1.normalizeError)(error).message}`);
183
+ }
184
+ }
185
+ if (backups.length === 0) {
186
+ throw (0, errors_1.cliError)("BACKUP_NOT_FOUND", "No valid backups were found.", {
187
+ directory: backupsDir,
188
+ });
189
+ }
190
+ return {
191
+ backups: (0, backups_1.sortBackupList)(backups),
192
+ warnings,
193
+ };
194
+ }
136
195
  /**
137
196
  * Formats a filesystem-safe timestamp for backup directory names.
138
197
  */
@@ -4,9 +4,23 @@ exports.setCodexSpawnImplementation = setCodexSpawnImplementation;
4
4
  exports.resetCodexSpawnImplementation = resetCodexSpawnImplementation;
5
5
  exports.runCodexLogin = runCodexLogin;
6
6
  exports.checkCodexAvailable = checkCodexAvailable;
7
+ exports.readCodexVersion = readCodexVersion;
8
+ exports.checkCodexVersion = checkCodexVersion;
7
9
  const node_child_process_1 = require("node:child_process");
8
10
  const errors_1 = require("../domain/errors");
9
11
  let spawnImplementation = node_child_process_1.spawnSync;
12
+ function getCodexInvocation(args) {
13
+ if (process.platform === "win32") {
14
+ return {
15
+ command: process.env.ComSpec || "cmd.exe",
16
+ args: ["/d", "/s", "/c", ["codex", ...args].join(" ")],
17
+ };
18
+ }
19
+ return {
20
+ command: "codex",
21
+ args,
22
+ };
23
+ }
10
24
  /**
11
25
  * Overrides the spawn implementation for tests.
12
26
  */
@@ -23,7 +37,8 @@ function resetCodexSpawnImplementation() {
23
37
  * Runs `codex login --with-api-key` in the target Codex directory.
24
38
  */
25
39
  function runCodexLogin(apiKey, workingDir) {
26
- const result = spawnImplementation("codex", ["login", "--with-api-key"], {
40
+ const invocation = getCodexInvocation(["login", "--with-api-key"]);
41
+ const result = spawnImplementation(invocation.command, invocation.args, {
27
42
  cwd: workingDir,
28
43
  input: `${apiKey}\n`,
29
44
  stdio: "pipe",
@@ -39,7 +54,8 @@ function runCodexLogin(apiKey, workingDir) {
39
54
  * Checks whether the Codex CLI is available on PATH.
40
55
  */
41
56
  function checkCodexAvailable() {
42
- const result = spawnImplementation("codex", ["--version"], {
57
+ const invocation = getCodexInvocation(["--version"]);
58
+ const result = spawnImplementation(invocation.command, invocation.args, {
43
59
  stdio: "pipe",
44
60
  encoding: "utf8",
45
61
  });
@@ -51,3 +67,64 @@ function checkCodexAvailable() {
51
67
  }
52
68
  return { ok: true };
53
69
  }
70
+ /**
71
+ * Reads the installed codex CLI version string.
72
+ */
73
+ function readCodexVersion() {
74
+ const invocation = getCodexInvocation(["--version"]);
75
+ const result = spawnImplementation(invocation.command, invocation.args, {
76
+ stdio: "pipe",
77
+ encoding: "utf8",
78
+ });
79
+ if (result.error || result.status !== 0) {
80
+ return {
81
+ ok: false,
82
+ cause: result.error?.message ?? (result.stderr.trim() || "Unknown failure"),
83
+ };
84
+ }
85
+ const raw = `${result.stdout ?? ""} ${result.stderr ?? ""}`.trim();
86
+ const match = raw.match(/(\d+\.\d+\.\d+)/);
87
+ if (!match) {
88
+ return {
89
+ ok: false,
90
+ cause: `Unable to parse codex version from output: ${raw || "(empty output)"}`,
91
+ };
92
+ }
93
+ return { ok: true, version: match[1] };
94
+ }
95
+ /**
96
+ * Compares the installed codex version against a minimum required version.
97
+ */
98
+ function checkCodexVersion(minVersion) {
99
+ const current = readCodexVersion();
100
+ if (!current.ok) {
101
+ return {
102
+ ok: false,
103
+ cause: current.cause,
104
+ };
105
+ }
106
+ if (compareVersions(current.version, minVersion) < 0) {
107
+ return {
108
+ ok: false,
109
+ currentVersion: current.version,
110
+ cause: `codex ${current.version} is below required ${minVersion}`,
111
+ };
112
+ }
113
+ return {
114
+ ok: true,
115
+ currentVersion: current.version,
116
+ };
117
+ }
118
+ function compareVersions(left, right) {
119
+ const leftParts = left.split(".").map((value) => Number.parseInt(value, 10));
120
+ const rightParts = right.split(".").map((value) => Number.parseInt(value, 10));
121
+ const length = Math.max(leftParts.length, rightParts.length);
122
+ for (let index = 0; index < length; index += 1) {
123
+ const leftValue = leftParts[index] ?? 0;
124
+ const rightValue = rightParts[index] ?? 0;
125
+ if (leftValue !== rightValue) {
126
+ return leftValue - rightValue;
127
+ }
128
+ }
129
+ return 0;
130
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findCodexDirCandidates = findCodexDirCandidates;
4
+ const config_repo_1 = require("./config-repo");
5
+ /**
6
+ * Finds candidate Codex home directories using the shared config-aware discovery rules.
7
+ */
8
+ function findCodexDirCandidates(explicitCodexDir) {
9
+ return (0, config_repo_1.findCodexDirCandidates)(explicitCodexDir);
10
+ }
@@ -33,15 +33,28 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CODEX_DIR_ENV_NAME = void 0;
36
37
  exports.resolveCodexDir = resolveCodexDir;
37
38
  exports.createCodexPaths = createCodexPaths;
38
39
  const os = __importStar(require("node:os"));
39
40
  const path = __importStar(require("node:path"));
41
+ exports.CODEX_DIR_ENV_NAME = "CODEXS_CODEX_DIR";
42
+ const DEVELOPMENT_DEFAULT_CODEX_DIR = path.resolve(process.cwd(), "dev-codex", "local-sandbox");
40
43
  /**
41
44
  * Resolves the working Codex directory, defaulting to `~/.codex`.
42
45
  */
43
46
  function resolveCodexDir(codexDir) {
44
- return codexDir ? path.resolve(codexDir) : path.join(os.homedir(), ".codex");
47
+ if (codexDir) {
48
+ return path.resolve(codexDir);
49
+ }
50
+ const envCodexDir = process.env[exports.CODEX_DIR_ENV_NAME];
51
+ if (envCodexDir) {
52
+ return path.resolve(envCodexDir);
53
+ }
54
+ if (process.env.NODE_ENV === "development") {
55
+ return DEVELOPMENT_DEFAULT_CODEX_DIR;
56
+ }
57
+ return path.join(os.homedir(), ".codex");
45
58
  }
46
59
  /**
47
60
  * Expands a Codex home directory into the file paths used by the CLI.
@@ -1,12 +1,53 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.readConfigFile = readConfigFile;
37
+ exports.readStructuredConfig = readStructuredConfig;
4
38
  exports.readCurrentProfile = readCurrentProfile;
5
39
  exports.listConfigProfiles = listConfigProfiles;
6
40
  exports.ensureProfileExists = ensureProfileExists;
7
41
  exports.updateTopLevelProfile = updateTopLevelProfile;
8
- const errors_1 = require("../domain/errors");
42
+ exports.createConfigMutationPlan = createConfigMutationPlan;
43
+ exports.applyConfigMutation = applyConfigMutation;
44
+ exports.findCodexDirCandidates = findCodexDirCandidates;
45
+ const fs = __importStar(require("node:fs"));
46
+ const os = __importStar(require("node:os"));
47
+ const path = __importStar(require("node:path"));
9
48
  const config_1 = require("../domain/config");
49
+ const errors_1 = require("../domain/errors");
50
+ const codex_paths_1 = require("./codex-paths");
10
51
  const fs_utils_1 = require("./fs-utils");
11
52
  /**
12
53
  * Reads config.toml and throws a typed error when the file is missing.
@@ -14,12 +55,26 @@ const fs_utils_1 = require("./fs-utils");
14
55
  function readConfigFile(configPath) {
15
56
  return (0, fs_utils_1.readRequiredFile)(configPath, "CONFIG_NOT_FOUND", "config.toml");
16
57
  }
58
+ /**
59
+ * Reads and parses config.toml into the managed structured document shape.
60
+ */
61
+ function readStructuredConfig(configPath) {
62
+ const content = readConfigFile(configPath);
63
+ try {
64
+ return (0, config_1.parseStructuredConfig)(content);
65
+ }
66
+ catch (error) {
67
+ throw (0, errors_1.cliError)("CONFIG_PARSE_ERROR", "Failed to parse config.toml.", {
68
+ file: configPath,
69
+ cause: (0, errors_1.normalizeError)(error).message,
70
+ });
71
+ }
72
+ }
17
73
  /**
18
74
  * Reads the active top-level profile from config.toml.
19
75
  */
20
76
  function readCurrentProfile(configPath) {
21
- const content = readConfigFile(configPath);
22
- const profile = (0, config_1.parseTopLevelProfile)(content);
77
+ const profile = readStructuredConfig(configPath).activeProfile ?? (0, config_1.parseTopLevelProfile)(readConfigFile(configPath));
23
78
  if (!profile) {
24
79
  throw (0, errors_1.cliError)("PROFILE_NOT_FOUND", "No top-level profile is set in config.toml.", {
25
80
  file: configPath,
@@ -31,26 +86,64 @@ function readCurrentProfile(configPath) {
31
86
  * Lists all named profile sections declared in config.toml.
32
87
  */
33
88
  function listConfigProfiles(configPath) {
34
- return (0, config_1.parseProfileNames)(readConfigFile(configPath));
89
+ return new Set(readStructuredConfig(configPath).profiles.map((profile) => profile.name));
35
90
  }
36
91
  /**
37
92
  * Verifies that a provider's target profile exists before a switch operation proceeds.
38
93
  */
39
94
  function ensureProfileExists(configPath, profile, provider) {
40
- const configContent = readConfigFile(configPath);
41
- const profiles = (0, config_1.parseProfileNames)(configContent);
42
- if (!profiles.has(profile)) {
95
+ const document = readStructuredConfig(configPath);
96
+ if (!document.profiles.some((entry) => entry.name === profile)) {
43
97
  throw (0, errors_1.cliError)("PROFILE_NOT_FOUND", `Profile "${profile}" does not exist in config.toml.`, {
44
98
  file: configPath,
45
99
  provider,
46
100
  profile,
47
101
  });
48
102
  }
49
- return configContent;
103
+ return document;
50
104
  }
51
105
  /**
52
106
  * Rewrites config.toml so the requested profile becomes the active top-level profile.
53
107
  */
54
108
  function updateTopLevelProfile(configPath, configContent, profile) {
55
- (0, fs_utils_1.writeTextFileAtomic)(configPath, (0, config_1.replaceTopLevelProfile)(configContent, profile));
109
+ (0, fs_utils_1.writeTextFileAtomic)(configPath, (0, config_1.applyPatchOperations)(configContent, (0, config_1.planConfigMutation)((0, config_1.parseStructuredConfig)(configContent), {
110
+ setActiveProfile: profile,
111
+ }).operations));
112
+ }
113
+ /**
114
+ * Exposes the config mutation planner to application services.
115
+ */
116
+ function createConfigMutationPlan(document, args) {
117
+ return (0, config_1.planConfigMutation)(document, args);
118
+ }
119
+ /**
120
+ * Applies a previously generated mutation plan to config.toml in one write.
121
+ */
122
+ function applyConfigMutation(configPath, document, plan) {
123
+ (0, fs_utils_1.writeTextFileAtomic)(configPath, (0, config_1.applyPatchOperations)(document.rawText, plan.operations));
124
+ }
125
+ /**
126
+ * Finds candidate Codex directories in a stable, non-recursive order.
127
+ */
128
+ function findCodexDirCandidates(explicitCodexDir) {
129
+ if (explicitCodexDir) {
130
+ return [(0, codex_paths_1.resolveCodexDir)(explicitCodexDir)];
131
+ }
132
+ const candidates = new Set();
133
+ const ordered = [];
134
+ const envCandidate = process.env[codex_paths_1.CODEX_DIR_ENV_NAME];
135
+ if (envCandidate) {
136
+ ordered.push((0, codex_paths_1.resolveCodexDir)(envCandidate));
137
+ }
138
+ if (process.env.NODE_ENV === "development") {
139
+ ordered.push(path.resolve(process.cwd(), "dev-codex", "local-sandbox"));
140
+ }
141
+ ordered.push(path.join(os.homedir(), ".codex"));
142
+ for (const candidate of ordered) {
143
+ if (!candidate || candidates.has(candidate) || !fs.existsSync(candidate)) {
144
+ continue;
145
+ }
146
+ candidates.add(candidate);
147
+ }
148
+ return [...candidates];
56
149
  }
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readProvidersFile = readProvidersFile;
37
37
  exports.readProvidersFileIfExists = readProvidersFileIfExists;
38
38
  exports.writeProvidersFile = writeProvidersFile;
39
+ exports.readProviderRecord = readProviderRecord;
40
+ exports.mergeProviders = mergeProviders;
39
41
  const fs = __importStar(require("node:fs"));
40
42
  const errors_1 = require("../domain/errors");
41
43
  const providers_1 = require("../domain/providers");
@@ -67,3 +69,30 @@ function readProvidersFileIfExists(providersPath) {
67
69
  function writeProvidersFile(providersPath, providers) {
68
70
  (0, fs_utils_1.writeTextFileAtomic)(providersPath, `${JSON.stringify((0, providers_1.sortProviders)(providers), null, 2)}\n`);
69
71
  }
72
+ /**
73
+ * Returns a single provider record or throws a typed not-found error.
74
+ */
75
+ function readProviderRecord(providersPath, providerName) {
76
+ const providers = readProvidersFile(providersPath);
77
+ const record = providers.providers[providerName];
78
+ if (!record) {
79
+ throw (0, errors_1.cliError)("PROVIDER_NOT_FOUND", `Provider "${providerName}" was not found.`, {
80
+ provider: providerName,
81
+ file: providersPath,
82
+ });
83
+ }
84
+ return record;
85
+ }
86
+ /**
87
+ * Merges imported providers into the current registry, preferring the imported side on conflicts.
88
+ */
89
+ function mergeProviders(current, imported) {
90
+ const providers = {};
91
+ for (const [name, record] of Object.entries(current.providers)) {
92
+ providers[name] = (0, providers_1.cleanProviderRecord)(record);
93
+ }
94
+ for (const [name, record] of Object.entries(imported.providers)) {
95
+ providers[name] = (0, providers_1.cleanProviderRecord)(record);
96
+ }
97
+ return (0, providers_1.sortProviders)({ providers });
98
+ }