@epic-cloudcontrol/daemon 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadConfig } from "../config.js";
3
+ describe("loadConfig", () => {
4
+ it("returns default values when no overrides", () => {
5
+ const config = loadConfig({});
6
+ expect(config.workerType).toBe("daemon");
7
+ expect(config.pollInterval).toBe(15000);
8
+ expect(config.heartbeatInterval).toBe(30000);
9
+ expect(config.capabilities).toContain("ai_execution");
10
+ });
11
+ it("applies overrides", () => {
12
+ const config = loadConfig({
13
+ apiUrl: "https://custom.example.com",
14
+ workerType: "cli",
15
+ pollInterval: 5000,
16
+ });
17
+ expect(config.apiUrl).toBe("https://custom.example.com");
18
+ expect(config.workerType).toBe("cli");
19
+ expect(config.pollInterval).toBe(5000);
20
+ });
21
+ it("capabilities default includes standard set", () => {
22
+ const config = loadConfig({});
23
+ expect(config.capabilities).toEqual(expect.arrayContaining(["browser", "filesystem", "shell", "ai_execution"]));
24
+ });
25
+ });
26
+ //# sourceMappingURL=config.test.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ModelRouter } from "../model-router.js";
3
+ describe("ModelRouter", () => {
4
+ it("initializes and detects available models", () => {
5
+ const router = new ModelRouter();
6
+ const models = router.listModels();
7
+ expect(Array.isArray(models)).toBe(true);
8
+ });
9
+ it("has a default model string", () => {
10
+ const router = new ModelRouter();
11
+ const defaultModel = router.getDefault();
12
+ // Returns "none" when no models are available, or a real model name
13
+ expect(typeof defaultModel).toBe("string");
14
+ expect(defaultModel.length).toBeGreaterThan(0);
15
+ });
16
+ it("select returns a result or throws when no models available", () => {
17
+ const router = new ModelRouter();
18
+ const models = router.listModels();
19
+ if (models.length === 0) {
20
+ // No models available — select should throw
21
+ expect(() => router.select("auto")).toThrow("No AI models available");
22
+ }
23
+ else {
24
+ const result = router.select("auto");
25
+ expect(result.name).toBe(router.getDefault());
26
+ }
27
+ });
28
+ it("select with null behaves same as auto", () => {
29
+ const router = new ModelRouter();
30
+ const models = router.listModels();
31
+ if (models.length === 0) {
32
+ expect(() => router.select(null)).toThrow("No AI models available");
33
+ }
34
+ else {
35
+ const result = router.select(null);
36
+ expect(result.name).toBe(router.getDefault());
37
+ }
38
+ });
39
+ it("falls back to default for unknown hint when models exist", () => {
40
+ const router = new ModelRouter();
41
+ const models = router.listModels();
42
+ if (models.length === 0) {
43
+ expect(() => router.select("nonexistent-model-xyz")).toThrow("No AI models available");
44
+ }
45
+ else {
46
+ const result = router.select("nonexistent-model-xyz");
47
+ expect(result.name).toBeTruthy();
48
+ }
49
+ });
50
+ it("selects by trait if available", () => {
51
+ const router = new ModelRouter();
52
+ const models = router.listModels();
53
+ if (models.some((m) => m.traits.includes("code"))) {
54
+ const result = router.select("code");
55
+ expect(result.name).toBeTruthy();
56
+ }
57
+ });
58
+ });
59
+ //# sourceMappingURL=model-router.test.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { saveProfile, loadProfile, listProfiles, deleteProfile, profileExists, } from "../profile.js";
3
+ describe("profile", () => {
4
+ const TEST_PROFILE = "vitest-temp-profile";
5
+ afterEach(() => {
6
+ try {
7
+ deleteProfile(TEST_PROFILE);
8
+ }
9
+ catch { }
10
+ });
11
+ it("saves and loads a named profile", () => {
12
+ saveProfile({
13
+ apiUrl: "https://test.example.com",
14
+ apiKey: "cc_test123",
15
+ workerName: "test-worker",
16
+ teamName: "Test Team",
17
+ }, TEST_PROFILE);
18
+ const loaded = loadProfile(TEST_PROFILE);
19
+ expect(loaded).not.toBeNull();
20
+ expect(loaded.apiUrl).toBe("https://test.example.com");
21
+ expect(loaded.apiKey).toBe("cc_test123");
22
+ expect(loaded.workerName).toBe("test-worker");
23
+ expect(loaded.teamName).toBe("Test Team");
24
+ });
25
+ it("returns null for non-existent profile", () => {
26
+ const loaded = loadProfile("nonexistent-profile-xyz");
27
+ expect(loaded).toBeNull();
28
+ });
29
+ it("lists profiles including test profile", () => {
30
+ saveProfile({
31
+ apiUrl: "https://test.example.com",
32
+ apiKey: "cc_test123",
33
+ }, TEST_PROFILE);
34
+ const profiles = listProfiles();
35
+ const found = profiles.find((p) => p.name === TEST_PROFILE);
36
+ expect(found).toBeDefined();
37
+ expect(found.profile.apiUrl).toBe("https://test.example.com");
38
+ });
39
+ it("deletes a profile", () => {
40
+ saveProfile({
41
+ apiUrl: "https://test.example.com",
42
+ apiKey: "cc_test123",
43
+ }, TEST_PROFILE);
44
+ expect(profileExists(TEST_PROFILE)).toBe(true);
45
+ const deleted = deleteProfile(TEST_PROFILE);
46
+ expect(deleted).toBe(true);
47
+ expect(profileExists(TEST_PROFILE)).toBe(false);
48
+ });
49
+ it("profileExists returns false for non-existent", () => {
50
+ expect(profileExists("nonexistent-xyz")).toBe(false);
51
+ });
52
+ });
53
+ //# sourceMappingURL=profile.test.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getDefaultSandboxConfig, buildSandboxPrompt, filterEnvironment, truncateOutput, createTaskTmpDir, cleanupTaskTmpDir, getRestrictedPath, } from "../sandbox.js";
3
+ import { existsSync } from "fs";
4
+ describe("sandbox", () => {
5
+ describe("getDefaultSandboxConfig", () => {
6
+ it("returns config with blocked commands", () => {
7
+ const config = getDefaultSandboxConfig();
8
+ expect(config.blockedCommands.length).toBeGreaterThan(0);
9
+ expect(config.blockedCommands).toContain("rm -rf /");
10
+ expect(config.blockedCommands).toContain("sudo");
11
+ });
12
+ it("returns config with blocked paths", () => {
13
+ const config = getDefaultSandboxConfig();
14
+ expect(config.blockedPaths.length).toBeGreaterThan(0);
15
+ expect(config.blockedPaths).toContain("/etc/shadow");
16
+ });
17
+ });
18
+ describe("buildSandboxPrompt", () => {
19
+ it("generates a non-empty prompt string", () => {
20
+ const config = getDefaultSandboxConfig();
21
+ const prompt = buildSandboxPrompt(config);
22
+ expect(prompt.length).toBeGreaterThan(100);
23
+ // The heading is "## Security Restrictions" (mixed case)
24
+ expect(prompt).toContain("Security Restrictions");
25
+ });
26
+ });
27
+ describe("filterEnvironment", () => {
28
+ it("filters to only allowed variables", () => {
29
+ const env = {
30
+ HOME: "/home/user",
31
+ PATH: "/usr/bin",
32
+ SECRET_KEY: "should-be-removed",
33
+ AWS_ACCESS_KEY: "should-be-removed",
34
+ };
35
+ const filtered = filterEnvironment(env, ["HOME", "PATH"]);
36
+ expect(filtered.HOME).toBe("/home/user");
37
+ expect(filtered.PATH).toBe("/usr/bin");
38
+ expect(filtered.SECRET_KEY).toBeUndefined();
39
+ expect(filtered.AWS_ACCESS_KEY).toBeUndefined();
40
+ });
41
+ });
42
+ describe("truncateOutput", () => {
43
+ it("returns short output unchanged", () => {
44
+ expect(truncateOutput("hello", 1000)).toBe("hello");
45
+ });
46
+ it("truncates long output", () => {
47
+ const long = "x".repeat(2000);
48
+ const result = truncateOutput(long, 100);
49
+ expect(result.length).toBeLessThan(500);
50
+ expect(result).toContain("OUTPUT TRUNCATED");
51
+ });
52
+ });
53
+ describe("createTaskTmpDir / cleanupTaskTmpDir", () => {
54
+ const testTaskId = "test-1234-abcd-5678";
55
+ it("creates and cleans up temp directory", () => {
56
+ const dir = createTaskTmpDir(testTaskId);
57
+ expect(existsSync(dir)).toBe(true);
58
+ // taskId.slice(0,8) = "test-123", so dir ends with "cloudcontrol-test-123"
59
+ expect(dir).toContain("cloudcontrol-test-123");
60
+ cleanupTaskTmpDir(testTaskId);
61
+ expect(existsSync(dir)).toBe(false);
62
+ });
63
+ });
64
+ describe("getRestrictedPath", () => {
65
+ it("includes standard system directories", () => {
66
+ const restricted = getRestrictedPath();
67
+ expect(restricted).toContain("/usr/bin");
68
+ expect(restricted).toContain("/bin");
69
+ });
70
+ it("does not include arbitrary non-system directories", () => {
71
+ const restricted = getRestrictedPath();
72
+ // Should not include unrelated user dirs like Documents, Downloads, etc.
73
+ expect(restricted).not.toContain("/Documents");
74
+ expect(restricted).not.toContain("/Downloads");
75
+ });
76
+ });
77
+ });
78
+ //# sourceMappingURL=sandbox.test.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DAEMON_VERSION } from "../version.js";
3
+ describe("version", () => {
4
+ it("reads version from package.json", () => {
5
+ expect(DAEMON_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
6
+ });
7
+ it("is not fallback version", () => {
8
+ expect(DAEMON_VERSION).not.toBe("0.0.0");
9
+ });
10
+ });
11
+ //# sourceMappingURL=version.test.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Optional Playwright browser automation for process steps.
3
+ * Only loaded when a browser step is encountered.
4
+ * Fails gracefully if Playwright is not installed.
5
+ */
6
+ import type { ProcessStep } from "./step-runner.js";
7
+ export declare function executeBrowserStep(step: ProcessStep): Promise<string>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Optional Playwright browser automation for process steps.
3
+ * Only loaded when a browser step is encountered.
4
+ * Fails gracefully if Playwright is not installed.
5
+ */
6
+ import { createLogger } from "./logger.js";
7
+ const log = createLogger("browser");
8
+ export async function executeBrowserStep(step) {
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ const { chromium } = await import("playwright");
11
+ const browser = await chromium.launch({ headless: true });
12
+ const page = await browser.newPage();
13
+ try {
14
+ switch (step.action) {
15
+ case "navigate": {
16
+ if (!step.target)
17
+ throw new Error("Navigate step requires a target URL");
18
+ log.info(`Navigating to ${step.target}`);
19
+ await page.goto(step.target, { timeout: 30000 });
20
+ const title = await page.title();
21
+ return `Navigated to ${step.target} — page title: "${title}"`;
22
+ }
23
+ case "click": {
24
+ if (!step.selector)
25
+ throw new Error("Click step requires a selector");
26
+ log.info(`Clicking ${step.selector}`);
27
+ await page.click(step.selector, { timeout: 10000 });
28
+ return `Clicked ${step.selector}`;
29
+ }
30
+ case "fill": {
31
+ if (!step.selector || !step.target)
32
+ throw new Error("Fill step requires selector and target (value)");
33
+ log.info(`Filling ${step.selector}`);
34
+ await page.fill(step.selector, step.target);
35
+ return `Filled ${step.selector} with value`;
36
+ }
37
+ case "screenshot": {
38
+ const path = `/tmp/cloudcontrol-screenshot-${Date.now()}.png`;
39
+ await page.screenshot({ path });
40
+ return `Screenshot saved to ${path}`;
41
+ }
42
+ default: {
43
+ if (step.target) {
44
+ await page.goto(step.target, { timeout: 30000 });
45
+ const title = await page.title();
46
+ return `Navigated to ${step.target} — page title: "${title}"`;
47
+ }
48
+ return `Browser step "${step.id}" completed (no specific action)`;
49
+ }
50
+ }
51
+ }
52
+ finally {
53
+ await browser.close();
54
+ }
55
+ }
56
+ //# sourceMappingURL=browser.js.map
package/dist/cli.js CHANGED
@@ -17,6 +17,8 @@ import { ModelRouter } from "./model-router.js";
17
17
  import { fetchWithRetry } from "./retry.js";
18
18
  import { DAEMON_VERSION } from "./version.js";
19
19
  import { install, uninstall, serviceStatus, getLogPath, getErrorLogPath, detectPlatform } from "./service-manager.js";
20
+ import { createLogger } from "./logger.js";
21
+ const log = createLogger("daemon");
20
22
  const program = new Command();
21
23
  program
22
24
  .name("cloudcontrol")
@@ -299,9 +301,11 @@ program
299
301
  console.log(`Default: ${router.getDefault()}`);
300
302
  console.log("");
301
303
  console.log("Add models:");
302
- console.log(" Install a CLI: npm install -g @google/gemini-cli");
303
- console.log(" API key: export ANTHROPIC_API_KEY=sk-ant-...");
304
+ console.log(" Anthropic: export ANTHROPIC_API_KEY=sk-ant-...");
305
+ console.log(" OpenAI: export OPENAI_API_KEY=sk-...");
306
+ console.log(" Google: export GOOGLE_API_KEY=...");
304
307
  console.log(" Ollama: export CLOUDCONTROL_OLLAMA_MODELS=llama3,mistral");
308
+ console.log(" CLI tool: npm install -g @google/gemini-cli");
305
309
  console.log(" Custom CLI: export CLOUDCONTROL_CLI_MODELS=\"mycli:mybinary:-p\"");
306
310
  });
