Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoju 6b3aa4ce35 fix(cli): usage not red + skill subcommand + --help flag on groups
1. No-args usage uses printCliLine (not printCliError), exit 1
2. 'skill [topic]' as first-class command (help --skill kept as compat)
3. 'workflow --help', 'thread --help' etc. show group subcommands
4. Role prompts updated: 'uncaged-workflow skill develop'

240 tests (6 new), build clean.

Closes #83

小橘 🍊
2026-05-07 15:17:20 +00:00
xingyue f042c9d640 Merge pull request 'feat(cli): help --skill <topic> for context-specific agent docs' (#82) from feat/81-skill-topics into main 2026-05-07 15:08:41 +00:00
xiaoju 66bca9ef03 feat(cli): help --skill <topic> — context-specific docs for agents
- help --skill (no args) → lists available topics
- help --skill cli → full CLI reference (was: help --skill)
- help --skill develop → thread ID, CAS, meta output guide for roles
- help --skill author → bundle structure, descriptor, role definition
- Role prompts updated: planner/coder reference 'help --skill develop'
- Legacy formatSkillDoc() preserved for compat
- 234 tests (15 new), build clean

Closes #81

小橘 🍊
2026-05-07 15:03:08 +00:00
xiaoju 309af39447 Merge pull request 'fix(cli): review nits — live --latest args + dispatchInit consistency' (#79) from fix/75-nits into main 2026-05-07 14:54:28 +00:00
5 changed files with 363 additions and 17 deletions
+133 -3
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test";
import { runCli } from "../src/cli-dispatch.js";
import { formatSkillDoc } from "../src/cmd-help.js";
import {
formatSkillDoc,
formatSkillIndex,
formatSkillTopic,
getSkillTopics,
} from "../src/cmd-help.js";
const STORAGE_ROOT = "/tmp/help-test-storage";
@@ -10,13 +15,89 @@ describe("help command", () => {
expect(code).toBe(0);
});
test("help --skill returns 0", async () => {
test("no args prints usage (not red) and returns 1", async () => {
const code = await runCli(STORAGE_ROOT, []);
expect(code).toBe(1);
});
});
describe("skill command", () => {
test("skill (no topic) lists topics and returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill"]);
expect(code).toBe(0);
});
test("skill cli returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
expect(code).toBe(0);
});
test("skill develop returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
expect(code).toBe(0);
});
test("skill author returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
expect(code).toBe(0);
});
test("skill unknown returns 1", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
expect(code).toBe(1);
});
});
describe("--help flag on groups", () => {
test("workflow --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
expect(code).toBe(0);
});
test("thread --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
expect(code).toBe(0);
});
test("cas --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
expect(code).toBe(0);
});
test("init --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
});
describe("legacy help --skill compat", () => {
test("help --skill still works (lists topics)", async () => {
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
expect(code).toBe(0);
});
});
describe("formatSkillDoc", () => {
describe("getSkillTopics", () => {
test("returns all topics", () => {
const topics = getSkillTopics();
const names = topics.map((t) => t.name);
expect(names).toContain("cli");
expect(names).toContain("develop");
expect(names).toContain("author");
});
});
describe("formatSkillIndex", () => {
test("lists all topics", () => {
const idx = formatSkillIndex();
expect(idx).toContain("cli");
expect(idx).toContain("develop");
expect(idx).toContain("author");
expect(idx).toContain("skill <topic>");
});
});
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
const doc = formatSkillDoc();
test("contains title", () => {
@@ -82,3 +163,52 @@ describe("formatSkillDoc", () => {
expect(doc).toContain("## Typical Workflow");
});
});
describe("formatSkillTopic('develop')", () => {
const doc = formatSkillTopic("develop");
test("returns non-null", () => {
expect(doc).not.toBeNull();
});
test("contains thread ID info", () => {
expect(doc).toContain("Thread ID");
expect(doc).toContain("Crockford Base32");
});
test("contains CAS commands", () => {
expect(doc).toContain("cas put");
expect(doc).toContain("cas get");
});
test("contains meta output section", () => {
expect(doc).toContain("Meta Output");
});
});
describe("formatSkillTopic('author')", () => {
const doc = formatSkillTopic("author");
test("returns non-null", () => {
expect(doc).not.toBeNull();
});
test("contains bundle structure", () => {
expect(doc).toContain("Bundle Structure");
expect(doc).toContain(".esm.js");
});
test("contains descriptor info", () => {
expect(doc).toContain("WorkflowDescriptor");
});
test("contains role definition", () => {
expect(doc).toContain("Role Definition");
});
});
describe("formatSkillTopic unknown", () => {
test("returns null for unknown topic", () => {
expect(formatSkillTopic("nonexistent")).toBeNull();
});
});
+49 -11
View File
@@ -3,7 +3,7 @@ import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
import { cmdGc } from "./cmd-gc.js";
import { formatSkillDoc } from "./cmd-help.js";
import { formatSkillDoc, formatSkillIndex, formatSkillTopic } from "./cmd-help.js";
import { cmdHistory } from "./cmd-history.js";
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
import { cmdKill } from "./cmd-kill.js";
@@ -538,6 +538,9 @@ export function formatCliUsage(): string {
lines.push(" uncaged-workflow run <name> [...] (shortcut for thread run)");
lines.push(" uncaged-workflow live <thread-id> [...] (shortcut for thread live)");
lines.push("");
lines.push(" uncaged-workflow skill [topic] agent-consumable reference docs");
lines.push(" uncaged-workflow help show this usage");
lines.push("");
lines.push("Environment variables:");
lines.push(
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
@@ -561,9 +564,16 @@ function dispatchGroup(
argv: string[],
): Promise<number> | null {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown ${tableName} subcommand: (none)`);
return Promise.resolve(1);
if (sub === undefined || sub === "--help" || sub === "-h") {
const entries = Object.entries(table);
const lines = [`${tableName} subcommands:\n`];
for (const [name, e] of entries) {
const args = e.args ? ` ${e.args}` : "";
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
lines.push(` ${e.description}\n`);
}
printCliLine(lines.join("\n"));
return Promise.resolve(sub === undefined ? 1 : 0);
}
const entry = table[sub];
if (entry === undefined) {
@@ -618,12 +628,39 @@ async function dispatchCas(storageRoot: string, argv: string[]): Promise<number>
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
if (argv.includes("--skill")) {
printCliLine(formatSkillDoc());
} else {
printCliLine(formatCliUsage());
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
const topic = argv[0];
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
}
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
// Legacy compat: help --skill [topic] → skill [topic]
const skillIdx = argv.indexOf("--skill");
if (skillIdx !== -1) {
const topic = argv[skillIdx + 1];
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
}
printCliLine(formatCliUsage());
return 0;
}
@@ -636,6 +673,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
skill: dispatchSkill,
// Top-level shortcuts (no deprecation)
run: dispatchRun,
@@ -661,12 +699,12 @@ const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length === 0) {
printCliError(formatCliUsage());
printCliLine(formatCliUsage());
return 1;
}
const command = argv[0];
if (command === undefined) {
printCliError(formatCliUsage());
printCliLine(formatCliUsage());
return 1;
}
const rest = argv.slice(1);
+179 -1
View File
@@ -1,6 +1,54 @@
import { getCommandRegistry } from "./cli-dispatch.js";
export function formatSkillDoc(): string {
type SkillTopic = {
name: string;
description: string;
format: () => string;
};
const SKILL_TOPICS: ReadonlyArray<SkillTopic> = [
{ name: "cli", description: "Full CLI command reference", format: formatSkillCli },
{
name: "develop",
description: "Guide for agents executing roles inside a workflow",
format: formatSkillDevelop,
},
{
name: "author",
description: "Guide for building and publishing workflow bundles",
format: formatSkillAuthor,
},
];
export function getSkillTopics(): ReadonlyArray<{ name: string; description: string }> {
return SKILL_TOPICS.map((t) => ({ name: t.name, description: t.description }));
}
export function formatSkillTopic(topic: string): string | null {
const entry = SKILL_TOPICS.find((t) => t.name === topic);
if (entry === undefined) {
return null;
}
return entry.format();
}
export function formatSkillIndex(): string {
const rows = SKILL_TOPICS.map((t) => `| \`${t.name}\` | ${t.description} |`);
return `# uncaged-workflow help --skill
Available topics:
| Topic | Description |
|-------|-------------|
${rows.join("\n")}
Usage: \`uncaged-workflow skill <topic>\`
`;
}
// ── cli topic (existing full reference) ────────────────────────────────
function formatSkillCli(): string {
const groups = getCommandRegistry();
const commandSections: string[] = [];
@@ -58,3 +106,133 @@ ${commandSections.join("\n\n")}
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
`;
}
// ── develop topic (for agents inside a workflow) ───────────────────────
function formatSkillDevelop(): string {
return `# Workflow Role Guide
Reference for agents executing roles (planner, coder, reviewer, etc.) inside a running workflow thread.
## Thread ID
Every thread has a 26-character Crockford Base32 ULID (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`).
It appears in the **first message** of the conversation. If unsure:
\`\`\`
uncaged-workflow thread list
\`\`\`
## CAS (Content-Addressable Storage)
Store and retrieve content by hash, scoped to the current thread.
| Operation | Command |
|-----------|---------|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
## Meta Output
Each role must produce structured output that the moderator extracts. The exact schema depends on the role, but the pattern is:
1. Do your work (write code, run tests, etc.)
2. Output a compact JSON object matching the role's schema
3. The moderator extracts and validates it automatically
## Thread Context
The conversation history contains outputs from previous roles. Read it to understand:
- What task was requested (from the initial prompt)
- What previous roles produced (plans, code changes, review results)
- What the moderator decided (which phase to work on, whether to retry)
`;
}
// ── author topic (for workflow developers) ─────────────────────────────
function formatSkillAuthor(): string {
return `# Workflow Authoring Guide
How to build, test, and publish workflow bundles for uncaged-workflow.
## Bundle Structure
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
\`\`\`
## WorkflowDescriptor
Defines the workflow's metadata and role sequence:
\`\`\`typescript
type WorkflowDescriptor = {
name: string; // verb-first kebab-case, e.g. "solve-issue"
description: string; // one-line summary
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
};
\`\`\`
## WorkflowRun
The main function that creates and returns a moderator:
\`\`\`typescript
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
\`\`\`
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
## Role Definition
Each role has:
| Field | Type | Purpose |
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`extractMode\` | "single" | Extraction mode |
## Development Workflow
\`\`\`bash
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + moderator + descriptor)
# 3. Build the ESM bundle
bun run build
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
`;
}
// ── Legacy compat ──────────────────────────────────────────────────────
/** @deprecated Use formatSkillTopic("cli") instead */
export function formatSkillDoc(): string {
return formatSkillCli();
}
@@ -11,7 +11,7 @@ export type CoderMeta = z.infer<typeof coderMetaSchema>;
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
Run \`uncaged-workflow help --skill\` for full CLI reference (thread ID lookup, CAS commands, etc.).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Reading phase details
@@ -14,7 +14,7 @@ export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
Run \`uncaged-workflow help --skill\` for full CLI reference (thread ID lookup, CAS commands, etc.).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Storing phase details — MANDATORY