Compare commits

...

16 Commits

Author SHA1 Message Date
xingyue cdf3c95622 feat: add setup command for provider/model config (#216 Phase 2)
New command: uncaged-workflow setup

CLI mode (agent-friendly):
  uncaged-workflow setup \
    --provider <name> --base-url <url> --api-key <key> \
    --default-model <provider/model> [--init-workspace <name>]

Interactive mode: prompts for each value when no flags given.

- Reads/writes workflow.yaml config section
- Idempotent: updates provider without losing workflows
- Sets maxDepth=3, supervisorInterval=3 on fresh config
- Optional --init-workspace creates workspace in cwd

Testing: #218
Ref: #216
2026-05-12 20:18:23 +08:00
xingyue a7fea10383 feat: init workspace generates bundle script (#216 Phase 1)
- Add scripts.bundle to generated package.json
- Generate scripts/bundle.ts that scans workflows/*-entry.ts,
  uses Bun.build() to produce dist/*.esm.js + dist/*.d.ts
- External: @uncaged/workflow-* packages (per bundle contract)
- Add tests for new scaffold files

Testing: #217
Ref: #216
2026-05-12 20:09:41 +08:00
xingyue 3846dc12a9 chore: bump all public packages to 0.3.3 2026-05-12 12:58:17 +08:00
xingyue c5fd84432f fix(agent): defer config validation to call time
Bundle top-level code runs during `workflow add` (descriptor extraction),
but agent config env vars (e.g. WORKFLOW_HERMES_COMMAND) are only available
at `workflow run` time. Deferring validation prevents premature throws.
2026-05-12 12:58:06 +08:00
xingyue 4c4dabb7a3 chore: bump all public packages to 0.3.2 2026-05-12 12:55:21 +08:00
xingyue 1b62cec0a2 feat(agent): require absolute path for command in hermes/cursor agent configs
BREAKING: HermesAgentConfig.command and CursorAgentConfig.command are now
required string fields (absolute path to CLI binary). Validation rejects
non-absolute paths at construction time.

- Eliminates PATH resolution ambiguity in spawned worker processes
- spawnCli: explicit env: process.env for clarity
- bundle-entry: WORKFLOW_CURSOR_COMMAND is now required
- Updated tests for both agents
2026-05-12 12:52:48 +08:00
xingyue ecc348f182 feat(agent): add command config to hermes/cursor agents + explicit env inheritance
- HermesAgentConfig.command: override hermes CLI path (default: "hermes")
- CursorAgentConfig.command: override cursor-agent CLI path (default: "cursor-agent")
- spawnCli: explicit env: process.env for clarity and future extensibility
- bundle-entry: read WORKFLOW_CURSOR_COMMAND from env
2026-05-12 12:49:28 +08:00
xingyue 41209f1ef8 docs: add end-to-end development flow to CLAUDE.md 2026-05-12 11:38:03 +08:00
xingyue 58a4aefcc4 refactor(publish): auto topological sort instead of hardcoded order
Kahn algorithm reads workspace:* deps from all package.json files
and publishes leaf-first. No manual maintenance when adding packages.
2026-05-12 11:36:29 +08:00
xingyue bbb79f821e feat(init): generate bunfig.toml with Gitea scoped registry + fix versions
- init workspace now generates bunfig.toml pointing @uncaged scope to Gitea
- Fix template versions: "*" → "^0.3.1" (packages are now published)
2026-05-12 11:31:41 +08:00
xingyue 05fbd4f5b5 feat(publish): add Gitea npm registry publish script + docs
- scripts/publish-all.sh: bun pm pack (resolves workspace:*) + npm publish
- All 14 public @uncaged/* packages published to git.shazhou.work
- CLAUDE.md: document Gitea registry, bunfig.toml scoped registry, publish workflow
- bun link docs demoted to alternative for un-published local changes
2026-05-12 11:30:52 +08:00
xingyue 7e7331eb2d chore: warn against bun install after link --consume 2026-05-12 11:10:04 +08:00
xingyue 0fbbf37548 chore(cli): add bun run link scripts + fix init template versions
- Add link/link:consume/link:unlink scripts to root package.json
- Document cross-repo bun link workflow in CLAUDE.md
- Fix hardcoded @uncaged/workflow-runtime "^0.1.0" → "*" in
  init workspace and init template scaffolds (actual version is 0.3.x)
2026-05-12 11:03:12 +08:00
xingyue 2af39463de scripts: link-all.sh support register/consume/unlink modes 2026-05-12 10:59:12 +08:00
xingyue 5f2458238f scripts: add link-all.sh for local @uncaged/* package linking 2026-05-12 10:56:31 +08:00
xingyue aadec0b96c feat: WorkflowList expandable cards with static graph (#198)
- Workflow cards click to expand/collapse
- Lazy-load descriptor on first expand
- Static WorkflowGraph (all nodes default state, no highlighting)
- Show description, version count, hash
- Fix WorkflowSummary type to match actual API response
2026-05-12 10:53:49 +08:00
43 changed files with 1180 additions and 79 deletions
+58
View File
@@ -245,6 +245,64 @@ bun run format # biome format --write
bun test # run tests
```
### Publishing to Gitea npm Registry
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
```bash
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
bun run publish:gitea
# Dry run — see what would be published
bun run publish:gitea:dry
```
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
### Workflow Workspace Setup
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
```toml
[install.scopes]
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
```
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
### Cross-repo Development (bun link)
Alternative for development against un-published local changes:
```bash
bun run link # Register all packages (from monorepo root)
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
bun run link:unlink # Restore original deps
```
### End-to-end: Monorepo → Registry → Workspace → Bundle
The recommended development flow for building workflows:
```
workflow/ (monorepo) — engine, runtime, templates, agents
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
git.shazhou.work npm registry — @uncaged/* scoped packages
│ bun install — via bunfig.toml scoped registry
my-workflows/ (workspace) — bunfig.toml + normal package.json
│ bun run build:develop — bun build → single .esm.js
uncaged-workflow workflow add — register bundle locally
uncaged-workflow run — execute workflow
```
1. **Monorepo changes**`bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
2. **Workspace**`bun install` fetches latest from Gitea, `bun install` is safe to run anytime
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
4. **Register & Run**`uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
## Commit Convention
```
+6 -1
View File
@@ -9,7 +9,12 @@
"check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"test": "bun run --filter '*' test"
"test": "bun run --filter '*' test",
"link": "./scripts/link-all.sh",
"link:consume": "./scripts/link-all.sh --consume",
"link:unlink": "./scripts/link-all.sh --unlink",
"publish:gitea": "./scripts/publish-all.sh",
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
test("setup --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
expect(code).toBe(0);
});
});
describe("getSkillTopics", () => {
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
expect(u).toContain("Thread execution:");
expect(u).toContain("Content-addressable storage:");
expect(u).toContain("Development:");
expect(u).toContain("Configuration:");
expect(u).toContain("setup [--provider <name>]");
expect(u).toContain("Shortcuts:");
expect(u).toContain("Reference:");
expect(u).toContain("skill [topic]");
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
expect(doc).toContain("### thread");
expect(doc).toContain("### cas");
expect(doc).toContain("### init");
expect(doc).toContain("### setup");
expect(doc).toContain("### Top-level shortcuts");
});
@@ -38,8 +38,16 @@ describe("init workspace", () => {
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
workspaces: string[];
scripts: { bundle: string };
};
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
expect(bundleSrc).toContain("Bun.build");
expect(bundleSrc).toContain("-entry.ts");
expect(bundleSrc).toContain("distDir");
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
type: string;
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readWorkflowRegistry } from "@uncaged/workflow-register";
import { runCli } from "../src/cli-dispatch.js";
import { cmdSetup } from "../src/commands/setup/index.js";
describe("setup command (CLI mode)", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await mkdir(storageRoot, { recursive: true });
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
const r = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok) {
return;
}
expect(reg.value.config).not.toBeNull();
if (reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope).toEqual({
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
});
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
expect(reg.value.config.maxDepth).toBe(3);
expect(reg.value.config.supervisorInterval).toBe(3);
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
expect(raw).toContain("dashscope");
expect(raw).toContain("qwen-plus");
});
test("idempotent: second run updates apiKey and preserves workflows", async () => {
const initialYaml = `config:
maxDepth: 7
supervisorInterval: 2
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: sk-old
models:
default: dashscope/qwen-plus
workflows:
keep-me:
hash: "0000000000000"
timestamp: 1
history: []
`;
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
const r2 = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-newkey",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r2.ok).toBe(true);
if (!r2.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
expect(reg.value.config.maxDepth).toBe(7);
expect(reg.value.config.supervisorInterval).toBe(2);
expect(reg.value.workflows["keep-me"]).toBeDefined();
if (reg.value.workflows["keep-me"] === undefined) {
return;
}
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
});
test("runCli setup dispatches with flags and exits 0", async () => {
const code = await runCli(storageRoot, [
"setup",
"--provider",
"openai",
"--base-url",
"https://api.openai.com/v1",
"--api-key",
"sk-test",
"--default-model",
"openai/gpt-4o",
]);
expect(code).toBe(0);
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchServe } from "./commands/serve/index.js";
import { dispatchSetup } from "./commands/setup/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
@@ -66,6 +67,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
setup: dispatchSetup,
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
+13
View File
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
const SETUP_USAGE_COMMANDS = [
{
name: "",
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
description:
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
},
] as const;
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
description: e.description,
})),
},
{
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
];
}
+3 -1
View File
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
};
export function formatUsageCommandLines(
@@ -38,9 +39,10 @@ export function formatCliUsage(
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
prefix: `${group.name}${namePart}${args}`,
description: cmd.description,
};
});
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -14,6 +14,9 @@ function rootPackageJson(workspaceName: string): string {
private: true,
type: "module",
workspaces: ["templates/*", "workflows"],
scripts: {
bundle: "bun run scripts/bundle.ts",
},
},
null,
2,
@@ -28,7 +31,7 @@ function workflowsPackageJson(): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -42,7 +45,7 @@ function biomeJson(): string {
{
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
files: {
includes: ["**", "!**/node_modules", "!**/dist"],
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
},
formatter: {
indentWidth: 2,
@@ -157,6 +160,12 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
`;
}
function bunfigToml(): string {
return `[install.scopes]
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
`;
}
function readmeMd(workspaceName: string): string {
return `# ${workspaceName}
@@ -184,6 +193,104 @@ uncaged-workflow init workspace ${workspaceName}
`;
}
function bundleTs(): string {
return [
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
'import { join } from "node:path";',
"",
'const rootDir = join(import.meta.dir, "..");',
'const workflowsDir = join(rootDir, "workflows");',
'const distDir = join(rootDir, "dist");',
"",
"type JsonDeps = {",
" dependencies: Record<string, string> | null;",
" devDependencies: Record<string, string> | null;",
"};",
"",
"function isEntryFile(name: string): boolean {",
' return name.endsWith("-entry.ts");',
"}",
"",
"function entryStem(name: string): string {",
' return name.slice(0, -".ts".length);',
"}",
"",
"async function uncagedWorkflowExternals(): Promise<string[]> {",
" const names = new Set<string>();",
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
" for (const pkgPath of paths) {",
" let raw: string;",
" try {",
' raw = await readFile(pkgPath, "utf8");',
" } catch {",
" continue;",
" }",
" const parsed = JSON.parse(raw) as JsonDeps;",
" const blocks = [parsed.dependencies, parsed.devDependencies];",
" for (const block of blocks) {",
" if (block == null) {",
" continue;",
" }",
" for (const key of Object.keys(block)) {",
' if (key.startsWith("@uncaged/workflow")) {',
" names.add(key);",
" }",
" }",
" }",
" }",
" if (names.size === 0) {",
' names.add("@uncaged/workflow-runtime");',
' names.add("@uncaged/workflow-protocol");',
" }",
" return [...names];",
"}",
"",
"async function main(): Promise<void> {",
" await mkdir(distDir, { recursive: true });",
" let files: string[];",
" try {",
" files = await readdir(workflowsDir);",
" } catch {",
' console.error("bundle: missing workflows/ directory");',
" process.exitCode = 1;",
" return;",
" }",
" const entries = files.filter(isEntryFile);",
" if (entries.length === 0) {",
' console.warn("bundle: no *-entry.ts files under workflows/");',
" return;",
" }",
" const external = await uncagedWorkflowExternals();",
" for (const file of entries) {",
" const stem = entryStem(file);",
" const entryPath = join(workflowsDir, file);",
" const result = await Bun.build({",
" entrypoints: [entryPath],",
" outdir: distDir,",
' format: "esm",',
' target: "node",',
" splitting: false,",
' naming: { entry: "[name].esm.js" },',
" external,",
" });",
" if (!result.success) {",
" for (const log of result.logs) {",
" console.error(log);",
" }",
` throw new Error(\`bundle failed for \${file}\`);`,
" }",
" const dts =",
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
" }",
"}",
"",
"await main();",
"",
].join("\n");
}
export async function cmdInitWorkspace(
parentDir: string,
workspaceName: string,
@@ -201,6 +308,7 @@ export async function cmdInitWorkspace(
await mkdir(rootPath, { recursive: false });
await mkdir(join(rootPath, "templates"), { recursive: false });
await mkdir(join(rootPath, "workflows"), { recursive: false });
await mkdir(join(rootPath, "scripts"), { recursive: false });
await Promise.all([
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
@@ -210,6 +318,8 @@ export async function cmdInitWorkspace(
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
]);
return ok({ rootPath });
@@ -0,0 +1,218 @@
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { printCliError, printCliLine } from "../../cli-output.js";
import { cmdSetup, printSetupSummary } from "./setup.js";
import type { SetupCliArgs } from "./types.js";
function usageSetup(): string {
return [
"uncaged-workflow setup — configure workflow.yaml providers and default model",
"",
"Non-interactive (agent mode):",
" uncaged-workflow setup \\",
" --provider <name> \\",
" --base-url <url> \\",
" --api-key <key> \\",
" --default-model <provider/model> \\",
" [--init-workspace <name>]",
"",
"Interactive: run with no flags (prompts for each value).",
"",
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
].join("\n");
}
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
return err(`${flag} requires a value`);
}
return ok(next);
}
type ParsedSetup = SetupCliArgs | "interactive" | "help";
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
"--provider": "provider",
"--base-url": "baseUrl",
"--api-key": "apiKey",
"--default-model": "defaultModel",
"--init-workspace": "initWorkspaceName",
};
function emptyFlagState(): Record<SetupFlagField, string | null> {
return {
provider: null,
baseUrl: null,
apiKey: null,
defaultModel: null,
initWorkspaceName: null,
};
}
function finalizeParsedSetup(
state: Record<SetupFlagField, string | null>,
): Result<ParsedSetup, string> {
const hasAnyFlag =
state.provider !== null ||
state.baseUrl !== null ||
state.apiKey !== null ||
state.defaultModel !== null ||
state.initWorkspaceName !== null;
if (!hasAnyFlag) {
return ok("interactive");
}
if (state.provider === null) {
return err(
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
);
}
const missing: string[] = [];
if (state.baseUrl === null) {
missing.push("--base-url");
}
if (state.apiKey === null) {
missing.push("--api-key");
}
if (state.defaultModel === null) {
missing.push("--default-model");
}
if (missing.length > 0) {
return err(`missing required flag(s): ${missing.join(", ")}`);
}
const b = state.baseUrl;
const k = state.apiKey;
const m = state.defaultModel;
if (b === null || k === null || m === null) {
return err("internal: missing required flags after validation");
}
return ok({
provider: state.provider,
baseUrl: b,
apiKey: k,
defaultModel: m,
initWorkspaceName: state.initWorkspaceName,
});
}
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
const state = emptyFlagState();
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === undefined) {
break;
}
if (tok === "--help" || tok === "-h") {
return ok("help");
}
const field = SETUP_FLAG_TO_FIELD[tok];
if (field === undefined) {
return err(`unknown argument: ${tok}`);
}
const v = requireNext(argv, i, tok);
if (!v.ok) {
return v;
}
state[field] = v.value;
i++;
}
return finalizeParsedSetup(state);
}
async function promptLine(
rl: { question: (q: string) => Promise<string> },
label: string,
): Promise<string> {
const raw = await rl.question(label);
return raw.trim();
}
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
const rl = createInterface({ input, output });
try {
const provider = await promptLine(rl, "Provider name (e.g. openai, dashscope): ");
if (provider === "") {
return err("provider name must not be empty");
}
const baseUrl = await promptLine(rl, "Base URL: ");
if (baseUrl === "") {
return err("base URL must not be empty");
}
const apiKey = await promptLine(rl, "API key: ");
if (apiKey === "") {
return err("API key must not be empty");
}
const defaultModel = await promptLine(rl, "Default model (provider/model): ");
if (defaultModel === "") {
return err("default model must not be empty");
}
const yn = await promptLine(
rl,
"Initialize a workflow workspace under the current directory? (y/n): ",
);
const lower = yn.toLowerCase();
let initWorkspaceName: string | null = null;
if (lower === "y" || lower === "yes") {
const name = await promptLine(rl, "Workspace directory name: ");
if (name === "") {
return err("workspace name must not be empty");
}
initWorkspaceName = name;
} else if (lower !== "n" && lower !== "no" && lower !== "") {
return err('expected "y" or "n" for workspace init prompt');
}
return ok({
provider,
baseUrl,
apiKey,
defaultModel,
initWorkspaceName,
});
} finally {
rl.close();
}
}
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseSetupArgv(argv);
if (!parsed.ok) {
printCliError(`${parsed.error}\n\n${usageSetup()}`);
return 1;
}
if (parsed.value === "help") {
printCliLine(usageSetup());
return 0;
}
let args: SetupCliArgs;
if (parsed.value === "interactive") {
const collected = await collectInteractiveSetup();
if (!collected.ok) {
printCliError(collected.error);
return 1;
}
args = collected.value;
} else {
args = parsed.value;
}
const result = await cmdSetup(storageRoot, args);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printSetupSummary(result.value);
return 0;
}
@@ -0,0 +1,3 @@
export { dispatchSetup } from "./dispatch.js";
export { type CmdSetupSuccess, cmdSetup, printSetupSummary } from "./setup.js";
export type { SetupCliArgs } from "./types.js";
@@ -0,0 +1,112 @@
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
import {
readWorkflowRegistry,
splitProviderModelRef,
workflowRegistryPath,
writeWorkflowRegistry,
} from "@uncaged/workflow-register";
import { createLogger } from "@uncaged/workflow-util";
import { printCliLine } from "../../cli-output.js";
import { cmdInitWorkspace } from "../init/index.js";
import type { SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
export type CmdSetupSuccess = {
registryPath: string;
provider: string;
defaultModel: string;
maxDepth: number;
supervisorInterval: number;
initWorkspaceRootPath: string | null;
};
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
): Result<WorkflowConfig, string> {
const modelSplit = splitProviderModelRef(input.defaultModel);
if (!modelSplit.ok) {
return err(modelSplit.error);
}
if (modelSplit.value.providerName !== input.provider) {
return err(
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
);
}
const maxDepth = prev === null ? 3 : prev.maxDepth;
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
const providers = {
...(prev === null ? {} : prev.providers),
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
};
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
return ok({
maxDepth,
supervisorInterval,
providers,
models,
});
}
export async function cmdSetup(
storageRoot: string,
input: SetupCliArgs,
): Promise<Result<CmdSetupSuccess, string>> {
const readResult = await readWorkflowRegistry(storageRoot);
if (!readResult.ok) {
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
return err(readResult.error.message);
}
const current = readResult.value;
const merged = mergeWorkflowConfig(current.config, input);
if (!merged.ok) {
return merged;
}
const nextConfig = merged.value;
const nextRegistry = {
config: nextConfig,
workflows: current.workflows,
};
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
if (!written.ok) {
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
return err(written.error.message);
}
const registryPath = workflowRegistryPath(storageRoot);
let initWorkspaceRootPath: string | null = null;
if (input.initWorkspaceName !== null) {
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
if (!initResult.ok) {
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
return err(initResult.error);
}
initWorkspaceRootPath = initResult.value.rootPath;
}
return ok({
registryPath,
provider: input.provider,
defaultModel: input.defaultModel,
maxDepth: nextConfig.maxDepth,
supervisorInterval: nextConfig.supervisorInterval,
initWorkspaceRootPath,
});
}
export function printSetupSummary(result: CmdSetupSuccess): void {
printCliLine(`wrote registry: ${result.registryPath}`);
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
printCliLine(`config.models.default = "${result.defaultModel}"`);
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
if (result.initWorkspaceRootPath !== null) {
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
}
}
@@ -0,0 +1,8 @@
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
export type SetupCliArgs = {
provider: string;
baseUrl: string;
apiKey: string;
defaultModel: string;
initWorkspaceName: string | null;
};
+2 -1
View File
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
const commandSections: string[] = [];
for (const group of groups) {
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
});
commandSections.push(
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
@@ -4,6 +4,7 @@ import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config with explicit workspace", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
@@ -14,6 +15,7 @@ describe("validateCursorAgentConfig", () => {
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
@@ -22,8 +24,23 @@ describe("validateCursorAgentConfig", () => {
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({
command: "cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("absolute path");
}
});
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "",
@@ -37,6 +54,7 @@ describe("validateCursorAgentConfig", () => {
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
@@ -50,6 +68,7 @@ describe("validateCursorAgentConfig", () => {
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
@@ -62,6 +81,7 @@ describe("validateCursorAgentConfig", () => {
describe("createCursorAgent", () => {
test("returns an AgentFn with explicit workspace", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
@@ -72,6 +92,7 @@ describe("createCursorAgent", () => {
test("returns an AgentFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
@@ -80,25 +101,25 @@ describe("createCursorAgent", () => {
expect(typeof agent).toBe("function");
});
test("throws on invalid config at construction", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
}),
).toThrow();
test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("throws when null workspace without llmProvider", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
}),
).toThrow();
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+6 -6
View File
@@ -30,16 +30,16 @@ function resolveCursorModel(model: string | null): string {
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
let workspace: string;
if (config.workspace !== null) {
@@ -71,7 +71,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
"--trust",
"--force",
];
const run = await spawnCli("cursor-agent", args, {
const run = await spawnCli(config.command, args, {
cwd: workspace,
timeoutMs,
});
@@ -1,6 +1,8 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
/** Absolute path to the cursor-agent CLI binary. */
command: string;
model: string | null;
timeout: number;
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
@@ -1,8 +1,13 @@
import { isAbsolute } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the cursor-agent CLI binary");
}
if (config.workspace !== null && config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
}
@@ -4,14 +4,28 @@ import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
describe("validateHermesAgentConfig", () => {
test("accepts valid config", () => {
const r = validateHermesAgentConfig({
command: "/usr/local/bin/hermes",
model: null,
timeout: null,
});
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateHermesAgentConfig({
command: "hermes",
model: null,
timeout: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("absolute path");
}
});
test("rejects negative timeout", () => {
const r = validateHermesAgentConfig({
command: "/usr/local/bin/hermes",
model: null,
timeout: -5,
});
@@ -23,10 +37,11 @@ describe("validateHermesAgentConfig", () => {
});
describe("createHermesAgent", () => {
test("returns an AgentFn", () => {
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
const agent = createHermesAgent({
command: "/usr/local/bin/hermes",
model: null,
timeout: null,
timeout: -5,
});
expect(typeof agent).toBe("function");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+6 -6
View File
@@ -26,14 +26,14 @@ function throwHermesSpawnError(error: SpawnCliError): never {
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const timeoutMs = config.timeout;
return async (ctx) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"chat",
@@ -47,7 +47,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
if (config.model !== null) {
args.push("--model", config.model);
}
const run = await spawnCli("hermes", args, {
const run = await spawnCli(config.command, args, {
cwd: null,
timeoutMs,
});
@@ -1,4 +1,6 @@
export type HermesAgentConfig = {
/** Absolute path to the hermes CLI binary. */
command: string;
model: string | null;
timeout: number | null;
};
@@ -1,8 +1,13 @@
import { isAbsolute } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { HermesAgentConfig } from "./types.js";
export function validateHermesAgentConfig(config: HermesAgentConfig): Result<void, string> {
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the hermes CLI binary");
}
if (config.timeout !== null && config.timeout < 0) {
return err("timeout must be null or a non-negative number (milliseconds)");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"scripts": {
"test": "bun test"
+13 -7
View File
@@ -66,8 +66,13 @@ export type AgentEndpoint = {
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
hash: string | null;
timestamp: number | null;
};
export type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
};
export type ThreadSummary = {
@@ -130,7 +135,7 @@ export type WorkflowDetail = {
name: string;
hash: string;
timestamp: number;
history: unknown[];
history: readonly WorkflowHistoryEntry[];
descriptor: WorkflowDescriptor | null;
};
@@ -147,14 +152,15 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma
return fetchJson(agentBase(agent), "/workflows");
}
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
}
export async function getWorkflowDescriptor(
agent: string,
name: string,
): Promise<WorkflowDescriptor | null> {
const res = await fetchJson<WorkflowDetail>(
agentBase(agent),
`/workflows/${encodeURIComponent(name)}`,
);
const res = await getWorkflowDetail(agent, name);
return res.descriptor;
}
@@ -1,12 +1,168 @@
import { listWorkflows } from "../api.ts";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkflowDetail } from "../api.ts";
import { getWorkflowDetail, listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
};
type DetailCacheEntry =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ok"; detail: WorkflowDetail };
function versionCount(detail: WorkflowDetail): number {
return detail.history.length + 1;
}
function ExpandedWorkflowBody({
cacheEntry,
staticNodeStates,
}: {
cacheEntry: DetailCacheEntry | undefined;
staticNodeStates: Map<string, NodeState>;
}) {
if (cacheEntry === undefined || cacheEntry.status === "loading") {
return (
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
Loading workflow details...
</p>
);
}
if (cacheEntry.status === "error") {
return (
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
{cacheEntry.message}
</p>
);
}
const { detail } = cacheEntry;
const descriptor = detail.descriptor;
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = versionCount(detail);
return (
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</p>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
</p>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
{descriptor !== null && edgeCount > 0 ? (
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
>
<span className="font-mono">Workflow graph</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={staticNodeStates}
onNodeClick={null}
/>
</div>
</div>
) : null}
</div>
);
}
export function WorkflowList({ agent }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
() => new Map(),
);
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
useEffect(() => {
setExpanded(new Set());
setDetailsByName(new Map());
}, [agent]);
const ensureDetailLoaded = useCallback(
(name: string) => {
setDetailsByName((prev) => {
const cur = prev.get(name);
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
return prev;
}
return new Map(prev).set(name, { status: "loading" });
});
void (async () => {
try {
const detail = await getWorkflowDetail(agent, name);
setDetailsByName((prev) => {
const next = new Map(prev);
next.set(name, { status: "ok", detail });
return next;
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setDetailsByName((prev) => {
const next = new Map(prev);
next.set(name, { status: "error", message });
return next;
});
}
})();
},
[agent],
);
function toggleExpanded(name: string) {
let shouldLoad = false;
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
return next;
}
next.add(name);
shouldLoad = true;
return next;
});
if (shouldLoad) {
ensureDetailLoaded(name);
}
}
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
@@ -21,26 +177,58 @@ export function WorkflowList({ agent }: Props) {
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
{workflows.map((w) => {
const isOpen = expanded.has(w.name);
return (
<div
key={w.name}
className="rounded-lg border overflow-hidden"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{w.currentHash}
</code>
</div>
))}
<button
type="button"
onClick={() => toggleExpanded(w.name)}
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
style={{ color: "var(--color-text)" }}
aria-expanded={isOpen}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="text-xs font-mono"
style={{ color: "var(--color-text-muted)" }}
>
{isOpen ? "▼" : "▶"}
</span>
<span className="font-medium">{w.name}</span>
</div>
<code
className="text-xs mt-1 block font-mono truncate"
style={{ color: "var(--color-accent)" }}
>
{w.hash !== null ? w.hash : "—"}
</code>
{w.timestamp !== null ? (
<span
className="text-xs mt-1 block"
style={{ color: "var(--color-text-muted)" }}
>
Updated {new Date(w.timestamp).toLocaleString()}
</span>
) : null}
</div>
</button>
{isOpen ? (
<div className="px-4 pb-4">
<ExpandedWorkflowBody
cacheEntry={detailsByName.get(w.name)}
staticNodeStates={staticNodeStates}
/>
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-register",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -31,6 +31,7 @@ const llmProvider = {
};
const agent = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND"),
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-template-develop",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-template-solve-issue",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -22,6 +22,7 @@ export function spawnCli(
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd === null ? undefined : options.cwd,
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"exports": {
".": {
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Link / unlink all @uncaged/* packages from the workflow monorepo.
#
# Usage:
# ./scripts/link-all.sh # Register all packages (run from monorepo root)
# ./scripts/link-all.sh --consume # Link all packages into CWD's project
# ./scripts/link-all.sh --unlink # Unregister all packages and restore CWD's deps
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MONOREPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Iterate package dirs, calling callback(dir, name) for each
each_pkg() {
local cb="$1"
for dir in "$MONOREPO_ROOT"/packages/*/; do
[[ -f "$dir/package.json" ]] || continue
local name
name=$(grep -m1 '"name"' "$dir/package.json" | sed 's/.*: *"\(.*\)".*/\1/')
"$cb" "$dir" "$name"
done
}
do_register() { printf " register %s\n" "$2"; (cd "$1" && bun link 2>&1) > /dev/null; }
do_consume() { printf " link %s\n" "$2"; (bun link "$2" 2>&1) > /dev/null; }
do_unlink() { printf " unlink %s\n" "$2"; (cd "$1" && bun unlink 2>&1) > /dev/null || true; }
case "${1:-}" in
--consume)
each_pkg do_consume
echo "✅ All @uncaged/* packages linked into $(pwd)"
echo " ⚠️ Do NOT run 'bun install' after this — it will overwrite the links"
echo " To restore: $0 --unlink"
;;
--unlink)
each_pkg do_unlink
if [[ -f "package.json" ]]; then
echo " reinstalling deps..."
bun install 2>&1 > /dev/null || true
fi
echo "✅ All @uncaged/* packages unlinked, deps restored"
;;
*)
each_pkg do_register
echo "✅ All @uncaged/* packages registered"
echo " cd <project> && $0 --consume"
;;
esac
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Publish all public @uncaged/* packages to Gitea npm registry.
#
# Usage:
# ./scripts/publish-all.sh # Publish all packages
# ./scripts/publish-all.sh --dry-run # Show what would be published
#
# Package order is auto-resolved via topological sort of workspace:* dependencies.
#
# Prerequisites:
# - .npmrc in monorepo root with Gitea auth token
# - bun (for packing with workspace:* resolution)
# - npm (for publishing tarballs)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MONOREPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REGISTRY="https://git.shazhou.work/api/packages/shazhou/npm/"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN="--dry-run"
echo "🔍 Dry run mode — no packages will be published"
echo
fi
# Topological sort: read all package.json files, build dependency graph, emit leaf-first order
ORDERED=$(python3 -c "
import json, os, sys
from pathlib import Path
pkgs_dir = Path('$MONOREPO_ROOT/packages')
# name -> dir_name, and dependency edges
name_to_dir = {}
deps_graph = {} # name -> set of @uncaged/* dependency names
for d in sorted(pkgs_dir.iterdir()):
pj = d / 'package.json'
if not pj.exists():
continue
data = json.loads(pj.read_text())
name = data.get('name', '')
if not name.startswith('@uncaged/'):
continue
if data.get('private'):
continue
name_to_dir[name] = d.name
local_deps = set()
for section in ('dependencies', 'devDependencies', 'peerDependencies'):
for dep, ver in data.get(section, {}).items():
if dep.startswith('@uncaged/') and dep in name_to_dir or ver == 'workspace:*':
local_deps.add(dep)
deps_graph[name] = local_deps
# Kahn's algorithm
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
for d in ds:
if d in in_degree:
in_degree[d] = in_degree.get(d, 0) # ensure exists
# Recount
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
for d in ds:
if d in in_degree:
in_degree[d] += 1
# Wait, direction is wrong. If A depends on B, B must be published first.
# So edge is: A -> B means B must come before A.
# in_degree[A] = number of deps A has (that are in our set)
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
for d in ds:
if d in in_degree:
pass # d is a dependency of n
in_degree[n] = len([d for d in ds if d in deps_graph])
queue = [n for n, deg in in_degree.items() if deg == 0]
queue.sort() # stable order
result = []
while queue:
node = queue.pop(0)
result.append(node)
for n, ds in deps_graph.items():
if node in ds:
in_degree[n] -= 1
if in_degree[n] == 0:
queue.append(n)
queue.sort()
for name in result:
print(name_to_dir[name])
")
ok=0
fail=0
while IFS= read -r pkg; do
dir="$MONOREPO_ROOT/packages/$pkg"
name=$(grep -m1 '"name"' "$dir/package.json" | sed 's/.*: *"\(.*\)".*/\1/')
cd "$dir"
# bun pm pack resolves workspace:* → actual versions
tgz=$(bun pm pack 2>&1 | grep '\.tgz' | grep -v packed | head -1 | tr -d ' ')
if [[ -z "$tgz" || ! -f "$tgz" ]]; then
echo "$name — pack failed"
((fail++)) || true
continue
fi
if npm publish "$tgz" --registry="$REGISTRY" $DRY_RUN 2>&1 | tail -1 | grep -q '+'; then
echo "$name"
((ok++)) || true
else
echo "⚠️ $name (may already exist at this version)"
fi
rm -f "$tgz"
done <<< "$ORDERED"
echo
echo "Published: $ok Skipped/Failed: $fail"