refactor(cli): reduce cognitive complexity in setup.ts
Extracts inline logic into focused helper functions to bring each function under the complexity threshold. Fixes #445
This commit is contained in:
@@ -0,0 +1,381 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
_discoverAgents,
|
||||||
|
_isBackspace,
|
||||||
|
_isTerminator,
|
||||||
|
_parseWhichOutput,
|
||||||
|
_printModelMenu,
|
||||||
|
_printProviderMenu,
|
||||||
|
_printValidationResult,
|
||||||
|
_resolveModelChoice,
|
||||||
|
_resolveProviderChoice,
|
||||||
|
_searchPathDirs,
|
||||||
|
} from "../commands/setup.js";
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1a. _searchPathDirs
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_searchPathDirs", () => {
|
||||||
|
test("returns empty array for empty PATH", async () => {
|
||||||
|
const result = await _searchPathDirs("");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds uwf-hermes in a single dir", async () => {
|
||||||
|
const dir = mkdirSync(join(tmpdir(), `uwf-test-${Date.now()}`), { recursive: true }) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const actualDir = dir ?? join(tmpdir(), `uwf-test-${Date.now()}`);
|
||||||
|
mkdirSync(actualDir, { recursive: true });
|
||||||
|
const filePath = join(actualDir, "uwf-hermes");
|
||||||
|
writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(actualDir);
|
||||||
|
expect(result).toContain("uwf-hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips non-uwf- prefixed binaries", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-2`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entry named exactly 'uwf'", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-3`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "uwf"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips non-executable files", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-4`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "uwf-foo"), "#!/bin/sh\n", { mode: 0o644 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates across PATH dirs", async () => {
|
||||||
|
const dir1 = join(tmpdir(), `uwf-test-${Date.now()}-5a`);
|
||||||
|
const dir2 = join(tmpdir(), `uwf-test-${Date.now()}-5b`);
|
||||||
|
mkdirSync(dir1, { recursive: true });
|
||||||
|
mkdirSync(dir2, { recursive: true });
|
||||||
|
writeFileSync(join(dir1, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir2, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(`${dir1}:${dir2}`);
|
||||||
|
expect(result).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns sorted array", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-6`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "uwf-zoo"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-alpha"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-mid"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual(["uwf-alpha", "uwf-mid", "uwf-zoo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips inaccessible/nonexistent directories silently", async () => {
|
||||||
|
const result = await _searchPathDirs("/nonexistent-dir-xyz-abc-12345");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1b. _parseWhichOutput
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_parseWhichOutput", () => {
|
||||||
|
test("returns empty array for empty string", () => {
|
||||||
|
expect(_parseWhichOutput("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses single path", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple paths", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes\n/usr/bin/uwf-claude-code")).toEqual([
|
||||||
|
"uwf-claude-code",
|
||||||
|
"uwf-hermes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates identical basenames from different dirs", () => {
|
||||||
|
expect(_parseWhichOutput("/a/uwf-hermes\n/b/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips blank lines", () => {
|
||||||
|
expect(_parseWhichOutput("/a/uwf-hermes\n\n/b/uwf-cursor")).toEqual([
|
||||||
|
"uwf-cursor",
|
||||||
|
"uwf-hermes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entry named exactly 'uwf'", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/bin/uwf")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips basenames not starting with uwf-", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/bin/node")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns sorted array", () => {
|
||||||
|
expect(_parseWhichOutput("/a/uwf-zoo\n/a/uwf-alpha")).toEqual(["uwf-alpha", "uwf-zoo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2a. _isTerminator
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_isTerminator", () => {
|
||||||
|
test("\\n is a terminator", () => {
|
||||||
|
expect(_isTerminator("\n")).toBe(true);
|
||||||
|
});
|
||||||
|
test("\\r is a terminator", () => {
|
||||||
|
expect(_isTerminator("\r")).toBe(true);
|
||||||
|
});
|
||||||
|
test("\\u0004 (EOT) is a terminator", () => {
|
||||||
|
expect(_isTerminator("")).toBe(true);
|
||||||
|
});
|
||||||
|
test("regular char is not a terminator", () => {
|
||||||
|
expect(_isTerminator("a")).toBe(false);
|
||||||
|
});
|
||||||
|
test("empty string is not a terminator", () => {
|
||||||
|
expect(_isTerminator("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2b. _isBackspace
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_isBackspace", () => {
|
||||||
|
test("\\u007F is a backspace", () => {
|
||||||
|
expect(_isBackspace("")).toBe(true);
|
||||||
|
});
|
||||||
|
test("\\b is a backspace", () => {
|
||||||
|
expect(_isBackspace("\b")).toBe(true);
|
||||||
|
});
|
||||||
|
test("regular char is not a backspace", () => {
|
||||||
|
expect(_isBackspace("x")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3a. _printProviderMenu
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printProviderMenu", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test("prints correct number of lines (one per provider + custom)", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printProviderMenu(providers);
|
||||||
|
// 2 providers + 1 custom = 3 lines
|
||||||
|
expect(lines.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom option number = providers.length + 1", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printProviderMenu(providers);
|
||||||
|
const lastLine = lines[lines.length - 1] ?? "";
|
||||||
|
expect(lastLine).toMatch(/3\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each provider line contains its label and baseUrl", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printProviderMenu(providers);
|
||||||
|
expect(lines[0]).toContain("OpenAI");
|
||||||
|
expect(lines[0]).toContain("https://api.openai.com/v1");
|
||||||
|
expect(lines[1]).toContain("xAI");
|
||||||
|
expect(lines[1]).toContain("https://api.x.ai/v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3b. _resolveProviderChoice
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_resolveProviderChoice", () => {
|
||||||
|
const providers = [
|
||||||
|
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||||
|
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test("valid index 1 returns first provider", () => {
|
||||||
|
const result = _resolveProviderChoice("1", providers);
|
||||||
|
expect(result).toEqual({ providerName: "openai", baseUrl: "https://api.openai.com/v1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid index N (last preset) returns last provider", () => {
|
||||||
|
const result = _resolveProviderChoice("3", providers);
|
||||||
|
expect(result).toEqual({ providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index providers.length+1 (custom) returns null", () => {
|
||||||
|
const result = _resolveProviderChoice("4", providers);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-numeric string returns null", () => {
|
||||||
|
expect(_resolveProviderChoice("abc", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 returns null (out of range)", () => {
|
||||||
|
expect(_resolveProviderChoice("0", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("N+2 returns null (out of range)", () => {
|
||||||
|
expect(_resolveProviderChoice("5", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("negative number returns null", () => {
|
||||||
|
expect(_resolveProviderChoice("-1", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3c. _resolveModelChoice
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_resolveModelChoice", () => {
|
||||||
|
test("numeric input within range returns model at that index", () => {
|
||||||
|
expect(_resolveModelChoice("2", ["a", "b", "c"])).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("numeric input out of range returns input as-is", () => {
|
||||||
|
expect(_resolveModelChoice("5", ["a"])).toBe("5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-numeric input returns input as-is", () => {
|
||||||
|
expect(_resolveModelChoice("gpt-4o", ["a", "b"])).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("numeric input 1 returns first model", () => {
|
||||||
|
expect(_resolveModelChoice("1", ["alpha", "beta"])).toBe("alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty models list with numeric input returns input as-is", () => {
|
||||||
|
expect(_resolveModelChoice("1", [])).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3d. _printModelMenu
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printModelMenu", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prints all models — each model name appears in output", () => {
|
||||||
|
const output: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
output.push(msg);
|
||||||
|
});
|
||||||
|
const models = ["model-a", "model-b", "model-c"];
|
||||||
|
_printModelMenu(models, 100);
|
||||||
|
const combined = output.join("\n");
|
||||||
|
for (const m of models) {
|
||||||
|
expect(combined).toContain(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single column when termCols is very small", () => {
|
||||||
|
const output: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
output.push(msg);
|
||||||
|
});
|
||||||
|
_printModelMenu(["a", "b", "c"], 1);
|
||||||
|
// Each model on its own row → 3 lines
|
||||||
|
expect(output.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wide terminal fits multiple columns", () => {
|
||||||
|
const output: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
output.push(msg);
|
||||||
|
});
|
||||||
|
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
|
||||||
|
_printModelMenu(models, 200);
|
||||||
|
// With wide terminal and short names, should fit in fewer than 6 rows
|
||||||
|
expect(output.length).toBeLessThan(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3e. _printValidationResult
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printValidationResult", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ok=true prints success message containing '✓'", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printValidationResult({ ok: true, error: null });
|
||||||
|
expect(lines.join("\n")).toContain("✓");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ok=false prints warning message containing '⚠'", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||||
|
expect(lines.join("\n")).toContain("⚠");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ok=false includes the error string in output", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||||
|
expect(lines.join("\n")).toContain("HTTP 401");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Regression
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_discoverAgents regression", () => {
|
||||||
|
test("returns an array (may be empty) — never throws", async () => {
|
||||||
|
const result = await _discoverAgents();
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { stdin as input, stdout as output } from "node:process";
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
@@ -137,75 +137,182 @@ function apiKeyEnvName(providerName: string): string {
|
|||||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Extracted helpers — _discoverAgents
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans directories from a PATH string for uwf-* executables.
|
||||||
|
*/
|
||||||
|
export async function _searchPathDirs(pathEnv: string): Promise<string[]> {
|
||||||
|
if (!pathEnv) return [];
|
||||||
|
const dirs = pathEnv.split(":").filter((d) => d.length > 0);
|
||||||
|
const agents = new Set<string>();
|
||||||
|
for (const dir of dirs) {
|
||||||
|
_scanDirForAgents(dir, agents);
|
||||||
|
}
|
||||||
|
return Array.from(agents).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scanDirForAgents(dir: string, agents: Set<string>): void {
|
||||||
|
try {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
const entries = readdirSync(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
||||||
|
if (_isExecutableFile(join(dir, entry))) {
|
||||||
|
agents.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip inaccessible directories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isExecutableFile(fullPath: string): boolean {
|
||||||
|
try {
|
||||||
|
const s = statSync(fullPath);
|
||||||
|
return s.isFile() && (s.mode & 0o111) !== 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the stdout of `which -a` into sorted unique basenames.
|
||||||
|
*/
|
||||||
|
export function _parseWhichOutput(text: string): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
const agents = new Set<string>();
|
||||||
|
for (const line of text.trim().split("\n")) {
|
||||||
|
if (!line) continue;
|
||||||
|
const basename = line.split("/").pop() ?? "";
|
||||||
|
if (basename.startsWith("uwf-") && basename !== "uwf") {
|
||||||
|
agents.add(basename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(agents).sort();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover uwf-* agent binaries in PATH.
|
* Discover uwf-* agent binaries in PATH.
|
||||||
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
|
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
|
||||||
*/
|
*/
|
||||||
async function _discoverAgents(): Promise<string[]> {
|
export async function _discoverAgents(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const agents = await _tryWhichDiscovery();
|
||||||
|
if (agents !== null) return agents;
|
||||||
|
return await _searchPathDirs(process.env.PATH ?? "");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _tryWhichDiscovery(): Promise<string[] | null> {
|
||||||
try {
|
try {
|
||||||
// Use which -a to find all uwf-* binaries in PATH
|
|
||||||
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
|
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await new Response(proc.stdout).text();
|
const text = await new Response(proc.stdout).text();
|
||||||
await proc.exited;
|
await proc.exited;
|
||||||
|
if (proc.exitCode !== 0) return null;
|
||||||
if (proc.exitCode !== 0) {
|
return _parseWhichOutput(text);
|
||||||
// Try alternative approach: search PATH directories manually
|
|
||||||
const pathEnv = process.env.PATH || "";
|
|
||||||
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
|
|
||||||
const agents = new Set<string>();
|
|
||||||
|
|
||||||
for (const dir of pathDirs) {
|
|
||||||
try {
|
|
||||||
if (!existsSync(dir)) continue;
|
|
||||||
const { readdirSync, statSync } = await import("node:fs");
|
|
||||||
const entries = readdirSync(dir);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
|
||||||
const fullPath = join(dir, entry);
|
|
||||||
try {
|
|
||||||
const stat = statSync(fullPath);
|
|
||||||
// Check if executable (owner, group, or other has execute bit)
|
|
||||||
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
|
|
||||||
agents.add(entry);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip if can't stat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip inaccessible directories
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(agents).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse which output - each line is a path to a binary
|
|
||||||
const paths = text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
const agents = new Set<string>();
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
const basename = path.split("/").pop();
|
|
||||||
if (basename?.startsWith("uwf-") && basename !== "uwf") {
|
|
||||||
agents.add(basename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(agents).sort();
|
|
||||||
} catch {
|
} catch {
|
||||||
// If all fails, return empty array
|
return null;
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Extracted helpers — onData closure (promptSecret)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns true for newline, carriage return, or EOF (EOT). */
|
||||||
|
export function _isTerminator(c: string): boolean {
|
||||||
|
return c === "\n" || c === "\r" || c === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true for DEL or backspace. */
|
||||||
|
export function _isBackspace(c: string): boolean {
|
||||||
|
return c === "" || c === "\b";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Extracted helpers — cmdSetupInteractive
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ProviderEntry = { name: string; label: string; baseUrl: string };
|
||||||
|
|
||||||
|
/** Prints the numbered provider list and custom option to stdout. */
|
||||||
|
export function _printProviderMenu(providers: readonly ProviderEntry[]): void {
|
||||||
|
const numWidth = String(providers.length + 1).length;
|
||||||
|
for (let i = 0; i < providers.length; i++) {
|
||||||
|
const p = providers[i];
|
||||||
|
if (!p) continue;
|
||||||
|
const num = String(i + 1).padStart(numWidth);
|
||||||
|
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||||
|
}
|
||||||
|
const customNum = String(providers.length + 1).padStart(numWidth);
|
||||||
|
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a numeric choice string to a preset provider, or null for custom/invalid. */
|
||||||
|
export function _resolveProviderChoice(
|
||||||
|
choice: string,
|
||||||
|
providers: readonly ProviderEntry[],
|
||||||
|
): { providerName: string; baseUrl: string } | null {
|
||||||
|
const n = Number.parseInt(choice, 10);
|
||||||
|
if (Number.isNaN(n) || n < 1 || n > providers.length) return null;
|
||||||
|
const p = providers[n - 1];
|
||||||
|
if (!p) return null;
|
||||||
|
return { providerName: p.name, baseUrl: p.baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves numeric index or literal model name to a model string. */
|
||||||
|
export function _resolveModelChoice(input: string, models: string[]): string {
|
||||||
|
const n = Number.parseInt(input, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1 && n <= models.length) {
|
||||||
|
return models[n - 1] ?? input;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the multi-column model list to stdout. */
|
||||||
|
export function _printModelMenu(models: string[], termCols: number): void {
|
||||||
|
const nw = String(models.length).length;
|
||||||
|
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||||
|
const colWidth = nw + 2 + maxLen + 4;
|
||||||
|
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||||
|
const rows = Math.ceil(models.length / cols);
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
let line = "";
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const idx = c * rows + r;
|
||||||
|
if (idx >= models.length) break;
|
||||||
|
const num = String(idx + 1).padStart(nw);
|
||||||
|
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||||
|
line += ` ${num}) ${name} `;
|
||||||
|
}
|
||||||
|
console.log(line.trimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationResult = { ok: boolean; error: string | null };
|
||||||
|
|
||||||
|
/** Prints the model validation result to stdout. */
|
||||||
|
export function _printValidationResult(validation: ValidationResult): void {
|
||||||
|
if (validation.ok) {
|
||||||
|
console.log("✓ Model verified — connection successful.\n");
|
||||||
|
} else {
|
||||||
|
console.log(`\n⚠ Warning: Could not reach model — ${validation.error}`);
|
||||||
|
console.log(
|
||||||
|
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||||
*/
|
*/
|
||||||
@@ -281,6 +388,46 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretState = {
|
||||||
|
buf: string;
|
||||||
|
rawWasSet: boolean;
|
||||||
|
resolve: (value: string) => void;
|
||||||
|
onData: (chunk: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function _handleSecretTerminator(state: SecretState): void {
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdin.removeListener("data", state.onData);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
state.resolve(state.buf.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleSecretBackspace(state: SecretState): void {
|
||||||
|
if (state.buf.length > 0) {
|
||||||
|
state.buf = state.buf.slice(0, -1);
|
||||||
|
process.stdout.write("\b \b");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleSecretChar(c: string, state: SecretState): boolean {
|
||||||
|
if (_isTerminator(c)) {
|
||||||
|
_handleSecretTerminator(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_isBackspace(c)) {
|
||||||
|
_handleSecretBackspace(state);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (c === "") {
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||||
|
process.exit(130);
|
||||||
|
}
|
||||||
|
state.buf += c;
|
||||||
|
process.stdout.write("*");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Read a line with terminal echo disabled (for secrets). */
|
/** Read a line with terminal echo disabled (for secrets). */
|
||||||
async function promptSecret(label: string): Promise<string> {
|
async function promptSecret(label: string): Promise<string> {
|
||||||
process.stdout.write(label);
|
process.stdout.write(label);
|
||||||
@@ -292,33 +439,13 @@ async function promptSecret(label: string): Promise<string> {
|
|||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
|
|
||||||
let buf = "";
|
const state: SecretState = { buf: "", rawWasSet, resolve, onData: () => {} };
|
||||||
const onData = (chunk: string) => {
|
state.onData = (chunk: string) => {
|
||||||
for (const c of chunk.toString()) {
|
for (const c of chunk.toString()) {
|
||||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
if (_handleSecretChar(c, state)) return;
|
||||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeListener("data", onData);
|
|
||||||
process.stdout.write("\n");
|
|
||||||
resolve(buf.trim());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (c === "\u007F" || c === "\b") {
|
|
||||||
if (buf.length > 0) {
|
|
||||||
buf = buf.slice(0, -1);
|
|
||||||
process.stdout.write("\b \b");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "\u0003") {
|
|
||||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
|
||||||
process.exit(130);
|
|
||||||
}
|
|
||||||
buf += c;
|
|
||||||
process.stdout.write("*");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
process.stdin.on("data", onData);
|
process.stdin.on("data", state.onData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +471,56 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _promptProviderSelection(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
): Promise<{ providerName: string; baseUrl: string }> {
|
||||||
|
console.log("Select a provider:\n");
|
||||||
|
_printProviderMenu(PRESET_PROVIDERS);
|
||||||
|
|
||||||
|
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||||
|
const choiceNum = Number.parseInt(choice, 10);
|
||||||
|
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||||
|
throw new Error(`Invalid choice: ${choice}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = _resolveProviderChoice(choice, PRESET_PROVIDERS);
|
||||||
|
if (preset) {
|
||||||
|
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||||
|
if (selected) {
|
||||||
|
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||||
|
}
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||||
|
if (!providerName) throw new Error("Provider name required");
|
||||||
|
const baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||||
|
if (!baseUrl) throw new Error("Base URL required");
|
||||||
|
return { providerName, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _promptModelSelection(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string> {
|
||||||
|
console.log("\nFetching available models...");
|
||||||
|
const models = await fetchModels(baseUrl, apiKey);
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
console.log("Could not fetch models. Enter model name manually.");
|
||||||
|
const model = (await rl.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||||
|
if (!model) throw new Error("Model required");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
console.log(`\nAvailable models (${models.length}):\n`);
|
||||||
|
_printModelMenu(models, process.stdout.columns || 100);
|
||||||
|
console.log(`\nChoose a number, or type a model name directly.`);
|
||||||
|
const modelInput = (await rl.question(`Default model [1-${models.length}]: `)).trim();
|
||||||
|
if (!modelInput) throw new Error("Model required");
|
||||||
|
return _resolveModelChoice(modelInput, models);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive setup — prompts user for provider, API key, model.
|
* Interactive setup — prompts user for provider, API key, model.
|
||||||
*/
|
*/
|
||||||
@@ -353,39 +530,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
try {
|
try {
|
||||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||||
|
|
||||||
// 1. Provider selection
|
const { providerName, baseUrl } = await _promptProviderSelection(rl);
|
||||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
|
||||||
console.log("Select a provider:\n");
|
|
||||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
|
||||||
const p = PRESET_PROVIDERS[i];
|
|
||||||
if (!p) continue;
|
|
||||||
const num = String(i + 1).padStart(numWidth);
|
|
||||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
|
||||||
}
|
|
||||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
|
||||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
|
||||||
|
|
||||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
|
||||||
const choiceNum = Number.parseInt(choice, 10);
|
|
||||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
|
||||||
throw new Error(`Invalid choice: ${choice}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let providerName: string;
|
|
||||||
let baseUrl: string;
|
|
||||||
|
|
||||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
|
||||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
|
||||||
if (!selected) throw new Error("Invalid selection");
|
|
||||||
providerName = selected.name;
|
|
||||||
baseUrl = selected.baseUrl;
|
|
||||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
|
||||||
} else {
|
|
||||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
|
||||||
if (!providerName) throw new Error("Provider name required");
|
|
||||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
|
||||||
if (!baseUrl) throw new Error("Base URL required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. API key
|
// 2. API key
|
||||||
rl.close();
|
rl.close();
|
||||||
@@ -394,47 +539,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
|
|
||||||
// 3. Model selection
|
// 3. Model selection
|
||||||
const rl2 = createInterface({ input, output });
|
const rl2 = createInterface({ input, output });
|
||||||
console.log("\nFetching available models...");
|
const model = await _promptModelSelection(rl2, baseUrl, apiKey);
|
||||||
const models = await fetchModels(baseUrl, apiKey);
|
|
||||||
|
|
||||||
let model: string;
|
|
||||||
if (models.length > 0) {
|
|
||||||
console.log(`\nAvailable models (${models.length}):\n`);
|
|
||||||
const nw = String(models.length).length;
|
|
||||||
// Multi-column layout
|
|
||||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
|
||||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
|
||||||
const termCols = process.stdout.columns || 100;
|
|
||||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
|
||||||
const rows = Math.ceil(models.length / cols);
|
|
||||||
for (let r = 0; r < rows; r++) {
|
|
||||||
let line = "";
|
|
||||||
for (let c = 0; c < cols; c++) {
|
|
||||||
const idx = c * rows + r;
|
|
||||||
if (idx >= models.length) break;
|
|
||||||
const num = String(idx + 1).padStart(nw);
|
|
||||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
|
||||||
line += ` ${num}) ${name} `;
|
|
||||||
}
|
|
||||||
console.log(line.trimEnd());
|
|
||||||
}
|
|
||||||
console.log(`\nChoose a number, or type a model name directly.`);
|
|
||||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
|
||||||
if (!modelInput) throw new Error("Model required");
|
|
||||||
const modelNum = Number.parseInt(modelInput, 10);
|
|
||||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
|
||||||
model = models[modelNum - 1] ?? modelInput;
|
|
||||||
} else {
|
|
||||||
model = modelInput;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("Could not fetch models. Enter model name manually.");
|
|
||||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
|
||||||
if (!model) throw new Error("Model required");
|
|
||||||
}
|
|
||||||
|
|
||||||
rl2.close();
|
rl2.close();
|
||||||
|
|
||||||
console.log(` → ${providerName}/${model}\n`);
|
console.log(` → ${providerName}/${model}\n`);
|
||||||
|
|
||||||
const setupResult = await cmdSetup({
|
const setupResult = await cmdSetup({
|
||||||
@@ -447,17 +553,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
|
|
||||||
// Show validation result
|
// Show validation result
|
||||||
if (setupResult.validation && typeof setupResult.validation === "object") {
|
if (setupResult.validation && typeof setupResult.validation === "object") {
|
||||||
const v = setupResult.validation as { ok: boolean; error?: string };
|
_printValidationResult(setupResult.validation as ValidationResult);
|
||||||
if (v.ok) {
|
|
||||||
console.log("✓ Model verified — connection successful.\n");
|
|
||||||
} else {
|
|
||||||
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
|
|
||||||
console.log(
|
|
||||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Setup complete! Get started:\n");
|
console.log("Setup complete! Get started:\n");
|
||||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||||
|
|||||||
Reference in New Issue
Block a user