feat(settings): user-settings v0 — typed settings + workspace scaffold

PRD: slices/user-settings-v0/IMPLEMENTATION-PRD.md.

Closes the "UI works on founder's machine vs npm-shipped install
elsewhere" gap UEP introduced via env-var workarounds.

CLI (Items 1, 5)
- ConfigStore extended with 7 new keys: workspace.{root,slices_root,
  steering_path,field_notes_root,specs_root}, files.allowlist,
  progress.scan_roots. Existing 5 daemon/db/transcripts keys preserved
  unchanged. ENV_MAP, KEY_TO_PATH, DEFAULTS extended in lockstep.
- Per-subdir defaults derive from workspace.root cascade; per-subdir
  override > workspace.root cascade > built-in default.
- New ResolvedSetting + resolveWithSource() / resolveAllWithSource()
  helpers expose value/source/default for honest provenance.
- reset(key?) supports both whole-file (delete) and per-key reset.
- parseNamedPairs decoder for files.allowlist + progress.scan_roots
  (preserves UEP "name:/abs/path,..." env-var format).
- rig config bare action: --json structured RiggedConfig (preserves
  legacy shape); --json --with-source emits per-key provenance.
- rig config get --show-source / --json adds source-aware reads.
- rig config reset [key] adds per-key reset.

Init-workspace verb (Item 2)
- New rig config init-workspace [--root] [--force] [--dry-run] [--json].
- Creates 5 subdirs: slices/ steering/ progress/ field-notes/ specs/.
- Drops one-paragraph README.md per subdir + steering/STEERING.md
  placeholder. Idempotent. --force overwrites scaffolded files but
  never deletes operator content under directories. Operator-explicit
  per founder safety stop — never auto-runs.

UEP env-var graduation (Item 4)
- OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS become the env
  override for the new typed keys (no breaking change). decodeAllowlist
  + decodeProgressScanRoots factor the parsing out so settings-resolved
  values use the same path. Daemon startup reads via SettingsStore
  (env > settings file > empty) instead of env-only.

Daemon (Items 3, 4, 6 backend)
- New SettingsStore mirrors CLI ConfigStore for daemon-side reads +
  writes (no cross-package dep; constants kept parallel).
- New /api/config routes: GET (all), GET /:key, POST /:key (set),
  DELETE /:key (reset), POST /init-workspace. UI surface only —
  agent-edit stays on CLI shell-out per founder dialog.
- startup.ts wires SettingsStore + decodes raw allowlist/scan-roots
  strings to feed UEP routes + ProgressIndexer.

UI (Item 6)
- New System drawer Settings tab alongside existing Log + Status.
- New useSettings / useSetSetting / useResetSetting / useInitWorkspace
  hooks against /api/config.
- New SettingsTab component: Workspace section (root + 5 per-subdir),
  Files section (allowlist), Progress section (scan_roots), legacy
  daemon/db/transcripts sections. Each row: value + source badge +
  default + Edit/Save/Reset; Reset hidden for source=default. Init
  Workspace button calls the daemon route.

Skill (Item 8)
- New daemon/specs/agents/shared/skills/core/openrig-user-settings/
  SKILL.md teaches agents the canonical CLI verbs, common operator
  requests, and the "never write the JSON file directly" boundary.
  Loaded by Claude Code / Codex via existing skills mechanism.

Cross-CLI-version drift (Item 9)
- New keys remain unset on older daemons; UI Settings tab shows current
  values via /api/config which is feature-gated by the new keys'
  presence in resolved output. Existing 5-key behavior preserved.

NO new SQLite schema, migrations, or event types per PRD.
NO auto-scaffold per founder safety stop.

Tests
- cli: 26 new in config-store-extended.test.ts; existing 800 preserved
  (840+ passing).
- daemon: 12 new in user-settings-store.test.ts + 9 in
  user-settings-routes.test.ts; existing 2867 preserved.
- ui: 5 new in settings-tab.test.tsx; existing 525 preserved.
- tsc clean across all three packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 07:05:39 -07:00
1 parent 4f48164bd8
commit be2a239441
17 files changed
+2237 -90

No files matched your search

