Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/argent-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@argent/tools-client": "file:../argent-tools-client"
"@argent/configuration-core": "file:../configuration-core",
"@argent/tools-client": "file:../argent-tools-client",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^25.9.0",
Expand Down
243 changes: 52 additions & 191 deletions packages/argent-cli/src/flags.ts
Original file line number Diff line number Diff line change
@@ -1,192 +1,39 @@
// Feature-flag storage + CLI for argent.
// Feature-flag CLI for argent: the `enable` / `disable` / `flags` commands.
//
// Flags are simple boolean toggles stored as JSON in:
// ~/.argent/flags.json (global, default scope)
// <project-root>/.argent/flags.json (project scope)
// This module is the command layer only — argv parsing and console output. The
// registry, JSON storage, and `isFlagEnabled` live in `@argent/configuration-core`
// (the pure source of truth); the primitives are imported below.
//
// `enable` writes `true`, `disable` removes the entry at the chosen scope.
// `isFlagEnabled` walks project → global, so an entry at the project scope
// shadows the same key at the global scope. To opt a single project out of
// a globally-enabled flag, hand-edit `<project>/.argent/flags.json` to
// `{"flags":{"name":false}}` — there is no CLI for an explicit override.
//
// FLAG_REGISTRY below is the single source of truth for which flags exist:
// `enable` only accepts a name listed there, and `argent flags` documents
// every entry. `disable` stays lenient (so a flag removed from the registry
// can still be cleared from storage), and `isFlagEnabled` only reads storage —
// it never consults the registry, keeping runtime callers decoupled from CLI
// validation.
//
// Deprecating a flag is safe: only the *write* path (`enable`) consults the
// registry. Every read path (readFlags / isFlagEnabled / `argent flags`) loads
// whatever booleans are stored regardless of the registry, so removing an entry
// from FLAG_REGISTRY never errors on a flags.json that still contains it.
// `argent flags` lists such leftovers under an "unrecognized" section so they
// can be cleaned up.

import * as fs from "node:fs";
import * as path from "node:path";
import { homedir } from "node:os";

export type FlagScope = "global" | "project";

interface FlagsFile {
flags?: Record<string, boolean>;
}

// A recognized feature flag. `name` is what users pass to enable/disable and
// what `isFlagEnabled` reads; `description` is shown by `argent flags`.
export interface FlagDefinition {
readonly name: string;
readonly description: string;
}

// The flags argent recognizes. Adding one entry here is the only change needed
// to make `argent enable <name>` accept it and `argent flags` document it.
// An empty registry means no flags are available yet.
export const FLAG_REGISTRY: readonly FlagDefinition[] = [
// {
// name: "example-flag",
// description: "One-line summary shown by `argent flags`.",
// },
];

// Look up a flag's definition — exported for consumers that want the
// description alongside isFlagEnabled(). Defaults to the built-in registry.
export function getFlagDefinition(
name: string,
registry: readonly FlagDefinition[] = FLAG_REGISTRY
): FlagDefinition | undefined {
return registry.find((def) => def.name === name);
}

// Markers used to find the project root for --scope project. Trimmed to the
// minimum needed: an existing `.argent` (so subsequent runs in subdirs find
// the dir created by the first run), a git repo, or an npm package.
const PROJECT_MARKERS = [".argent", ".git", "package.json"];

export interface FlagsPathOptions {
cwd?: string;
homeDir?: string;
}

export function resolveProjectRoot(startDir: string): string {
const initial = path.resolve(startDir);
let current = initial;
while (true) {
for (const marker of PROJECT_MARKERS) {
if (fs.existsSync(path.join(current, marker))) return current;
}
const parent = path.dirname(current);
if (parent === current) return initial;
current = parent;
}
}

export function getFlagsPath(scope: FlagScope, options: FlagsPathOptions = {}): string {
const home = options.homeDir ?? homedir();
if (scope === "global") {
return path.join(home, ".argent", "flags.json");
}
const cwd = options.cwd ?? process.cwd();
return path.join(resolveProjectRoot(cwd), ".argent", "flags.json");
}

function readFlagsFile(filePath: string): Record<string, boolean> {
let raw: string;
try {
raw = fs.readFileSync(filePath, "utf8");
} catch {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const flags = (parsed as FlagsFile).flags;
if (!flags || typeof flags !== "object" || Array.isArray(flags)) return {};
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
if (typeof v === "boolean") out[k] = v;
}
return out;
}

