Skip to content

Commit 4d477d4

Browse files
authored
fix: allow global commands from the user home directory
Closes #58
1 parent 367d3c5 commit 4d477d4

3 files changed

Lines changed: 104 additions & 10 deletions

File tree

lib/cli.js

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createHash } from "node:crypto";
22
import { lstat, readFile, realpath } from "node:fs/promises";
3-
import { dirname, relative, resolve } from "node:path";
3+
import { dirname, join, relative, resolve } from "node:path";
44
import {
55
inspectInstallation as inspectBaseInstallation,
66
reasoningWarnings,
@@ -131,11 +131,39 @@ async function semanticCrossScope(options) {
131131
return entries;
132132
}
133133

134+
function flagPresent(argv, name) {
135+
return argv.some((token) => token === name || token.startsWith(`${name}=`));
136+
}
137+
134138
function isStatus(argv) {
135139
if (argv[0] === "v2") return ["status", "doctor"].includes(argv[1]);
136140
return ["status", "doctor"].includes(argv[0]);
137141
}
138142

143+
function isOperational(argv) {
144+
if (argv[0] === "v2") {
145+
return ["enable", "disable", "status", "install", "uninstall", "doctor"].includes(argv[1]);
146+
}
147+
return ["enable", "disable", "status", "install", "uninstall", "doctor", "adopt", "repair"].includes(argv[0]);
148+
}
149+
150+
function samePath(left, right) {
151+
if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase();
152+
return left === right;
153+
}
154+
155+
function scopeAwareOptions(argv, normalized) {
156+
if (!flagPresent(argv, "--global")) return normalized;
157+
158+
// Global commands select only the user-level installation. Give the enhanced
159+
// diagnostics a non-overlapping synthetic project view so a cwd that happens
160+
// to be the user home cannot block the explicitly selected global scope.
161+
return {
162+
...normalized,
163+
cwd: join(normalized.home, ".codex-model-router-global-command")
164+
};
165+
}
166+
139167
export { reasoningWarnings };
140168

141169
export async function resolveLocations(options = {}, global = false) {
@@ -144,27 +172,38 @@ export async function resolveLocations(options = {}, global = false) {
144172

145173
export async function inspectInstallation(options = {}, global = false) {
146174
const normalized = await canonicalOptions(options);
147-
const report = await inspectBaseInstallation(normalized, global);
148-
report.cross_scope = await semanticCrossScope(normalized);
175+
const scoped = global ? scopeAwareOptions(["status", "--global"], normalized) : normalized;
176+
const report = await inspectBaseInstallation(scoped, global);
177+
report.roots.project = normalized.cwd;
178+
report.cross_scope = await semanticCrossScope(scoped);
149179
return report;
150180
}
151181

152182
export async function runCli(argv, options = {}) {
153183
const normalized = await canonicalOptions(options);
154-
if (!isStatus(argv)) return runEnhancedCli(argv, normalized);
155-
156184
const output = normalized.output ?? console.log;
157-
const json = argv.some((token) => token === "--json" || token.startsWith("--json="));
185+
const global = flagPresent(argv, "--global");
186+
187+
if (isOperational(argv) && !global && samePath(normalized.cwd, normalized.home)) {
188+
output("fail: project scope cannot use the user home as its project root; use --global or change to an actual project directory");
189+
return 1;
190+
}
191+
192+
const scoped = scopeAwareOptions(argv, normalized);
193+
if (!isStatus(argv)) return runEnhancedCli(argv, scoped);
194+
195+
const json = flagPresent(argv, "--json");
158196
const captured = [];
159197
const baseExit = await runEnhancedCli(argv, {
160-
...normalized,
198+
...scoped,
161199
output: json ? (line) => captured.push(String(line)) : output
162200
});
163-
const crossScope = await semanticCrossScope(normalized);
201+
const crossScope = await semanticCrossScope(scoped);
164202
const conflict = crossScope.some((entry) => entry.status === "conflict");
165203

166204
if (json) {
167205
const report = JSON.parse(captured.join("\n"));
206+
report.roots.project = normalized.cwd;
168207
report.cross_scope = crossScope;
169208
output(JSON.stringify(report, null, 2));
170209
} else {

test/enhanced-cli.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ test("default reasoning is warning-free and weak profiles use stable warning cod
3434
);
3535
});
3636

37-
test("overlapping project and global Codex roots are rejected before writes", async () => {
37+
test("overlapping project and global Codex roots are rejected before project writes", async () => {
3838
const dir = await fixture();
3939
try {
4040
const lines = [];
41-
const result = await runCli(["enable", "--global", "--dry-run"], {
41+
const result = await runCli(["enable", "--dry-run"], {
4242
cwd: dir.project,
4343
home: dir.home,
4444
env: { CODEX_HOME: join(dir.project, ".codex") },

test/global-home-scope.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import test from "node:test";
6+
import { runCli } from "../lib/cli.js";
7+
8+
const quiet = () => {};
9+
const codexAvailable = () => ({ status: 0, stdout: "codex-cli 9.9.9\n", stderr: "" });
10+
11+
async function fixture() {
12+
const home = await mkdtemp(join(tmpdir(), "codex-model-router-global-home-"));
13+
return {
14+
home,
15+
cleanup: () => rm(home, { recursive: true, force: true })
16+
};
17+
}
18+
19+
test("explicit global lifecycle commands work when cwd equals home", async () => {
20+
const dir = await fixture();
21+
try {
22+
const options = {
23+
cwd: dir.home,
24+
home: dir.home,
25+
output: quiet,
26+
spawnSync: codexAvailable
27+
};
28+
29+
assert.equal(await runCli(["enable", "--global"], options), 0);
30+
assert.equal(await runCli(["status", "--global"], options), 0);
31+
assert.equal(await runCli(["repair", "--global"], options), 0);
32+
assert.equal(await runCli(["v2", "status", "--global"], options), 0);
33+
assert.equal(await runCli(["disable", "--global"], options), 0);
34+
} finally {
35+
await dir.cleanup();
36+
}
37+
});
38+
39+
test("project lifecycle commands reject the user home with an actionable message", async () => {
40+
const dir = await fixture();
41+
try {
42+
const lines = [];
43+
const result = await runCli(["enable"], {
44+
cwd: dir.home,
45+
home: dir.home,
46+
output: (line) => lines.push(String(line)),
47+
spawnSync: codexAvailable
48+
});
49+
50+
assert.equal(result, 1);
51+
assert.ok(lines.some((line) => line.includes("use --global or change to an actual project directory")));
52+
} finally {
53+
await dir.cleanup();
54+
}
55+
});

0 commit comments

Comments
 (0)