307
311
  // ── status ────────────────────────────────────────────
@@ -377,7 +381,7 @@ program
377
381
  model: opts.model,
378
382
  });
379
383
  const stop = () => {
380
- console.log("\n[daemon] Shutting down all workers...");
384
+ log.info("Shutting down all workers...");
381
385
  shutdown();
382
386
  process.exit(0);
383
387
  };
@@ -417,7 +421,7 @@ program
417
421
  └─────────────────────────────────────┘
418
422
  `);
419
423
  // Register worker (with retry)
420
- console.log("[daemon] Registering worker...");
424
+ log.info("Registering worker...");
421
425
  let worker;
422
426
  try {
423
427
  const registerRes = await fetchWithRetry(`${cfg.apiUrl}/api/workers`, {
@@ -444,22 +448,22 @@ program
444
448
  maxRetries: 5,
445
449
  baseDelayMs: 2000,
446
450
  onRetry: (attempt, err) => {
447
- console.log(`[daemon] Registration retry ${attempt}: ${err.message}`);
451
+ log.info(`Registration retry ${attempt}: ${err.message}`);
448
452
  },
449
453
  });
450
454
  ({ worker } = await registerRes.json());
451
455
  }
452
456
  catch (err) {
453
- console.error(`[daemon] Failed to register after retries: ${err.message}`);
457
+ log.error(`Failed to register after retries: ${err.message}`);
454
458
  process.exit(1);
455
459
  }
456
- console.log(`[daemon] Registered as worker ${worker.id}`);
460
+ log.info(`Registered as worker ${worker.id}`);
457
461
  executor.setWorkerId(worker.id);
458
462
  // Track if currently executing (one task at a time)
459
463
  let executing = false;
460
464
  async function executeTask(taskId) {
461
465
  if (executing) {
462
- console.log(`[daemon] Already executing, skipping task ${taskId.slice(0, 8)}`);
466
+ log.info(`Already executing, skipping task ${taskId.slice(0, 8)}`);
463
467
  return;
464
468
  }
465
469
  executing = true;
@@ -468,11 +472,11 @@ program
468
472
  const task = await executor.claimTask(taskId);
469
473
  if (!task)
470
474
  return;
471
- console.log(`[daemon] Claimed: ${task.title}`);
475
+ log.info(`Claimed: ${task.title}`);
472
476
  await executor.executeTask(task);
473
477
  }
474
478
  catch (err) {
475
- console.error(`[daemon] Execution error:`, err.message);
479
+ log.error(`Execution error: ${err.message}`);
476
480
  }
477
481
  finally {
478
482
  executing = false;
@@ -500,7 +504,7 @@ program
500
504
  try {
501
505
  const pending = await executor.pollTasks();
502
506
  if (pending.length > 0) {
503
- console.log(`[daemon] Found ${pending.length} pending task(s)`);
507
+ log.info(`Found ${pending.length} pending task(s)`);
504
508
  await executeTask(pending[0].id);
505
509
  }
506
510
  }
@@ -512,14 +516,14 @@ program
512
516
  const heartbeatTimer = setInterval(() => sendHeartbeat(), cfg.heartbeatInterval);
513
517
  pollForTasks();
514
518
  const shutdown = () => {
515
- console.log("\n[daemon] Shutting down...");
519
+ log.info("Shutting down...");
516
520
  clearInterval(pollTimer);
517
521
  clearInterval(heartbeatTimer);
518
522
  process.exit(0);
519
523
  };
520
524
  process.on("SIGINT", shutdown);
521
525
  process.on("SIGTERM", shutdown);
522
- console.log(`[daemon] Running. Polling every ${cfg.pollInterval / 1000}s. Press Ctrl+C to stop.`);
526
+ log.info(`Running. Polling every ${cfg.pollInterval / 1000}s. Press Ctrl+C to stop.`);
523
527
  });
524
528
  program.parse();
525
529
  //# sourceMappingURL=cli.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Minimal structured logger for the CloudControl daemon.
3
+ * - JSON output when NODE_ENV=production or CLOUDCONTROL_LOG_FORMAT=json
4
+ * - Human-readable colored output otherwise
5
+ * - No external dependencies
6
+ */
7
+ export declare function createLogger(prefix: string): {
8
+ debug: (msg: string, meta?: Record<string, unknown>) => void;
9
+ info: (msg: string, meta?: Record<string, unknown>) => void;
10
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
11
+ error: (msg: string, meta?: Record<string, unknown>) => void;
12
+ };
13
+ export type Logger = ReturnType<typeof createLogger>;
package/dist/logger.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Minimal structured logger for the CloudControl daemon.
3
+ * - JSON output when NODE_ENV=production or CLOUDCONTROL_LOG_FORMAT=json
4
+ * - Human-readable colored output otherwise
5
+ * - No external dependencies
6
+ */
7
+ const LEVEL_PRIORITY = {
8
+ debug: 0,
9
+ info: 1,
10
+ warn: 2,
11
+ error: 3,
12
+ };
13
+ const LEVEL_COLORS = {
14
+ debug: "\x1b[90m",
15
+ info: "\x1b[36m",
16
+ warn: "\x1b[33m",
17
+ error: "\x1b[31m",
18
+ };
19
+ const RESET = "\x1b[0m";
20
+ function isJsonMode() {
21
+ return process.env.NODE_ENV === "production" || process.env.CLOUDCONTROL_LOG_FORMAT === "json";
22
+ }
23
+ function getMinLevel() {
24
+ return process.env.CLOUDCONTROL_LOG_LEVEL || "info";
25
+ }
26
+ function shouldLog(level) {
27
+ return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[getMinLevel()];
28
+ }
29
+ function write(level, prefix, message, meta) {
30
+ if (!shouldLog(level))
31
+ return;
32
+ if (isJsonMode()) {
33
+ const entry = {
34
+ timestamp: new Date().toISOString(),
35
+ level,
36
+ component: prefix,
37
+ message,
38
+ };
39
+ if (meta)
40
+ entry.meta = meta;
41
+ process.stderr.write(JSON.stringify(entry) + "\n");
42
+ }
43
+ else {
44
+ const color = LEVEL_COLORS[level];
45
+ const ts = new Date().toISOString().slice(11, 19);
46
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
47
+ const line = `${color}${ts}${RESET} [${prefix}] ${message}${metaStr}`;
48
+ if (level === "error") {
49
+ console.error(line);
50
+ }
51
+ else {
52
+ console.log(line);
53
+ }
54
+ }
55
+ }
56
+ export function createLogger(prefix) {
57
+ return {
58
+ debug: (msg, meta) => write("debug", prefix, msg, meta),
59
+ info: (msg, meta) => write("info", prefix, msg, meta),
60
+ warn: (msg, meta) => write("warn", prefix, msg, meta),
61
+ error: (msg, meta) => write("error", prefix, msg, meta),
62
+ };
63
+ }
64
+ //# sourceMappingURL=logger.js.map
@@ -29,6 +29,8 @@ import { z } from "zod";
29
29
  import os from "os";
30
30
  import { DAEMON_VERSION } from "./version.js";
31
31
  import { loadProfile, listProfiles } from "./profile.js";
32
+ import { createLogger } from "./logger.js";
33
+ const log = createLogger("mcp");
32
34
  const profileName = process.env.CLOUDCONTROL_PROFILE;
33
35
  const profile = profileName ? loadProfile(profileName) : null;
34
36
  let API_URL = process.env.CLOUDCONTROL_API_URL || profile?.apiUrl || "http://localhost:3000";
@@ -498,25 +500,25 @@ server.resource("cloudcontrol://health", "cloudcontrol://health", async () => {
498
500
  async function main() {
499
501
  try {
500
502
  workerId = await registerWorker();
501
- console.error(`[mcp] Registered as worker ${workerId} (${WORKER_NAME})`);
503
+ log.info(`Registered as worker ${workerId} (${WORKER_NAME})`);
502
504
  }
503
505
  catch (err) {
504
- console.error(`[mcp] Worker registration failed: ${err.message}`);
506
+ log.error(`Worker registration failed: ${err.message}`);
505
507
  }
506
508
  // Auto-discover new companies on startup (non-blocking)
507
509
  refreshProfiles().then((lines) => {
508
510
  const newLines = lines.filter((l) => l.startsWith("✓"));
509
511
  if (newLines.length > 0) {
510
- console.error(`[mcp] Auto-refresh: discovered ${newLines.length} new company(s)`);
512
+ log.info(`Auto-refresh: discovered ${newLines.length} new company(s)`);
511
513
  for (const l of newLines)
512
- console.error(`[mcp] ${l}`);
514
+ log.info(l);
513
515
  }
514
516
  }).catch(() => { });
515
517
  const heartbeatTimer = setInterval(sendHeartbeat, 30_000);
516
518
  const transport = new StdioServerTransport();
517
519
  await server.connect(transport);
518
- console.error("[mcp] CloudControl MCP server running");
520
+ log.info("CloudControl MCP server running");
519
521
  process.on("SIGINT", () => { clearInterval(heartbeatTimer); process.exit(0); });
520
522
  }
521
- main().catch((err) => { console.error("[mcp] Fatal:", err); process.exit(1); });
523
+ main().catch((err) => { log.error("Fatal:", { error: String(err) }); process.exit(1); });
522
524
  //# sourceMappingURL=mcp-server.js.map
@@ -1,7 +1,11 @@
1
1
  import { ClaudeAdapter } from "./models/claude.js";
2
+ import { OpenAIAdapter } from "./models/openai.js";
3
+ import { GeminiAPIAdapter } from "./models/gemini-api.js";
2
4
  import { OllamaAdapter } from "./models/ollama.js";
3
5
  import { CliAdapter, KNOWN_CLIS, parseCliModels } from "./models/cli-adapter.js";
4
6
  import { execSync } from "child_process";
7
+ import { createLogger } from "./logger.js";
8
+ const log = createLogger("router");
5
9
  function isCommandInstalled(command) {
6
10
  try {
7
11
  execSync(`which ${command}`, { stdio: "ignore" });
@@ -45,7 +49,39 @@ export class ModelRouter {
45
49
  available: true,
46
50
  });
47
51
  }
48
- // 2. Auto-detect installed CLIs from KNOWN_CLIS
52
+ // 2. OpenAI API models (require OPENAI_API_KEY)
53
+ const openaiKey = process.env.OPENAI_API_KEY;
54
+ if (openaiKey && openaiKey.length > 0) {
55
+ this.models.push({
56
+ name: "gpt-4o",
57
+ adapter: new OpenAIAdapter("gpt-4o", openaiKey),
58
+ traits: ["smartest", "code", "vision"],
59
+ available: true,
60
+ });
61
+ this.models.push({
62
+ name: "gpt-4o-mini",
63
+ adapter: new OpenAIAdapter("gpt-4o-mini", openaiKey),
64
+ traits: ["cheapest", "fastest"],
65
+ available: true,
66
+ });
67
+ }
68
+ // 3. Google Gemini API models (require GOOGLE_API_KEY)
69
+ const googleKey = process.env.GOOGLE_API_KEY;
70
+ if (googleKey && googleKey.length > 0) {
71
+ this.models.push({
72
+ name: "gemini-pro",
73
+ adapter: new GeminiAPIAdapter("gemini-2.5-pro", googleKey),
74
+ traits: ["smartest", "code", "vision"],
75
+ available: true,
76
+ });
77
+ this.models.push({
78
+ name: "gemini-flash",
79
+ adapter: new GeminiAPIAdapter("gemini-2.5-flash", googleKey),
80
+ traits: ["cheapest", "fastest"],
81
+ available: true,
82
+ });
83
+ }
84
+ // 5. Auto-detect installed CLIs from KNOWN_CLIS
49
85
  const cliTraits = {
50
86
  "claude-code": ["smartest", "code", "local"],
51
87
  gemini: ["smartest", "code", "local"],
@@ -63,7 +99,7 @@ export class ModelRouter {
63
99
  });
64
100
  }
65
101
  }
66
- // 3. Custom CLI models from env var
102
+ // 6. Custom CLI models from env var
67
103
  // Format: "name:command:args;name2:command2:args"
68
104
  // Or just names from KNOWN_CLIS: "gemini;aider"
69
105
  const customClis = process.env.CLOUDCONTROL_CLI_MODELS;
@@ -81,7 +117,7 @@ export class ModelRouter {
81
117
  });
82
118
  }
83
119
  }
84
- // 4. Ollama models
120
+ // 7. Ollama models
85
121
  const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
86
122
  const ollamaModels = process.env.CLOUDCONTROL_OLLAMA_MODELS?.split(",") || [];
87
123
  for (const model of ollamaModels) {
@@ -99,8 +135,8 @@ export class ModelRouter {
99
135
  const localModel = this.models.find((m) => m.traits.includes("local"));
100
136
  const cheapApi = this.models.find((m) => m.name === "claude-haiku");
101
137
  this.defaultModel = localModel?.name || cheapApi?.name || this.models[0]?.name || "";
102
- console.log(`[router] Available models: ${this.models.map((m) => m.name).join(", ") || "none"}`);
103
- console.log(`[router] Default model: ${this.defaultModel || "none"}`);
138
+ log.info(`Available models: ${this.models.map((m) => m.name).join(", ") || "none"}`);
139
+ log.info(`Default model: ${this.defaultModel || "none"}`);
104
140
  }
105
141
  /**
106
142
  * Select the best model for a task based on its modelHint.
@@ -120,7 +156,7 @@ export class ModelRouter {
120
156
  if (candidates.length > 0) {
121
157
  return { adapter: candidates[0].adapter, name: candidates[0].name };
122
158
  }
123
- console.log(`[router] No model matches hint "${modelHint}", using default "${this.defaultModel}"`);
159
+ log.info(`No model matches hint "${modelHint}", using default "${this.defaultModel}"`);
124
160
  return this.getByName(this.defaultModel);
125
161
  }
126
162
  getByName(name) {
@@ -129,7 +165,7 @@ export class ModelRouter {
129
165
  return { adapter: model.adapter, name: model.name };
130
166
  const fallback = this.models.find((m) => m.available);
131
167
  if (fallback) {
132
- console.log(`[router] Default "${name}" not available, falling back to "${fallback.name}"`);
168
+ log.info(`Default "${name}" not available, falling back to "${fallback.name}"`);
133
169
  return { adapter: fallback.adapter, name: fallback.name };
134
170
  }
135
171
  throw new Error("No AI models available. Install a CLI (claude, gemini, etc.), set ANTHROPIC_API_KEY, or configure Ollama.");
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Google Gemini API Adapter
3
+ *
4
+ * Calls the Google Generative AI API directly (no SDK dependency).
5
+ * Supports Gemini 2.5 Pro, Flash, and any model on the API.
6
+ *
7
+ * Requires: GOOGLE_API_KEY environment variable.
8
+ */
9
+ import { type SandboxConfig } from "../sandbox.js";
10
+ import type { ExecutionResult } from "./claude.js";
11
+ export declare class GeminiAPIAdapter {
12
+ private model;
13
+ private apiKey;
14
+ private sandboxConfig;
15
+ constructor(model?: string, apiKey?: string, sandboxConfig?: SandboxConfig);
16
+ execute(task: {
17
+ title: string;
18
+ description?: string | null;
19
+ taskType?: string | null;
20
+ context?: Record<string, unknown> | null;
21
+ processHint?: string | null;
22
+ humanContext?: string | null;
23
+ }): Promise<ExecutionResult>;
24
+ }