function writeFlagsFile(filePath: string, flags: Record<string, boolean>): void {
// No flags left ⇒ remove the file (and the .argent dir if it becomes empty)
// so disable-after-enable round trips leave a clean tree. Sibling files
// (tool-server.json, tool-server.log, etc.) keep the dir alive when present.
if (Object.keys(flags).length === 0) {
if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true });
const parent = path.dirname(filePath);
try {
if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) {
fs.rmdirSync(parent);
}
} catch {
// non-fatal
}
return;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// Atomic write via tmp+rename so a reader never observes a torn/partial JSON
// payload. (Concurrent read-modify-write is still last-writer-wins, but two
// argent CLI invocations racing on the same flag file are not expected.)
const tmp = `${filePath}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ flags } satisfies FlagsFile, null, 2) + "\n");
fs.renameSync(tmp, filePath);
}

export function readFlags(
scope: FlagScope,
options: FlagsPathOptions = {}
): Record<string, boolean> {
return readFlagsFile(getFlagsPath(scope, options));
// `enable` writes `true` for a registry-listed flag; `disable` removes the entry
// at the chosen scope. `enable` is the only path that consults the registry —
// `disable` stays lenient so a flag removed from the registry can still be
// cleared from storage, and `argent flags` lists such leftovers under an
// "unrecognized" section so they can be cleaned up.

import pc from "picocolors";
import {
FLAG_REGISTRY,
getFlagDefinition,
getFlagsPath,
readFlags,
setFlag,
unsetFlag,
type FlagScope,
type FlagDefinition,
} from "@argent/configuration-core";

// Green for enabled, red for disabled. The label is padded first, then wrapped,
// so column alignment is computed from the plain text and never thrown off by
// ANSI escapes. We only colorize for an interactive TTY: picocolors' own
// auto-detection also turns colors on when the CI env var is set (e.g. GitHub
// Actions), which would leak escapes into captured/piped output, so we gate on
// isTTY ourselves to keep piped/redirected/CI output plain.
function colorState(enabled: boolean): string {
const label = (enabled ? "enabled" : "disabled").padEnd(8);
if (!process.stdout.isTTY) return label;
return enabled ? pc.green(label) : pc.red(label);
}

export function setFlag(
name: string,
value: boolean,
scope: FlagScope,
options: FlagsPathOptions = {}
): void {
const filePath = getFlagsPath(scope, options);
const current = readFlagsFile(filePath);
current[name] = value;
writeFlagsFile(filePath, current);
}

// Removes the entry from the given scope so the next layer (or the default)
// takes effect. Returns true when an entry was removed.
export function unsetFlag(name: string, scope: FlagScope, options: FlagsPathOptions = {}): boolean {
const filePath = getFlagsPath(scope, options);
const current = readFlagsFile(filePath);
// hasOwn, not `in`: flag names like "toString"/"constructor" are valid per
// FLAG_NAME_RE but live on Object.prototype, so `in` would report them as
// present (and delete a no-op) even when they were never stored.
if (!Object.hasOwn(current, name)) return false;
delete current[name];
writeFlagsFile(filePath, current);
return true;
}

// Effective value: project overrides global. Returns false when the flag is
// not set in either scope — flags are opt-in.
export function isFlagEnabled(name: string, options: FlagsPathOptions = {}): boolean {
// hasOwn, not `in`: otherwise prototype keys ("toString", "constructor", …)
// resolve to a truthy Object.prototype member for a flag that was never set.
const projectFlags = readFlags("project", options);
if (Object.hasOwn(projectFlags, name)) return projectFlags[name]!;
const globalFlags = readFlags("global", options);
if (Object.hasOwn(globalFlags, name)) return globalFlags[name]!;
return false;
}

// ── CLI handlers ──────────────────────────────────────────────────────────────

// Flag names: start with a letter, then letters/digits/dot/underscore/dash.
// Keeps file contents predictable and avoids shell-quoting surprises.
const FLAG_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
Expand Down Expand Up @@ -251,7 +98,19 @@ function parseScope(raw: string): FlagScope {
throw new Error(`--scope must be "project" or "global", got "${raw}"`);
}

function printToggleHelp(command: "enable" | "disable"): void {
// Renders the registry as an indented "Available flags:" block for --help
// output: one line per flag, `name <padding> description`, so users can see
// what they can toggle without running `argent flags` first.
function formatAvailableFlags(registry: readonly FlagDefinition[]): string {
if (registry.length === 0) {
return "Available flags:\n (none defined)";
}
const maxName = registry.reduce((m, def) => Math.max(m, def.name.length), 0);
const lines = registry.map((def) => ` ${def.name.padEnd(maxName)} ${def.description}`);
return ["Available flags:", ...lines].join("\n");
}

function printToggleHelp(command: "enable" | "disable", registry: readonly FlagDefinition[]): void {
const summary =
command === "enable"
? "Enable a predefined feature flag (see `argent flags`) at the chosen scope."
Expand All @@ -261,6 +120,8 @@ function printToggleHelp(command: "enable" | "disable"): void {

${summary}

${formatAvailableFlags(registry)}

Storage:
~/.argent/flags.json (global, default)
<project-root>/.argent/flags.json (project, with --scope project)
Expand All @@ -277,7 +138,7 @@ function runToggle(
registry: readonly FlagDefinition[]
): void {
if (argv.includes("--help") || argv.includes("-h")) {
printToggleHelp(command);
printToggleHelp(command, registry);
return;
}

Expand Down Expand Up @@ -334,6 +195,8 @@ export function flags(argv: string[], registry: readonly FlagDefinition[] = FLAG
List the available feature flags and their current state. Flags are
predefined; project-scoped values override global ones.

${formatAvailableFlags(registry)}

Options:
--json Print machine-readable JSON
`);
Expand Down Expand Up @@ -396,9 +259,8 @@ Options:
console.log("Feature flags (project overrides global):");
const maxName = registryView.reduce((m, f) => Math.max(m, f.name.length), 0);
for (const f of registryView) {
const stateLabel = f.enabled ? "enabled" : "disabled";
const scopeLabel = f.scope ? ` (${f.scope})` : "";
console.log(` ${f.name.padEnd(maxName, " ")} ${stateLabel.padEnd(8)}${scopeLabel}`);
console.log(` ${f.name.padEnd(maxName, " ")} ${colorState(f.enabled)}${scopeLabel}`);
console.log(` ${" ".repeat(maxName)} ${f.description}`);
}
}
Expand All @@ -407,8 +269,7 @@ Options:
console.log("\nStored but no longer recognized (safe to `argent disable`):");
const maxName = unrecognized.reduce((m, f) => Math.max(m, f.name.length), 0);
for (const f of unrecognized) {
const stateLabel = f.enabled ? "enabled" : "disabled";
console.log(` ${f.name.padEnd(maxName, " ")} ${stateLabel.padEnd(8)} (${f.scope})`);
console.log(` ${f.name.padEnd(maxName, " ")} ${colorState(f.enabled)} (${f.scope})`);
}
}

Expand Down
Loading