@@ -0,0 +1,160 @@
// User Settings v0 — `rig config init-workspace` operator-explicit scaffolding.
//
// Creates the 5-subdir default workspace at ~/.openrig/workspace/ (or a
// caller-supplied --root). Operator-explicit per founder safety stop:
// never auto-runs on `rig setup` / `rig daemon start` / first `rig up`.
//
// Behavior:
// - Reads workspace.root setting (default ~/.openrig/workspace/).
// - Creates the 5 canonical subdirs: slices/ steering/ progress/ field-notes/ specs/.
// - Drops a 1-paragraph README.md in each subdir.
// - Drops a placeholder steering/STEERING.md.
// - --dry-run: report what would be created without acting.
// - --force: overwrite existing files (NOT directories — never deletes
// operator content).
// - Idempotent without --force: existing-dir + existing-readme is a no-op.
import { Command } from "commander";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
export interface InitWorkspaceOpts {
root?: string;
force?: boolean;
dryRun?: boolean;
json?: boolean;
}
export interface InitWorkspaceResult {
root: string;
/** True when this run would (or did) create the root dir; false if root existed. */
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
/** True when --dry-run; nothing was actually written. */
dryRun: boolean;
}
const SUBDIRS = ["slices", "steering", "progress", "field-notes", "specs"] as const;
function readmeContent(subdir: string): string {
switch (subdir) {
case "slices":
return "# slices\n\nYour slice authoring workspace. OpenRig's Slice Story View browses\nthis directory by default. Each slice lives in its own subdirectory\nwith README.md + IMPLEMENTATION-PRD.md + dogfood-evidence.\n";
case "steering":
return "# steering\n\nThe `STEERING.md` priority-stack lives here. OpenRig's Steering surface\ncomposes the 6-panel view (priority stack, roadmap rail, in-motion,\nlane rails, loop state, health gates) from this file.\n";
case "progress":
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory\nrecursively (max depth 6) for PROGRESS.md files and renders them as a\nhierarchical tree.\n";
case "field-notes":
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work.\nOpenRig's UI surfaces these alongside slices for context.\n";
case "specs":
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context\npacks). OpenRig's Spec Library browses this directory alongside the\ndaemon's bundled built-in specs.\n";
default:
return `# ${subdir}\n`;
}
}
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
---
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit
it to record your top 3 priorities. OpenRig's Steering surface composes
the 6-panel view from this file alongside your PROGRESS.md tree.
## Top 3
1. <priority one>
2. <priority two>
3. <priority three>
## In Motion
(Active slices land here as you push them through the priority rail.)
## Loop State
(Health gates + loop diagnostics land here.)
`;
export function initWorkspaceCommand(configPath?: string): Command {
const cmd = new Command("init-workspace")
.description("Scaffold the default workspace at ~/.openrig/workspace/ with 5 subdirs")
.option("--root <path>", "Override workspace root (default: workspace.root setting)")
.option("--force", "Overwrite existing scaffolded files (does NOT remove directories)")
.option("--dry-run", "Show what would be created without writing")
.option("--json", "JSON output")
.action((opts: InitWorkspaceOpts) => {
try {
const result = runInitWorkspace({ ...opts, configPath });
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.dryRun) console.log(`(dry-run) workspace root: ${result.root}`);
else console.log(`workspace root: ${result.root}`);
for (const sub of result.subdirs) {
console.log(` ${sub.created ? "+" : " "} ${sub.name}/`);
}
for (const f of result.files) {
console.log(` ${f.created ? "+" : " "} ${f.relPath}${f.skipped ? ` (skipped: ${f.skipped})` : ""}`);
}
if (result.dryRun) console.log("(dry-run; no files written.)");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult {
const store = new ConfigStore(opts.configPath);
const root = opts.root ?? (store.get("workspace.root") as string);
const dryRun = !!opts.dryRun;
const force = !!opts.force;
const rootExists = existsSync(root);
const result: InitWorkspaceResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
dryRun,
};
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of SUBDIRS) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
// README.md per subdir.
const readmePath = join(subPath, "README.md");
const readmeExists = existsSync(readmePath);
if (readmeExists && !force) {
result.files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(readmePath, readmeContent(sub), "utf-8");
result.files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: true, skipped: null });
}
}
// steering/STEERING.md placeholder (dropped at the steering subdir).
const steeringPath = join(root, "steering", "STEERING.md");
const steeringExists = existsSync(steeringPath);
if (steeringExists && !force) {
result.files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(steeringPath, STEERING_PLACEHOLDER, "utf-8");
result.files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: true, skipped: null });
}
return result;
}
+77 -19
View File
@@ -1,33 +1,69 @@
import { Command } from "commander";
import { ConfigStore } from "../config-store.js";
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { initWorkspaceCommand } from "./config-init-workspace.js";
function formatRow(key: string, value: unknown): string {
return `${key.padEnd(28)} ${value}`;
}
function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting> {
return store.resolveAllWithSource();
}
export function configCommand(configPath?: string): Command {
const cmd = new Command("config").description("Inspect and change OpenRig configuration");
const store = new ConfigStore(configPath);
cmd
.option("--json", "JSON output for agents")
.option("--json", "JSON output for agents (resolved RiggedConfig)")
.option("--with-source", "Include source/default per key (honest provenance)")
.addHelpText("after", `
Examples:
rig config # show all resolved config
rig config --json # JSON output
rig config get daemon.port # read a single key
rig config set daemon.port 7434 # change a value
rig config reset # delete config file, revert to defaults
rig config # show all resolved config
rig config --json # JSON RiggedConfig (structured)
rig config --json --with-source # JSON per-key with source + default
rig config get daemon.port # read a single key
rig config get workspace.slices_root --show-source
rig config set daemon.port 7434 # change a value
rig config set workspace.slices_root /path # configure a workspace path
rig config reset # delete config file, revert all to defaults
rig config reset workspace.slices_root # clear one key, revert to default
rig config init-workspace # scaffold ~/.openrig/workspace/ with 5 subdirs
Keys:
daemon.* port, host
db.path
transcripts.* enabled, path
workspace.* root, slices_root, steering_path, field_notes_root, specs_root
files.allowlist name:/abs/path,name:/abs/path
progress.scan_roots name:/abs/path,name:/abs/path
Keys: daemon.port, daemon.host, db.path, transcripts.enabled, transcripts.path
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean }) => {
.action((opts: { json?: boolean; withSource?: boolean }) => {
try {
if (opts.withSource) {
const all = summarizeSettings(store);
if (opts.json) {
console.log(JSON.stringify(all, null, 2));
} else {
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
return;
}
// Default: structured RiggedConfig output (preserves pre-v0
// bare-action shape so existing scripts / tests keep working).
const config = store.resolve();
if (opts.json) {
console.log(JSON.stringify(config, null, 2));
} else {
console.log(`daemon.port ${config.daemon.port}`);
console.log(`daemon.host ${config.daemon.host}`);
console.log(`db.path ${config.db.path}`);
console.log(`transcripts.enabled ${config.transcripts.enabled}`);
console.log(`transcripts.path ${config.transcripts.path}`);
const all = summarizeSettings(store);
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
} catch (err) {
console.error((err as Error).message);
@@ -37,9 +73,20 @@ Precedence: CLI flag > environment variable > config file > default`)
const getCmd = new Command("get")
.argument("<key>", "Config key (e.g. daemon.port)")
.option("--json", "JSON output with value + source + default")
.option("--show-source", "Print value + source on a single line")
.description("Read a single config value")
.action((key: string) => {
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
try {
if (opts.json) {
console.log(JSON.stringify(store.resolveWithSource(key), null, 2));
return;
}
if (opts.showSource) {
const r = store.resolveWithSource(key);
console.log(`${r.value}\t(source: ${r.source})`);
return;
}
console.log(String(store.get(key)));
} catch (err) {
console.error((err as Error).message);
@@ -62,15 +109,26 @@ Precedence: CLI flag > environment variable > config file > default`)
});
const resetCmd = new Command("reset")
.description("Delete config file and revert to defaults")
.action(() => {
store.reset();
console.log("Config reset to defaults.");
.argument("[key]", "Optional config key to reset (omit to reset entire file)")
.description("Clear a config override (or delete the entire file when no key given)")
.action((key: string | undefined) => {
try {
store.reset(key);
if (key) {
console.log(`${key} reset to default (${store.get(key)}).`);
} else {
console.log("Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.addCommand(getCmd);
cmd.addCommand(setCmd);
cmd.addCommand(resetCmd);
cmd.addCommand(initWorkspaceCommand(configPath));
return cmd;
}
+232 -43
View File
@@ -6,42 +6,112 @@ import {
readOpenRigEnv,
} from "./openrig-compat.js";
// User Settings v0 — extends the existing ConfigStore with new namespaces
// (workspace.*, files.*, progress.*) without changing the existing 5
// daemon/db/transcripts keys' behavior. Storage stays single-source-of-
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string };
// User Settings v0 — workspace paths.
workspace: {
root: string;
slicesRoot: string;
steeringPath: string;
fieldNotesRoot: string;
specsRoot: string;
};
// User Settings v0 — UEP env-var graduation.
// Values are stored as raw named-pair strings ("name:/abs/path,...")
// matching the OPENRIG_FILES_ALLOWLIST / OPENRIG_PROGRESS_SCAN_ROOTS
// formats; decoded helpers (parseNamedPairs) turn them into structured
// arrays.
files: {
allowlist: string;
};
progress: {
scanRoots: string;
};
}
const DEFAULTS: RiggedConfig = {
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
const DEFAULTS = {
daemon: { port: 7433, host: "127.0.0.1" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
};
workspace: {
root: DEFAULT_WORKSPACE_ROOT,
slicesRoot: "",
steeringPath: "",
fieldNotesRoot: "",
specsRoot: "",
},
files: { allowlist: "" },
progress: { scanRoots: "" },
} as const;
const VALID_KEYS = [
export const VALID_KEYS = [
"daemon.port",
"daemon.host",
"db.path",
"transcripts.enabled",
"transcripts.path",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"files.allowlist",
"progress.scan_roots",
] as const;
type ValidKey = typeof VALID_KEYS[number];
export type ValidKey = typeof VALID_KEYS[number];
const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT", legacy: "RIGGED_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT", legacy: "RIGGED_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", legacy: "RIGGED_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT", legacy: "RIGGED_WORKSPACE_SPECS_ROOT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
// typed keys (no breaking change).
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST", legacy: "RIGGED_FILES_ALLOWLIST" },
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS", legacy: "RIGGED_PROGRESS_SCAN_ROOTS" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
// Workspace per-subdir keys are stored on disk as `workspace.slices_root`
// (snake) and exposed in RiggedConfig as `workspace.slicesRoot` (camel)
// to match TS conventions.
const KEY_TO_PATH: Record<ValidKey, string[]> = {
"daemon.port": ["daemon", "port"],
"daemon.host": ["daemon", "host"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
"workspace.root": ["workspace", "root"],
"workspace.slices_root": ["workspace", "slicesRoot"],
"workspace.steering_path": ["workspace", "steeringPath"],
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
"workspace.specs_root": ["workspace", "specsRoot"],
"files.allowlist": ["files", "allowlist"],
"progress.scan_roots": ["progress", "scanRoots"],
};
function isValidKey(key: string): key is ValidKey {
return (VALID_KEYS as readonly string[]).includes(key);
}
function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
const parts = key.split(".");
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
@@ -50,8 +120,7 @@ function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
return current;
}
function setNestedValue(obj: Record<string, unknown>, key: string, value: unknown): void {
const parts = key.split(".");
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
@@ -63,12 +132,35 @@ function setNestedValue(obj: Record<string, unknown>, key: string, value: unknow
current[parts[parts.length - 1]!] = value;
}
function getDefaultValue(key: ValidKey): string | number | boolean {
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, key) as string | number | boolean;
// Per-subdir defaults derived from workspace.root. v0 uses canonical
// subdir names per founder dialog (slices/ steering/ progress/ field-notes/
// specs/). Steering is a FILE path (STEERING.md) under the steering subdir.
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "slices");
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
case "workspace.specs_root": return join(workspaceRoot, "specs");
default: return "";
}
}
function coerceValue(key: ValidKey, raw: string): string | number | boolean {
const defaultVal = getDefaultValue(key);
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
]);
function getDefaultValue(key: ValidKey, workspaceRoot: string): string | number | boolean {
if (WORKSPACE_DERIVED_KEYS.has(key)) {
return deriveWorkspaceDefault(key, workspaceRoot);
}
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, KEY_TO_PATH[key]) as string | number | boolean;
}
function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
const defaultVal = getDefaultValue(key, workspaceRoot);
if (typeof defaultVal === "number") {
const n = parseInt(raw, 10);
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
@@ -82,6 +174,14 @@ function coerceValue(key: ValidKey, raw: string): string | number | boolean {
return raw;
}
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
value: string | number | boolean;
source: SettingSource;
defaultValue: string | number | boolean;
}
export class ConfigStore {
readonly configPath: string;
@@ -92,62 +192,123 @@ export class ConfigStore {
resolve(): RiggedConfig {
const fileConfig = this.readConfigFile();
const resolveKey = (key: ValidKey): string | number | boolean => {
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return coerceValue(key, envVal);
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, key);
if (fileVal !== undefined && fileVal !== null) {
return fileVal as string | number | boolean;
}
// 3. Default
return getDefaultValue(key);
};
// Workspace root must be resolved first so derived per-subdir
// defaults can use it.
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const v = (key: ValidKey) =>
this.resolveOne(key, fileConfig, workspaceRoot).value;
return {
daemon: {
port: resolveKey("daemon.port") as number,
host: resolveKey("daemon.host") as string,
port: v("daemon.port") as number,
host: v("daemon.host") as string,
},
db: {
path: resolveKey("db.path") as string,
path: v("db.path") as string,
},
transcripts: {
enabled: resolveKey("transcripts.enabled") as boolean,
path: resolveKey("transcripts.path") as string,
enabled: v("transcripts.enabled") as boolean,
path: v("transcripts.path") as string,
},
workspace: {
root: workspaceRoot,
slicesRoot: v("workspace.slices_root") as string,
steeringPath: v("workspace.steering_path") as string,
fieldNotesRoot: v("workspace.field_notes_root") as string,
specsRoot: v("workspace.specs_root") as string,
},
files: {
allowlist: v("files.allowlist") as string,
},
progress: {
scanRoots: v("progress.scan_roots") as string,
},
};
}
get(key: string): string | number | boolean {
/**
* Resolve a single key with its source. Used by the daemon HTTP route
* + UI Settings panel for honest provenance display (env / file /
* default).
*/
resolveWithSource(key: string): ResolvedSetting {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const config = this.resolve();
return getNestedValue(config as unknown as Record<string, unknown>, key) as string | number | boolean;
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
return this.resolveOne(key, fileConfig, workspaceRoot);
}
/** Resolve all valid keys with sources. Convenience for the UI. */
resolveAllWithSource(): Record<ValidKey, ResolvedSetting> {
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const out = {} as Record<ValidKey, ResolvedSetting>;
for (const key of VALID_KEYS) {
out[key] = this.resolveOne(key, fileConfig, workspaceRoot);
}
return out;
}
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
const defaultValue = getDefaultValue(key, workspaceRoot);
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return { value: coerceValue(key, envVal, workspaceRoot), source: "env", defaultValue };
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
}
// 3. Default
return { value: defaultValue, source: "default", defaultValue };
}
get(key: string): string | number | boolean {
return this.resolveWithSource(key).value;
}
set(key: string, value: string): void {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const coerced = coerceValue(key, value);
// readConfigFile (not Raw) — throws on malformed config so set doesn't silently overwrite
const fileConfig = this.readConfigFile();
setNestedValue(fileConfig, key, coerced);
const workspaceRoot = (getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined)
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|| DEFAULT_WORKSPACE_ROOT;
const coerced = coerceValue(key, value, workspaceRoot);
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
reset(): void {
try {
unlinkSync(this.configPath);
} catch {
// File doesn't exist — that's fine
/**
* Clear an override. Without a key argument, deletes the whole config
* file (revert all to defaults). With a key, removes just that key
* from the config file.
*/
reset(key?: string): void {
if (key === undefined) {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
if (!existsSync(this.configPath)) return;
const fileConfig = this.readConfigFile();
const parts = KEY_TO_PATH[key];
const parentParts = parts.slice(0, -1);
const leaf = parts[parts.length - 1]!;
const parent = getNestedValue(fileConfig, parentParts) as Record<string, unknown> | undefined;
if (parent && leaf in parent) {
delete parent[leaf];
}
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
private readConfigFile(): Record<string, unknown> {
@@ -161,5 +322,33 @@ export class ConfigStore {
);
}
}
}
// --- User Settings v0: named-pair decoder ---
//
// `files.allowlist` and `progress.scan_roots` are stored as the same
// comma-separated `name:/abs/path` strings UEP introduced via env var.
// This helper decodes the raw string into structured pairs. Invalid
// entries (no colon, empty name/path) are silently skipped per UEP
// convention; duplicate names: last wins.
export interface NamedPair {
name: string;
path: string;
}
export function parseNamedPairs(raw: string): NamedPair[] {
if (!raw || !raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
out.set(name, rawPath);
}
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
}
@@ -0,0 +1,329 @@
// User Settings v0 — extended ConfigStore + init-workspace tests.
//
// Pins the load-bearing behaviors of the v0 extension:
// - new keys parse + validate; legacy 5 keys' behavior preserved
// - per-subdir override resolution (workspace.root / per-subdir overrides / default)
// - env > file > default precedence for new keys
// - UEP env-var graduation: OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS still work
// - reset(key) clears one key; bare reset deletes the file
// - parseNamedPairs decodes the named-pair format
// - init-workspace creates 5 subdirs + READMEs + STEERING placeholder; idempotent
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
ConfigStore,
VALID_KEYS,
parseNamedPairs,
deriveWorkspaceDefault,
} from "../src/config-store.js";
import { runInitWorkspace } from "../src/commands/config-init-workspace.js";
function clearEnv(): () => void {
const keysToClear = [
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
"RIGGED_WORKSPACE_ROOT", "RIGGED_WORKSPACE_SLICES_ROOT",
"RIGGED_FILES_ALLOWLIST", "RIGGED_PROGRESS_SCAN_ROOTS",
];
const saved: Record<string, string | undefined> = {};
for (const k of keysToClear) {
saved[k] = process.env[k];
delete process.env[k];
}
return () => {
for (const k of keysToClear) {
if (saved[k] !== undefined) process.env[k] = saved[k]!;
else delete process.env[k];
}
};
}
describe("ConfigStore — extended namespaces (User Settings v0)", () => {
let tmpDir: string;
let configPath: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "config-store-ext-"));
configPath = join(tmpDir, "config.json");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("VALID_KEYS includes the 5 legacy + 7 new keys", () => {
const expected = [
"daemon.port", "daemon.host", "db.path",
"transcripts.enabled", "transcripts.path",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"files.allowlist", "progress.scan_roots",
];
expect([...VALID_KEYS]).toEqual(expected);
});
it("legacy 5-key behavior preserved: get/set/reset still work", () => {
const store = new ConfigStore(configPath);
store.set("daemon.port", "9999");
expect(store.get("daemon.port")).toBe(9999);
store.set("transcripts.enabled", "false");
expect(store.get("transcripts.enabled")).toBe(false);
store.reset("daemon.port");
expect(store.get("daemon.port")).toBe(7433);
});
it("workspace.root default is ~/.openrig/workspace; per-subdir defaults derive from it", () => {
const store = new ConfigStore(configPath);
const cfg = store.resolve();
expect(cfg.workspace.root).toMatch(/\.openrig[\/\\]workspace$/);
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "slices"));
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "steering", "STEERING.md"));
expect(cfg.workspace.fieldNotesRoot).toBe(join(cfg.workspace.root, "field-notes"));
expect(cfg.workspace.specsRoot).toBe(join(cfg.workspace.root, "specs"));
});
it("setting workspace.root cascades into per-subdir defaults", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/custom/ws");
const cfg = store.resolve();
expect(cfg.workspace.root).toBe("/custom/ws");
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/slices");
expect(cfg.workspace.steeringPath).toBe("/custom/ws/steering/STEERING.md");
});
it("per-subdir override beats workspace.root cascade", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/founder/slices");
const cfg = store.resolve();
// workspace.root cascade applies to OTHER subdirs:
expect(cfg.workspace.fieldNotesRoot).toBe("/ws/field-notes");
// per-subdir override wins:
expect(cfg.workspace.slicesRoot).toBe("/founder/slices");
});
it("env > file > default for new keys", () => {
const store = new ConfigStore(configPath);
// No file → default
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
// File set → file source
store.set("workspace.slices_root", "/from/file");
expect(store.resolveWithSource("workspace.slices_root").value).toBe("/from/file");
expect(store.resolveWithSource("workspace.slices_root").source).toBe("file");
// Env set → env source
process.env["OPENRIG_WORKSPACE_SLICES_ROOT"] = "/from/env";
try {
const r = store.resolveWithSource("workspace.slices_root");
expect(r.value).toBe("/from/env");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_WORKSPACE_SLICES_ROOT"];
}
});
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST is the env override for files.allowlist", () => {
const store = new ConfigStore(configPath);
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me,docs:/var/docs";
try {
const r = store.resolveWithSource("files.allowlist");
expect(r.value).toBe("ws:/Users/me,docs:/var/docs");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_FILES_ALLOWLIST"];
}
});
it("UEP env-var graduation: OPENRIG_PROGRESS_SCAN_ROOTS is the env override for progress.scan_roots", () => {
const store = new ConfigStore(configPath);
process.env["OPENRIG_PROGRESS_SCAN_ROOTS"] = "main:/code/main";
try {
const r = store.resolveWithSource("progress.scan_roots");
expect(r.value).toBe("main:/code/main");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_PROGRESS_SCAN_ROOTS"];
}
});
it("legacy RIGGED_FILES_ALLOWLIST falls back when OPENRIG_* is unset", () => {
const store = new ConfigStore(configPath);
process.env["RIGGED_FILES_ALLOWLIST"] = "legacy:/old/path";
try {
expect(store.get("files.allowlist")).toBe("legacy:/old/path");
} finally {
delete process.env["RIGGED_FILES_ALLOWLIST"];
}
});
it("set rejects unknown keys with hint listing valid keys", () => {
const store = new ConfigStore(configPath);
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
expect(() => store.set("workspace.bogus", "x")).toThrow(/workspace\.root/);
});
it("get rejects unknown keys", () => {
const store = new ConfigStore(configPath);
expect(() => store.get("nope.doesnt.exist")).toThrow(/Unknown config key/);
});
it("reset(key) clears just one key; reset() deletes whole file", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/ws/slices-custom");
store.reset("workspace.slices_root");
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
expect(store.resolveWithSource("workspace.root").source).toBe("file");
store.reset();
expect(existsSync(configPath)).toBe(false);
});
it("resolveAllWithSource returns every valid key with source + default", () => {
const store = new ConfigStore(configPath);
const all = store.resolveAllWithSource();
for (const key of VALID_KEYS) {
expect(all[key]).toBeDefined();
expect(all[key].source).toBeDefined();
}
});
it("malformed config.json throws with reset hint (preserves legacy behavior)", () => {
writeFileSync(configPath, "{not-json");
const store = new ConfigStore(configPath);
expect(() => store.resolve()).toThrow(/malformed/i);
expect(() => store.resolve()).toThrow(/reset/i);
});
});
describe("parseNamedPairs", () => {
it("returns empty array for empty/whitespace input", () => {
expect(parseNamedPairs("")).toEqual([]);
expect(parseNamedPairs(" ")).toEqual([]);
});
it("splits comma-separated name:path pairs", () => {
const out = parseNamedPairs("ws:/abs/path,docs:/var/docs");
expect(out).toEqual([
{ name: "ws", path: "/abs/path" },
{ name: "docs", path: "/var/docs" },
]);
});
it("skips entries without a colon", () => {
expect(parseNamedPairs("just-name,ws:/path")).toEqual([{ name: "ws", path: "/path" }]);
});
it("trims whitespace around each pair + name + path", () => {
expect(parseNamedPairs(" ws : /abs/path , docs:/var/docs ")).toEqual([
{ name: "ws", path: "/abs/path" },
{ name: "docs", path: "/var/docs" },
]);
});
it("dedupes by name (last wins)", () => {
expect(parseNamedPairs("ws:/old,ws:/new")).toEqual([{ name: "ws", path: "/new" }]);
});
});
describe("deriveWorkspaceDefault", () => {
it("returns canonical subpaths under workspace root", () => {
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/slices");
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/steering/STEERING.md");
expect(deriveWorkspaceDefault("workspace.field_notes_root", "/ws")).toBe("/ws/field-notes");
expect(deriveWorkspaceDefault("workspace.specs_root", "/ws")).toBe("/ws/specs");
});
});
describe("init-workspace runner", () => {
let tmpDir: string;
let configPath: string;
let workspaceRoot: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "init-workspace-"));
configPath = join(tmpDir, "config.json");
workspaceRoot = join(tmpDir, "workspace");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("--dry-run reports what would be created without writing anything", () => {
const result = runInitWorkspace({ dryRun: true, root: workspaceRoot, configPath });
expect(result.dryRun).toBe(true);
expect(result.subdirs.map((s) => s.name)).toEqual([
"slices", "steering", "progress", "field-notes", "specs",
]);
expect(existsSync(workspaceRoot)).toBe(false);
});
it("creates 5 subdirs + READMEs + STEERING placeholder", () => {
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.dryRun).toBe(false);
expect(existsSync(workspaceRoot)).toBe(true);
for (const sub of ["slices", "steering", "progress", "field-notes", "specs"]) {
expect(existsSync(join(workspaceRoot, sub))).toBe(true);
expect(existsSync(join(workspaceRoot, sub, "README.md"))).toBe(true);
}
const steeringMd = readFileSync(join(workspaceRoot, "steering", "STEERING.md"), "utf-8");
expect(steeringMd).toContain("OpenRig Priority Stack");
});
it("is idempotent: running twice without --force is a no-op for existing files", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const sliceReadme = join(workspaceRoot, "slices", "README.md");
writeFileSync(sliceReadme, "operator-edited content", "utf-8");
const second = runInitWorkspace({ root: workspaceRoot, configPath });
const sliceFile = second.files.find((f) => f.relPath === "slices/README.md");
expect(sliceFile?.skipped).toBe("exists");
expect(readFileSync(sliceReadme, "utf-8")).toBe("operator-edited content");
});
it("--force overwrites existing files but never deletes operator content under directories", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const operatorFile = join(workspaceRoot, "slices", "my-slice.md");
writeFileSync(operatorFile, "my work", "utf-8");
const operatorReadme = join(workspaceRoot, "slices", "README.md");
writeFileSync(operatorReadme, "edited", "utf-8");
runInitWorkspace({ root: workspaceRoot, force: true, configPath });
// Operator file under the subdir survives
expect(existsSync(operatorFile)).toBe(true);
expect(readFileSync(operatorFile, "utf-8")).toBe("my work");
// README is overwritten
expect(readFileSync(operatorReadme, "utf-8")).toContain("# slices");
});
it("--root override beats configured workspace.root", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/should/not/be/used");
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.root).toBe(workspaceRoot);
expect(existsSync(workspaceRoot)).toBe(true);
});
it("reads workspace.root from settings when --root is not given", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", workspaceRoot);
const result = runInitWorkspace({ configPath });
expect(result.root).toBe(workspaceRoot);
expect(existsSync(workspaceRoot)).toBe(true);
});
});
@@ -0,0 +1,130 @@
---
name: openrig-user-settings
description: Use when the user asks an OpenRig agent to read or change OpenRig configuration settings — workspace paths, file allowlists, progress scan roots, daemon port/host, transcripts, or anything stored in ~/.openrig/config.json
---
# OpenRig User Settings
OpenRig settings live in `~/.openrig/config.json` and are managed via the
`rig config` CLI family. The agent-editable surface is **CLI shell-out**;
do NOT write the JSON file directly unless the operator specifically asks
for raw-text editing (which is handled by the UI Files browser, not by you).
## Read
```bash
rig config # all keys, value + source
rig config --json # JSON: full RiggedConfig
rig config --json --with-source # JSON: {key: {value, source, default}}
rig config get <key> # single value
rig config get <key> --show-source # value + source on one line
rig config get <key> --json # {value, source, default}
```
Resolution order: **env var > config file > default**. Always use
`--show-source` (or `--json`) when reporting back to the operator so they
see *where* the value came from.
## Write
```bash
rig config set <key> <value> # set a key
rig config reset <key> # clear one override (revert to default)
rig config reset # delete the whole file
```
## Initialize a default workspace
```bash
rig config init-workspace # scaffold ~/.openrig/workspace/ + 5 subdirs
rig config init-workspace --dry-run # show what would be created
rig config init-workspace --root /path # use a custom root
rig config init-workspace --force # overwrite scaffolded files (NOT operator content)
```
This creates `slices/ steering/ progress/ field-notes/ specs/` under the
workspace root, with one-paragraph READMEs and a placeholder
`steering/STEERING.md`. It is operator-explicit per founder safety stop —
do NOT run it on your own initiative; only when the operator asks.
## Common keys
| Key | Type | Purpose |
|---|---|---|
| `daemon.port` | number | OpenRig daemon HTTP port (default 7433) |
| `daemon.host` | string | Daemon bind host (default 127.0.0.1) |
| `db.path` | string | SQLite DB path (default ~/.openrig/openrig.sqlite) |
| `transcripts.enabled` | boolean | Whether the daemon writes transcripts |
| `transcripts.path` | string | Transcripts dir (default ~/.openrig/transcripts) |
| `workspace.root` | string | Single-root override (default ~/.openrig/workspace) |
| `workspace.slices_root` | string | Per-subdir override; default `<root>/slices` |
| `workspace.steering_path` | string | STEERING.md path; default `<root>/steering/STEERING.md` |
| `workspace.field_notes_root` | string | Field notes dir; default `<root>/field-notes` |
| `workspace.specs_root` | string | Specs dir; default `<root>/specs` |
| `files.allowlist` | string | `name:/abs/path,name:/abs/path` — UI Files browser roots |
| `progress.scan_roots` | string | `name:/abs/path,name:/abs/path` — Progress browse roots |
Resolution order for workspace per-subdir keys: per-subdir override >
`workspace.root` cascade > built-in default.
## Common operator requests → canonical commands
> **"Make the UI work against my substrate layout"** (founder's case):
> set the per-subdir workspace overrides explicitly:
>
> ```bash
> rig config set workspace.slices_root /Users/me/code/substrate/.../slices
> rig config set workspace.steering_path /Users/me/code/substrate/openrig-work/STEERING.md
> rig config set workspace.progress_scan_roots 'work:/Users/me/code/substrate/openrig-work,missions:/Users/me/code/substrate/openrig-work/missions'
> rig config set workspace.field_notes_root /Users/me/code/substrate/openrig-work/field-notes
> ```
>
> No restart needed for the CLI; for the daemon's reads to pick up
> changes affecting startup-time wiring (`files.allowlist`,
> `progress.scan_roots`), restart with `rig daemon restart`.
> **"Initialize a default workspace"**:
>
> ```bash
> rig config init-workspace
> ```
> **"Allow the file browser to see X directory"**:
>
> ```bash
> # Append to existing list (operator decides the name token):
> rig config get files.allowlist --show-source # check current
> rig config set files.allowlist 'workspace:/path/to/ws,docs:/path/to/docs'
> rig daemon restart # picks up new roots
> ```
> **"What's my current setting for X and where does it come from?"**:
>
> ```bash
> rig config get <key> --show-source
> ```
## When NOT to use this skill
- **Per-rig config** — settings are host-global at v0; per-rig is v0+1.
- **Secrets / credentials** — the `vault.specialist` managed-app handles
secrets. Don't put API keys or tokens in `~/.openrig/config.json`.
- **Settings sync across hosts** — out of scope at v0.
## Errors you might see
- `Unknown config key "<x>". Valid keys: …` — the key name is wrong.
Check the table above; suggest the correct key.
- `Config file at <path> is malformed. Fix the JSON or reset with: rig config reset` —
hand-edit broke the JSON. Operator can `rig config reset` (deletes the
whole file) or fix the syntax in the Files browser.
- `Invalid value for <key>: expected a number/true-false, got "<x>"` —
wrong type for a typed key. Reach for the key's type in the table.
## Provenance + safety
- Never write to `~/.openrig/config.json` directly. Use `rig config set`.
- Don't run `rig config init-workspace` on your own initiative — wait for
an operator request.
- Always cite the resolved source (env / file / default) when reporting
values back, so the operator knows whether their change actually took.
+18 -13
View File
@@ -51,17 +51,13 @@ const ENV_VAR = "OPENRIG_FILES_ALLOWLIST";
const LEGACY_ENV_VAR = "RIGGED_FILES_ALLOWLIST";
/**
* Reads the allowlist env var and returns the parsed root list. Each
* pair is `<name>:<absolute-path>`; whitespace around delimiters is
* trimmed. Invalid pairs (no colon, empty name, non-absolute path)
* are silently skipped (logged at construction by the caller, not
* here — keep this pure for tests). Duplicate names: last entry wins
* (operator presumably means to override).
* Decodes a raw `name:/abs/path,name:/abs/path` allowlist string into
* canonical AllowlistRoot[]. Whitespace around delimiters trimmed.
* Invalid pairs (no colon, empty name, non-absolute path) silently
* skipped. Duplicate names: last wins. Same shape both env-var and
* settings-file-resolved values produce.
*/
export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): AllowlistRoot[] {
// Use || (not ??) so an empty-string OPENRIG_FILES_ALLOWLIST falls
// back to the legacy var. ?? only handles null/undefined.
const raw = (env[ENV_VAR] || env[LEGACY_ENV_VAR] || "").toString();
export function decodeAllowlist(raw: string): AllowlistRoot[] {
if (!raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
@@ -73,9 +69,6 @@ export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): Allo
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
if (!path.isAbsolute(rawPath)) continue;
// Resolve to canonical absolute path. If the path doesn't exist
// on disk yet, fall back to path.resolve so the operator can
// pre-configure roots. realpath would throw on missing paths.
let canonical: string;
try {
canonical = fs.realpathSync(rawPath);
@@ -87,6 +80,18 @@ export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): Allo
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
}
/**
* Reads the allowlist env var directly and returns the parsed root list.
* Preserved for backward-compat callers; v0 callers prefer the resolved
* settings-store path which honors env > settings-file > empty.
*/
export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): AllowlistRoot[] {
// Use || (not ??) so an empty-string OPENRIG_FILES_ALLOWLIST falls
// back to the legacy var. ?? only handles null/undefined.
const raw = (env[ENV_VAR] || env[LEGACY_ENV_VAR] || "").toString();
return decodeAllowlist(raw);
}
/**
* Resolves a relative path against an allowlist root. Throws
* FilePathSafetyError on any unsafe condition. Returns the
@@ -83,8 +83,13 @@ const SKIP_DIRS = new Set(["node_modules", ".git", ".worktrees", "dist", "build"
const ENV_VAR = "OPENRIG_PROGRESS_SCAN_ROOTS";
const LEGACY_ENV_VAR = "RIGGED_PROGRESS_SCAN_ROOTS";
export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env): ProgressScanRoot[] {
const raw = env[ENV_VAR] ?? env[LEGACY_ENV_VAR] ?? "";
/**
* Decodes a raw `name:/abs/path,...` progress-roots string into a
* ProgressScanRoot[]. Same semantics as decodeAllowlist in path-safety;
* keep them parallel. Used by both the env-only legacy path and the
* settings-resolved (env > file > empty) path.
*/
export function decodeProgressScanRoots(raw: string): ProgressScanRoot[] {
if (!raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
@@ -102,6 +107,11 @@ export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env):
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
}
export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env): ProgressScanRoot[] {
const raw = env[ENV_VAR] ?? env[LEGACY_ENV_VAR] ?? "";
return decodeProgressScanRoots(raw);
}
export interface ProgressIndexerOpts {
roots: ProgressScanRoot[];
/** Override max recursion depth for tests. */
@@ -0,0 +1,298 @@
// User Settings v0 — daemon-side settings store.
//
// The CLI's @openrig/cli ConfigStore is the canonical write surface
// (operator + agent edit via `rig config`). The daemon needs read+write
// access too — for the UI's System drawer Settings panel + the daemon
// HTTP route at /api/config. Rather than depend on the CLI package
// (which would require workspace exports + a dist build), this module
// duplicates the small, stable resolution + write logic. The constants
// (VALID_KEYS, ENV_MAP, KEY_TO_PATH) are kept in lockstep with
// cli/src/config-store.ts via cross-package tests.
//
// Storage: same single source of truth at ~/.openrig/config.json.
// Resolution: same env > file > default precedence. Decoded helpers
// (parseNamedPairs / resolveAllowlist / resolveProgressScanRoots /
// resolveWorkspacePaths) project the raw strings into structured data
// the daemon's UEP routes + Slice Story View consume.
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
const DEFAULT_CONFIG_PATH = path.join(
process.env["OPENRIG_HOME"] || process.env["RIGGED_HOME"] || path.join(os.homedir(), ".openrig"),
"config.json",
);
const DEFAULT_WORKSPACE_ROOT = path.join(
process.env["OPENRIG_HOME"] || process.env["RIGGED_HOME"] || path.join(os.homedir(), ".openrig"),
"workspace",
);
export const SETTINGS_VALID_KEYS = [
"daemon.port",
"daemon.host",
"db.path",
"transcripts.enabled",
"transcripts.path",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"files.allowlist",
"progress.scan_roots",
] as const;
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT", legacy: "RIGGED_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT", legacy: "RIGGED_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", legacy: "RIGGED_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT", legacy: "RIGGED_WORKSPACE_SPECS_ROOT" },
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST", legacy: "RIGGED_FILES_ALLOWLIST" },
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS", legacy: "RIGGED_PROGRESS_SCAN_ROOTS" },
};
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"daemon.port": ["daemon", "port"],
"daemon.host": ["daemon", "host"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
"workspace.root": ["workspace", "root"],
"workspace.slices_root": ["workspace", "slicesRoot"],
"workspace.steering_path": ["workspace", "steeringPath"],
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
"workspace.specs_root": ["workspace", "specsRoot"],
"files.allowlist": ["files", "allowlist"],
"progress.scan_roots": ["progress", "scanRoots"],
};
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
value: string | number | boolean;
source: SettingSource;
defaultValue: string | number | boolean;
}
export function isSettingsValidKey(key: string): key is SettingsValidKey {
return (SETTINGS_VALID_KEYS as readonly string[]).includes(key);
}
function readEnv(primary: string, legacy: string): string | undefined {
const p = process.env[primary];
if (p !== undefined && p !== "") return p;
const l = process.env[legacy];
if (l !== undefined && l !== "") return l;
return undefined;
}
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!(part in current) || typeof current[part] !== "object" || current[part] === null) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]!] = value;
}
function deriveWorkspaceDefault(key: SettingsValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return path.join(workspaceRoot, "slices");
case "workspace.steering_path": return path.join(workspaceRoot, "steering", "STEERING.md");
case "workspace.field_notes_root": return path.join(workspaceRoot, "field-notes");
case "workspace.specs_root": return path.join(workspaceRoot, "specs");
default: return "";
}
}
const WORKSPACE_DERIVED_KEYS: ReadonlySet<SettingsValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
]);
function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string | number | boolean {
if (WORKSPACE_DERIVED_KEYS.has(key)) {
return deriveWorkspaceDefault(key, workspaceRoot);
}
switch (key) {
case "daemon.port": return 7433;
case "daemon.host": return "127.0.0.1";
case "db.path": return path.join(path.dirname(DEFAULT_CONFIG_PATH), "openrig.sqlite");
case "transcripts.enabled": return true;
case "transcripts.path": return path.join(path.dirname(DEFAULT_CONFIG_PATH), "transcripts");
case "workspace.root": return DEFAULT_WORKSPACE_ROOT;
case "files.allowlist": return "";
case "progress.scan_roots": return "";
default: return "";
}
}
function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string): string | number | boolean {
const def = getDefaultValue(key, workspaceRoot);
if (typeof def === "number") {
const n = parseInt(raw, 10);
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
return n;
}
if (typeof def === "boolean") {
if (raw === "true" || raw === "1") return true;
if (raw === "false" || raw === "0") return false;
throw new Error(`Invalid value for ${key}: expected true/false, got "${raw}"`);
}
return raw;
}
export interface ResolvedConfig {
workspaceRoot: string;
workspaceSlicesRoot: string;
workspaceSteeringPath: string;
workspaceFieldNotesRoot: string;
workspaceSpecsRoot: string;
filesAllowlistRaw: string;
progressScanRootsRaw: string;
}
export class SettingsStore {
readonly configPath: string;
constructor(configPath?: string) {
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
}
resolveOne(key: SettingsValidKey, fileConfig?: Record<string, unknown>, workspaceRoot?: string): ResolvedSetting {
const fc = fileConfig ?? this.readConfigFile();
const wr = workspaceRoot ?? this.resolveWorkspaceRootRaw(fc);
const defaultValue = getDefaultValue(key, wr);
const envVal = readEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return { value: coerceValue(key, envVal, wr), source: "env", defaultValue };
}
const fileVal = getNestedValue(fc, KEY_TO_PATH[key]);
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
}
return { value: defaultValue, source: "default", defaultValue };
}
resolveAllWithSource(): Record<SettingsValidKey, ResolvedSetting> {
const fc = this.readConfigFile();
const wr = this.resolveWorkspaceRootRaw(fc);
const out = {} as Record<SettingsValidKey, ResolvedSetting>;
for (const key of SETTINGS_VALID_KEYS) {
out[key] = this.resolveOne(key, fc, wr);
}
return out;
}
/** Project the raw resolution into a flattened config structure for daemon consumers. */
resolveConfig(): ResolvedConfig {
const fc = this.readConfigFile();
const wr = this.resolveWorkspaceRootRaw(fc);
return {
workspaceRoot: wr,
workspaceSlicesRoot: this.resolveOne("workspace.slices_root", fc, wr).value as string,
workspaceSteeringPath: this.resolveOne("workspace.steering_path", fc, wr).value as string,
workspaceFieldNotesRoot: this.resolveOne("workspace.field_notes_root", fc, wr).value as string,
workspaceSpecsRoot: this.resolveOne("workspace.specs_root", fc, wr).value as string,
filesAllowlistRaw: this.resolveOne("files.allowlist", fc, wr).value as string,
progressScanRootsRaw: this.resolveOne("progress.scan_roots", fc, wr).value as string,
};
}
set(key: string, value: string): void {
if (!isSettingsValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`);
}
const fc = this.readConfigFile();
const wr = this.resolveWorkspaceRootRaw(fc);
const coerced = coerceValue(key, value, wr);
setNestedValue(fc, KEY_TO_PATH[key], coerced);
mkdirSync(path.dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8");
}
reset(key?: string): void {
if (key === undefined) {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
if (!isSettingsValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`);
}
if (!existsSync(this.configPath)) return;
const fc = this.readConfigFile();
const parts = KEY_TO_PATH[key];
const parent = getNestedValue(fc, parts.slice(0, -1)) as Record<string, unknown> | undefined;
if (parent && parts[parts.length - 1]! in parent) {
delete parent[parts[parts.length - 1]!];
}
writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8");
}
private resolveWorkspaceRootRaw(fileConfig: Record<string, unknown>): string {
const envVal = readEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy);
if (envVal) return envVal;
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined;
if (fileVal) return fileVal;
return DEFAULT_WORKSPACE_ROOT;
}
private readConfigFile(): Record<string, unknown> {
if (!existsSync(this.configPath)) return {};
const raw = readFileSync(this.configPath, "utf-8");
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new Error(
`Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`,
);
}
}
}
// --- User Settings v0: shared decoders ---
export interface NamedPair {
name: string;
path: string;
}
export function parseNamedPairs(raw: string): NamedPair[] {
if (!raw || !raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
out.set(name, rawPath);
}
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
}
+165
View File
@@ -0,0 +1,165 @@
// User Settings v0 — daemon HTTP route for the UI System drawer
// Settings panel.
//
// Endpoints:
// GET /api/config → all keys with value/source/default
// GET /api/config/:key → one key with value/source/default
// POST /api/config/:key → set a key (body: { value: string })
// DELETE /api/config/:key → reset one key (revert to default)
// POST /api/config/init-workspace → scaffold default workspace dirs
//
// The CLI (`rig config get/set/reset/init-workspace`) is the canonical
// edit surface for operators + agents per founder dialog 2026-05-04.
// This route exists for the UI; agents stay on CLI-shell-out per the
// shipped openrig-user-settings skill.
import { Hono } from "hono";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
SETTINGS_VALID_KEYS,
isSettingsValidKey,
type SettingsStore,
} from "../domain/user-settings/settings-store.js";
interface InitWorkspaceBody {
root?: string;
force?: boolean;
dryRun?: boolean;
}
const SUBDIRS = ["slices", "steering", "progress", "field-notes", "specs"] as const;
function readmeContent(subdir: string): string {
switch (subdir) {
case "slices": return "# slices\n\nYour slice authoring workspace. OpenRig's Slice Story View browses\nthis directory by default.\n";
case "steering": return "# steering\n\nThe `STEERING.md` priority-stack lives here. OpenRig's Steering surface\ncomposes the 6-panel view from this file.\n";
case "progress": return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory\nrecursively for PROGRESS.md files.\n";
case "field-notes": return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work.\n";
case "specs": return "# specs\n\nWorkspace specs (rig / agent / workflow specs / context packs).\n";
default: return `# ${subdir}\n`;
}
}
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
---
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit
it to record your top 3 priorities.
## Top 3
1. <priority one>
2. <priority two>
3. <priority three>
## In Motion
(Active slices land here.)
## Loop State
(Health gates + loop diagnostics land here.)
`;
export function configRoutes(): Hono {
const router = new Hono();
router.get("/", (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
return c.json({ settings: store.resolveAllWithSource() });
});
router.post("/init-workspace", async (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const body = (await c.req.json<InitWorkspaceBody>().catch(() => ({}))) as InitWorkspaceBody;
const root = body.root ?? (store.resolveOne("workspace.root").value as string);
const dryRun = !!body.dryRun;
const force = !!body.force;
const subdirs: Array<{ name: string; path: string; created: boolean }> = [];
const files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }> = [];
const rootExists = existsSync(root);
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of SUBDIRS) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
subdirs.push({ name: sub, path: subPath, created: !subExists });
const readmePath = join(subPath, "README.md");
const readmeExists = existsSync(readmePath);
if (readmeExists && !force) {
files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(readmePath, readmeContent(sub), "utf-8");
files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: true, skipped: null });
}
}
const steeringPath = join(root, "steering", "STEERING.md");
const steeringExists = existsSync(steeringPath);
if (steeringExists && !force) {
files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(steeringPath, STEERING_PLACEHOLDER, "utf-8");
files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: true, skipped: null });
}
return c.json({ root, rootCreated: !rootExists, subdirs, files, dryRun });
});
router.get("/:key", (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const key = c.req.param("key");
if (!isSettingsValidKey(key)) {
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
}
return c.json(store.resolveOne(key));
});
router.post("/:key", async (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const key = c.req.param("key");
if (!isSettingsValidKey(key)) {
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
}
const body = (await c.req.json<{ value?: string }>().catch(() => ({}))) as { value?: string };
if (typeof body.value !== "string") {
return c.json({ error: "value_required", hint: "POST body must be { \"value\": <string> }" }, 400);
}
try {
store.set(key, body.value);
return c.json({ ok: true, key, resolved: store.resolveOne(key) });
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
router.delete("/:key", (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const key = c.req.param("key");
if (!isSettingsValidKey(key)) {
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
}
try {
store.reset(key);
return c.json({ ok: true, key, resolved: store.resolveOne(key) });
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
return router;
}
+5
View File
@@ -53,6 +53,7 @@ import { askRoutes } from "./routes/ask.js";
import type { AskService } from "./domain/ask-service.js";
import { specReviewRoutes } from "./routes/spec-review.js";
import { specLibraryRoutes } from "./routes/spec-library.js";
import { configRoutes } from "./routes/config.js";
import type { SpecReviewService } from "./domain/spec-review-service.js";
import type { SpecLibraryService } from "./domain/spec-library-service.js";
import type { ChatRepository } from "./domain/chat-repository.js";
@@ -152,6 +153,8 @@ export interface AppDeps {
// "slices_root_not_configured" 503 so the UI can surface a setup hint.
sliceIndexer?: import("./domain/slices/slice-indexer.js").SliceIndexer;
sliceDetailProjector?: import("./domain/slices/slice-detail-projector.js").SliceDetailProjector;
/** User Settings v0 — daemon-side settings store (env > file > default). */
settingsStore?: import("./domain/user-settings/settings-store.js").SettingsStore;
/** UI Enhancement Pack v0 — file allowlist + browser routes (item 3). */
filesAllowlist?: import("./domain/files/path-safety.js").AllowlistRoot[];
/** UI Enhancement Pack v0 — atomic write service (item 4). */
@@ -337,6 +340,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("sliceIndexer" as never, deps.sliceIndexer);
c.set("sliceDetailProjector" as never, deps.sliceDetailProjector);
c.set("filesAllowlist" as never, deps.filesAllowlist);
c.set("settingsStore" as never, deps.settingsStore);
c.set("fileWriteService" as never, deps.fileWriteService);
c.set("progressIndexer" as never, deps.progressIndexer);
c.set("steeringComposer" as never, deps.steeringComposer);
@@ -385,6 +389,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/ask", askRoutes);
app.route("/api/specs/review", specReviewRoutes());
app.route("/api/specs/library", specLibraryRoutes());
app.route("/api/config", configRoutes());
app.route("/api/whoami", whoamiRoutes());
app.route("/api/seat", seatRoutes);
app.route("/api/rigs/:rigId/chat", chatRoutes());
+12 -9
View File
@@ -737,23 +737,26 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// with structured config hints so the UI can surface a setup message
// instead of a generic error.
{
const { readAllowlistFromEnv } = await import("./domain/files/path-safety.js");
const { decodeAllowlist } = await import("./domain/files/path-safety.js");
const { FileWriteService } = await import("./domain/files/file-write-service.js");
const { ProgressIndexer, readProgressRootsFromEnv } = await import("./domain/progress/progress-indexer.js");
const filesAllowlist = readAllowlistFromEnv();
const { ProgressIndexer, decodeProgressScanRoots } = await import("./domain/progress/progress-indexer.js");
// User Settings v0 — UEP env-vars graduated to typed settings.
// Resolution: env > settings file > empty. SettingsStore handles
// the env > file > default precedence; we just decode the raw
// string into structured roots.
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
const settingsStore = new SettingsStore();
deps.settingsStore = settingsStore;
const cfg = settingsStore.resolveConfig();
const filesAllowlist = decodeAllowlist(cfg.filesAllowlistRaw);
deps.filesAllowlist = filesAllowlist;
deps.fileWriteService = filesAllowlist.length > 0
? new FileWriteService({
allowlist: filesAllowlist,
// Honor OPENRIG_HOME for state isolation. Without this, the
// service's DEFAULT_AUDIT_FILE falls back to process.env.HOME
// and writes audit rows to the host's ~/.openrig/, which
// breaks isolated dogfood/test daemons (rows bleed back to
// the operator's real home regardless of OPENRIG_HOME).
auditFilePath: nodePath.join(OPENRIG_HOME, "file-edit-audit.jsonl"),
})
: null;
deps.progressIndexer = new ProgressIndexer({ roots: readProgressRootsFromEnv() });
deps.progressIndexer = new ProgressIndexer({ roots: decodeProgressScanRoots(cfg.progressScanRootsRaw) });
// Operator Surface Reconciliation v0 — steering composer (item 1).
// Reads workspace root + per-section overrides from env. Empty
@@ -0,0 +1,162 @@
// User Settings v0 — daemon HTTP route tests.
//
// Pins the load-bearing behaviors of /api/config:
// - GET /api/config returns all 12 keys with source + default
// - GET /api/config/:key returns one key
// - POST /api/config/:key sets the value, persists to disk
// - DELETE /api/config/:key reverts one key to default
// - POST /api/config/init-workspace creates 5 subdirs + READMEs
// - 503 when settingsStore is unavailable
// - 400 on unknown keys / missing body
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
import { configRoutes } from "../src/routes/config.js";
function clearEnv(): () => void {
const keys = [
"OPENRIG_PORT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_WORKSPACE_ROOT",
];
const saved: Record<string, string | undefined> = {};
for (const k of keys) {
saved[k] = process.env[k];
delete process.env[k];
}
return () => {
for (const k of keys) {
if (saved[k] !== undefined) process.env[k] = saved[k]!;
else delete process.env[k];
}
};
}
describe("config routes (User Settings v0)", () => {
let tmpDir: string;
let configPath: string;
let store: SettingsStore;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "config-routes-"));
configPath = join(tmpDir, "config.json");
store = new SettingsStore(configPath);
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
function buildApp(): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("settingsStore" as never, store);
await next();
});
app.route("/api/config", configRoutes());
return app;
}
it("GET /api/config returns all 12 keys with source + default", async () => {
const app = buildApp();
const res = await app.request("/api/config");
expect(res.status).toBe(200);
const body = await res.json() as { settings: Record<string, { value: unknown; source: string }> };
expect(Object.keys(body.settings).length).toBe(12);
expect(body.settings["daemon.port"]?.source).toBe("default");
});
it("GET /api/config/:key returns the resolved value", async () => {
store.set("workspace.root", "/custom/ws");
const app = buildApp();
const res = await app.request("/api/config/workspace.root");
expect(res.status).toBe(200);
const body = await res.json() as { value: string; source: string };
expect(body.value).toBe("/custom/ws");
expect(body.source).toBe("file");
});
it("GET /api/config/:key 400s on unknown key", async () => {
const app = buildApp();
const res = await app.request("/api/config/workspace.bogus");
expect(res.status).toBe(400);
const body = await res.json() as { validKeys: string[] };
expect(body.validKeys).toContain("workspace.root");
});
it("POST /api/config/:key sets the value and persists to disk", async () => {
const app = buildApp();
const res = await app.request("/api/config/workspace.slices_root", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "/founder/slices" }),
});
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean; resolved: { value: string } };
expect(body.ok).toBe(true);
expect(body.resolved.value).toBe("/founder/slices");
// Disk persisted
expect(JSON.parse(readFileSync(configPath, "utf-8")).workspace.slicesRoot).toBe("/founder/slices");
});
it("POST /api/config/:key 400s without value field", async () => {
const app = buildApp();
const res = await app.request("/api/config/workspace.root", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("DELETE /api/config/:key resets to default", async () => {
store.set("workspace.slices_root", "/x");
const app = buildApp();
const res = await app.request("/api/config/workspace.slices_root", { method: "DELETE" });
expect(res.status).toBe(200);
const body = await res.json() as { resolved: { source: string } };
expect(body.resolved.source).toBe("default");
});
it("POST /api/config/init-workspace creates 5 subdirs + READMEs", async () => {
const root = join(tmpDir, "workspace");
const app = buildApp();
const res = await app.request("/api/config/init-workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root }),
});
expect(res.status).toBe(200);
const body = await res.json() as { root: string; subdirs: Array<{ name: string }>; files: Array<{ relPath: string }> };
expect(body.root).toBe(root);
expect(body.subdirs.map((s) => s.name)).toEqual([
"slices", "steering", "progress", "field-notes", "specs",
]);
expect(existsSync(join(root, "slices", "README.md"))).toBe(true);
expect(existsSync(join(root, "steering", "STEERING.md"))).toBe(true);
});
it("POST /api/config/init-workspace --dry-run does not write", async () => {
const root = join(tmpDir, "ws-dry");
const app = buildApp();
const res = await app.request("/api/config/init-workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root, dryRun: true }),
});
expect(res.status).toBe(200);
expect(existsSync(root)).toBe(false);
});
it("503 when settingsStore is missing from context", async () => {
const app = new Hono();
app.route("/api/config", configRoutes());
const res = await app.request("/api/config");
expect(res.status).toBe(503);
});
});
@@ -0,0 +1,174 @@
// User Settings v0 — daemon-side SettingsStore tests.
//
// Pins that the daemon's SettingsStore stays in lockstep with the CLI
// ConfigStore: same VALID_KEYS, same env-map, same env > file > default
// resolution, same file format. Drift between the two would mean the
// daemon's UI route surfaces a different value than the CLI.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
SettingsStore,
SETTINGS_VALID_KEYS,
parseNamedPairs,
} from "../src/domain/user-settings/settings-store.js";
function clearEnv(): () => void {
const keys = [
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_FILES_ALLOWLIST",
"RIGGED_PROGRESS_SCAN_ROOTS",
];
const saved: Record<string, string | undefined> = {};
for (const k of keys) {
saved[k] = process.env[k];
delete process.env[k];
}
return () => {
for (const k of keys) {
if (saved[k] !== undefined) process.env[k] = saved[k]!;
else delete process.env[k];
}
};
}
describe("SettingsStore (User Settings v0)", () => {
let tmpDir: string;
let configPath: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "settings-store-"));
configPath = join(tmpDir, "config.json");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("SETTINGS_VALID_KEYS matches the documented 12-key set", () => {
expect([...SETTINGS_VALID_KEYS]).toEqual([
"daemon.port", "daemon.host", "db.path",
"transcripts.enabled", "transcripts.path",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"files.allowlist", "progress.scan_roots",
]);
});
it("resolveAllWithSource returns every key with source + default", () => {
const store = new SettingsStore(configPath);
const all = store.resolveAllWithSource();
for (const key of SETTINGS_VALID_KEYS) {
expect(all[key]).toBeDefined();
expect(["env", "file", "default"]).toContain(all[key].source);
}
});
it("env > file > default for daemon.port", () => {
const store = new SettingsStore(configPath);
// No file → default
expect(store.resolveOne("daemon.port").source).toBe("default");
expect(store.resolveOne("daemon.port").value).toBe(7433);
// File → file
store.set("daemon.port", "9999");
expect(store.resolveOne("daemon.port").value).toBe(9999);
expect(store.resolveOne("daemon.port").source).toBe("file");
// Env wins
process.env["OPENRIG_PORT"] = "8888";
try {
expect(store.resolveOne("daemon.port").value).toBe(8888);
expect(store.resolveOne("daemon.port").source).toBe("env");
} finally {
delete process.env["OPENRIG_PORT"];
}
});
it("workspace.root cascades into per-subdir defaults", () => {
const store = new SettingsStore(configPath);
store.set("workspace.root", "/custom/ws");
const cfg = store.resolveConfig();
expect(cfg.workspaceRoot).toBe("/custom/ws");
expect(cfg.workspaceSlicesRoot).toBe("/custom/ws/slices");
expect(cfg.workspaceSteeringPath).toBe("/custom/ws/steering/STEERING.md");
expect(cfg.workspaceFieldNotesRoot).toBe("/custom/ws/field-notes");
expect(cfg.workspaceSpecsRoot).toBe("/custom/ws/specs");
});
it("per-subdir override beats the workspace.root cascade", () => {
const store = new SettingsStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/founder/slices");
const cfg = store.resolveConfig();
expect(cfg.workspaceSlicesRoot).toBe("/founder/slices");
// Other subdirs still cascade from workspace.root:
expect(cfg.workspaceFieldNotesRoot).toBe("/ws/field-notes");
});
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST resolves files.allowlist", () => {
const store = new SettingsStore(configPath);
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me";
try {
expect(store.resolveOne("files.allowlist").value).toBe("ws:/Users/me");
expect(store.resolveOne("files.allowlist").source).toBe("env");
} finally {
delete process.env["OPENRIG_FILES_ALLOWLIST"];
}
});
it("set + reset round-trips through the file format", () => {
const store = new SettingsStore(configPath);
store.set("workspace.slices_root", "/founder/slices");
expect(existsSync(configPath)).toBe(true);
expect(store.resolveOne("workspace.slices_root").value).toBe("/founder/slices");
store.reset("workspace.slices_root");
expect(store.resolveOne("workspace.slices_root").source).toBe("default");
store.reset();
expect(existsSync(configPath)).toBe(false);
});
it("set rejects unknown keys", () => {
const store = new SettingsStore(configPath);
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
});
it("malformed JSON throws with reset hint", () => {
writeFileSync(configPath, "{not json");
const store = new SettingsStore(configPath);
expect(() => store.resolveAllWithSource()).toThrow(/malformed/i);
});
it("file format matches CLI ConfigStore: nested keys persist as JSON", () => {
const store = new SettingsStore(configPath);
store.set("workspace.slices_root", "/x");
store.set("daemon.port", "1234");
const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8"));
expect(raw.workspace.slicesRoot).toBe("/x");
expect(raw.daemon.port).toBe(1234);
});
});
describe("parseNamedPairs (daemon copy)", () => {
it("decodes name:path,name:path pairs", () => {
expect(parseNamedPairs("ws:/abs/a,docs:/abs/b")).toEqual([
{ name: "ws", path: "/abs/a" },
{ name: "docs", path: "/abs/b" },
]);
});
it("returns empty for empty/whitespace", () => {
expect(parseNamedPairs("")).toEqual([]);
expect(parseNamedPairs(" ")).toEqual([]);
});
});
+16 -4
View File
@@ -2,8 +2,11 @@ import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { PanelsTopLeft, ServerCog } from "lucide-react";
import { LogFeedList } from "./ActivityFeed.js";
import { SettingsTab } from "./system/SettingsTab.js";
import type { ActivityEvent } from "../hooks/useActivityFeed.js";
type SystemTab = "log" | "status" | "settings";
async function fetchHealth(): Promise<boolean> {
const res = await fetch("/healthz");
if (!res.ok) throw new Error("unhealthy");
@@ -19,7 +22,7 @@ async function fetchCmux(): Promise<{ available: boolean }> {
interface SystemPanelProps {
onClose: () => void;
events: ActivityEvent[];
initialTab?: "log" | "status";
initialTab?: SystemTab;
}
function statusTone(ok: boolean | null): string {
@@ -33,7 +36,7 @@ function statusLabel(ok: boolean | null, positive: string, negative: string, unk
}
export function SystemPanel({ onClose, events, initialTab = "log" }: SystemPanelProps) {
const [activeTab, setActiveTab] = useState<"log" | "status">(initialTab);
const [activeTab, setActiveTab] = useState<SystemTab>(initialTab);
useEffect(() => {
setActiveTab(initialTab);
@@ -88,13 +91,21 @@ export function SystemPanel({ onClose, events, initialTab = "log" }: SystemPanel
>
Status
</button>
<button
data-testid="system-tab-settings"
onClick={() => setActiveTab("settings")}
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "settings" ? "border-b-2 border-stone-800 font-bold text-stone-900" : "text-stone-400"}`}
>
Settings
</button>
</div>
{activeTab === "log" ? (
{activeTab === "log" && (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden" data-testid="system-log-tab">
<LogFeedList events={events} />
</div>
) : (
)}
{activeTab === "status" && (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4" data-testid="system-status-tab">
<section className="border border-stone-300/28 bg-white/12 px-3 py-3">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-3">Runtime</div>
@@ -124,6 +135,7 @@ export function SystemPanel({ onClose, events, initialTab = "log" }: SystemPanel
</section>
</div>
)}
{activeTab === "settings" && <SettingsTab />}
</aside>
);
}
@@ -0,0 +1,201 @@
// User Settings v0 — System drawer Settings tab.
//
// Three sections at v0: Workspace, Files, Progress. Each setting shows
// the resolved value + source (env / file / default) + default.
// Operators set per-key via inline form; Init Workspace button + Reset
// button per setting.
//
// Keep this read+write surface small. The CLI (`rig config get/set/reset`)
// is the canonical agent-edit path per founder dialog.
import { useState, type ReactNode } from "react";
import {
useSettings,
useSetSetting,
useResetSetting,
useInitWorkspace,
type SettingsKey,
type ResolvedSetting,
} from "../../hooks/useSettings.js";
interface SettingsRowProps {
label: string;
settingKey: SettingsKey;
resolved: ResolvedSetting;
testIdPrefix: string;
}
function SettingsRow({ label, settingKey, resolved, testIdPrefix }: SettingsRowProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(String(resolved.value ?? ""));
const [error, setError] = useState<string | null>(null);
const setMutation = useSetSetting();
const resetMutation = useResetSetting();
const isOverridden = resolved.source !== "default";
const onSave = async () => {
setError(null);
try {
await setMutation.mutateAsync({ key: settingKey, value: draft });
setEditing(false);
} catch (err) {
setError((err as Error).message);
}
};
const onReset = async () => {
setError(null);
try {
await resetMutation.mutateAsync(settingKey);
} catch (err) {
setError((err as Error).message);
}
};
return (
<div
data-testid={`${testIdPrefix}-${settingKey}`}
className="border border-stone-300/40 bg-white/8 px-3 py-2 space-y-1"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-stone-700 truncate">{label}</span>
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500 shrink-0">
source: {resolved.source}
</span>
</div>
{editing ? (
<div className="space-y-1">
<input
data-testid={`${testIdPrefix}-${settingKey}-input`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="w-full border border-stone-300 bg-white/80 px-2 py-1 font-mono text-[10px]"
/>
<div className="flex gap-2">
<button
data-testid={`${testIdPrefix}-${settingKey}-save`}
onClick={() => void onSave()}
disabled={setMutation.isPending}
className="font-mono text-[8px] uppercase border border-stone-300 px-2 py-0.5 hover:bg-stone-200 disabled:opacity-50"
>
Save
</button>
<button
onClick={() => { setEditing(false); setDraft(String(resolved.value ?? "")); setError(null); }}
className="font-mono text-[8px] uppercase text-stone-500 hover:text-stone-900"
>
Cancel
</button>
</div>
</div>
) : (
<div className="space-y-0.5">
<div className="font-mono text-[10px] text-stone-800 break-all">{String(resolved.value ?? "")}</div>
<div className="font-mono text-[8px] text-stone-500 break-all">default: {String(resolved.defaultValue ?? "")}</div>
<div className="flex gap-1 pt-1">
<button
data-testid={`${testIdPrefix}-${settingKey}-edit`}
onClick={() => { setEditing(true); setError(null); }}
className="font-mono text-[8px] uppercase border border-stone-300 px-1 py-0.5 hover:bg-stone-200"
>
Edit
</button>
{isOverridden && (
<button
data-testid={`${testIdPrefix}-${settingKey}-reset`}
onClick={() => void onReset()}
disabled={resetMutation.isPending}
className="font-mono text-[8px] uppercase border border-stone-300 px-1 py-0.5 hover:bg-stone-200 disabled:opacity-50"
>
Reset
</button>
)}
</div>
</div>
)}
{error && <div data-testid={`${testIdPrefix}-${settingKey}-error`} className="font-mono text-[9px] text-red-600">{error}</div>}
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-2">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">{title}</div>
<div className="space-y-1">{children}</div>
</section>
);
}
export function SettingsTab() {
const { data, isLoading, error } = useSettings();
const initWorkspace = useInitWorkspace();
const [initResult, setInitResult] = useState<string | null>(null);
const [initError, setInitError] = useState<string | null>(null);
const onInitWorkspace = async () => {
setInitError(null);
setInitResult(null);
try {
const r = await initWorkspace.mutateAsync({});
setInitResult(`Initialized at ${r.root} — created ${r.subdirs.filter((s) => s.created).length} subdir(s).`);
} catch (err) {
setInitError((err as Error).message);
}
};
if (isLoading) {
return <div data-testid="settings-loading" className="px-4 py-3 font-mono text-[10px] text-stone-400">Loading settings…</div>;
}
if (error || !data) {
return (
<div data-testid="settings-error" className="px-4 py-3 font-mono text-[10px] text-red-600">
Could not load settings: {(error as Error)?.message ?? "unknown"}
</div>
);
}
const s = data.settings;
return (
<div data-testid="settings-tab" className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
<Section title="Workspace">
<SettingsRow label="Workspace root" settingKey="workspace.root" resolved={s["workspace.root"]} testIdPrefix="setting" />
<SettingsRow label="Slices root" settingKey="workspace.slices_root" resolved={s["workspace.slices_root"]} testIdPrefix="setting" />
<SettingsRow label="Steering path" settingKey="workspace.steering_path" resolved={s["workspace.steering_path"]} testIdPrefix="setting" />
<SettingsRow label="Field notes root" settingKey="workspace.field_notes_root" resolved={s["workspace.field_notes_root"]} testIdPrefix="setting" />
<SettingsRow label="Specs root" settingKey="workspace.specs_root" resolved={s["workspace.specs_root"]} testIdPrefix="setting" />
<button
data-testid="settings-init-workspace"
onClick={() => void onInitWorkspace()}
disabled={initWorkspace.isPending}
className="mt-2 font-mono text-[9px] uppercase border border-stone-400 px-2 py-1 hover:bg-stone-200 disabled:opacity-50"
>
{initWorkspace.isPending ? "Initializing…" : "Init Workspace"}
</button>
{initResult && <div data-testid="settings-init-result" className="font-mono text-[9px] text-stone-600">{initResult}</div>}
{initError && <div data-testid="settings-init-error" className="font-mono text-[9px] text-red-600">{initError}</div>}
</Section>
<Section title="Files (browser allowlist)">
<SettingsRow label="Allowlist (name:/abs/path,...)" settingKey="files.allowlist" resolved={s["files.allowlist"]} testIdPrefix="setting" />
</Section>
<Section title="Progress">
<SettingsRow label="Scan roots (name:/abs/path,...)" settingKey="progress.scan_roots" resolved={s["progress.scan_roots"]} testIdPrefix="setting" />
</Section>
<Section title="Daemon (legacy)">
<SettingsRow label="Port" settingKey="daemon.port" resolved={s["daemon.port"]} testIdPrefix="setting" />
<SettingsRow label="Host" settingKey="daemon.host" resolved={s["daemon.host"]} testIdPrefix="setting" />
</Section>
<Section title="Database / Transcripts (legacy)">
<SettingsRow label="DB path" settingKey="db.path" resolved={s["db.path"]} testIdPrefix="setting" />
<SettingsRow label="Transcripts enabled" settingKey="transcripts.enabled" resolved={s["transcripts.enabled"]} testIdPrefix="setting" />
<SettingsRow label="Transcripts path" settingKey="transcripts.path" resolved={s["transcripts.path"]} testIdPrefix="setting" />
</Section>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
// User Settings v0 — UI hooks for the daemon /api/config route.
//
// Consumed by the System drawer Settings tab. Bypasses CLI shell-out
// per founder dialog (CLI is the canonical agent-edit path; UI uses
// the daemon HTTP route directly).
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
value: string | number | boolean;
source: SettingSource;
defaultValue: string | number | boolean;
}
export type SettingsKey =
| "daemon.port" | "daemon.host" | "db.path"
| "transcripts.enabled" | "transcripts.path"
| "workspace.root" | "workspace.slices_root" | "workspace.steering_path"
| "workspace.field_notes_root" | "workspace.specs_root"
| "files.allowlist" | "progress.scan_roots";
export interface SettingsResponse {
settings: Record<SettingsKey, ResolvedSetting>;
}
async function fetchSettings(): Promise<SettingsResponse> {
const res = await fetch("/api/config");
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json();
}
export function useSettings() {
return useQuery({
queryKey: ["settings", "all"],
queryFn: fetchSettings,
staleTime: 0,
});
}
export function useSetSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { key: SettingsKey; value: string }) => {
const res = await fetch(`/api/config/${encodeURIComponent(input.key)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: input.value }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json() as Promise<{ ok: boolean; resolved: ResolvedSetting }>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}
export function useResetSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (key: SettingsKey) => {
const res = await fetch(`/api/config/${encodeURIComponent(key)}`, { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json() as Promise<{ ok: boolean; resolved: ResolvedSetting }>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}
export interface InitWorkspaceResponse {
root: string;
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
dryRun: boolean;
}
export function useInitWorkspace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { root?: string; force?: boolean; dryRun?: boolean }) => {
const res = await fetch("/api/config/init-workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json() as Promise<InitWorkspaceResponse>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}
+136
View File
@@ -0,0 +1,136 @@
// User Settings v0 — System drawer Settings tab tests.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { SettingsTab } from "../src/components/system/SettingsTab.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => cleanup());
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Response;
}
function makeSettingsResponse() {
return {
settings: {
"daemon.port": { value: 7433, source: "default", defaultValue: 7433 },
"daemon.host": { value: "127.0.0.1", source: "default", defaultValue: "127.0.0.1" },
"db.path": { value: "/Users/me/.openrig/openrig.sqlite", source: "default", defaultValue: "/Users/me/.openrig/openrig.sqlite" },
"transcripts.enabled": { value: true, source: "default", defaultValue: true },
"transcripts.path": { value: "/Users/me/.openrig/transcripts", source: "default", defaultValue: "/Users/me/.openrig/transcripts" },
"workspace.root": { value: "/Users/me/.openrig/workspace", source: "default", defaultValue: "/Users/me/.openrig/workspace" },
"workspace.slices_root": { value: "/Users/me/.openrig/workspace/slices", source: "default", defaultValue: "/Users/me/.openrig/workspace/slices" },
"workspace.steering_path": { value: "/Users/me/.openrig/workspace/steering/STEERING.md", source: "default", defaultValue: "/Users/me/.openrig/workspace/steering/STEERING.md" },
"workspace.field_notes_root": { value: "/Users/me/.openrig/workspace/field-notes", source: "default", defaultValue: "/Users/me/.openrig/workspace/field-notes" },
"workspace.specs_root": { value: "/Users/me/.openrig/workspace/specs", source: "default", defaultValue: "/Users/me/.openrig/workspace/specs" },
"files.allowlist": { value: "", source: "default", defaultValue: "" },
"progress.scan_roots": { value: "ws:/Users/me/work", source: "env", defaultValue: "" },
},
};
}
describe("SettingsTab — User Settings v0", () => {
it("renders all 12 settings rows with source badges", async () => {
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse()));
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("settings-tab")).toBeDefined());
expect(screen.getByTestId("setting-workspace.root")).toBeDefined();
expect(screen.getByTestId("setting-workspace.slices_root")).toBeDefined();
expect(screen.getByTestId("setting-workspace.steering_path")).toBeDefined();
expect(screen.getByTestId("setting-files.allowlist")).toBeDefined();
expect(screen.getByTestId("setting-progress.scan_roots")).toBeDefined();
expect(screen.getByTestId("setting-daemon.port")).toBeDefined();
expect(screen.getByTestId("setting-db.path")).toBeDefined();
// Source badge surfaces the env-resolved row honestly.
expect(screen.getByTestId("setting-progress.scan_roots").textContent).toContain("source: env");
expect(screen.getByTestId("setting-workspace.root").textContent).toContain("source: default");
});
it("Edit + Save sends POST to /api/config/:key with the value", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (init?.method === "POST" && String(url).includes("/api/config/workspace.slices_root")) {
return jsonResponse({ ok: true, key: "workspace.slices_root", resolved: { value: "/founder/slices", source: "file", defaultValue: "" } });
}
return jsonResponse(makeSettingsResponse());
});
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("setting-workspace.slices_root")).toBeDefined());
fireEvent.click(screen.getByTestId("setting-workspace.slices_root-edit"));
const input = screen.getByTestId("setting-workspace.slices_root-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "/founder/slices" } });
fireEvent.click(screen.getByTestId("setting-workspace.slices_root-save"));
await waitFor(() => {
expect(calls.some((c) =>
c.url.includes("/api/config/workspace.slices_root")
&& c.init?.method === "POST"
&& (c.init.body as string).includes("/founder/slices")
)).toBe(true);
});
});
it("Reset button is hidden for default-source rows; visible for non-default", async () => {
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse()));
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("setting-workspace.root")).toBeDefined());
// workspace.root is source=default → no reset
expect(screen.queryByTestId("setting-workspace.root-reset")).toBeNull();
// progress.scan_roots is source=env → reset visible (env still counts as overridden)
expect(screen.queryByTestId("setting-progress.scan_roots-reset")).toBeDefined();
});
it("Init Workspace button POSTs to /api/config/init-workspace", async () => {
let initCalled = false;
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
if (String(url).includes("/api/config/init-workspace")) {
initCalled = true;
return jsonResponse({
root: "/Users/me/.openrig/workspace",
rootCreated: true,
subdirs: [
{ name: "slices", path: "/x/slices", created: true },
{ name: "steering", path: "/x/steering", created: true },
{ name: "progress", path: "/x/progress", created: true },
{ name: "field-notes", path: "/x/field-notes", created: true },
{ name: "specs", path: "/x/specs", created: true },
],
files: [],
dryRun: false,
});
}
return jsonResponse(makeSettingsResponse());
});
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("settings-init-workspace")).toBeDefined());
fireEvent.click(screen.getByTestId("settings-init-workspace"));
await waitFor(() => expect(initCalled).toBe(true));
await waitFor(() => expect(screen.getByTestId("settings-init-result")).toBeDefined());
expect(screen.getByTestId("settings-init-result").textContent).toContain("created 5 subdir");
});
it("renders a friendly error when the daemon route 503s", async () => {
mockFetch.mockResolvedValue(jsonResponse({ error: "settings_unavailable" }, 503));
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("settings-error")).toBeDefined());
});
});