From fa97a7c92a8692a4640856b3a036174723e59ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 25 May 2026 16:21:51 +0000 Subject: [PATCH] feat(cli): add uwf config get/set/list subcommand Add configuration management commands to uwf CLI: - uwf config list: display all config values (masks API keys) - uwf config get : retrieve specific value using dot notation - uwf config set : update config value with auto-creation Implementation: - New file packages/cli-workflow/src/commands/config.ts with helper functions - Comprehensive test coverage (32 tests) in config.test.ts - Supports nested path navigation via dot notation - Auto-creates intermediate objects when setting new paths - Masks apiKeyEnv values in list output for security Resolves #526 Co-Authored-By: Claude Opus 4.6 --- .../cli-workflow/src/__tests__/config.test.ts | 467 ++++++++++++++++++ packages/cli-workflow/src/cli.ts | 42 ++ packages/cli-workflow/src/commands/config.ts | 225 +++++++++ 3 files changed, 734 insertions(+) create mode 100644 packages/cli-workflow/src/__tests__/config.test.ts create mode 100644 packages/cli-workflow/src/commands/config.ts diff --git a/packages/cli-workflow/src/__tests__/config.test.ts b/packages/cli-workflow/src/__tests__/config.test.ts new file mode 100644 index 0000000..e2b860d --- /dev/null +++ b/packages/cli-workflow/src/__tests__/config.test.ts @@ -0,0 +1,467 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + cmdConfigGet, + cmdConfigList, + cmdConfigSet, + getConfigPath, + getNestedValue, + maskApiKeys, + parseDotPath, + setNestedValue, +} from "../commands/config.js"; + +describe("config command", () => { + // Helper function to create a test config + function createTestConfig(tempDir: string, content: string): string { + const configPath = getConfigPath(tempDir); + writeFileSync(configPath, content, "utf8"); + return configPath; + } + + // Sample test config + const sampleConfig = `providers: + dashscope: + baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 + apiKeyEnv: DASHSCOPE_API_KEY + openai: + baseUrl: https://api.openai.com/v1 + apiKeyEnv: OPENAI_API_KEY +models: + default: + provider: dashscope + name: qwen-max + gpt4: + provider: openai + name: gpt-4 +agents: + hermes: + command: uwf-hermes + args: + - --provider + - dashscope + claude-code: + command: claude-code + args: + - --profile + - work +defaultAgent: hermes +defaultModel: default +`; + + describe("helper functions", () => { + describe("parseDotPath", () => { + test("splits dot notation correctly", () => { + expect(parseDotPath("a.b.c")).toEqual(["a", "b", "c"]); + expect(parseDotPath("defaultAgent")).toEqual(["defaultAgent"]); + expect(parseDotPath("providers.dashscope.baseUrl")).toEqual([ + "providers", + "dashscope", + "baseUrl", + ]); + }); + }); + + describe("getNestedValue", () => { + test("traverses nested objects", () => { + const obj = { + a: { b: { c: "value" } }, + x: "simple", + }; + expect(getNestedValue(obj, ["a", "b", "c"])).toBe("value"); + expect(getNestedValue(obj, ["x"])).toBe("simple"); + }); + + test("returns undefined for non-existent paths", () => { + const obj = { a: { b: "value" } }; + expect(getNestedValue(obj, ["a", "c"])).toBeUndefined(); + expect(getNestedValue(obj, ["x", "y"])).toBeUndefined(); + }); + }); + + describe("setNestedValue", () => { + test("creates intermediate objects and sets value", () => { + const obj: Record = {}; + setNestedValue(obj, ["a", "b", "c"], "value"); + expect(obj).toEqual({ a: { b: { c: "value" } } }); + }); + + test("preserves existing values", () => { + const obj: Record = { a: { x: "keep" } }; + setNestedValue(obj, ["a", "b"], "new"); + expect(obj).toEqual({ a: { x: "keep", b: "new" } }); + }); + + test("overwrites existing value at path", () => { + const obj: Record = { a: { b: "old" } }; + setNestedValue(obj, ["a", "b"], "new"); + expect(obj).toEqual({ a: { b: "new" } }); + }); + }); + + describe("maskApiKeys", () => { + test("deep clones and masks all apiKeyEnv values in providers", () => { + const config = { + providers: { + dashscope: { + baseUrl: "https://example.com", + apiKeyEnv: "DASHSCOPE_API_KEY", + }, + openai: { + baseUrl: "https://api.openai.com", + apiKeyEnv: "OPENAI_API_KEY", + }, + }, + models: { + default: { provider: "dashscope" }, + }, + }; + const masked = maskApiKeys(config); + expect(masked).toEqual({ + providers: { + dashscope: { + baseUrl: "https://example.com", + apiKeyEnv: "***MASKED***", + }, + openai: { + baseUrl: "https://api.openai.com", + apiKeyEnv: "***MASKED***", + }, + }, + models: { + default: { provider: "dashscope" }, + }, + }); + // Ensure it's a deep clone + expect(masked).not.toBe(config); + }); + + test("handles config without providers", () => { + const config = { models: { default: { provider: "test" } } }; + const masked = maskApiKeys(config); + expect(masked).toEqual(config); + }); + }); + }); + + describe("cmdConfigList", () => { + test("returns full config when file exists", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigList(tempDir); + expect(result).toBeDefined(); + expect(typeof result).toBe("object"); + expect(result).toHaveProperty("providers"); + expect(result).toHaveProperty("models"); + expect(result).toHaveProperty("agents"); + expect(result).toHaveProperty("defaultAgent"); + expect(result).toHaveProperty("defaultModel"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("masks all apiKeyEnv values in providers section", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = (await cmdConfigList(tempDir)) as Record; + const providers = result.providers as Record; + const dashscope = providers.dashscope as Record; + const openai = providers.openai as Record; + expect(dashscope.apiKeyEnv).toBe("***MASKED***"); + expect(openai.apiKeyEnv).toBe("***MASKED***"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when config file doesn't exist", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + await expect(cmdConfigList(tempDir)).rejects.toThrow(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("returns empty object when config file is empty", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, ""); + const result = await cmdConfigList(tempDir); + expect(result).toEqual({}); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when config file is invalid YAML", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, "invalid: yaml: [broken"); + await expect(cmdConfigList(tempDir)).rejects.toThrow(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe("cmdConfigGet", () => { + test("retrieves top-level string value (defaultAgent)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigGet(tempDir, "defaultAgent"); + expect(result).toBe("hermes"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("retrieves top-level string value (defaultModel)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigGet(tempDir, "defaultModel"); + expect(result).toBe("default"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("retrieves nested object (providers.dashscope)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigGet(tempDir, "providers.dashscope"); + expect(result).toEqual({ + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKeyEnv: "DASHSCOPE_API_KEY", + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("retrieves deeply nested string (providers.dashscope.baseUrl)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl"); + expect(result).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("retrieves nested string in models (models.default.provider)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigGet(tempDir, "models.default.provider"); + expect(result).toBe("dashscope"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("retrieves array value (agents.hermes.args)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigGet(tempDir, "agents.hermes.args"); + expect(result).toEqual(["--provider", "dashscope"]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when key doesn't exist", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + await expect(cmdConfigGet(tempDir, "nonexistent.key")).rejects.toThrow(/Key not found/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when config file doesn't exist", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + await expect(cmdConfigGet(tempDir, "defaultAgent")).rejects.toThrow(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when accessing property on non-object", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + await expect(cmdConfigGet(tempDir, "defaultAgent.foo")).rejects.toThrow(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe("cmdConfigSet", () => { + test("sets top-level string value (defaultAgent)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigSet(tempDir, "defaultAgent", "claude-code"); + expect(result).toEqual({ key: "defaultAgent", value: "claude-code" }); + // Verify it was written + const updated = await cmdConfigGet(tempDir, "defaultAgent"); + expect(updated).toBe("claude-code"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("sets nested string value (providers.dashscope.baseUrl)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const newUrl = "https://new-api.example.com/v1"; + const result = await cmdConfigSet(tempDir, "providers.dashscope.baseUrl", newUrl); + expect(result).toEqual({ + key: "providers.dashscope.baseUrl", + value: newUrl, + }); + // Verify it was written + const updated = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl"); + expect(updated).toBe(newUrl); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("creates new nested path (providers.newprovider.baseUrl)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const newUrl = "https://new-provider.com/v1"; + const result = await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", newUrl); + expect(result).toEqual({ + key: "providers.newprovider.baseUrl", + value: newUrl, + }); + // Verify it was created + const updated = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl"); + expect(updated).toBe(newUrl); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("sets array value for args key with valid JSON array", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const newArgs = '["--new", "--flags"]'; + const result = await cmdConfigSet(tempDir, "agents.hermes.args", newArgs); + expect(result).toEqual({ + key: "agents.hermes.args", + value: ["--new", "--flags"], + }); + // Verify it was written + const updated = await cmdConfigGet(tempDir, "agents.hermes.args"); + expect(updated).toEqual(["--new", "--flags"]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("preserves existing config values when updating one key", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + await cmdConfigSet(tempDir, "defaultAgent", "claude-code"); + // Verify other values are preserved + const defaultModel = await cmdConfigGet(tempDir, "defaultModel"); + expect(defaultModel).toBe("default"); + const dashscopeUrl = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl"); + expect(dashscopeUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("creates config file if it doesn't exist", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + const result = await cmdConfigSet(tempDir, "defaultAgent", "hermes"); + expect(result).toEqual({ key: "defaultAgent", value: "hermes" }); + // Verify file was created + const configPath = getConfigPath(tempDir); + const content = readFileSync(configPath, "utf8"); + expect(content).toContain("defaultAgent: hermes"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when setting property on non-object", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "bar")).rejects.toThrow(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("throws error when array value is invalid JSON for args key", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + await expect( + cmdConfigSet(tempDir, "agents.hermes.args", "[invalid json"), + ).rejects.toThrow(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("sets deeply nested model config (models.gpt4.provider)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigSet(tempDir, "models.gpt4.provider", "new-provider"); + expect(result).toEqual({ + key: "models.gpt4.provider", + value: "new-provider", + }); + // Verify it was written + const updated = await cmdConfigGet(tempDir, "models.gpt4.provider"); + expect(updated).toBe("new-provider"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("sets agent command (agents.claude-code.command)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "test-config-")); + try { + createTestConfig(tempDir, sampleConfig); + const result = await cmdConfigSet(tempDir, "agents.claude-code.command", "new-command"); + expect(result).toEqual({ + key: "agents.claude-code.command", + value: "new-command", + }); + // Verify it was written + const updated = await cmdConfigGet(tempDir, "agents.claude-code.command"); + expect(updated).toBe("new-command"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index 3ecddaa..16ff419 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -13,6 +13,7 @@ import { cmdCasSchemaList, cmdCasWalk, } from "./commands/cas.js"; +import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js"; import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js"; import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js"; import { @@ -711,6 +712,47 @@ log }); }); +const config = program.command("config").description("Configuration management"); + +config + .command("list") + .description("Display all configuration values (masks API keys)") + .action(() => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdConfigList(storageRoot); + writeOutput(result); + }); + }); + +config + .command("get") + .description("Get a specific configuration value") + .argument( + "", + "Dot-notation path to config value (e.g., defaultAgent, providers.dashscope.baseUrl)", + ) + .action((key: string) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdConfigGet(storageRoot, key); + writeOutput({ value: result }); + }); + }); + +config + .command("set") + .description("Set a specific configuration value") + .argument("", "Dot-notation path to config value") + .argument("", "New value (use JSON array for 'args' key, e.g., '[\"--flag\"]')") + .action((key: string, value: string) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdConfigSet(storageRoot, key, value); + writeOutput(result); + }); + }); + program.parseAsync(process.argv).catch((e: unknown) => { const message = e instanceof Error ? e.message : String(e); process.stderr.write(`${message}\n`); diff --git a/packages/cli-workflow/src/commands/config.ts b/packages/cli-workflow/src/commands/config.ts new file mode 100644 index 0000000..6f3fc46 --- /dev/null +++ b/packages/cli-workflow/src/commands/config.ts @@ -0,0 +1,225 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse, stringify } from "yaml"; + +/** + * Returns the path to the config.yaml file + */ +export function getConfigPath(storageRoot: string): string { + return join(storageRoot, "config.yaml"); +} + +/** + * Load and parse YAML config file + */ +export function loadConfig(configPath: string): Record { + if (!existsSync(configPath)) { + throw new Error(`Config file not found: ${configPath}`); + } + const content = readFileSync(configPath, "utf8"); + if (!content.trim()) { + return {}; + } + try { + const parsed = parse(content); + return (parsed ?? {}) as Record; + } catch (error) { + throw new Error( + `Invalid YAML in config file: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Save config as YAML + */ +export function saveConfig(configPath: string, config: Record): void { + const dir = join(configPath, ".."); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const yaml = stringify(config); + writeFileSync(configPath, yaml, "utf8"); +} + +/** + * Parse dot-notation key into path segments + */ +export function parseDotPath(key: string): string[] { + return key.split("."); +} + +/** + * Get nested value from object using path array + */ +export function getNestedValue(obj: Record, path: string[]): unknown { + let current: unknown = obj; + for (const segment of path) { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +/** + * Set nested value in object using path array (mutates obj) + */ +export function setNestedValue(obj: Record, path: string[], value: unknown): void { + if (path.length === 0) { + throw new Error("Path cannot be empty"); + } + + let current: Record = obj; + + // Navigate/create to the parent of the target + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + const next = current[segment]; + + if (next === null || next === undefined) { + // Create intermediate object + const newObj: Record = {}; + current[segment] = newObj; + current = newObj; + } else if (typeof next === "object" && !Array.isArray(next)) { + // Navigate into existing object + current = next as Record; + } else { + // Cannot navigate into non-object + throw new Error( + `Cannot set property '${path[i + 1]}' on non-object at path '${path.slice(0, i + 1).join(".")}'`, + ); + } + } + + // Set the final value + const lastSegment = path[path.length - 1]; + current[lastSegment] = value; +} + +/** + * Deep clone and mask all apiKeyEnv values in providers section + */ +export function maskApiKeys(config: Record): Record { + // Deep clone + const cloned = JSON.parse(JSON.stringify(config)) as Record; + + // Mask apiKeyEnv values in providers + if (cloned.providers && typeof cloned.providers === "object") { + const providers = cloned.providers as Record; + for (const providerName of Object.keys(providers)) { + const provider = providers[providerName]; + if (provider && typeof provider === "object") { + const providerObj = provider as Record; + if ("apiKeyEnv" in providerObj) { + providerObj.apiKeyEnv = "***MASKED***"; + } + } + } + } + + return cloned; +} + +/** + * List all configuration values (masks API keys) + */ +export async function cmdConfigList(storageRoot: string): Promise { + const configPath = getConfigPath(storageRoot); + const config = loadConfig(configPath); + const masked = maskApiKeys(config); + return masked; +} + +/** + * Get a specific configuration value + */ +export async function cmdConfigGet(storageRoot: string, key: string): Promise { + const configPath = getConfigPath(storageRoot); + const config = loadConfig(configPath); + const path = parseDotPath(key); + const value = getNestedValue(config, path); + + if (value === undefined) { + throw new Error(`Key not found: ${key}`); + } + + return value; +} + +/** + * Parse value for args key (must be JSON array) + */ +function parseArgsValue(value: string): unknown { + if (value.startsWith("[")) { + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + throw new Error("Value must be an array"); + } + return parsed; + } catch (error) { + throw new Error( + `Invalid JSON array for args key: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + throw new Error("Value for 'args' key must be a JSON array starting with '['"); +} + +/** + * Validate that we're not setting a property on a non-object + */ +function validateParentPath( + config: Record, + path: string[], + lastSegment: string, +): void { + if (path.length > 1) { + const parentPath = path.slice(0, -1); + const parent = getNestedValue(config, parentPath); + if (parent !== null && parent !== undefined && typeof parent !== "object") { + throw new Error( + `Cannot set property '${lastSegment}' on non-object at path '${parentPath.join(".")}'`, + ); + } + } +} + +/** + * Set a specific configuration value + */ +export async function cmdConfigSet( + storageRoot: string, + key: string, + value: string, +): Promise { + const configPath = getConfigPath(storageRoot); + + // Load existing config or create empty one + let config: Record; + if (existsSync(configPath)) { + config = loadConfig(configPath); + } else { + config = {}; + } + + const path = parseDotPath(key); + const lastSegment = path[path.length - 1]; + + // Parse value if it's for an array key (args) + let parsedValue: unknown = value; + if (lastSegment === "args") { + parsedValue = parseArgsValue(value); + } + + // Validate we're not setting a property on a non-object + validateParentPath(config, path, lastSegment); + + setNestedValue(config, path, parsedValue); + saveConfig(configPath, config); + + return { key, value: parsedValue }; +}