otterly 0.3.2 → 0.3.4

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.
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ const { values, positionals } = parseArgs({
17
17
  });
18
18
  const command = positionals[0] || "serve";
19
19
  if (values.version) {
20
- console.log("0.3.2");
20
+ console.log("0.3.4");
21
21
  process.exit(0);
22
22
  }
23
23
  if (values.help || command === "help") {
package/dist/engine.js CHANGED
@@ -1,18 +1,146 @@
1
+ import { execFileSync } from "child_process";
2
+ import { Worker } from "worker_threads";
1
3
  import { normalizeEvents, createEventContext } from "./events.js";
2
4
  import { classifyError, AgentError } from "./errors.js";
3
5
  import { wrapPermissionHandler } from "./permissions.js";
4
6
  import { Session } from "./session.js";
5
7
  let cachedQueryFn = null;
8
+ let resolvedMode = null;
9
+ /**
10
+ * Find the `claude` CLI binary. Returns the name or null.
11
+ */
12
+ function findClaudeCLI() {
13
+ for (const bin of ["claude", "claude-code"]) {
14
+ try {
15
+ execFileSync("which", [bin], { stdio: "pipe" });
16
+ execFileSync(bin, ["--version"], { stdio: "pipe", timeout: 5000 });
17
+ return bin;
18
+ }
19
+ catch {
20
+ // Try next
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ /**
26
+ * Build a shell command string for `claude -p`.
27
+ * Escapes the prompt for safe shell embedding.
28
+ */
29
+ function buildCLICommand(cliBin, prompt, opts) {
30
+ // Shell-escape single quotes in prompt
31
+ const safePrompt = prompt.replace(/'/g, "'\\''");
32
+ const parts = [cliBin, "-p", `'${safePrompt}'`, "--output-format", "stream-json", "--verbose"];
33
+ if (opts.cwd)
34
+ parts.push("--cwd", `'${String(opts.cwd).replace(/'/g, "'\\''")}'`);
35
+ if (opts.model)
36
+ parts.push("--model", String(opts.model));
37
+ if (opts.maxTurns)
38
+ parts.push("--max-turns", String(opts.maxTurns));
39
+ if (opts.systemPrompt) {
40
+ const safe = String(opts.systemPrompt).replace(/'/g, "'\\''");
41
+ parts.push("--system-prompt", `'${safe}'`);
42
+ }
43
+ if (opts.resume)
44
+ parts.push("--resume", String(opts.resume));
45
+ if (opts.permissionMode)
46
+ parts.push("--permission-mode", String(opts.permissionMode));
47
+ if (opts.allowedTools) {
48
+ for (const tool of opts.allowedTools)
49
+ parts.push("--allowedTools", tool);
50
+ }
51
+ if (opts.disallowedTools) {
52
+ for (const tool of opts.disallowedTools)
53
+ parts.push("--disallowedTools", tool);
54
+ }
55
+ return parts.join(" ");
56
+ }
57
+ /**
58
+ * Build a QueryFn that runs `claude -p` via execSync in a worker thread.
59
+ *
60
+ * The Bun-compiled `claude` binary doesn't pipe stdout to Node.js child_process
61
+ * async APIs (spawn/exec), but execSync through a shell works reliably.
62
+ * We run it in a worker thread to avoid blocking the event loop.
63
+ */
64
+ function createCLIQueryFn(cliBin) {
65
+ return function cliQuery(args) {
66
+ const opts = args.options || {};
67
+ const prompt = typeof args.prompt === "string" ? args.prompt : JSON.stringify(args.prompt);
68
+ const cmd = buildCLICommand(cliBin, prompt, opts);
69
+ return {
70
+ async *[Symbol.asyncIterator]() {
71
+ // Run execSync in a worker thread to keep the event loop free
72
+ const stdout = await new Promise((resolve, reject) => {
73
+ const workerCode = `
74
+ const { parentPort, workerData } = require('worker_threads');
75
+ const { execSync } = require('child_process');
76
+ try {
77
+ const out = execSync(workerData.cmd, {
78
+ encoding: 'utf-8',
79
+ timeout: workerData.timeout,
80
+ maxBuffer: 50 * 1024 * 1024,
81
+ env: process.env,
82
+ });
83
+ parentPort.postMessage({ ok: true, data: out });
84
+ } catch (err) {
85
+ parentPort.postMessage({ ok: false, error: err.message });
86
+ }
87
+ `;
88
+ const timeout = opts.abortController
89
+ ? 10 * 60 * 1000 // 10 min for streaming
90
+ : 5 * 60 * 1000; // 5 min for one-shot
91
+ const worker = new Worker(workerCode, {
92
+ eval: true,
93
+ workerData: { cmd, timeout },
94
+ });
95
+ worker.on("message", (msg) => {
96
+ if (msg.ok) {
97
+ resolve(msg.data || "");
98
+ }
99
+ else {
100
+ reject(new Error(msg.error || "CLI execution failed"));
101
+ }
102
+ });
103
+ worker.on("error", reject);
104
+ worker.on("exit", (code) => {
105
+ if (code !== 0)
106
+ reject(new Error(`Worker exited with code ${code}`));
107
+ });
108
+ // Handle abort
109
+ const ac = opts.abortController;
110
+ if (ac) {
111
+ ac.signal.addEventListener("abort", () => {
112
+ worker.terminate();
113
+ reject(new Error("Aborted"));
114
+ });
115
+ }
116
+ });
117
+ // Parse the NDJSON output line by line and yield each event
118
+ const lines = stdout.trim().split("\n");
119
+ for (const line of lines) {
120
+ if (!line.trim())
121
+ continue;
122
+ try {
123
+ yield JSON.parse(line);
124
+ }
125
+ catch {
126
+ // Skip non-JSON lines
127
+ }
128
+ }
129
+ },
130
+ };
131
+ };
132
+ }
6
133
  async function resolveSDK() {
7
134
  if (cachedQueryFn)
8
135
  return cachedQueryFn;
9
- // Try the primary SDK first, then the alternative package name
136
+ // 1. Try the npm SDK packages
10
137
  for (const pkg of ["@anthropic-ai/claude-code", "@anthropic-ai/claude-agent-sdk"]) {
11
138
  try {
12
139
  const mod = await import(pkg);
13
140
  const fn = mod.query || mod.default?.query;
14
141
  if (typeof fn === "function") {
15
142
  cachedQueryFn = fn;
143
+ resolvedMode = "sdk";
16
144
  return cachedQueryFn;
17
145
  }
18
146
  }
@@ -20,7 +148,14 @@ async function resolveSDK() {
20
148
  // Try next
21
149
  }
22
150
  }
23
- throw new AgentError("SDK_NOT_FOUND", "Could not find Claude Code SDK. Install it:\n npm install @anthropic-ai/claude-code");
151
+ // 2. Fall back to the CLI binary (works regardless of install method)
152
+ const cliBin = await findClaudeCLI();
153
+ if (cliBin) {
154
+ cachedQueryFn = createCLIQueryFn(cliBin);
155
+ resolvedMode = "cli";
156
+ return cachedQueryFn;
157
+ }
158
+ throw new AgentError("SDK_NOT_FOUND", "Could not find Claude Code. Install it from https://docs.anthropic.com/en/docs/claude-code or:\n npm install -g @anthropic-ai/claude-code");
24
159
  }
25
160
  export class ClaudeEngine {
26
161
  defaults;
@@ -114,7 +114,7 @@ export async function startApiServer(opts = {}) {
114
114
  }
115
115
  // GET / — server info
116
116
  if (req.method === "GET" && path === "/") {
117
- jsonResponse(res, 200, { name: "otterly", version: "0.3.2", playground: "/playground" });
117
+ jsonResponse(res, 200, { name: "otterly", version: "0.3.4", playground: "/playground" });
118
118
  return;
119
119
  }
120
120
  // ── POST routes: auth → rate limit → circuit breaker → queue ──
@@ -1335,7 +1335,7 @@ function playgroundHTML() {
1335
1335
  <div class="header-left">
1336
1336
  <span class="logo">\u{1F9A6}</span>
1337
1337
  <span class="logo-text">otterly</span>
1338
- <span class="version-badge">v0.3.1</span>
1338
+ <span class="version-badge">v0.3.4</span>
1339
1339
  </div>
1340
1340
  <div class="header-center">
1341
1341
  <nav class="nav">
@@ -5,7 +5,7 @@ import { AgentError } from "../errors.js";
5
5
  import { apiSessions } from "./session-store.js";
6
6
  import { errorToHttpStatus } from "./openai-compat.js";
7
7
  import { logError } from "./logger.js";
8
- const PKG_VERSION = "0.3.2";
8
+ const PKG_VERSION = "0.3.4";
9
9
  /**
10
10
  * GET /api/status — health check with queue and circuit breaker stats
11
11
  */
@@ -3,7 +3,7 @@ export const openApiSpec = {
3
3
  openapi: "3.0.3",
4
4
  info: {
5
5
  title: "Otterly API",
6
- version: "0.3.2",
6
+ version: "0.3.4",
7
7
  description: "Local inference server with OpenAI-compatible and native endpoints. " +
8
8
  "WebSocket available at ws://localhost:{port}/ws for interactive sessions.",
9
9
  },
@@ -21,7 +21,7 @@ export const openApiSpec = {
21
21
  type: "object",
22
22
  properties: {
23
23
  status: { type: "string", example: "ok" },
24
- version: { type: "string", example: "0.3.2" },
24
+ version: { type: "string", example: "0.3.4" },
25
25
  activeSessions: { type: "integer" },
26
26
  queue: {
27
27
  type: "object",
@@ -67,7 +67,7 @@ export const openApiSpec = {
67
67
  type: "object",
68
68
  properties: {
69
69
  name: { type: "string", example: "otterly" },
70
- version: { type: "string", example: "0.3.2" },
70
+ version: { type: "string", example: "0.3.4" },
71
71
  playground: { type: "string", example: "/playground" },
72
72
  },
73
73
  required: ["name", "version", "playground"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "otterly",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Local AI inference for your apps. Use Claude Code instead of paying for API tokens.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",