mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
8d2a499eaf
## 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.
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { argentHomeDir, configFilePath } from "../src/paths.js";
|
|
import { readConfigObject, updateConfig } from "../src/config.js";
|
|
import {
|
|
getRememberedAgent,
|
|
setRememberedAgent,
|
|
clearRememberedAgent,
|
|
} from "../src/config-access.js";
|
|
|
|
// Redirect the `~/.argent` home into a tmp dir by mutating process.env.HOME
|
|
// (consumed by os.homedir() via argentHomeDir).
|
|
let tmpHome: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-config-home-")));
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
process.env.HOME = tmpHome;
|
|
process.env.USERPROFILE = tmpHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = originalHome;
|
|
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = originalUserProfile;
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
function readConfigFile(): unknown {
|
|
return JSON.parse(fs.readFileSync(configFilePath(), "utf8"));
|
|
}
|
|
|
|
describe("paths", () => {
|
|
it("locates config.json under the argent home", () => {
|
|
expect(configFilePath()).toBe(path.join(argentHomeDir(), "config.json"));
|
|
expect(argentHomeDir()).toBe(path.join(tmpHome, ".argent"));
|
|
});
|
|
});
|
|
|
|
describe("readConfigObject / updateConfig", () => {
|
|
it("returns an empty object when the file is missing", () => {
|
|
expect(readConfigObject()).toEqual({});
|
|
});
|
|
|
|
it("returns an empty object when the file is malformed", () => {
|
|
fs.mkdirSync(argentHomeDir(), { recursive: true });
|
|
fs.writeFileSync(configFilePath(), "not json{", "utf8");
|
|
expect(readConfigObject()).toEqual({});
|
|
});
|
|
|
|
it("creates and persists a new key", () => {
|
|
updateConfig((c) => {
|
|
c.telemetry = { enabled: false };
|
|
});
|
|
expect(readConfigFile()).toEqual({ telemetry: { enabled: false } });
|
|
});
|
|
|
|
it("merges rather than clobbering keys it does not touch", () => {
|
|
updateConfig((c) => {
|
|
c.telemetry = { enabled: false };
|
|
});
|
|
updateConfig((c) => {
|
|
c.notices = { firstRun: true };
|
|
});
|
|
expect(readConfigFile()).toEqual({
|
|
telemetry: { enabled: false },
|
|
notices: { firstRun: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("remembered agent (lens config)", () => {
|
|
it("returns null when nothing is stored", () => {
|
|
expect(getRememberedAgent()).toBeNull();
|
|
});
|
|
|
|
it("persists and reads back the chosen agent", () => {
|
|
setRememberedAgent("claude");
|
|
expect(getRememberedAgent()).toBe("claude");
|
|
expect(readConfigFile()).toEqual({ lens: { agent: "claude" } });
|
|
});
|
|
|
|
it("overwrites a previously remembered agent", () => {
|
|
setRememberedAgent("claude");
|
|
setRememberedAgent("codex");
|
|
expect(getRememberedAgent()).toBe("codex");
|
|
});
|
|
|
|
it("treats a blank stored agent as none", () => {
|
|
updateConfig((c) => {
|
|
c.lens = { agent: " " };
|
|
});
|
|
expect(getRememberedAgent()).toBeNull();
|
|
});
|
|
|
|
it("clears the remembered agent without dropping sibling keys", () => {
|
|
updateConfig((c) => {
|
|
c.telemetry = { enabled: true };
|
|
});
|
|
setRememberedAgent("claude");
|
|
clearRememberedAgent();
|
|
expect(getRememberedAgent()).toBeNull();
|
|
expect(readConfigFile()).toEqual({ telemetry: { enabled: true }, lens: {} });
|
|
});
|
|
|
|
it("clearing when nothing is stored is a no-op that doesn't throw", () => {
|
|
expect(() => clearRememberedAgent()).not.toThrow();
|
|
expect(getRememberedAgent()).toBeNull();
|
|
});
|
|
});
|