Compare commits

...

18 Commits

Author SHA1 Message Date
xiaoju b0c73b5439 fix(cli): fix config masking, agent normalization, and add key validation
CI / test (pull_request) Failing after 17m6s
This commit addresses three related issues in the CLI config and setup commands:

1. Issue #531: Fix config list apiKey masking
   - maskApiKeys() now checks for 'apiKey' instead of 'apiKeyEnv'
   - Updated tests to use apiKey field throughout

2. Issue #532: Add config set key validation
   - Reject unknown top-level keys with helpful error messages
   - Reject unknown nested fields in providers/models/agents
   - Reject incomplete paths and nested paths on scalar keys
   - Added VALID_CONFIG_KEYS schema and validateConfigKey() function

3. Issue #533: Fix agent name double-prefix in setup
   - mergeConfig() now uses _agentNameFromBinary() to normalize agent names
   - 'uwf-hermes' input now produces 'hermes' key with 'uwf-hermes' command
   - Added tests for prefixed agent names

All tests passing, no regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:57:55 +00:00
xiaonuo bbbe4651c2 Merge pull request 'refactor: apiKeyEnv → apiKey, store actual secret in config' (#530) from fix/528-refactor-apikey into main
CI / test (push) Failing after 35s
2026-05-26 05:37:51 +00:00
xiaonuo 7dfe0eb6a9 Merge pull request 'feat(cli): add uwf config get/set/list subcommand' (#527) from fix/526-config-subcommand into main
CI / test (push) Has been cancelled
2026-05-26 05:37:32 +00:00
xiaoju 47a4268b9b docs: update all documentation to reflect apiKey refactoring (#528)
CI / test (pull_request) Failing after 33s
Update all documentation files that contained outdated apiKeyEnv
references to use the new apiKey approach.

## Changes

- docs/architecture.md: Update config example to use apiKey field
- docs/wf-stateless-design.md: Update config examples and type
  definitions to use apiKey instead of apiKeyEnv
- docs/builtin-agent-research.md: Update ProviderConfig type
  definition and code examples

All documentation now consistent with the code implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:34:49 +00:00
xiaoju 0c90b88e08 refactor(protocol,cli,agent): replace apiKeyEnv with apiKey (#528)
CI / test (pull_request) Failing after 7m20s
Breaking change: Store API keys directly in config.yaml instead of
environment variable names.

## Changes

### @uncaged/workflow-protocol
- Change ProviderConfig.apiKeyEnv: string → apiKey: string
- Update README to reflect new type

### @uncaged/workflow-util-agent
- extract.ts: Remove dotenv loading, use providerEntry.apiKey directly
- storage.ts: Update normalizeProviders to validate apiKey field
- Update error messages to reference apiKey instead of apiKeyEnv

### @uncaged/cli-workflow
- setup.ts: Write actual API key to config.yaml, not .env
- Remove apiKeyEnvName(), loadEnvFile(), saveEnvFile() functions
- Remove getEnvPath() function
- Update cmdSetup to not return envPath in result
- Update README to reflect config.yaml stores API keys
- Fix setup-validate.test.ts to not expect envPath in result

## Verification
-  bun run build passes
-  All tests pass (260/260 in cli-workflow, 55/55 in util-agent)
-  bun run check passes (only pre-existing warnings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:23:33 +00:00
xiaoju 5583a9da00 chore: retrigger CI
CI / test (pull_request) Failing after 1m36s
2026-05-26 05:21:11 +00:00
xiaoju 4a0cb7c615 ci: replace lint+typecheck with unified check step
CI / test (pull_request) Failing after 9m1s
Fixes CI failure — 'lint' script didn't exist in package.json.
bun run check already covers tsc + biome + log-tag lint.
2026-05-26 05:04:47 +00:00
xiaoju fa97a7c92a feat(cli): add uwf config get/set/list subcommand
CI / test (pull_request) Failing after 23m14s
Add configuration management commands to uwf CLI:
- uwf config list: display all config values (masks API keys)
- uwf config get <key>: retrieve specific value using dot notation
- uwf config set <key> <value>: 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 <noreply@anthropic.com>
2026-05-25 16:21:51 +00:00
xiaomo 0097633a3b Merge pull request 'fix: cancelled threads show distinct "cancelled" status' (#525) from fix/522-cancelled-thread-status into main
CI / test (push) Failing after 5m57s
2026-05-25 15:51:29 +00:00
xiaomo 04591296b2 Merge pull request 'fix: bin entry point to dist/cli.js for node compatibility' (#524) from fix/523-bin-entry-point into main
CI / test (push) Has been cancelled
2026-05-25 15:51:18 +00:00
xiaoju 96039dbbbf fix: cancelled threads show distinct status instead of completed
CI / test (pull_request) Failing after 34s
Fixes #522
2026-05-25 15:39:59 +00:00
xiaoju 5230462b8d fix: bin entry point to dist/cli.js for node compatibility
CI / test (pull_request) Failing after 9m12s
Fixes #523
2026-05-25 15:35:55 +00:00
xiaomo 4a39d3fdef Merge pull request 'feat(skill): expand uwf skill with architecture, yaml, moderator, list subcommands' (#521) from fix/517-expand-skill into main
CI / test (push) Failing after 22m38s
2026-05-25 15:00:34 +00:00
xingyue 4de13cea44 fix: correct skill references and remove hardcoded test path
CI / test (pull_request) Failing after 23m48s
- moderator-reference: use nested map graph format matching evaluate.ts
- yaml-reference: use goal/procedure/output/capabilities/frontmatter fields
  matching actual WorkflowPayload, not fabricated system/outputSchema
- skill.test.ts: replace hardcoded absolute path with __dirname-relative
- skill.test.ts: assert 'frontmatter' instead of 'outputSchema'
2026-05-25 22:59:38 +08:00
xingyue d9d542c570 fix: correct biome suppressions and formatting for #517
CI / test (pull_request) Failing after 9m9s
2026-05-25 22:47:00 +08:00
xingyue cf6115517c fix: auto-fix biome lint violations in skill.test.ts 2026-05-25 22:44:32 +08:00
xingyue 108f134020 feat(skill): add architecture, yaml, moderator, list subcommands (#517) 2026-05-25 22:42:05 +08:00
xiaomo 8123399189 Merge pull request 'fix(uwf-hermes): read turn data from session file instead of ACP stream' (#520) from fix/519-read-session-file into main
CI / test (push) Failing after 17m33s
2026-05-25 14:24:41 +00:00
30 changed files with 1455 additions and 87 deletions
+2 -5
View File
@@ -18,11 +18,8 @@ jobs:
- name: Install dependencies
run: bun install
- name: Lint
run: bun run lint
- name: Type check
run: bun run typecheck
- name: Check
run: bun run check
- name: Test
run: bun test
+2 -1
View File
@@ -12,4 +12,5 @@ packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
.claude
tmp
tmp.worktrees/
.worktrees/
+1
View File
@@ -4,6 +4,7 @@
"includes": [
"**",
"!**/dist",
"!.worktrees",
"!**/node_modules",
"!**/legacy-packages",
"!scripts",
+1 -1
View File
@@ -391,7 +391,7 @@ Everything else is immutable CAS content.
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
apiKey: "sk-..."
models:
sonnet:
+2 -2
View File
@@ -402,7 +402,7 @@ workflow 怎么配置和使用 model?
```136:160:packages/workflow-protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
apiKey: string;
};
export type ModelConfig = {
@@ -429,7 +429,7 @@ export type WorkflowConfig = {
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
const providerEntry = config.providers[modelEntry.provider];
const apiKey = process.env[providerEntry.apiKeyEnv];
const apiKey = providerEntry.apiKey;
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
+4 -4
View File
@@ -280,13 +280,13 @@ threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
apiKey: "sk-..."
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
apiKey: "sk-ant-..."
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
apiKey: "sk-or-..."
models:
sonnet:
@@ -465,7 +465,7 @@ type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
apiKey: string; // API key stored directly
};
type ModelConfig = {
+1 -1
View File
@@ -119,7 +119,7 @@ uwf setup --provider openai --base-url https://api.openai.com/v1 \
--api-key sk-... --model gpt-4o --agent hermes
```
Config: `~/.uncaged/workflow/config.yaml`. API keys: `~/.uncaged/workflow/.env`.
Config: `~/.uncaged/workflow/config.yaml` (includes API keys).
### Skill
+1 -1
View File
@@ -8,7 +8,7 @@
],
"type": "module",
"bin": {
"uwf": "./src/cli.ts"
"uwf": "./dist/cli.js"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
@@ -0,0 +1,622 @@
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
apiKey: sk-test-dashscope-key
openai:
baseUrl: https://api.openai.com/v1
apiKey: sk-test-openai-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<string, unknown> = {};
setNestedValue(obj, ["a", "b", "c"], "value");
expect(obj).toEqual({ a: { b: { c: "value" } } });
});
test("preserves existing values", () => {
const obj: Record<string, unknown> = { 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<string, unknown> = { a: { b: "old" } };
setNestedValue(obj, ["a", "b"], "new");
expect(obj).toEqual({ a: { b: "new" } });
});
});
describe("maskApiKeys", () => {
test("deep clones and masks all apiKey values in providers", () => {
const config = {
providers: {
dashscope: {
baseUrl: "https://example.com",
apiKey: "sk-test-key-12345",
},
openai: {
baseUrl: "https://api.openai.com",
apiKey: "sk-another-secret",
},
},
models: {
default: { provider: "dashscope" },
},
};
const masked = maskApiKeys(config);
expect(masked).toEqual({
providers: {
dashscope: {
baseUrl: "https://example.com",
apiKey: "***MASKED***",
},
openai: {
baseUrl: "https://api.openai.com",
apiKey: "***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 apiKey values in providers section", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = (await cmdConfigList(tempDir)) as Record<string, unknown>;
const providers = result.providers as Record<string, unknown>;
const dashscope = providers.dashscope as Record<string, unknown>;
const openai = providers.openai as Record<string, unknown>;
expect(dashscope.apiKey).toBe("***MASKED***");
expect(openai.apiKey).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",
apiKey: "sk-test-dashscope-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 });
}
});
});
describe("cmdConfigSet validation", () => {
test("rejects unknown top-level key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "unknownKey", "value")).rejects.toThrow(
/Unknown config key.*unknownKey/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in providers", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(
cmdConfigSet(tempDir, "providers.myProvider.unknownField", "value"),
).rejects.toThrow(/Unknown field.*unknownField.*providers/);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in models", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "models.default.invalidField", "value")).rejects.toThrow(
/Unknown field.*invalidField.*models/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in agents", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agents.hermes.badField", "value")).rejects.toThrow(
/Unknown field.*badField.*agents/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects nested path on scalar key (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "value")).rejects.toThrow(
/defaultAgent.*scalar|Cannot set property/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects nested path on scalar key (defaultModel)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultModel.bar", "value")).rejects.toThrow(
/defaultModel.*scalar|Cannot set property/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (providers without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "providers.myProvider", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (models without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "models.myModel", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (agents without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agents.myAgent", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in providers", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", "https://example.com");
await cmdConfigSet(tempDir, "providers.newprovider.apiKey", "sk-test");
const baseUrl = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
const apiKey = await cmdConfigGet(tempDir, "providers.newprovider.apiKey");
expect(baseUrl).toBe("https://example.com");
expect(apiKey).toBe("sk-test");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in models", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "models.gpt4.provider", "openai");
await cmdConfigSet(tempDir, "models.gpt4.name", "gpt-4o");
const provider = await cmdConfigGet(tempDir, "models.gpt4.provider");
const name = await cmdConfigGet(tempDir, "models.gpt4.name");
expect(provider).toBe("openai");
expect(name).toBe("gpt-4o");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in agents", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "agents.hermes.command", "uwf-hermes");
await cmdConfigSet(tempDir, "agents.hermes.args", '["--flag"]');
const command = await cmdConfigGet(tempDir, "agents.hermes.command");
const args = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(command).toBe("uwf-hermes");
expect(args).toEqual(["--flag"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
});
@@ -40,6 +40,7 @@ describe("resolveHeadHash", () => {
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId);
@@ -64,6 +65,7 @@ describe("resolveHeadHash", () => {
workflow: workflowHash,
head: historicalHash,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId);
@@ -87,18 +89,21 @@ describe("resolveHeadHash", () => {
workflow: workflowHash,
head: hash1,
completedAt: Date.now() - 2000,
reason: null,
});
await appendThreadHistory(tmpDir, {
thread: threadId2,
workflow: workflowHash,
head: hash2,
completedAt: Date.now() - 1000,
reason: null,
});
await appendThreadHistory(tmpDir, {
thread: threadId3,
workflow: workflowHash,
head: hash3,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId2);
@@ -134,4 +134,34 @@ describe("cmdSetup agent configuration", () => {
const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config2.defaultAgent).toBe("builtin");
});
test("normalizes agent name with uwf- prefix to bare name", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-hermes" });
expect(result.defaultAgent).toBe("hermes");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
expect(config.defaultAgent).toBe("hermes");
// Verify no duplicate uwf- prefix
expect(config.agents["uwf-hermes"]).toBeUndefined();
});
test("normalizes uwf-claude-code to claude-code", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-claude-code" });
expect(result.defaultAgent).toBe("claude-code");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
expect(config.defaultAgent).toBe("claude-code");
// Verify no duplicate uwf- prefix
expect(config.agents["uwf-claude-code"]).toBeUndefined();
});
});
@@ -129,9 +129,8 @@ describe("cmdSetup with validation", () => {
const result = await cmdSetup(setupArgs());
expect(result.validation).toEqual({ ok: true, value: undefined });
// Config files should still be written
// Config file should still be written
expect(result.configPath).toBeTruthy();
expect(result.envPath).toBeTruthy();
});
test("includes validation failure — config still saved", async () => {
@@ -143,8 +142,7 @@ describe("cmdSetup with validation", () => {
expect(result.validation).toBeDefined();
expect((result.validation as { ok: boolean }).ok).toBe(false);
// Config files should still be written despite validation failure
// Config file should still be written despite validation failure
expect(result.configPath).toBeTruthy();
expect(result.envPath).toBeTruthy();
});
});
@@ -0,0 +1,78 @@
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import {
cmdSkillArchitecture,
cmdSkillCli,
cmdSkillList,
cmdSkillModerator,
cmdSkillYaml,
} from "../commands/skill.js";
describe("skill commands", () => {
test("skill list returns all skill names", () => {
const result = cmdSkillList();
expect(result).toBeInstanceOf(Array);
expect(result).toContain("cli");
expect(result).toContain("architecture");
expect(result).toContain("yaml");
expect(result).toContain("moderator");
for (const name of result) {
expect(typeof name).toBe("string");
expect(name).toMatch(/^\S+$/);
}
});
test("skill architecture returns non-empty markdown string", () => {
const result = cmdSkillArchitecture();
expect(typeof result).toBe("string");
expect(result).toContain("CAS");
expect(result).toContain("Thread");
expect(result).toContain("Workflow");
expect(result).toContain("Step");
expect(result.length).toBeGreaterThan(200);
});
test("skill yaml returns non-empty markdown string", () => {
const result = cmdSkillYaml();
expect(typeof result).toBe("string");
expect(result).toContain("roles");
expect(result).toContain("graph");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(200);
});
test("skill moderator returns non-empty markdown string", () => {
const result = cmdSkillModerator();
expect(typeof result).toBe("string");
expect(result).toContain("routing");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
// Check for edge or graph
expect(result).toMatch(/edge|graph/i);
});
test("skill cli returns CLI reference markdown", () => {
const result = cmdSkillCli();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
});
test("skill help subcommand is suppressed", () => {
const output = execFileSync("bun", ["src/cli.ts", "skill", "--help"], {
cwd: join(__dirname, "..", ".."),
encoding: "utf-8",
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
});
expect(output).not.toMatch(/help\s+\[command\]/i);
expect(output).toContain("cli");
expect(output).toContain("architecture");
expect(output).toContain("yaml");
expect(output).toContain("moderator");
expect(output).toContain("list");
});
});
@@ -0,0 +1,85 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { appendThreadHistory, loadThreadHistory } from "../store.js";
describe("thread cancel status", () => {
test("cancelled history entry has reason 'cancelled'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL1" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("cancelled");
});
test("completed history entry has reason 'completed'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL2" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("completed");
});
test("legacy history entry without reason parses as null", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL3" as ThreadId;
// Simulate legacy entry without reason field
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: null,
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBeNull();
});
test("mixed completed and cancelled entries preserve distinct reasons", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL4" as ThreadId,
workflow: "test-workflow",
head: "head1" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL5" as ThreadId,
workflow: "test-workflow",
head: "head2" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(2);
expect(history[0]?.reason).toBe("completed");
expect(history[1]?.reason).toBe("cancelled");
});
});
@@ -74,6 +74,7 @@ async function completeThread(
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
reason: null,
});
}
@@ -758,6 +758,7 @@ describe("cmdStepList with completed threads", () => {
workflow: workflowHash,
head: step2Hash,
completedAt: Date.now(),
reason: null,
});
const result = await cmdStepList(tmpDir, threadId);
@@ -886,6 +887,7 @@ describe("cmdStepShow with completed threads", () => {
workflow: workflowHash,
head: stepHash,
completedAt: Date.now(),
reason: null,
});
const result = await cmdStepShow(tmpDir, stepHash);
@@ -949,6 +951,7 @@ describe("cmdThreadRead with completed threads", () => {
workflow: workflowHash,
head: stepHash,
completedAt: Date.now(),
reason: null,
});
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
@@ -1011,6 +1014,7 @@ describe("cmdThreadRead with completed threads", () => {
workflow: workflowHash,
head: step3Hash,
completedAt: Date.now(),
reason: null,
});
const markdown = await cmdThreadRead(
+82 -5
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env node
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { Command } from "commander";
@@ -13,9 +13,16 @@ 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 { cmdSkillCli } from "./commands/skill.js";
import {
cmdSkillArchitecture,
cmdSkillCli,
cmdSkillList,
cmdSkillModerator,
cmdSkillYaml,
} from "./commands/skill.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
cmdThreadCancel,
@@ -175,11 +182,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
if (raw === "active") return ["idle", "running"];
const parts = raw.split(",").map((s) => s.trim());
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
const validStatuses: ThreadStatus[] = ["idle", "running", "completed", "cancelled"];
for (const part of parts) {
if (!validStatuses.includes(part as ThreadStatus)) {
process.stderr.write(
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`,
`Invalid status: ${part}. Must be one of: idle, running, completed, cancelled, active\n`,
);
process.exit(1);
}
@@ -232,7 +239,7 @@ thread
.description("List threads")
.option(
"--status <status>",
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values",
"Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
)
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
@@ -473,6 +480,7 @@ For more information, see: uwf help thread list
});
const skill = program.command("skill").description("Built-in skill references for agents");
skill.addHelpCommand(false);
skill
.command("cli")
@@ -481,6 +489,34 @@ skill
console.log(cmdSkillCli());
});
skill
.command("architecture")
.description("Print the architecture reference")
.action(() => {
console.log(cmdSkillArchitecture());
});
skill
.command("yaml")
.description("Print the workflow YAML schema reference")
.action(() => {
console.log(cmdSkillYaml());
});
skill
.command("moderator")
.description("Print the moderator reference")
.action(() => {
console.log(cmdSkillModerator());
});
skill
.command("list")
.description("List all available skill names")
.action(() => {
console.log(cmdSkillList().join("\n"));
});
program
.command("setup")
.description("Configure provider, model, and agent")
@@ -676,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(
"<key>",
"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("<key>", "Dot-notation path to config value")
.argument("<value>", "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`);
@@ -0,0 +1,289 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parse, stringify } from "yaml";
/**
* Valid configuration key schema
*/
const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[] }> = {
providers: {
nested: true,
knownFields: ["baseUrl", "apiKey"],
},
models: {
nested: true,
knownFields: ["provider", "name"],
},
agents: {
nested: true,
knownFields: ["command", "args"],
},
defaultAgent: { nested: false },
defaultModel: { nested: false },
};
/**
* Validate a config key path against the known schema
*/
function validateConfigKey(path: string[]): void {
if (path.length === 0) {
throw new Error("Path cannot be empty");
}
const topLevel = path[0];
const schema = VALID_CONFIG_KEYS[topLevel];
if (!schema) {
const validKeys = Object.keys(VALID_CONFIG_KEYS).join(", ");
throw new Error(`Unknown config key: ${topLevel}. Valid top-level keys are: ${validKeys}`);
}
// Scalar keys cannot have nested paths
if (!schema.nested && path.length > 1) {
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
}
// Nested keys must have at least 3 segments (e.g., providers.myProvider.baseUrl)
if (schema.nested && path.length < 3) {
const fields = schema.knownFields?.join(", ") ?? "";
throw new Error(
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
);
}
// Validate the field name for nested keys
if (schema.nested && path.length >= 3 && schema.knownFields) {
const field = path[path.length - 1];
if (!schema.knownFields.includes(field)) {
throw new Error(
`Unknown field '${field}' in ${topLevel}. Valid fields are: ${schema.knownFields.join(", ")}`,
);
}
}
}
/**
* 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<string, unknown> {
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<string, unknown>;
} 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<string, unknown>): 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<string, unknown>, 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<string, unknown>)[segment];
}
return current;
}
/**
* Set nested value in object using path array (mutates obj)
*/
export function setNestedValue(obj: Record<string, unknown>, path: string[], value: unknown): void {
if (path.length === 0) {
throw new Error("Path cannot be empty");
}
let current: Record<string, unknown> = 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<string, unknown> = {};
current[segment] = newObj;
current = newObj;
} else if (typeof next === "object" && !Array.isArray(next)) {
// Navigate into existing object
current = next as Record<string, unknown>;
} 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 apiKey values in providers section
*/
export function maskApiKeys(config: Record<string, unknown>): Record<string, unknown> {
// Deep clone
const cloned = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
// Mask apiKey values in providers
if (cloned.providers && typeof cloned.providers === "object") {
const providers = cloned.providers as Record<string, unknown>;
for (const providerName of Object.keys(providers)) {
const provider = providers[providerName];
if (provider && typeof provider === "object") {
const providerObj = provider as Record<string, unknown>;
if ("apiKey" in providerObj) {
providerObj.apiKey = "***MASKED***";
}
}
}
}
return cloned;
}
/**
* List all configuration values (masks API keys)
*/
export async function cmdConfigList(storageRoot: string): Promise<unknown> {
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<unknown> {
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<string, unknown>,
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<unknown> {
const configPath = getConfigPath(storageRoot);
// Load existing config or create empty one
let config: Record<string, unknown>;
if (existsSync(configPath)) {
config = loadConfig(configPath);
} else {
config = {};
}
const path = parseDotPath(key);
// Validate the key path
validateConfigKey(path);
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 };
}
+2 -46
View File
@@ -85,10 +85,6 @@ function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
@@ -106,37 +102,6 @@ function loadExistingConfig(configPath: string): Record<string, unknown> {
return {};
}
/**
* Load existing .env as key=value map.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
}
}
} catch {
// ignore
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
// ──────────────────────────────────────────────────────────────────────────────
// Extracted helpers — _discoverAgents
// ──────────────────────────────────────────────────────────────────────────────
@@ -397,8 +362,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
: {}
) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
providers[args.provider] = { baseUrl: args.baseUrl, apiKey: args.apiKey };
const models = (
typeof existing.models === "object" && existing.models !== null
@@ -413,7 +377,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
: {}
) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
// Ensure the selected agent has an entry
if (!agents[agentName]) {
agents[agentName] = { command: `uwf-${agentName}`, args: [] };
@@ -437,25 +401,17 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
// Validate model connectivity
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
+12 -1
View File
@@ -1 +1,12 @@
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
export {
generateArchitectureReference as cmdSkillArchitecture,
generateCliReference as cmdSkillCli,
generateModeratorReference as cmdSkillModerator,
generateYamlReference as cmdSkillYaml,
} from "@uncaged/workflow-util";
const SKILL_NAMES = ["cli", "architecture", "yaml", "moderator"] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
}
+8 -3
View File
@@ -331,7 +331,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
fail(`thread not found: ${threadId}`);
}
export type ThreadStatus = "idle" | "running" | "completed";
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
@@ -389,7 +389,7 @@ async function collectCompletedThreads(
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
status: "completed",
status: entry.reason === "cancelled" ? "cancelled" : "completed",
});
}
}
@@ -444,7 +444,10 @@ export async function cmdThreadList(
let items = await collectActiveThreads(storageRoot, uwf, index);
// Collect completed threads (if relevant for status filter)
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
const includeCompleted =
statusFilter === null ||
statusFilter.includes("completed") ||
statusFilter.includes("cancelled");
if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread));
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
@@ -811,6 +814,7 @@ async function archiveThread(
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
}
@@ -1147,6 +1151,7 @@ export async function cmdThreadCancel(
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
};
await appendThreadHistory(storageRoot, historyEntry);
+10 -1
View File
@@ -88,6 +88,7 @@ export function getHistoryPath(storageRoot: string): string {
export type ThreadHistoryLine = ThreadListItem & {
completedAt: number;
reason: "completed" | "cancelled" | null;
};
export type UwfStore = {
@@ -228,7 +229,15 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
typeof head === "string" &&
typeof completedAt === "number"
) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
const reason = rec.reason;
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
lines.push({
thread: thread as ThreadId,
workflow,
head,
completedAt,
reason: parsedReason,
});
}
}
return lines;
+1 -1
View File
@@ -100,7 +100,7 @@ type ProviderAlias = string;
type ModelAlias = string;
type AgentAlias = string;
type ProviderConfig = { baseUrl: string; apiKeyEnv: string };
type ProviderConfig = { baseUrl: string; apiKey: string };
type ModelConfig = {
provider: ProviderAlias;
name: string;
+1 -1
View File
@@ -151,7 +151,7 @@ export type Scenario = string;
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
apiKey: string;
};
export type ModelConfig = {
+4 -6
View File
@@ -1,8 +1,7 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/workflow-protocol";
import { config as loadDotenv } from "dotenv";
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
import { createAgentStore, resolveStorageRoot } from "./storage.js";
export type ResolvedLlmProvider = {
baseUrl: string;
@@ -38,9 +37,9 @@ export function resolveModel(config: WorkflowConfig, alias: ModelAlias): Resolve
if (providerEntry === undefined) {
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
}
const apiKey = process.env[providerEntry.apiKeyEnv];
const apiKey = providerEntry.apiKey;
if (apiKey === undefined || apiKey === "") {
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
throw new Error(`missing API key for provider: ${modelEntry.provider}`);
}
return {
baseUrl: providerEntry.baseUrl,
@@ -130,7 +129,7 @@ export type ExtractResult = {
/**
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
* Loads config.yaml and .env from the workflow storage root.
* Loads config.yaml from the workflow storage root.
*/
export async function extract(
rawOutput: string,
@@ -138,7 +137,6 @@ export async function extract(
config: WorkflowConfig,
): Promise<ExtractResult> {
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const { store } = await createAgentStore(storageRoot);
const schema = getSchema(store, outputSchema);
+4 -4
View File
@@ -84,11 +84,11 @@ function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig>
throw new Error(`config.providers.${name} must be a mapping`);
}
const baseUrl = entry.baseUrl;
const apiKeyEnv = entry.apiKeyEnv;
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
const apiKey = entry.apiKey;
if (typeof baseUrl !== "string" || typeof apiKey !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKey`);
}
providers[name] = { baseUrl, apiKeyEnv };
providers[name] = { baseUrl, apiKey };
}
return providers;
}
@@ -0,0 +1,60 @@
export function generateArchitectureReference(): string {
return `# Workflow Engine — Architecture Reference
## Key Concepts
### CAS (Content-Addressed Storage)
Every artifact in the workflow engine is stored as a CAS node — an immutable, content-addressed record identified by its XXH64 hash (13-char Crockford Base32). CAS provides deduplication, integrity verification, and an append-only audit trail.
Stored artifacts include:
- **Workflow definitions** — the YAML-parsed payload
- **Step nodes** — each moderator→agent→extract cycle
- **Detail nodes** — per-step metadata and turn history
- **Turn records** — individual agent interactions within a step
### Thread
A Thread is a single execution of a Workflow, identified by a ULID (26-char Crockford Base32: 10 timestamp + 16 random). Thread state is an immutable CAS chain — each step points to its predecessor via a \`prev\` hash, forming a linked list.
Active threads are indexed in \`threads.yaml\`; completed threads move to \`history.jsonl\`.
A thread progresses by running \`uwf thread exec\`, which performs one moderator→agent→extract cycle per step.
### Workflow
A Workflow is a YAML definition (\`WorkflowPayload\`) stored as a CAS node. It defines:
- **Roles** — named actors with system prompts and output schemas
- **Graph** — status-based routing edges between roles
- **Conditions** — edge predicates evaluated by the moderator
Workflow names follow verb-first kebab-case: \`solve-issue\`, \`review-code\`.
### Step
A Step is one moderator→agent→extract cycle, stored as a CAS node (\`StepNodePayload\`). Each step contains:
- **output** — the agent's extracted frontmatter output
- **detail** — a CAS reference to turn-level records
- **prev** — CAS hash of the previous step (forming the chain)
- **role** — which role produced this step
### Turn
A Turn is an agent-internal interaction within a single Step. Turns are stored per-turn in the detail node, capturing the raw agent I/O before extraction.
## Data Flow
\`\`\`
uwf thread exec <thread-id>
→ Moderator evaluates graph edges based on current status
→ Selects next role (or $END)
→ Agent CLI is spawned with context
→ Agent produces frontmatter markdown
→ Extract pipeline parses output into structured data
→ New CAS step node is appended to the thread chain
\`\`\`
## Storage Layout
All data lives under \`~/.uncaged/workflow/\`:
- \`cas/\` — content-addressed store (XXH64-keyed)
- \`threads.yaml\` — active thread index
- \`history.jsonl\` — completed thread archive
- \`registry.yaml\` — workflow name → CAS hash mapping
`;
}
+3
View File
@@ -1,3 +1,4 @@
export { generateArchitectureReference } from "./architecture-reference.js";
export { encodeUint64AsCrockford } from "./base32.js";
export { generateCliReference } from "./cli-reference.js";
export { env } from "./env.js";
@@ -13,6 +14,7 @@ export {
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js";
export { generateModeratorReference } from "./moderator-reference.js";
export type {
CreateProcessLoggerOptions,
ProcessLogFn,
@@ -25,3 +27,4 @@ export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { LogFn, Result } from "./types.js";
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
export { generateYamlReference } from "./yaml-reference.js";
@@ -0,0 +1,56 @@
export function generateModeratorReference(): string {
return `# Moderator Reference
## Overview
The moderator is the workflow engine's routing component. It evaluates the directed graph defined in the workflow YAML to determine the next role (or \`$END\`) after each step — with zero LLM cost.
## Status-Based Routing
The moderator uses **status-based routing**: it inspects the previous step's extracted output (specifically the \`$status\` field) and looks up the corresponding edge in the graph.
### Graph Structure
The graph is a nested map: \`Record<Role | "$START", Record<Status, Target>>\`. Each role maps its possible \`$status\` values to a target with a \`role\` and \`prompt\`:
\`\`\`yaml
graph:
$START:
_: { role: planner, prompt: "Analyze the issue." }
planner:
ready: { role: developer, prompt: "Implement the plan (CAS hash: {{{plan}}})." }
insufficient_info: { role: $END, prompt: "Not enough info." }
developer:
done: { role: reviewer, prompt: "Review branch {{{branch}}} at {{{worktree}}}." }
failed: { role: $END, prompt: "Developer failed: {{{reason}}}." }
reviewer:
approved: { role: tester, prompt: "Run tests on {{{branch}}} at {{{worktree}}}." }
rejected: { role: developer, prompt: "Fix issues: {{{comments}}}." }
\`\`\`
### Routing Algorithm
1. Look up \`graph[lastRole]\` to get the status map for the current role
2. Look up \`statusMap[lastOutput.$status]\` to get the target
3. If target role is \`$END\`, mark thread as completed
4. Otherwise, render the edge prompt (Mustache templates with \`{{{field}}}\` from output) and spawn the next agent
### Edge Prompts and Mustache Templates
Edge prompts use triple-brace Mustache syntax (\`{{{field}}}\`) to interpolate values from the previous step's output into the next agent's task prompt. This passes structured data (branch names, file paths, CAS hashes) between roles without manual wiring.
## Special Nodes
- \`$START\` — entry point; uses status key \`_\` (unconditional) since there is no previous output
- \`$END\` — terminal node; thread completes when reached and is moved to history
## Integration with Steps
Each \`uwf thread exec\` cycle:
1. Moderator reads the thread's head step output
2. Looks up \`graph[lastRole][output.$status]\` to pick the next role
3. If next is \`$END\`, marks thread as completed
4. Otherwise, renders the edge prompt and spawns the agent for the selected role
5. Extract pipeline parses agent output → new step node → append to CAS chain
`;
}
@@ -0,0 +1,82 @@
export function generateYamlReference(): string {
return `# Workflow YAML Schema Reference
## Top-Level Structure
A workflow YAML file defines the complete workflow specification:
\`\`\`yaml
name: solve-issue # verb-first kebab-case identifier
description: "..." # human-readable description
roles: # named actors in the workflow
planner:
description: "Analyzes issue and outputs a plan"
goal: "You are a planning agent."
capabilities:
- issue-analysis
- planning
procedure: |
1. Read the issue
2. Produce a test spec
output: "Output the plan summary. Set $status to ready or insufficient_info."
frontmatter: # JSON Schema for structured output (drives routing)
oneOf:
- properties:
$status: { const: ready }
plan: { type: string }
required: [$status, plan]
- properties:
$status: { const: insufficient_info }
required: [$status]
graph: # status-based routing (nested map)
$START:
_: { role: planner, prompt: "Analyze the issue." }
planner:
ready: { role: developer, prompt: "Implement plan {{{plan}}}." }
insufficient_info: { role: $END, prompt: "Not enough info." }
\`\`\`
## roles
Each role defines an actor in the workflow:
| Field | Type | Description |
|-------|------|-------------|
| \`description\` | string | Short description of the role's purpose |
| \`goal\` | string | System-level goal statement for the agent |
| \`capabilities\` | string[] | Tags describing what the role can do |
| \`procedure\` | string | Step-by-step instructions for the agent |
| \`output\` | string | Description of expected output format |
| \`frontmatter\` | JSON Schema | Defines the structured output the agent must produce |
### frontmatter
The \`frontmatter\` field is a standard JSON Schema object. The extract pipeline validates agent output against it. Key conventions:
- \`$status\` field drives routing decisions in the graph
- Use \`const\` or \`enum\` to constrain status values
- Use \`oneOf\` to define multiple valid output shapes (one per status)
- All \`required\` fields must appear in the agent's frontmatter output
## graph
The graph is a nested map defining status-based routing:
\`\`\`
Record<Role | "$START", Record<Status, { role: string, prompt: string }>>
\`\`\`
| Level | Key | Value |
|-------|-----|-------|
| Outer | Role name or \`$START\` | Status map for that role |
| Inner | \`$status\` value (or \`_\` for unconditional) | Target: \`{ role, prompt }\` |
### Special Nodes
- \`$START\` — entry point; uses status key \`_\` (unconditional, no previous output)
- \`$END\` — terminal node; thread completes when reached
### Edge Prompts
Prompts use triple-brace Mustache templates (\`{{{field}}}\`) to interpolate values from the previous step's output. Example: \`"Implement plan {{{plan}}} in repo {{{repoPath}}}."\`
`;
}