@orionpotter/menv 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # menv
2
+
3
+ `mm` is a profile manager for AI CLI tools.
4
+
5
+ It manages provider profiles such as `openai`, `openrouter`, or custom gateways, then applies the selected profile to a downstream CLI client. The current MVP supports `codex` by updating `~/.codex/config.toml`.
6
+
7
+ ## What problem it solves
8
+
9
+ If you regularly switch between different model endpoints, API keys, or models, you usually end up editing one of these by hand:
10
+
11
+ - shell environment variables
12
+ - `.env` files
13
+ - client config files such as `~/.codex/config.toml`
14
+ - ad hoc scripts for each provider
15
+
16
+ `mm` gives you a single place to define named profiles and switch clients between them safely.
17
+
18
+ ## Core concepts
19
+
20
+ - `profile`: one saved configuration bundle, for example `openai-main` or `router-fast`
21
+ - `client`: one downstream CLI whose config file can be managed by `mm`
22
+ - `use`: apply one profile to one client
23
+ - `doctor`: compare the selected profile with the actual client config and report drift
24
+ - `sync`: re-apply the selected profile to the client config
25
+ - `rollback`: restore the most recent backup created during `use` or `sync`
26
+
27
+ ## Current MVP scope
28
+
29
+ Implemented now:
30
+
31
+ - profile storage in `mm` config
32
+ - alias support
33
+ - client abstraction for future expansion
34
+ - Codex client support
35
+ - config drift detection
36
+ - one-step sync back to Codex config
37
+ - backup and rollback
38
+ - npm package with `mm` executable
39
+
40
+ Not implemented yet:
41
+
42
+ - multiple backup history
43
+ - encrypted secret storage
44
+ - direct model invocation through `mm`
45
+ - non-Codex client adapters
46
+
47
+ ## Install
48
+
49
+ Local development:
50
+
51
+ ```bash
52
+ npm install
53
+ npm link
54
+ mm.cmd --help
55
+ ```
56
+
57
+ After publishing to npm:
58
+
59
+ ```bash
60
+ npm install -g @orionpotter/menv
61
+ mm --help
62
+ ```
63
+
64
+ Notes:
65
+
66
+ - On Windows PowerShell with restrictive execution policy, use `mm.cmd`.
67
+ - The npm package name is `@orionpotter/menv` while the executable command remains `mm`.
68
+
69
+ ## Quick start
70
+
71
+ ### 1. Add profiles
72
+
73
+ ```bash
74
+ mm add openai-main --provider openai --model gpt-5.4 --base-url https://api.openai.com/v1 --api-key-env OPENAI_API_KEY
75
+ mm add router-fast --provider openrouter --model openai/gpt-4o-mini --base-url https://openrouter.ai/api/v1 --api-key-env OPENROUTER_API_KEY
76
+ ```
77
+
78
+ ### 2. Point Codex at one profile
79
+
80
+ ```bash
81
+ mm use openai-main --client codex
82
+ ```
83
+
84
+ This updates `~/.codex/config.toml` so Codex will use the selected profile values.
85
+
86
+ ### 3. Inspect current state
87
+
88
+ ```bash
89
+ mm current --client codex
90
+ mm which --client codex
91
+ mm doctor --client codex
92
+ ```
93
+
94
+ ### 4. Re-sync after drift
95
+
96
+ If you manually edited `~/.codex/config.toml` or another tool changed it:
97
+
98
+ ```bash
99
+ mm sync --client codex
100
+ ```
101
+
102
+ ### 5. Roll back to the last backup
103
+
104
+ ```bash
105
+ mm rollback --client codex
106
+ ```
107
+
108
+ ## Concrete Codex example
109
+
110
+ Suppose your current `~/.codex/config.toml` points to one endpoint, but you want to temporarily switch Codex to OpenRouter.
111
+
112
+ Create a profile:
113
+
114
+ ```bash
115
+ mm add router-fast \
116
+ --provider openrouter \
117
+ --model openai/gpt-4o-mini \
118
+ --base-url https://openrouter.ai/api/v1 \
119
+ --api-key-env OPENROUTER_API_KEY
120
+ ```
121
+
122
+ Apply it to Codex:
123
+
124
+ ```bash
125
+ mm use router-fast --client codex
126
+ ```
127
+
128
+ Now inspect the result:
129
+
130
+ ```bash
131
+ mm which --client codex
132
+ ```
133
+
134
+ You should see the selected profile values and the actual values found in `~/.codex/config.toml`.
135
+
136
+ If they drift apart later:
137
+
138
+ ```bash
139
+ mm doctor --client codex
140
+ mm sync --client codex
141
+ ```
142
+
143
+ If you want to go back to the previous Codex config snapshot:
144
+
145
+ ```bash
146
+ mm rollback --client codex
147
+ ```
148
+
149
+ ## Alias example
150
+
151
+ Aliases let you switch by intent instead of provider details.
152
+
153
+ ```bash
154
+ mm alias set fast router-fast
155
+ mm use fast --client codex
156
+ ```
157
+
158
+ ## Commands
159
+
160
+ ```bash
161
+ mm list
162
+ mm clients
163
+ mm current [--client codex]
164
+ mm which [--client codex]
165
+ mm use <profile> [--client codex] [--project]
166
+ mm sync [--client codex]
167
+ mm doctor [--client codex]
168
+ mm rollback [--client codex]
169
+ mm add <profile> --provider NAME --model MODEL --base-url URL [--api-key KEY] [--api-key-env ENV]
170
+ mm remove <profile>
171
+ mm config set <key> <value>
172
+ mm config get <key>
173
+ mm config list
174
+ mm alias list
175
+ mm alias set <name> <profile>
176
+ mm alias remove <name>
177
+ ```
178
+
179
+ ## How `mm use` works
180
+
181
+ `mm use <profile> --client codex` does two things:
182
+
183
+ 1. It records the selected profile in `mm`'s own config.
184
+ 2. It applies that profile to Codex by updating `~/.codex/config.toml`.
185
+
186
+ For Codex, the current implementation writes these top-level keys when present:
187
+
188
+ - `provider`
189
+ - `model`
190
+ - `base_url`
191
+ - `api_key`
192
+ - `api_key_env`
193
+ - `model_reasoning_effort`
194
+
195
+ Existing unrelated TOML sections are preserved.
196
+
197
+ ## Backup and rollback semantics
198
+
199
+ Before `mm use` or `mm sync` writes the client config, `mm` creates one backup file:
200
+
201
+ - Codex backup path: `~/.codex/config.toml.bak`
202
+
203
+ `mm rollback --client codex` restores that backup.
204
+
205
+ Important limitation in the current MVP:
206
+
207
+ - backup history is single-slot
208
+ - the latest write replaces the previous backup
209
+ - if you sync after a bad manual edit, rollback restores the state immediately before that sync, not an older historical version
210
+
211
+ ## Validation behavior
212
+
213
+ For Codex, `mm doctor` currently checks:
214
+
215
+ - whether the selected profile has a `model`
216
+ - whether `baseURL` is missing
217
+ - whether both `apiKey` and `apiKeyEnv` are missing
218
+ - whether the current Codex config has drifted from the selected profile
219
+
220
+ ## Config files
221
+
222
+ `mm` config:
223
+
224
+ - Windows: `%APPDATA%/mm/config.json`
225
+ - macOS/Linux: `~/.config/mm/config.json`
226
+
227
+ Current supported client config:
228
+
229
+ - Codex: `~/.codex/config.toml`
230
+
231
+ Project override file:
232
+
233
+ - `.mm.json`
234
+
235
+ ## Development
236
+
237
+ ```bash
238
+ npm install
239
+ npm run check
240
+ npm run build
241
+ npm link
242
+ ```
243
+
244
+ ## Publish to npm
245
+
246
+ Manual publish:
247
+
248
+ ```bash
249
+ npm login
250
+ npm publish --provenance
251
+ ```
252
+
253
+ GitHub Actions publish:
254
+
255
+ - workflow file: `.github/workflows/publish.yml`
256
+ - trigger: push tag matching `v*` or manual dispatch
257
+ - required secret: `NPM_TOKEN`
258
+
259
+ ## Roadmap
260
+
261
+ Planned next steps:
262
+
263
+ - multiple backup history and named rollback targets
264
+ - more client adapters beyond Codex
265
+ - safer secret handling
266
+ - richer profile schema per client
267
+
package/dist/cli.js ADDED
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCli = runCli;
4
+ exports.handleCliError = handleCliError;
5
+ const store_1 = require("./config/store");
6
+ const resolver_1 = require("./core/resolver");
7
+ const errors_1 = require("./errors");
8
+ const clients_1 = require("./clients");
9
+ const output_1 = require("./output");
10
+ function parseArgs(argv) {
11
+ const positionals = [];
12
+ const flags = {};
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ const token = argv[index];
15
+ if (!token.startsWith("--")) {
16
+ positionals.push(token);
17
+ continue;
18
+ }
19
+ const key = token.slice(2);
20
+ const next = argv[index + 1];
21
+ if (!next || next.startsWith("--")) {
22
+ flags[key] = true;
23
+ continue;
24
+ }
25
+ flags[key] = next;
26
+ index += 1;
27
+ }
28
+ return { positionals, flags };
29
+ }
30
+ function asString(flag) {
31
+ return typeof flag === "string" ? flag : undefined;
32
+ }
33
+ function requirePositional(value, message) {
34
+ if (!value) {
35
+ throw new errors_1.ConfigError(message);
36
+ }
37
+ return value;
38
+ }
39
+ function printHelp() {
40
+ console.log(`mm - client config manager
41
+
42
+ Commands:
43
+ mm list
44
+ mm clients
45
+ mm current [--client codex]
46
+ mm which [--client codex]
47
+ mm use <profile> [--client codex] [--project]
48
+ mm sync [--client codex]
49
+ mm doctor [--client codex]
50
+ mm rollback [--client codex]
51
+ mm add <profile> --provider NAME --model MODEL --base-url URL [--api-key KEY] [--api-key-env ENV]
52
+ mm remove <profile>
53
+ mm config set <key> <value>
54
+ mm config get <key>
55
+ mm config list
56
+ mm alias list
57
+ mm alias set <name> <profile>
58
+ mm alias remove <name>`);
59
+ }
60
+ function getClientName(flagValue, storeDefault) {
61
+ return flagValue ?? storeDefault;
62
+ }
63
+ async function runCli(argv) {
64
+ const store = new store_1.ConfigStore();
65
+ const resolver = new resolver_1.Resolver(store);
66
+ const [command, ...rest] = argv;
67
+ const parsed = parseArgs(rest);
68
+ const config = await store.readGlobalConfig();
69
+ const clientName = getClientName(asString(parsed.flags.client), config.defaultClient);
70
+ switch (command) {
71
+ case undefined:
72
+ case "help":
73
+ case "--help":
74
+ printHelp();
75
+ return;
76
+ case "clients": {
77
+ for (const client of (0, clients_1.listClientNames)()) {
78
+ console.log(client);
79
+ }
80
+ return;
81
+ }
82
+ case "list": {
83
+ (0, output_1.printList)(config);
84
+ return;
85
+ }
86
+ case "current": {
87
+ const resolved = await resolver.resolveProfile({ client: clientName });
88
+ const state = await (0, clients_1.getClientAdapter)(clientName).readState();
89
+ (0, output_1.printCurrent)(resolved, state);
90
+ return;
91
+ }
92
+ case "which": {
93
+ const resolved = await resolver.resolveProfile({ client: clientName });
94
+ const state = await (0, clients_1.getClientAdapter)(clientName).readState();
95
+ (0, output_1.printWhich)(resolved, state);
96
+ return;
97
+ }
98
+ case "use": {
99
+ const profileName = requirePositional(parsed.positionals[0], "Usage: mm use <profile>");
100
+ const scope = parsed.flags.project ? "project" : "global";
101
+ await store.setCurrentProfile(clientName, profileName, scope);
102
+ const client = (0, clients_1.getClientAdapter)(clientName);
103
+ const resolved = await resolver.resolveProfile({ client: clientName });
104
+ const validationIssues = client.validateProfile(resolved.profile).filter((issue) => issue.level === "error");
105
+ if (validationIssues.length > 0) {
106
+ (0, output_1.printIssues)(validationIssues);
107
+ throw new errors_1.ConfigError("Profile validation failed.");
108
+ }
109
+ const result = await client.applyProfile(resolved.profileName, resolved.profile);
110
+ console.log(`updated ${scope} profile for ${clientName} to ${profileName}`);
111
+ (0, output_1.printApplyResult)(result);
112
+ return;
113
+ }
114
+ case "sync": {
115
+ const client = (0, clients_1.getClientAdapter)(clientName);
116
+ const resolved = await resolver.resolveProfile({ client: clientName });
117
+ const validationIssues = client.validateProfile(resolved.profile).filter((issue) => issue.level === "error");
118
+ if (validationIssues.length > 0) {
119
+ (0, output_1.printIssues)(validationIssues);
120
+ throw new errors_1.ConfigError("Profile validation failed.");
121
+ }
122
+ const result = await client.applyProfile(resolved.profileName, resolved.profile);
123
+ (0, output_1.printApplyResult)(result);
124
+ return;
125
+ }
126
+ case "doctor": {
127
+ const client = (0, clients_1.getClientAdapter)(clientName);
128
+ const resolved = await resolver.resolveProfile({ client: clientName });
129
+ const state = await client.readState();
130
+ const issues = [
131
+ ...client.validateProfile(resolved.profile),
132
+ ...client.compareProfile(state, resolved.profile)
133
+ ];
134
+ (0, output_1.printIssues)(issues);
135
+ return;
136
+ }
137
+ case "rollback": {
138
+ const result = await (0, clients_1.getClientAdapter)(clientName).rollback();
139
+ (0, output_1.printRollbackResult)(result);
140
+ return;
141
+ }
142
+ case "add": {
143
+ const profileName = requirePositional(parsed.positionals[0], "Usage: mm add <profile> --provider NAME --model MODEL --base-url URL");
144
+ await store.upsertProfile(profileName, {
145
+ provider: asString(parsed.flags.provider),
146
+ model: asString(parsed.flags.model),
147
+ baseURL: asString(parsed.flags["base-url"]),
148
+ apiKey: asString(parsed.flags["api-key"]),
149
+ apiKeyEnv: asString(parsed.flags["api-key-env"]),
150
+ reasoningEffort: asString(parsed.flags["reasoning-effort"]),
151
+ organization: asString(parsed.flags.organization),
152
+ region: asString(parsed.flags.region),
153
+ deployment: asString(parsed.flags.deployment)
154
+ });
155
+ console.log(`profile ${profileName} updated`);
156
+ return;
157
+ }
158
+ case "remove": {
159
+ const profileName = requirePositional(parsed.positionals[0], "Usage: mm remove <profile>");
160
+ await store.removeProfile(profileName);
161
+ console.log(`profile ${profileName} removed`);
162
+ return;
163
+ }
164
+ case "config": {
165
+ const subcommand = parsed.positionals[0];
166
+ if (subcommand === "set") {
167
+ const key = requirePositional(parsed.positionals[1], "Usage: mm config set <key> <value>");
168
+ const value = requirePositional(parsed.positionals[2], "Usage: mm config set <key> <value>");
169
+ await store.setConfigValue(key, value);
170
+ console.log(`config updated: ${key}`);
171
+ return;
172
+ }
173
+ if (subcommand === "get") {
174
+ const key = requirePositional(parsed.positionals[1], "Usage: mm config get <key>");
175
+ const value = await store.getConfigValue(key);
176
+ console.log(value === undefined ? "(unset)" : JSON.stringify(value, null, 2));
177
+ return;
178
+ }
179
+ if (subcommand === "list") {
180
+ (0, output_1.printConfig)(config);
181
+ return;
182
+ }
183
+ throw new errors_1.ConfigError("Usage: mm config <set|get|list> ...");
184
+ }
185
+ case "alias": {
186
+ const subcommand = parsed.positionals[0];
187
+ if (subcommand === "list") {
188
+ for (const [name, target] of Object.entries(config.aliases)) {
189
+ console.log(`${name}: ${target}`);
190
+ }
191
+ return;
192
+ }
193
+ if (subcommand === "set") {
194
+ const name = requirePositional(parsed.positionals[1], "Usage: mm alias set <name> <profile>");
195
+ const profileName = requirePositional(parsed.positionals[2], "Usage: mm alias set <name> <profile>");
196
+ await store.setAlias(name, profileName);
197
+ console.log(`alias ${name} -> ${profileName}`);
198
+ return;
199
+ }
200
+ if (subcommand === "remove") {
201
+ const name = requirePositional(parsed.positionals[1], "Usage: mm alias remove <name>");
202
+ await store.removeAlias(name);
203
+ console.log(`alias ${name} removed`);
204
+ return;
205
+ }
206
+ throw new errors_1.ConfigError("Usage: mm alias <list|set|remove> ...");
207
+ }
208
+ default:
209
+ throw new errors_1.ConfigError(`Unknown command: ${command}`);
210
+ }
211
+ }
212
+ function handleCliError(error) {
213
+ if (error instanceof errors_1.MmError) {
214
+ console.error(`error: ${error.message}`);
215
+ process.exit(1);
216
+ }
217
+ if (error instanceof Error) {
218
+ console.error(`error: ${error.message}`);
219
+ process.exit(1);
220
+ }
221
+ console.error("error: unexpected failure");
222
+ process.exit(1);
223
+ }
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CodexClientAdapter = void 0;
4
+ const node_os_1 = require("node:os");
5
+ const node_path_1 = require("node:path");
6
+ const promises_1 = require("node:fs/promises");
7
+ const node_fs_1 = require("node:fs");
8
+ const fs_1 = require("../utils/fs");
9
+ const errors_1 = require("../errors");
10
+ function parseTopLevelToml(content) {
11
+ const values = {};
12
+ const lines = content.split(/\r?\n/);
13
+ for (const line of lines) {
14
+ const trimmed = line.trim();
15
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("[")) {
16
+ continue;
17
+ }
18
+ const match = /^([A-Za-z0-9_\-]+)\s*=\s*"(.*)"\s*$/.exec(trimmed);
19
+ if (match) {
20
+ values[match[1]] = match[2];
21
+ }
22
+ }
23
+ return values;
24
+ }
25
+ function upsertTopLevelToml(content, values) {
26
+ const lines = content === "" ? [] : content.split(/\r?\n/);
27
+ const updatedKeys = [];
28
+ const pending = new Map(Object.entries(values).filter((entry) => entry[1] !== undefined));
29
+ for (let index = 0; index < lines.length; index += 1) {
30
+ const line = lines[index];
31
+ const match = /^([A-Za-z0-9_\-]+)\s*=/.exec(line.trim());
32
+ if (!match) {
33
+ continue;
34
+ }
35
+ const key = match[1];
36
+ if (!pending.has(key)) {
37
+ continue;
38
+ }
39
+ lines[index] = `${key} = ${JSON.stringify(pending.get(key))}`;
40
+ updatedKeys.push(key);
41
+ pending.delete(key);
42
+ }
43
+ const insertionIndex = lines.findIndex((line) => line.trim().startsWith("["));
44
+ const appended = Array.from(pending.entries()).map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
45
+ updatedKeys.push(...Array.from(pending.keys()));
46
+ if (appended.length > 0) {
47
+ if (insertionIndex === -1) {
48
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
49
+ lines.push("");
50
+ }
51
+ lines.push(...appended);
52
+ }
53
+ else {
54
+ const head = lines.slice(0, insertionIndex);
55
+ const tail = lines.slice(insertionIndex);
56
+ const merged = [...head];
57
+ if (merged.length > 0 && merged[merged.length - 1] !== "") {
58
+ merged.push("");
59
+ }
60
+ merged.push(...appended, "", ...tail);
61
+ return { content: `${merged.join("\n")}\n`, updatedKeys };
62
+ }
63
+ }
64
+ return { content: `${lines.join("\n")}\n`, updatedKeys };
65
+ }
66
+ class CodexClientAdapter {
67
+ name = "codex";
68
+ getConfigPath() {
69
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex", "config.toml");
70
+ }
71
+ async readState() {
72
+ const configPath = this.getConfigPath();
73
+ const content = (0, node_fs_1.existsSync)(configPath) ? await (0, promises_1.readFile)(configPath, "utf8") : "";
74
+ return {
75
+ client: this.name,
76
+ configPath,
77
+ values: parseTopLevelToml(content)
78
+ };
79
+ }
80
+ validateProfile(profile) {
81
+ const issues = [];
82
+ if (!profile.model) {
83
+ issues.push({ level: "error", message: "profile.model is required for Codex." });
84
+ }
85
+ if (!profile.baseURL) {
86
+ issues.push({ level: "warn", message: "profile.baseURL is unset; Codex may continue using the previous endpoint." });
87
+ }
88
+ if (!profile.apiKey && !profile.apiKeyEnv) {
89
+ issues.push({ level: "warn", message: "Neither apiKey nor apiKeyEnv is set; authentication may fail." });
90
+ }
91
+ return issues;
92
+ }
93
+ compareProfile(state, profile) {
94
+ const issues = [];
95
+ const expected = {
96
+ provider: profile.provider,
97
+ model: profile.model,
98
+ base_url: profile.baseURL,
99
+ api_key: profile.apiKey,
100
+ api_key_env: profile.apiKeyEnv,
101
+ model_reasoning_effort: profile.reasoningEffort
102
+ };
103
+ for (const [key, value] of Object.entries(expected)) {
104
+ if (value === undefined) {
105
+ continue;
106
+ }
107
+ const actual = state.values[key];
108
+ if (actual !== value) {
109
+ issues.push({
110
+ level: "warn",
111
+ message: `${key} drifted: codex has ${actual ?? "(unset)"}, profile wants ${value}.`
112
+ });
113
+ }
114
+ }
115
+ if (issues.length === 0) {
116
+ issues.push({ level: "info", message: "Codex config matches the selected profile." });
117
+ }
118
+ return issues;
119
+ }
120
+ async applyProfile(profileName, profile) {
121
+ const configPath = this.getConfigPath();
122
+ await (0, fs_1.ensureDirForFile)(configPath);
123
+ const backupPath = await (0, fs_1.backupFile)(configPath);
124
+ const currentContent = (0, node_fs_1.existsSync)(configPath) ? await (0, promises_1.readFile)(configPath, "utf8") : "";
125
+ const { content, updatedKeys } = upsertTopLevelToml(currentContent, {
126
+ provider: profile.provider,
127
+ model: profile.model,
128
+ base_url: profile.baseURL,
129
+ api_key: profile.apiKey,
130
+ api_key_env: profile.apiKeyEnv,
131
+ model_reasoning_effort: profile.reasoningEffort
132
+ });
133
+ await (0, promises_1.writeFile)(configPath, content, "utf8");
134
+ return {
135
+ client: this.name,
136
+ configPath,
137
+ updatedKeys,
138
+ backupPath
139
+ };
140
+ }
141
+ async rollback() {
142
+ const configPath = this.getConfigPath();
143
+ const backupPath = await (0, fs_1.restoreBackupFile)(configPath);
144
+ if (!backupPath) {
145
+ throw new errors_1.ConfigError(`No backup found for ${configPath}.`);
146
+ }
147
+ return {
148
+ client: this.name,
149
+ configPath,
150
+ backupPath
151
+ };
152
+ }
153
+ }
154
+ exports.CodexClientAdapter = CodexClientAdapter;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getClientAdapter = getClientAdapter;
4
+ exports.listClientNames = listClientNames;
5
+ const errors_1 = require("../errors");
6
+ const codex_1 = require("./codex");
7
+ const REGISTRY = {
8
+ codex: new codex_1.CodexClientAdapter()
9
+ };
10
+ function getClientAdapter(name) {
11
+ const adapter = REGISTRY[name];
12
+ if (!adapter) {
13
+ throw new errors_1.ClientNotFoundError(`Client adapter not found for ${name}.`);
14
+ }
15
+ return adapter;
16
+ }
17
+ function listClientNames() {
18
+ return Object.keys(REGISTRY);
19
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConfigStore = void 0;
4
+ const node_process_1 = require("node:process");
5
+ const node_os_1 = require("node:os");
6
+ const node_path_1 = require("node:path");
7
+ const fs_1 = require("../utils/fs");
8
+ const DEFAULT_GLOBAL_CONFIG = {
9
+ defaultClient: "codex",
10
+ currentProfiles: {},
11
+ profiles: {},
12
+ aliases: {}
13
+ };
14
+ class ConfigStore {
15
+ getGlobalConfigPath() {
16
+ if (process.platform === "win32" && process.env.APPDATA) {
17
+ return (0, node_path_1.join)(process.env.APPDATA, "mm", "config.json");
18
+ }
19
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "mm", "config.json");
20
+ }
21
+ getProjectConfigPath(startDir = (0, node_process_1.cwd)()) {
22
+ const found = (0, fs_1.findUp)(".mm.json", startDir);
23
+ return found ?? (0, node_path_1.join)(startDir, ".mm.json");
24
+ }
25
+ async readGlobalConfig() {
26
+ const config = await (0, fs_1.readJsonFile)(this.getGlobalConfigPath());
27
+ return this.mergeGlobalConfig(config);
28
+ }
29
+ async writeGlobalConfig(config) {
30
+ await (0, fs_1.writeJsonFile)(this.getGlobalConfigPath(), config);
31
+ }
32
+ async readProjectConfig(startDir = (0, node_process_1.cwd)()) {
33
+ return (0, fs_1.readJsonFile)(this.getProjectConfigPath(startDir));
34
+ }
35
+ async writeProjectConfig(config, startDir = (0, node_process_1.cwd)()) {
36
+ await (0, fs_1.writeJsonFile)(this.getProjectConfigPath(startDir), config);
37
+ }
38
+ async setCurrentProfile(client, profileName, scope) {
39
+ if (scope === "project") {
40
+ const projectConfig = (await this.readProjectConfig()) ?? {};
41
+ projectConfig.profile = profileName;
42
+ await this.writeProjectConfig(projectConfig);
43
+ return;
44
+ }
45
+ const globalConfig = await this.readGlobalConfig();
46
+ globalConfig.currentProfiles[client] = profileName;
47
+ await this.writeGlobalConfig(globalConfig);
48
+ }
49
+ async upsertProfile(name, profile) {
50
+ const globalConfig = await this.readGlobalConfig();
51
+ globalConfig.profiles[name] = {
52
+ ...globalConfig.profiles[name],
53
+ ...profile
54
+ };
55
+ await this.writeGlobalConfig(globalConfig);
56
+ }
57
+ async removeProfile(name) {
58
+ const globalConfig = await this.readGlobalConfig();
59
+ delete globalConfig.profiles[name];
60
+ for (const client of Object.keys(globalConfig.currentProfiles)) {
61
+ if (globalConfig.currentProfiles[client] === name) {
62
+ delete globalConfig.currentProfiles[client];
63
+ }
64
+ }
65
+ delete globalConfig.aliases[name];
66
+ await this.writeGlobalConfig(globalConfig);
67
+ }
68
+ async setAlias(name, profileName) {
69
+ const globalConfig = await this.readGlobalConfig();
70
+ globalConfig.aliases[name] = profileName;
71
+ await this.writeGlobalConfig(globalConfig);
72
+ }
73
+ async removeAlias(name) {
74
+ const globalConfig = await this.readGlobalConfig();
75
+ delete globalConfig.aliases[name];
76
+ await this.writeGlobalConfig(globalConfig);
77
+ }
78
+ async setConfigValue(key, value) {
79
+ const globalConfig = await this.readGlobalConfig();
80
+ const path = key.split(".");
81
+ let cursor = globalConfig;
82
+ for (const segment of path.slice(0, -1)) {
83
+ const next = cursor[segment];
84
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
85
+ cursor[segment] = {};
86
+ }
87
+ cursor = cursor[segment];
88
+ }
89
+ cursor[path[path.length - 1]] = value;
90
+ await this.writeGlobalConfig(globalConfig);
91
+ }
92
+ async getConfigValue(key) {
93
+ const globalConfig = await this.readGlobalConfig();
94
+ return key.split(".").reduce((acc, segment) => {
95
+ if (!acc || typeof acc !== "object") {
96
+ return undefined;
97
+ }
98
+ return acc[segment];
99
+ }, globalConfig);
100
+ }
101
+ mergeGlobalConfig(config) {
102
+ return {
103
+ defaultClient: config?.defaultClient ?? DEFAULT_GLOBAL_CONFIG.defaultClient,
104
+ currentProfiles: {
105
+ ...DEFAULT_GLOBAL_CONFIG.currentProfiles,
106
+ ...(config?.currentProfiles ?? {})
107
+ },
108
+ profiles: {
109
+ ...DEFAULT_GLOBAL_CONFIG.profiles,
110
+ ...(config?.profiles ?? {})
111
+ },
112
+ aliases: {
113
+ ...DEFAULT_GLOBAL_CONFIG.aliases,
114
+ ...(config?.aliases ?? {})
115
+ }
116
+ };
117
+ }
118
+ }
119
+ exports.ConfigStore = ConfigStore;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Resolver = void 0;
4
+ const errors_1 = require("../errors");
5
+ class Resolver {
6
+ store;
7
+ constructor(store) {
8
+ this.store = store;
9
+ }
10
+ async resolveProfile(options) {
11
+ const globalConfig = await this.store.readGlobalConfig();
12
+ const projectConfig = await this.store.readProjectConfig();
13
+ const profileField = this.pickFirst([
14
+ { value: options.profileOverride, source: "command line" },
15
+ { value: projectConfig?.profile, source: "project config" },
16
+ { value: process.env.MM_PROFILE, source: "environment" },
17
+ { value: globalConfig.currentProfiles[options.client], source: "global config" }
18
+ ]);
19
+ if (!profileField.value) {
20
+ throw new errors_1.ConfigError(`No active profile for client ${options.client}. Use \`mm use <profile>\` first.`);
21
+ }
22
+ const expandedProfileName = globalConfig.aliases[profileField.value] ?? profileField.value;
23
+ const profile = globalConfig.profiles[expandedProfileName];
24
+ if (!profile) {
25
+ throw new errors_1.ProfileNotFoundError(`Unknown profile: ${expandedProfileName}`);
26
+ }
27
+ const profileSource = globalConfig.aliases[profileField.value] && expandedProfileName !== profileField.value
28
+ ? `${profileField.source} via alias`
29
+ : profileField.source;
30
+ return {
31
+ client: options.client,
32
+ profileName: expandedProfileName,
33
+ profile,
34
+ resolvedFrom: {
35
+ profileName: profileSource
36
+ }
37
+ };
38
+ }
39
+ pickFirst(values) {
40
+ for (const item of values) {
41
+ if (item.value !== undefined && item.value !== "") {
42
+ return item;
43
+ }
44
+ }
45
+ return { value: undefined, source: "unset" };
46
+ }
47
+ }
48
+ exports.Resolver = Resolver;
package/dist/errors.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClientNotFoundError = exports.ProfileNotFoundError = exports.ConfigError = exports.MmError = void 0;
4
+ class MmError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = new.target.name;
8
+ }
9
+ }
10
+ exports.MmError = MmError;
11
+ class ConfigError extends MmError {
12
+ }
13
+ exports.ConfigError = ConfigError;
14
+ class ProfileNotFoundError extends MmError {
15
+ }
16
+ exports.ProfileNotFoundError = ProfileNotFoundError;
17
+ class ClientNotFoundError extends MmError {
18
+ }
19
+ exports.ClientNotFoundError = ClientNotFoundError;
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const cli_1 = require("./cli");
5
+ void (0, cli_1.runCli)(process.argv.slice(2)).catch(cli_1.handleCliError);
package/dist/output.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.printConfig = printConfig;
4
+ exports.printList = printList;
5
+ exports.printCurrent = printCurrent;
6
+ exports.printWhich = printWhich;
7
+ exports.printApplyResult = printApplyResult;
8
+ exports.printRollbackResult = printRollbackResult;
9
+ exports.printIssues = printIssues;
10
+ function printConfig(config) {
11
+ console.log(JSON.stringify(config, null, 2));
12
+ }
13
+ function printList(config) {
14
+ console.log("clients:");
15
+ for (const [client, profile] of Object.entries(config.currentProfiles)) {
16
+ console.log(` ${client}: ${profile}`);
17
+ }
18
+ console.log("profiles:");
19
+ for (const [name, profile] of Object.entries(config.profiles)) {
20
+ const summary = [profile.provider, profile.model].filter(Boolean).join(" / ");
21
+ console.log(` ${name}${summary ? ` -> ${summary}` : ""}`);
22
+ }
23
+ console.log("aliases:");
24
+ for (const [name, target] of Object.entries(config.aliases)) {
25
+ console.log(` ${name} -> ${target}`);
26
+ }
27
+ }
28
+ function printCurrent(resolved, state) {
29
+ console.log(`client: ${resolved.client}`);
30
+ console.log(`profile: ${resolved.profileName}`);
31
+ console.log(`provider: ${resolved.profile.provider ?? "(unset)"}`);
32
+ console.log(`model: ${resolved.profile.model ?? "(unset)"}`);
33
+ console.log(`baseURL: ${resolved.profile.baseURL ?? "(unset)"}`);
34
+ console.log(`configPath: ${state.configPath}`);
35
+ console.log(`source: ${resolved.resolvedFrom.profileName}`);
36
+ }
37
+ function printWhich(resolved, state) {
38
+ console.log(`client: ${resolved.client}`);
39
+ console.log(`profile: ${resolved.profileName}`);
40
+ console.log(`configPath: ${state.configPath}`);
41
+ console.log(`resolvedFrom.profile: ${resolved.resolvedFrom.profileName}`);
42
+ console.log(`profile.provider: ${resolved.profile.provider ?? "(unset)"}`);
43
+ console.log(`profile.model: ${resolved.profile.model ?? "(unset)"}`);
44
+ console.log(`profile.baseURL: ${resolved.profile.baseURL ?? "(unset)"}`);
45
+ console.log(`profile.apiKey: ${resolved.profile.apiKey ? "(set)" : "(unset)"}`);
46
+ console.log(`profile.apiKeyEnv: ${resolved.profile.apiKeyEnv ?? "(unset)"}`);
47
+ console.log(`codex.provider: ${state.values.provider ?? "(unset)"}`);
48
+ console.log(`codex.model: ${state.values.model ?? "(unset)"}`);
49
+ console.log(`codex.base_url: ${state.values.base_url ?? "(unset)"}`);
50
+ console.log(`codex.api_key: ${state.values.api_key ? "(set)" : "(unset)"}`);
51
+ console.log(`codex.api_key_env: ${state.values.api_key_env ?? "(unset)"}`);
52
+ }
53
+ function printApplyResult(result) {
54
+ console.log(`client: ${result.client}`);
55
+ console.log(`configPath: ${result.configPath}`);
56
+ console.log(`updatedKeys: ${result.updatedKeys.join(", ") || "(none)"}`);
57
+ if (result.backupPath) {
58
+ console.log(`backup: ${result.backupPath}`);
59
+ }
60
+ }
61
+ function printRollbackResult(result) {
62
+ console.log(`client: ${result.client}`);
63
+ console.log(`configPath: ${result.configPath}`);
64
+ console.log(`restoredFrom: ${result.backupPath}`);
65
+ }
66
+ function printIssues(issues) {
67
+ if (issues.length === 0) {
68
+ console.log("ok: no issues found");
69
+ return;
70
+ }
71
+ for (const issue of issues) {
72
+ console.log(`${issue.level}: ${issue.message}`);
73
+ }
74
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readJsonFile = readJsonFile;
4
+ exports.writeJsonFile = writeJsonFile;
5
+ exports.ensureDirForFile = ensureDirForFile;
6
+ exports.backupFile = backupFile;
7
+ exports.restoreBackupFile = restoreBackupFile;
8
+ exports.findUp = findUp;
9
+ const node_fs_1 = require("node:fs");
10
+ const promises_1 = require("node:fs/promises");
11
+ const node_path_1 = require("node:path");
12
+ async function readJsonFile(filePath) {
13
+ if (!(0, node_fs_1.existsSync)(filePath)) {
14
+ return undefined;
15
+ }
16
+ const content = await (0, promises_1.readFile)(filePath, "utf8");
17
+ return JSON.parse(content);
18
+ }
19
+ async function writeJsonFile(filePath, value) {
20
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(filePath), { recursive: true });
21
+ await (0, promises_1.writeFile)(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
22
+ }
23
+ async function ensureDirForFile(filePath) {
24
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(filePath), { recursive: true });
25
+ }
26
+ async function backupFile(filePath) {
27
+ if (!(0, node_fs_1.existsSync)(filePath)) {
28
+ return undefined;
29
+ }
30
+ const backupPath = `${filePath}.bak`;
31
+ await (0, promises_1.copyFile)(filePath, backupPath);
32
+ return backupPath;
33
+ }
34
+ async function restoreBackupFile(filePath) {
35
+ const backupPath = `${filePath}.bak`;
36
+ if (!(0, node_fs_1.existsSync)(backupPath)) {
37
+ return undefined;
38
+ }
39
+ await (0, promises_1.rename)(backupPath, filePath);
40
+ return backupPath;
41
+ }
42
+ function findUp(fileName, startDir) {
43
+ let current = startDir;
44
+ while (true) {
45
+ const candidate = (0, node_path_1.join)(current, fileName);
46
+ if ((0, node_fs_1.existsSync)(candidate)) {
47
+ return candidate;
48
+ }
49
+ const parent = (0, node_path_1.dirname)(current);
50
+ if (parent === current || current === (0, node_path_1.parse)(current).root) {
51
+ return undefined;
52
+ }
53
+ current = parent;
54
+ }
55
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@orionpotter/menv",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/OrionPotter/menv"
7
+ },
8
+ "description": "CLI profile manager for Codex and other AI CLIs",
9
+ "type": "commonjs",
10
+ "bin": {
11
+ "mm": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "check": "tsc -p tsconfig.json --noEmit",
20
+ "prepare": "npm run build",
21
+ "prepublishOnly": "npm run check && npm run build",
22
+ "start": "node dist/index.js"
23
+ },
24
+ "keywords": [
25
+ "cli",
26
+ "codex",
27
+ "config",
28
+ "provider-manager",
29
+ "npm"
30
+ ],
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.6.0",
37
+ "typescript": "^5.9.3"
38
+ }
39
+ }
40
+