Files
filip131311 8d2a499eaf Project-scope configuration with schema-driven merge + argent config (#534)
## Summary

Adds a general, **scoped** configuration system on top of the shared
`config.json`, plus a universal `argent config` command to manage it.
Motivated by the discussion on #401 (iOS device set): config that needs
to work naturally with the MCP server should be read off disk at runtime
like telemetry consent is — not threaded through CLI flags/env and a
kill-and-respawn dance. This lands the reusable machinery; the
device-set becomes a one-line schema entry + a runtime reader on top of
it.

Four pieces, matching the requirements:

### 1. Project-scope config files
`config.json` now resolves at two scopes:
- `~/.argent/config.json` (**global**, default)
- `<project-root>/.argent/config.json` (**project**, resolved by the
same `.argent`/`.git`/`package.json` marker walk as feature flags)

`configFilePath` / `readConfigObject` / `updateConfig` take an optional
`scope` (default `global`), so **every existing caller is unchanged**
(telemetry, notices, lens). Per-scope lock + atomic write are preserved.

### 2. Schema-driven merged reads
`getConfigValue(def)` reads each scope the value's schema entry allows,
merges them under the entry's policy, and falls back to the entry's
default. This is the reader consumers should call instead of poking at a
single global file. The existing **lens** getters (`getRememberedAgent`
etc.) are migrated onto it as the first live consumer — a repo can now
pin its lens agent, falling back to the user's global choice.

### 3. A registration file for configurations (`config-schema.ts`)
Adding a configuration is a one-liner: `key`, `description`, allowed
`scopes`, a `parse` validator, and a `merge` policy. Merge presets:
- `prioritize-local` (project wins) · `prioritize-global` (global wins)
- `prioritize-restrictive` (booleans/numbers — opt-out/stricter wins)
- `union` / `intersection` (arrays)
- …plus an escape hatch: pass your **own** `(inputs) => value` function
for a bespoke rule.

### 4. `argent config` — one universal command
`argent config <list | get | set | unset>` with `--scope global|project`
and `--json`. New configs are added as **schema entries**, not new
top-level `argent` commands, to avoid bloat.

**Telemetry stays backward-compatible** on its dedicated `argent
telemetry` command (which also drains/resets the live client). It's
surfaced *read-only* under `argent config` via a `manageCommand` guard —
so `argent config set telemetry.enabled` refuses and points at `argent
telemetry`, and a committed project file can never re-enable a global
opt-out (`prioritize-restrictive`, global-only).

## Testing

- **Unit / integration**: 71 configuration-core tests (merge presets +
custom fn, dotted-path + prototype-pollution guards, scope merge,
scope/validation/manage-command errors, `listConfig`, `coerceCliValue`)
and 11 real-filesystem CLI tests driving the actual `config()` entry
point across sandboxed HOME + project cwd. Full suites green:
configuration-core 71, argent-cli 268, telemetry 281, argent 60.
- **Live, on the built binary** (`node packages/argent/dist/cli.js …`):
set global + project (from a deep subdir) → effective get returns the
project value; per-scope gets return each file; both files land in the
right place; `argent telemetry disable/enable` writes `config.json` and
`argent config get telemetry.enabled` reflects it read-only; unknown-key
/ invalid-value / bad-scope errors exit non-zero with hints.
- `tsc --build` across the workspace, prettier, and eslint
(`--max-warnings 0`) all clean.

## Notes / follow-ups
- Config-file reads are not yet fingerprint-cached the way telemetry's
consent read is; a long-lived tool-server re-reads on each
`getConfigValue`. Fine for CLI/low-frequency use; a hot runtime consumer
(e.g. a future device-set reader) may want the same mtime/inode cache.
2026-07-22 12:47:42 +02:00

218 lines
8.5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { config } from "../src/config.js";
// Real-filesystem integration test: drives the actual `config()` entry point
// against a sandboxed global home (HOME) and project cwd (a tmp dir with a
// `.git` marker so the project root resolves there). No fs mocks — this
// exercises the command → configuration-core → disk path end to end.
let homeDir: string;
let projectDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalCwd: string;
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
class ExitError extends Error {
constructor(public code: number) {
super(`process.exit(${code})`);
}
}
beforeEach(() => {
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-home-")));
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-proj-")));
fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true });
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
originalCwd = process.cwd();
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
process.chdir(projectDir);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new ExitError(code ?? 0);
}) as never);
});
afterEach(() => {
process.chdir(originalCwd);
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
vi.restoreAllMocks();
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
});
function output(): string {
return logSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n");
}
function errors(): string {
return errSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n");
}
describe("argent config — set/get across scopes", () => {
it("sets global and reads it back as the effective value", () => {
config(["set", "lens.agent", "claude"]);
logSpy.mockClear();
config(["get", "lens.agent"]);
expect(output()).toBe("claude");
// Landed in the global config file.
const globalCfg = JSON.parse(
fs.readFileSync(path.join(homeDir, ".argent", "config.json"), "utf8")
);
expect(globalCfg).toEqual({ lens: { agent: "claude" } });
});
it("project scope overrides global under prioritize-local", () => {
config(["set", "lens.agent", "claude", "--scope", "global"]);
config(["set", "lens.agent", "codex", "--scope", "project"]);
logSpy.mockClear();
config(["get", "lens.agent"]);
expect(output()).toBe("codex");
// The project file lives under the project root, not HOME.
const projCfg = JSON.parse(
fs.readFileSync(path.join(projectDir, ".argent", "config.json"), "utf8")
);
expect(projCfg).toEqual({ lens: { agent: "codex" } });
});
it("get --scope shows only that scope's stored value", () => {
config(["set", "lens.agent", "claude", "--scope", "global"]);
config(["set", "lens.agent", "codex", "--scope", "project"]);
logSpy.mockClear();
config(["get", "lens.agent", "--scope", "global"]);
expect(output()).toBe("claude");
logSpy.mockClear();
config(["get", "lens.agent", "--scope", "project"]);
expect(output()).toBe("codex");
});
it("get prints (unset) for an absent value", () => {
config(["get", "lens.agent"]);
expect(output()).toBe("(unset)");
});
it("unset removes the value at a scope", () => {
config(["set", "lens.agent", "claude"]);
config(["unset", "lens.agent"]);
logSpy.mockClear();
config(["get", "lens.agent"]);
expect(output()).toBe("(unset)");
});
it("set reports the normalized value that was stored", () => {
config(["set", "lens.agent", " codex "]);
// The message echoes the trimmed value that actually landed on disk, not the
// raw CLI input. (Assert on the un-colorized value segment — the key is
// wrapped in bold ANSI codes when color is enabled, e.g. in CI.)
expect(output()).toContain("= codex (global).");
expect(output()).not.toContain(" codex ");
logSpy.mockClear();
config(["get", "lens.agent"]);
expect(output()).toBe("codex");
});
it("a no-op unset does not create the project config file", () => {
const projectCfg = path.join(projectDir, ".argent", "config.json");
config(["unset", "lens.agent", "--scope", "project"]);
expect(output()).toContain("was not set at project scope");
expect(fs.existsSync(projectCfg)).toBe(false);
});
it("a project-scope write echoes the resolved project root", () => {
config(["set", "lens.agent", "codex", "--scope", "project"]);
// (Assert on the un-colorized segment after the bold key.)
expect(output()).toContain(`(project: ${projectDir}).`);
// A real project (a `.git` marker at projectDir) is not degenerate — no warning.
expect(errors()).toBe("");
logSpy.mockClear();
config(["unset", "lens.agent", "--scope", "project"]);
expect(output()).toContain(`(project: ${projectDir}).`);
});
it("warns when project scope resolves to the home directory", () => {
// Materialize ~/.argent via a global write, then run from a non-project dir
// under HOME: the marker walk stops at ~/.argent, so "project" IS global.
config(["set", "lens.agent", "claude"]);
const nested = path.join(homeDir, "not-a-project");
fs.mkdirSync(nested);
process.chdir(nested);
logSpy.mockClear();
config(["set", "lens.agent", "codex", "--scope", "project"]);
expect(errors()).toContain("resolved to the home directory");
expect(output()).toContain(`(project: ${homeDir}).`);
// The write landed in the global config file.
const globalCfg = JSON.parse(
fs.readFileSync(path.join(homeDir, ".argent", "config.json"), "utf8")
);
expect(globalCfg).toEqual({ lens: { agent: "codex" } });
});
});
describe("argent config — validation & errors", () => {
it("rejects an unknown key with exit 2 and a hint", () => {
expect(() => config(["set", "does.not.exist", "1"])).toThrow(ExitError);
expect(errors()).toMatch(/Unknown configuration key/);
expect(errors()).toMatch(/argent config list/);
});
it("refuses to set a telemetry key, pointing at the dedicated command", () => {
expect(() => config(["set", "telemetry.enabled", "false"])).toThrow(ExitError);
expect(errors()).toMatch(/argent telemetry/);
});
it("rejects an invalid --scope", () => {
expect(() => config(["get", "lens.agent", "--scope", "bogus"])).toThrow(ExitError);
expect(errors()).toMatch(/--scope must be/);
});
it("rejects a value that fails the schema validator", () => {
// lens.agent must be a non-blank string; a JSON number is invalid.
expect(() => config(["set", "lens.agent", "42"])).toThrow(ExitError);
expect(errors()).toMatch(/Invalid value/);
});
});
describe("argent config — list & json", () => {
it("list --json reports every schema entry with per-scope values", () => {
config(["set", "lens.agent", "codex", "--scope", "project"]);
logSpy.mockClear();
config(["list", "--json"]);
const parsed = JSON.parse(output());
const lens = parsed.config.find((e: { key: string }) => e.key === "lens.agent");
expect(lens.project).toBe("codex");
expect(lens.effective).toBe("codex");
const telemetry = parsed.config.find((e: { key: string }) => e.key === "telemetry.enabled");
expect(telemetry.manageCommand).toBe("argent telemetry");
});
it("telemetry.enabled reads as true (opt-out default) on a fresh install", () => {
config(["get", "telemetry.enabled"]);
expect(output()).toBe("true");
logSpy.mockClear();
config(["list", "--json"]);
const parsed = JSON.parse(output());
const telemetry = parsed.config.find((e: { key: string }) => e.key === "telemetry.enabled");
expect(telemetry.effective).toBe(true);
});
it("get --json emits a structured record", () => {
config(["set", "lens.agent", "claude"]);
logSpy.mockClear();
config(["get", "lens.agent", "--json"]);
expect(JSON.parse(output())).toEqual({
key: "lens.agent",
scope: "effective",
value: "claude",
});
});
});