oh-my-fable 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -128,8 +128,10 @@ console.log(result.ctx.plan.steps);
128
128
  npm i oh-my-fable # zero runtime dependencies
129
129
  ```
130
130
 
131
- Node ≥ 18. The `AnthropicProvider` talks to the API over `fetch` no SDK. Bring
132
- any model by implementing the `Provider` interface (three methods).
131
+ Node ≥ 18. Ships with `AnthropicProvider` and `OpenAICompatProvider` (works with
132
+ OpenAI, Ollama, LM Studio, OpenRouter, Groq… — `ollama("llama3.1")` for a local
133
+ model with no key), both over `fetch`, no SDK. Or bring any model by implementing
134
+ the `Provider` interface (three methods).
133
135
 
134
136
  ## Or use it from the terminal
135
137
 
@@ -138,14 +140,33 @@ Don't want to write code? It ships a CLI (zero extra deps):
138
140
  ```bash
139
141
  npx oh-my-fable demo # watch crash → resume, no API key
140
142
 
143
+ # already use Claude Code or Codex? drive it — uses that login, no separate key:
144
+ npx oh-my-fable run "outline a talk on durable agents" --provider claude
145
+
146
+ # or a LOCAL model (Ollama / LM Studio), also no key:
147
+ npx oh-my-fable run "outline a talk on durable agents" --provider ollama --model llama3.1
148
+
149
+ # or any hosted model:
141
150
  export ANTHROPIC_API_KEY=sk-...
142
151
  npx oh-my-fable run "summarize README.md into SUMMARY.md" --tools fs
143
- npx oh-my-fable run "outline a talk on durable agents" --success "5 sections"
144
152
 
145
153
  npx oh-my-fable list # your saved runs
146
154
  npx oh-my-fable resume run_abc123 # continue one from its checkpoint
147
155
  ```
148
156
 
157
+ **You don't need an Anthropic API key.** Pick how it talks to a model:
158
+
159
+ | `--provider` | uses | key? |
160
+ | --- | --- | --- |
161
+ | `claude` / `codex` | your Claude Code / Codex CLI login | **none** — rides the CLI's auth |
162
+ | `ollama` | a local Ollama model | **none** |
163
+ | `--base-url <url>` | LM Studio / OpenRouter / Groq / any OpenAI-compatible | per that server |
164
+ | `openai` | OpenAI | `OPENAI_API_KEY` |
165
+ | *(default)* | Anthropic | `ANTHROPIC_API_KEY` |
166
+
167
+ (The CLI-driven providers are text-only — great for planning/reasoning runs; they
168
+ don't expose `--tools`.)
169
+
149
170
  You watch the plan form and each step get reflected on, live. `--tools fs` gives
150
171
  the agent a sandboxed `read_file`/`write_file`/`list_dir` (confined to the working
151
172
  directory) so a terminal run can actually produce files; without it, runs are
package/dist/cli.cjs CHANGED
@@ -905,6 +905,164 @@ var AnthropicProvider = class {
905
905
  }
906
906
  };
907
907
 
908
+ // src/providers/openai.ts
909
+ var FINISH = { stop: "end", tool_calls: "tool_use", function_call: "tool_use", length: "max_tokens" };
910
+ var OpenAICompatProvider = class {
911
+ name;
912
+ baseUrl;
913
+ apiKey;
914
+ model;
915
+ maxRetries;
916
+ defaultMaxTokens;
917
+ constructor(opts) {
918
+ if (!opts.baseUrl) throw new Error("OpenAICompatProvider needs a baseUrl.");
919
+ if (!opts.model) throw new Error("OpenAICompatProvider needs a model.");
920
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
921
+ this.apiKey = opts.apiKey;
922
+ this.model = opts.model;
923
+ this.name = opts.label ?? "openai-compatible";
924
+ this.maxRetries = opts.maxRetries ?? 4;
925
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? 4096;
926
+ }
927
+ estimateTokens(messages) {
928
+ return estimateTokens(messages);
929
+ }
930
+ async complete(req) {
931
+ const body = {
932
+ model: this.model,
933
+ messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
934
+ max_tokens: req.maxTokens ?? this.defaultMaxTokens,
935
+ temperature: req.temperature ?? 1
936
+ };
937
+ if (req.tools?.length) {
938
+ body["tools"] = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
939
+ }
940
+ const data = await withRetry(
941
+ async () => {
942
+ const headers = { "content-type": "application/json" };
943
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
944
+ const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers, body: JSON.stringify(body) });
945
+ if (!res.ok) {
946
+ const text = await res.text().catch(() => "");
947
+ const err = new Error(`${this.name} ${res.status}: ${text.slice(0, 300)}`);
948
+ err.status = res.status;
949
+ throw err;
950
+ }
951
+ return await res.json();
952
+ },
953
+ {
954
+ retries: this.maxRetries,
955
+ isRetryable: (e) => {
956
+ const s = e.status;
957
+ return s === void 0 || s === 429 || s >= 500 && s < 600;
958
+ }
959
+ }
960
+ );
961
+ const choice = data.choices?.[0];
962
+ const content = typeof choice?.message?.content === "string" ? choice.message.content : "";
963
+ const toolCalls = [];
964
+ for (const tc of choice?.message?.tool_calls ?? []) {
965
+ if (!tc.function?.name) continue;
966
+ let input = {};
967
+ try {
968
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
969
+ } catch {
970
+ input = tc.function.arguments ?? {};
971
+ }
972
+ toolCalls.push({ id: tc.id ?? tc.function.name, name: tc.function.name, input });
973
+ }
974
+ return {
975
+ content,
976
+ toolCalls: toolCalls.length ? toolCalls : void 0,
977
+ tokensIn: data.usage?.prompt_tokens ?? 0,
978
+ tokensOut: data.usage?.completion_tokens ?? 0,
979
+ stopReason: FINISH[choice?.finish_reason ?? ""] ?? "end"
980
+ };
981
+ }
982
+ };
983
+ function ollama(model, opts = {}) {
984
+ return new OpenAICompatProvider({ baseUrl: "http://localhost:11434/v1", model, label: "ollama", ...opts });
985
+ }
986
+
987
+ // src/providers/cli.ts
988
+ var import_node_child_process = require("child_process");
989
+ function flatten(messages) {
990
+ const parts = [];
991
+ for (const m of messages) {
992
+ if (m.role === "assistant") parts.push(`Assistant: ${m.content}`);
993
+ else parts.push(m.content);
994
+ }
995
+ return parts.join("\n\n");
996
+ }
997
+ function runCli(command, args, input, timeoutMs, env) {
998
+ return new Promise((resolve2, reject) => {
999
+ let child;
1000
+ try {
1001
+ child = (0, import_node_child_process.spawn)(command, args, { env: { ...process.env, ...env } });
1002
+ } catch (e) {
1003
+ return reject(e);
1004
+ }
1005
+ let out = "";
1006
+ let err = "";
1007
+ const timer = setTimeout(() => {
1008
+ child.kill("SIGKILL");
1009
+ reject(new Error(`${command} timed out after ${timeoutMs} ms`));
1010
+ }, timeoutMs);
1011
+ child.stdout.on("data", (d) => out += d);
1012
+ child.stderr.on("data", (d) => err += d);
1013
+ child.on("error", (e) => {
1014
+ clearTimeout(timer);
1015
+ reject(e.code === "ENOENT" ? new Error(`"${command}" is not installed or not on your PATH.`) : e);
1016
+ });
1017
+ child.on("close", (code) => {
1018
+ clearTimeout(timer);
1019
+ if (code === 0) resolve2(out);
1020
+ else reject(new Error(`${command} exited ${code}: ${err.trim().slice(0, 300)}`));
1021
+ });
1022
+ if (input !== null) child.stdin.write(input);
1023
+ child.stdin.end();
1024
+ });
1025
+ }
1026
+ var CliProvider = class {
1027
+ name;
1028
+ command;
1029
+ args;
1030
+ promptVia;
1031
+ parse;
1032
+ env;
1033
+ timeoutMs;
1034
+ constructor(opts) {
1035
+ this.command = opts.command;
1036
+ this.args = opts.args ?? [];
1037
+ this.promptVia = opts.promptVia ?? "arg";
1038
+ this.parse = opts.parse ?? ((s) => s.trim());
1039
+ this.env = opts.env;
1040
+ this.timeoutMs = opts.timeoutMs ?? 12e4;
1041
+ this.name = opts.label ?? `cli:${opts.command}`;
1042
+ }
1043
+ estimateTokens(messages) {
1044
+ return estimateTokens(messages);
1045
+ }
1046
+ async complete(req) {
1047
+ const prompt = flatten(req.messages);
1048
+ const args = this.promptVia === "arg" ? [...this.args, prompt] : this.args;
1049
+ const stdout = await runCli(this.command, args, this.promptVia === "stdin" ? prompt : null, this.timeoutMs, this.env);
1050
+ const content = this.parse(stdout);
1051
+ return {
1052
+ content,
1053
+ tokensIn: estimateTokens(req.messages),
1054
+ tokensOut: Math.ceil(content.length / 4),
1055
+ stopReason: "end"
1056
+ };
1057
+ }
1058
+ };
1059
+ function claudeCode(opts = {}) {
1060
+ return new CliProvider({ command: "claude", args: ["-p"], promptVia: "arg", label: "claude-code", ...opts });
1061
+ }
1062
+ function codexCli(opts = {}) {
1063
+ return new CliProvider({ command: "codex", args: ["exec"], promptVia: "arg", label: "codex", ...opts });
1064
+ }
1065
+
908
1066
  // src/index.ts
909
1067
  function buildDeps(config, serializable) {
910
1068
  const provider = config.provider;
@@ -1006,10 +1164,27 @@ function renderer() {
1006
1164
  };
1007
1165
  }
1008
1166
  function makeProvider(flags) {
1167
+ const str = (k) => typeof flags[k] === "string" ? flags[k] : void 0;
1168
+ const model = str("model");
1169
+ const provider = str("provider");
1170
+ const baseUrl = str("base-url");
1171
+ const apiKey = str("api-key");
1009
1172
  try {
1010
- return new AnthropicProvider({ model: typeof flags["model"] === "string" ? flags["model"] : void 0 });
1173
+ if (provider === "claude" || provider === "claude-code") return claudeCode();
1174
+ if (provider === "codex") return codexCli();
1175
+ if (provider === "ollama") return ollama(model ?? "llama3.1", baseUrl ? { baseUrl } : {});
1176
+ if (provider === "openai") {
1177
+ return new OpenAICompatProvider({ baseUrl: baseUrl ?? "https://api.openai.com/v1", apiKey: apiKey ?? process.env["OPENAI_API_KEY"], model: model ?? "gpt-4o-mini", label: "openai" });
1178
+ }
1179
+ if (baseUrl) {
1180
+ if (!model) fail("--base-url needs --model too.");
1181
+ return new OpenAICompatProvider({ baseUrl, apiKey: apiKey ?? process.env["OPENAI_API_KEY"], model });
1182
+ }
1183
+ return new AnthropicProvider({ model });
1011
1184
  } catch (err) {
1012
- fail(err.message + "\nSet ANTHROPIC_API_KEY, or run `oh-my-fable demo` (no key needed).");
1185
+ fail(
1186
+ err.message + "\n\nNo API key needed \u2014 pick one:\n --provider claude (your Claude Code login)\n --provider ollama --model llama3.1 (a local model)\nOr just watch the mechanics: oh-my-fable demo"
1187
+ );
1013
1188
  }
1014
1189
  }
1015
1190
  function commonConfig(flags, provider) {
@@ -1152,18 +1327,24 @@ ${bold("Usage")}
1152
1327
  oh-my-fable list list saved runs
1153
1328
  oh-my-fable demo watch crash \u2192 resume, scripted (no API key)
1154
1329
 
1330
+ ${bold("Model")} ${dim("(default: Anthropic, needs ANTHROPIC_API_KEY)")}
1331
+ --provider claude drive your Claude Code CLI \u2014 uses its login, NO separate key
1332
+ --provider codex drive your Codex CLI \u2014 same idea
1333
+ --provider ollama --model llama3.1 a LOCAL model \u2014 no API key, no cost
1334
+ --provider openai --model gpt-4o-mini OpenAI (OPENAI_API_KEY)
1335
+ --base-url <url> --model <id> [--api-key] any OpenAI-compatible server (LM Studio, OpenRouter, Groq, \u2026)
1336
+
1155
1337
  ${bold("Options for run")}
1156
1338
  --success "a; b" success criteria (semicolon-separated)
1157
1339
  --tools fs allow sandboxed read_file/write_file/list_dir (default: none)
1158
- --model <id> model for the Anthropic provider
1159
1340
  --max-steps <n> step budget --max-tokens <n> token budget
1160
1341
  --runs-dir <dir> where checkpoints live (default: runs/)
1161
1342
  --quiet no live event stream
1162
1343
 
1163
1344
  ${bold("Examples")}
1164
- oh-my-fable run "summarize README.md into SUMMARY.md" --tools fs
1165
- oh-my-fable run "outline a talk on durable agents" --success "an outline with 5 sections"
1166
- oh-my-fable demo
1345
+ oh-my-fable run "outline a talk on durable agents" --provider ollama --model llama3.1
1346
+ oh-my-fable run "summarize README.md into SUMMARY.md" --tools fs ${dim("# uses Anthropic")}
1347
+ oh-my-fable demo ${dim("# no key at all")}
1167
1348
 
1168
1349
  ${dim(`It's also a library: import { run, AnthropicProvider } from "oh-my-fable".`)}
1169
1350
  `);
package/dist/cli.js CHANGED
@@ -904,6 +904,164 @@ var AnthropicProvider = class {
904
904
  }
905
905
  };
906
906
 
907
+ // src/providers/openai.ts
908
+ var FINISH = { stop: "end", tool_calls: "tool_use", function_call: "tool_use", length: "max_tokens" };
909
+ var OpenAICompatProvider = class {
910
+ name;
911
+ baseUrl;
912
+ apiKey;
913
+ model;
914
+ maxRetries;
915
+ defaultMaxTokens;
916
+ constructor(opts) {
917
+ if (!opts.baseUrl) throw new Error("OpenAICompatProvider needs a baseUrl.");
918
+ if (!opts.model) throw new Error("OpenAICompatProvider needs a model.");
919
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
920
+ this.apiKey = opts.apiKey;
921
+ this.model = opts.model;
922
+ this.name = opts.label ?? "openai-compatible";
923
+ this.maxRetries = opts.maxRetries ?? 4;
924
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? 4096;
925
+ }
926
+ estimateTokens(messages) {
927
+ return estimateTokens(messages);
928
+ }
929
+ async complete(req) {
930
+ const body = {
931
+ model: this.model,
932
+ messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
933
+ max_tokens: req.maxTokens ?? this.defaultMaxTokens,
934
+ temperature: req.temperature ?? 1
935
+ };
936
+ if (req.tools?.length) {
937
+ body["tools"] = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
938
+ }
939
+ const data = await withRetry(
940
+ async () => {
941
+ const headers = { "content-type": "application/json" };
942
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
943
+ const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers, body: JSON.stringify(body) });
944
+ if (!res.ok) {
945
+ const text = await res.text().catch(() => "");
946
+ const err = new Error(`${this.name} ${res.status}: ${text.slice(0, 300)}`);
947
+ err.status = res.status;
948
+ throw err;
949
+ }
950
+ return await res.json();
951
+ },
952
+ {
953
+ retries: this.maxRetries,
954
+ isRetryable: (e) => {
955
+ const s = e.status;
956
+ return s === void 0 || s === 429 || s >= 500 && s < 600;
957
+ }
958
+ }
959
+ );
960
+ const choice = data.choices?.[0];
961
+ const content = typeof choice?.message?.content === "string" ? choice.message.content : "";
962
+ const toolCalls = [];
963
+ for (const tc of choice?.message?.tool_calls ?? []) {
964
+ if (!tc.function?.name) continue;
965
+ let input = {};
966
+ try {
967
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
968
+ } catch {
969
+ input = tc.function.arguments ?? {};
970
+ }
971
+ toolCalls.push({ id: tc.id ?? tc.function.name, name: tc.function.name, input });
972
+ }
973
+ return {
974
+ content,
975
+ toolCalls: toolCalls.length ? toolCalls : void 0,
976
+ tokensIn: data.usage?.prompt_tokens ?? 0,
977
+ tokensOut: data.usage?.completion_tokens ?? 0,
978
+ stopReason: FINISH[choice?.finish_reason ?? ""] ?? "end"
979
+ };
980
+ }
981
+ };
982
+ function ollama(model, opts = {}) {
983
+ return new OpenAICompatProvider({ baseUrl: "http://localhost:11434/v1", model, label: "ollama", ...opts });
984
+ }
985
+
986
+ // src/providers/cli.ts
987
+ import { spawn } from "child_process";
988
+ function flatten(messages) {
989
+ const parts = [];
990
+ for (const m of messages) {
991
+ if (m.role === "assistant") parts.push(`Assistant: ${m.content}`);
992
+ else parts.push(m.content);
993
+ }
994
+ return parts.join("\n\n");
995
+ }
996
+ function runCli(command, args, input, timeoutMs, env) {
997
+ return new Promise((resolve2, reject) => {
998
+ let child;
999
+ try {
1000
+ child = spawn(command, args, { env: { ...process.env, ...env } });
1001
+ } catch (e) {
1002
+ return reject(e);
1003
+ }
1004
+ let out = "";
1005
+ let err = "";
1006
+ const timer = setTimeout(() => {
1007
+ child.kill("SIGKILL");
1008
+ reject(new Error(`${command} timed out after ${timeoutMs} ms`));
1009
+ }, timeoutMs);
1010
+ child.stdout.on("data", (d) => out += d);
1011
+ child.stderr.on("data", (d) => err += d);
1012
+ child.on("error", (e) => {
1013
+ clearTimeout(timer);
1014
+ reject(e.code === "ENOENT" ? new Error(`"${command}" is not installed or not on your PATH.`) : e);
1015
+ });
1016
+ child.on("close", (code) => {
1017
+ clearTimeout(timer);
1018
+ if (code === 0) resolve2(out);
1019
+ else reject(new Error(`${command} exited ${code}: ${err.trim().slice(0, 300)}`));
1020
+ });
1021
+ if (input !== null) child.stdin.write(input);
1022
+ child.stdin.end();
1023
+ });
1024
+ }
1025
+ var CliProvider = class {
1026
+ name;
1027
+ command;
1028
+ args;
1029
+ promptVia;
1030
+ parse;
1031
+ env;
1032
+ timeoutMs;
1033
+ constructor(opts) {
1034
+ this.command = opts.command;
1035
+ this.args = opts.args ?? [];
1036
+ this.promptVia = opts.promptVia ?? "arg";
1037
+ this.parse = opts.parse ?? ((s) => s.trim());
1038
+ this.env = opts.env;
1039
+ this.timeoutMs = opts.timeoutMs ?? 12e4;
1040
+ this.name = opts.label ?? `cli:${opts.command}`;
1041
+ }
1042
+ estimateTokens(messages) {
1043
+ return estimateTokens(messages);
1044
+ }
1045
+ async complete(req) {
1046
+ const prompt = flatten(req.messages);
1047
+ const args = this.promptVia === "arg" ? [...this.args, prompt] : this.args;
1048
+ const stdout = await runCli(this.command, args, this.promptVia === "stdin" ? prompt : null, this.timeoutMs, this.env);
1049
+ const content = this.parse(stdout);
1050
+ return {
1051
+ content,
1052
+ tokensIn: estimateTokens(req.messages),
1053
+ tokensOut: Math.ceil(content.length / 4),
1054
+ stopReason: "end"
1055
+ };
1056
+ }
1057
+ };
1058
+ function claudeCode(opts = {}) {
1059
+ return new CliProvider({ command: "claude", args: ["-p"], promptVia: "arg", label: "claude-code", ...opts });
1060
+ }
1061
+ function codexCli(opts = {}) {
1062
+ return new CliProvider({ command: "codex", args: ["exec"], promptVia: "arg", label: "codex", ...opts });
1063
+ }
1064
+
907
1065
  // src/index.ts
908
1066
  function buildDeps(config, serializable) {
909
1067
  const provider = config.provider;
@@ -1005,10 +1163,27 @@ function renderer() {
1005
1163
  };
1006
1164
  }
1007
1165
  function makeProvider(flags) {
1166
+ const str = (k) => typeof flags[k] === "string" ? flags[k] : void 0;
1167
+ const model = str("model");
1168
+ const provider = str("provider");
1169
+ const baseUrl = str("base-url");
1170
+ const apiKey = str("api-key");
1008
1171
  try {
1009
- return new AnthropicProvider({ model: typeof flags["model"] === "string" ? flags["model"] : void 0 });
1172
+ if (provider === "claude" || provider === "claude-code") return claudeCode();
1173
+ if (provider === "codex") return codexCli();
1174
+ if (provider === "ollama") return ollama(model ?? "llama3.1", baseUrl ? { baseUrl } : {});
1175
+ if (provider === "openai") {
1176
+ return new OpenAICompatProvider({ baseUrl: baseUrl ?? "https://api.openai.com/v1", apiKey: apiKey ?? process.env["OPENAI_API_KEY"], model: model ?? "gpt-4o-mini", label: "openai" });
1177
+ }
1178
+ if (baseUrl) {
1179
+ if (!model) fail("--base-url needs --model too.");
1180
+ return new OpenAICompatProvider({ baseUrl, apiKey: apiKey ?? process.env["OPENAI_API_KEY"], model });
1181
+ }
1182
+ return new AnthropicProvider({ model });
1010
1183
  } catch (err) {
1011
- fail(err.message + "\nSet ANTHROPIC_API_KEY, or run `oh-my-fable demo` (no key needed).");
1184
+ fail(
1185
+ err.message + "\n\nNo API key needed \u2014 pick one:\n --provider claude (your Claude Code login)\n --provider ollama --model llama3.1 (a local model)\nOr just watch the mechanics: oh-my-fable demo"
1186
+ );
1012
1187
  }
1013
1188
  }
1014
1189
  function commonConfig(flags, provider) {
@@ -1151,18 +1326,24 @@ ${bold("Usage")}
1151
1326
  oh-my-fable list list saved runs
1152
1327
  oh-my-fable demo watch crash \u2192 resume, scripted (no API key)
1153
1328
 
1329
+ ${bold("Model")} ${dim("(default: Anthropic, needs ANTHROPIC_API_KEY)")}
1330
+ --provider claude drive your Claude Code CLI \u2014 uses its login, NO separate key
1331
+ --provider codex drive your Codex CLI \u2014 same idea
1332
+ --provider ollama --model llama3.1 a LOCAL model \u2014 no API key, no cost
1333
+ --provider openai --model gpt-4o-mini OpenAI (OPENAI_API_KEY)
1334
+ --base-url <url> --model <id> [--api-key] any OpenAI-compatible server (LM Studio, OpenRouter, Groq, \u2026)
1335
+
1154
1336
  ${bold("Options for run")}
1155
1337
  --success "a; b" success criteria (semicolon-separated)
1156
1338
  --tools fs allow sandboxed read_file/write_file/list_dir (default: none)
1157
- --model <id> model for the Anthropic provider
1158
1339
  --max-steps <n> step budget --max-tokens <n> token budget
1159
1340
  --runs-dir <dir> where checkpoints live (default: runs/)
1160
1341
  --quiet no live event stream
1161
1342
 
1162
1343
  ${bold("Examples")}
1163
- oh-my-fable run "summarize README.md into SUMMARY.md" --tools fs
1164
- oh-my-fable run "outline a talk on durable agents" --success "an outline with 5 sections"
1165
- oh-my-fable demo
1344
+ oh-my-fable run "outline a talk on durable agents" --provider ollama --model llama3.1
1345
+ oh-my-fable run "summarize README.md into SUMMARY.md" --tools fs ${dim("# uses Anthropic")}
1346
+ oh-my-fable demo ${dim("# no key at all")}
1166
1347
 
1167
1348
  ${dim(`It's also a library: import { run, AnthropicProvider } from "oh-my-fable".`)}
1168
1349
  `);
package/dist/index.cjs CHANGED
@@ -21,22 +21,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AnthropicProvider: () => AnthropicProvider,
24
+ CliProvider: () => CliProvider,
24
25
  ContextManager: () => ContextManager,
25
26
  DEFAULT_CONFIG: () => DEFAULT_CONFIG,
26
27
  Executor: () => Executor,
27
28
  FileStore: () => FileStore,
28
29
  MemoryStore: () => MemoryStore,
30
+ OpenAICompatProvider: () => OpenAICompatProvider,
29
31
  Planner: () => Planner,
30
32
  Reflector: () => Reflector,
31
33
  ScriptedProvider: () => ScriptedProvider,
32
34
  ToolRegistry: () => ToolRegistry,
33
35
  checkBudget: () => checkBudget,
36
+ claudeCode: () => claudeCode,
37
+ codexCli: () => codexCli,
34
38
  createContext: () => createContext,
35
39
  defineTool: () => defineTool,
36
40
  estimateTokens: () => estimateTokens,
37
41
  fsTools: () => fsTools,
38
42
  genId: () => genId,
39
43
  nextPendingStep: () => nextPendingStep,
44
+ ollama: () => ollama,
40
45
  reply: () => reply,
41
46
  resolveSerializable: () => resolveSerializable,
42
47
  resume: () => resume,
@@ -925,6 +930,164 @@ var AnthropicProvider = class {
925
930
  }
926
931
  };
927
932
 
933
+ // src/providers/openai.ts
934
+ var FINISH = { stop: "end", tool_calls: "tool_use", function_call: "tool_use", length: "max_tokens" };
935
+ var OpenAICompatProvider = class {
936
+ name;
937
+ baseUrl;
938
+ apiKey;
939
+ model;
940
+ maxRetries;
941
+ defaultMaxTokens;
942
+ constructor(opts) {
943
+ if (!opts.baseUrl) throw new Error("OpenAICompatProvider needs a baseUrl.");
944
+ if (!opts.model) throw new Error("OpenAICompatProvider needs a model.");
945
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
946
+ this.apiKey = opts.apiKey;
947
+ this.model = opts.model;
948
+ this.name = opts.label ?? "openai-compatible";
949
+ this.maxRetries = opts.maxRetries ?? 4;
950
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? 4096;
951
+ }
952
+ estimateTokens(messages) {
953
+ return estimateTokens(messages);
954
+ }
955
+ async complete(req) {
956
+ const body = {
957
+ model: this.model,
958
+ messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
959
+ max_tokens: req.maxTokens ?? this.defaultMaxTokens,
960
+ temperature: req.temperature ?? 1
961
+ };
962
+ if (req.tools?.length) {
963
+ body["tools"] = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
964
+ }
965
+ const data = await withRetry(
966
+ async () => {
967
+ const headers = { "content-type": "application/json" };
968
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
969
+ const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers, body: JSON.stringify(body) });
970
+ if (!res.ok) {
971
+ const text = await res.text().catch(() => "");
972
+ const err = new Error(`${this.name} ${res.status}: ${text.slice(0, 300)}`);
973
+ err.status = res.status;
974
+ throw err;
975
+ }
976
+ return await res.json();
977
+ },
978
+ {
979
+ retries: this.maxRetries,
980
+ isRetryable: (e) => {
981
+ const s = e.status;
982
+ return s === void 0 || s === 429 || s >= 500 && s < 600;
983
+ }
984
+ }
985
+ );
986
+ const choice = data.choices?.[0];
987
+ const content = typeof choice?.message?.content === "string" ? choice.message.content : "";
988
+ const toolCalls = [];
989
+ for (const tc of choice?.message?.tool_calls ?? []) {
990
+ if (!tc.function?.name) continue;
991
+ let input = {};
992
+ try {
993
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
994
+ } catch {
995
+ input = tc.function.arguments ?? {};
996
+ }
997
+ toolCalls.push({ id: tc.id ?? tc.function.name, name: tc.function.name, input });
998
+ }
999
+ return {
1000
+ content,
1001
+ toolCalls: toolCalls.length ? toolCalls : void 0,
1002
+ tokensIn: data.usage?.prompt_tokens ?? 0,
1003
+ tokensOut: data.usage?.completion_tokens ?? 0,
1004
+ stopReason: FINISH[choice?.finish_reason ?? ""] ?? "end"
1005
+ };
1006
+ }
1007
+ };
1008
+ function ollama(model, opts = {}) {
1009
+ return new OpenAICompatProvider({ baseUrl: "http://localhost:11434/v1", model, label: "ollama", ...opts });
1010
+ }
1011
+
1012
+ // src/providers/cli.ts
1013
+ var import_node_child_process = require("child_process");
1014
+ function flatten(messages) {
1015
+ const parts = [];
1016
+ for (const m of messages) {
1017
+ if (m.role === "assistant") parts.push(`Assistant: ${m.content}`);
1018
+ else parts.push(m.content);
1019
+ }
1020
+ return parts.join("\n\n");
1021
+ }
1022
+ function runCli(command, args, input, timeoutMs, env) {
1023
+ return new Promise((resolve2, reject) => {
1024
+ let child;
1025
+ try {
1026
+ child = (0, import_node_child_process.spawn)(command, args, { env: { ...process.env, ...env } });
1027
+ } catch (e) {
1028
+ return reject(e);
1029
+ }
1030
+ let out = "";
1031
+ let err = "";
1032
+ const timer = setTimeout(() => {
1033
+ child.kill("SIGKILL");
1034
+ reject(new Error(`${command} timed out after ${timeoutMs} ms`));
1035
+ }, timeoutMs);
1036
+ child.stdout.on("data", (d) => out += d);
1037
+ child.stderr.on("data", (d) => err += d);
1038
+ child.on("error", (e) => {
1039
+ clearTimeout(timer);
1040
+ reject(e.code === "ENOENT" ? new Error(`"${command}" is not installed or not on your PATH.`) : e);
1041
+ });
1042
+ child.on("close", (code) => {
1043
+ clearTimeout(timer);
1044
+ if (code === 0) resolve2(out);
1045
+ else reject(new Error(`${command} exited ${code}: ${err.trim().slice(0, 300)}`));
1046
+ });
1047
+ if (input !== null) child.stdin.write(input);
1048
+ child.stdin.end();
1049
+ });
1050
+ }
1051
+ var CliProvider = class {
1052
+ name;
1053
+ command;
1054
+ args;
1055
+ promptVia;
1056
+ parse;
1057
+ env;
1058
+ timeoutMs;
1059
+ constructor(opts) {
1060
+ this.command = opts.command;
1061
+ this.args = opts.args ?? [];
1062
+ this.promptVia = opts.promptVia ?? "arg";
1063
+ this.parse = opts.parse ?? ((s) => s.trim());
1064
+ this.env = opts.env;
1065
+ this.timeoutMs = opts.timeoutMs ?? 12e4;
1066
+ this.name = opts.label ?? `cli:${opts.command}`;
1067
+ }
1068
+ estimateTokens(messages) {
1069
+ return estimateTokens(messages);
1070
+ }
1071
+ async complete(req) {
1072
+ const prompt = flatten(req.messages);
1073
+ const args = this.promptVia === "arg" ? [...this.args, prompt] : this.args;
1074
+ const stdout = await runCli(this.command, args, this.promptVia === "stdin" ? prompt : null, this.timeoutMs, this.env);
1075
+ const content = this.parse(stdout);
1076
+ return {
1077
+ content,
1078
+ tokensIn: estimateTokens(req.messages),
1079
+ tokensOut: Math.ceil(content.length / 4),
1080
+ stopReason: "end"
1081
+ };
1082
+ }
1083
+ };
1084
+ function claudeCode(opts = {}) {
1085
+ return new CliProvider({ command: "claude", args: ["-p"], promptVia: "arg", label: "claude-code", ...opts });
1086
+ }
1087
+ function codexCli(opts = {}) {
1088
+ return new CliProvider({ command: "codex", args: ["exec"], promptVia: "arg", label: "codex", ...opts });
1089
+ }
1090
+
928
1091
  // src/index.ts
929
1092
  function buildDeps(config, serializable) {
930
1093
  const provider = config.provider;
@@ -958,22 +1121,27 @@ async function runWith(ctx, config) {
958
1121
  // Annotate the CommonJS export names for ESM import in node:
959
1122
  0 && (module.exports = {
960
1123
  AnthropicProvider,
1124
+ CliProvider,
961
1125
  ContextManager,
962
1126
  DEFAULT_CONFIG,
963
1127
  Executor,
964
1128
  FileStore,
965
1129
  MemoryStore,
1130
+ OpenAICompatProvider,
966
1131
  Planner,
967
1132
  Reflector,
968
1133
  ScriptedProvider,
969
1134
  ToolRegistry,
970
1135
  checkBudget,
1136
+ claudeCode,
1137
+ codexCli,
971
1138
  createContext,
972
1139
  defineTool,
973
1140
  estimateTokens,
974
1141
  fsTools,
975
1142
  genId,
976
1143
  nextPendingStep,
1144
+ ollama,
977
1145
  reply,
978
1146
  resolveSerializable,
979
1147
  resume,
package/dist/index.d.cts CHANGED
@@ -377,6 +377,75 @@ declare class AnthropicProvider implements Provider {
377
377
  complete(req: CompletionRequest): Promise<CompletionResult>;
378
378
  }
379
379
 
380
+ interface OpenAICompatOptions {
381
+ /** e.g. http://localhost:11434/v1 (Ollama), http://localhost:1234/v1 (LM Studio), https://api.openai.com/v1 */
382
+ baseUrl: string;
383
+ /** Optional — local servers don't need one. */
384
+ apiKey?: string;
385
+ model: string;
386
+ label?: string;
387
+ maxRetries?: number;
388
+ defaultMaxTokens?: number;
389
+ }
390
+ /**
391
+ * Talks the OpenAI Chat Completions format — which means it works with almost
392
+ * everything: **Ollama and LM Studio (local, no API key)**, llama.cpp's server,
393
+ * OpenRouter, Groq, Together, OpenAI itself… Point `baseUrl` at any of them.
394
+ * No SDK, no dependencies.
395
+ */
396
+ declare class OpenAICompatProvider implements Provider {
397
+ readonly name: string;
398
+ private readonly baseUrl;
399
+ private readonly apiKey?;
400
+ private readonly model;
401
+ private readonly maxRetries;
402
+ private readonly defaultMaxTokens;
403
+ constructor(opts: OpenAICompatOptions);
404
+ estimateTokens(messages: Message[]): number;
405
+ complete(req: CompletionRequest): Promise<CompletionResult>;
406
+ }
407
+ /** Convenience: a local Ollama model, no API key. `ollama("llama3.1")`. */
408
+ declare function ollama(model: string, opts?: Partial<OpenAICompatOptions>): OpenAICompatProvider;
409
+
410
+ interface CliProviderOptions {
411
+ /** The executable, e.g. "claude" or "codex". */
412
+ command: string;
413
+ /** Fixed args before the prompt, e.g. ["-p"] for Claude Code print mode. */
414
+ args?: string[];
415
+ /** Pass the prompt as the final arg ("arg", default) or on stdin ("stdin"). */
416
+ promptVia?: "arg" | "stdin";
417
+ /** Extract the assistant text from the command's stdout. Default: trim. */
418
+ parse?: (stdout: string) => string;
419
+ env?: Record<string, string>;
420
+ timeoutMs?: number;
421
+ label?: string;
422
+ }
423
+ /**
424
+ * Drives an agentic CLI (Claude Code, Codex, …) in non-interactive mode: every
425
+ * model call shells out to the command and captures its stdout. This means
426
+ * people who use those CLIs via a **subscription login use oh-my-fable with no
427
+ * separate API key** — it rides whatever auth the CLI already has.
428
+ *
429
+ * Note: it returns text only (no native tool-calling), so the agent runs in
430
+ * pure-reasoning mode; `--tools fs` is unavailable through a CLI provider.
431
+ */
432
+ declare class CliProvider implements Provider {
433
+ readonly name: string;
434
+ private readonly command;
435
+ private readonly args;
436
+ private readonly promptVia;
437
+ private readonly parse;
438
+ private readonly env?;
439
+ private readonly timeoutMs;
440
+ constructor(opts: CliProviderOptions);
441
+ estimateTokens(messages: Message[]): number;
442
+ complete(req: CompletionRequest): Promise<CompletionResult>;
443
+ }
444
+ /** Claude Code in print mode — uses your existing `claude` auth (subscription or key). */
445
+ declare function claudeCode(opts?: Partial<CliProviderOptions>): CliProvider;
446
+ /** OpenAI Codex CLI in non-interactive exec mode — uses your existing `codex` auth. */
447
+ declare function codexCli(opts?: Partial<CliProviderOptions>): CliProvider;
448
+
380
449
  /** Run an agent to completion (or to a budget halt). The whole run is checkpointed every step. */
381
450
  declare function run(goal: Goal | string, config: RunConfig): Promise<RunResult>;
382
451
  /** Resume a run from its last checkpoint — same plan, same progress, continues where it died. */
@@ -384,4 +453,4 @@ declare function resume(runId: string, config: RunConfig): Promise<RunResult>;
384
453
  /** Continue a RunContext you already hold in memory (advanced; same as resume without the store load). */
385
454
  declare function runWith(ctx: RunContext, config: RunConfig): Promise<RunResult>;
386
455
 
387
- export { type AnthropicOptions, AnthropicProvider, type BudgetState, type CompletionRequest, type CompletionResult, ContextManager, DEFAULT_CONFIG, type Digest, Executor, FileStore, type Goal, type LoopDeps, MemoryStore, type Message, type Observation, type Plan, type PlanStatus, Planner, type Progress, type Provider, type Reflection, Reflector, type Role, type RunConfig, type RunContext, type RunEvent, type RunResult, type RunStatus, type RunSummary, ScriptedProvider, type ScriptedResponse, type SerializableConfig, type Step, type StepStatus, type StopReason, type Store, type Tool, type ToolCall, ToolRegistry, type ToolResult, type ToolSchema, checkBudget, createContext, defineTool, estimateTokens, fsTools, genId, nextPendingStep, reply, resolveSerializable, resume, run, runLoop, runWith, withRetry };
456
+ export { type AnthropicOptions, AnthropicProvider, type BudgetState, CliProvider, type CliProviderOptions, type CompletionRequest, type CompletionResult, ContextManager, DEFAULT_CONFIG, type Digest, Executor, FileStore, type Goal, type LoopDeps, MemoryStore, type Message, type Observation, type OpenAICompatOptions, OpenAICompatProvider, type Plan, type PlanStatus, Planner, type Progress, type Provider, type Reflection, Reflector, type Role, type RunConfig, type RunContext, type RunEvent, type RunResult, type RunStatus, type RunSummary, ScriptedProvider, type ScriptedResponse, type SerializableConfig, type Step, type StepStatus, type StopReason, type Store, type Tool, type ToolCall, ToolRegistry, type ToolResult, type ToolSchema, checkBudget, claudeCode, codexCli, createContext, defineTool, estimateTokens, fsTools, genId, nextPendingStep, ollama, reply, resolveSerializable, resume, run, runLoop, runWith, withRetry };
package/dist/index.d.ts CHANGED
@@ -377,6 +377,75 @@ declare class AnthropicProvider implements Provider {
377
377
  complete(req: CompletionRequest): Promise<CompletionResult>;
378
378
  }
379
379
 
380
+ interface OpenAICompatOptions {
381
+ /** e.g. http://localhost:11434/v1 (Ollama), http://localhost:1234/v1 (LM Studio), https://api.openai.com/v1 */
382
+ baseUrl: string;
383
+ /** Optional — local servers don't need one. */
384
+ apiKey?: string;
385
+ model: string;
386
+ label?: string;
387
+ maxRetries?: number;
388
+ defaultMaxTokens?: number;
389
+ }
390
+ /**
391
+ * Talks the OpenAI Chat Completions format — which means it works with almost
392
+ * everything: **Ollama and LM Studio (local, no API key)**, llama.cpp's server,
393
+ * OpenRouter, Groq, Together, OpenAI itself… Point `baseUrl` at any of them.
394
+ * No SDK, no dependencies.
395
+ */
396
+ declare class OpenAICompatProvider implements Provider {
397
+ readonly name: string;
398
+ private readonly baseUrl;
399
+ private readonly apiKey?;
400
+ private readonly model;
401
+ private readonly maxRetries;
402
+ private readonly defaultMaxTokens;
403
+ constructor(opts: OpenAICompatOptions);
404
+ estimateTokens(messages: Message[]): number;
405
+ complete(req: CompletionRequest): Promise<CompletionResult>;
406
+ }
407
+ /** Convenience: a local Ollama model, no API key. `ollama("llama3.1")`. */
408
+ declare function ollama(model: string, opts?: Partial<OpenAICompatOptions>): OpenAICompatProvider;
409
+
410
+ interface CliProviderOptions {
411
+ /** The executable, e.g. "claude" or "codex". */
412
+ command: string;
413
+ /** Fixed args before the prompt, e.g. ["-p"] for Claude Code print mode. */
414
+ args?: string[];
415
+ /** Pass the prompt as the final arg ("arg", default) or on stdin ("stdin"). */
416
+ promptVia?: "arg" | "stdin";
417
+ /** Extract the assistant text from the command's stdout. Default: trim. */
418
+ parse?: (stdout: string) => string;
419
+ env?: Record<string, string>;
420
+ timeoutMs?: number;
421
+ label?: string;
422
+ }
423
+ /**
424
+ * Drives an agentic CLI (Claude Code, Codex, …) in non-interactive mode: every
425
+ * model call shells out to the command and captures its stdout. This means
426
+ * people who use those CLIs via a **subscription login use oh-my-fable with no
427
+ * separate API key** — it rides whatever auth the CLI already has.
428
+ *
429
+ * Note: it returns text only (no native tool-calling), so the agent runs in
430
+ * pure-reasoning mode; `--tools fs` is unavailable through a CLI provider.
431
+ */
432
+ declare class CliProvider implements Provider {
433
+ readonly name: string;
434
+ private readonly command;
435
+ private readonly args;
436
+ private readonly promptVia;
437
+ private readonly parse;
438
+ private readonly env?;
439
+ private readonly timeoutMs;
440
+ constructor(opts: CliProviderOptions);
441
+ estimateTokens(messages: Message[]): number;
442
+ complete(req: CompletionRequest): Promise<CompletionResult>;
443
+ }
444
+ /** Claude Code in print mode — uses your existing `claude` auth (subscription or key). */
445
+ declare function claudeCode(opts?: Partial<CliProviderOptions>): CliProvider;
446
+ /** OpenAI Codex CLI in non-interactive exec mode — uses your existing `codex` auth. */
447
+ declare function codexCli(opts?: Partial<CliProviderOptions>): CliProvider;
448
+
380
449
  /** Run an agent to completion (or to a budget halt). The whole run is checkpointed every step. */
381
450
  declare function run(goal: Goal | string, config: RunConfig): Promise<RunResult>;
382
451
  /** Resume a run from its last checkpoint — same plan, same progress, continues where it died. */
@@ -384,4 +453,4 @@ declare function resume(runId: string, config: RunConfig): Promise<RunResult>;
384
453
  /** Continue a RunContext you already hold in memory (advanced; same as resume without the store load). */
385
454
  declare function runWith(ctx: RunContext, config: RunConfig): Promise<RunResult>;
386
455
 
387
- export { type AnthropicOptions, AnthropicProvider, type BudgetState, type CompletionRequest, type CompletionResult, ContextManager, DEFAULT_CONFIG, type Digest, Executor, FileStore, type Goal, type LoopDeps, MemoryStore, type Message, type Observation, type Plan, type PlanStatus, Planner, type Progress, type Provider, type Reflection, Reflector, type Role, type RunConfig, type RunContext, type RunEvent, type RunResult, type RunStatus, type RunSummary, ScriptedProvider, type ScriptedResponse, type SerializableConfig, type Step, type StepStatus, type StopReason, type Store, type Tool, type ToolCall, ToolRegistry, type ToolResult, type ToolSchema, checkBudget, createContext, defineTool, estimateTokens, fsTools, genId, nextPendingStep, reply, resolveSerializable, resume, run, runLoop, runWith, withRetry };
456
+ export { type AnthropicOptions, AnthropicProvider, type BudgetState, CliProvider, type CliProviderOptions, type CompletionRequest, type CompletionResult, ContextManager, DEFAULT_CONFIG, type Digest, Executor, FileStore, type Goal, type LoopDeps, MemoryStore, type Message, type Observation, type OpenAICompatOptions, OpenAICompatProvider, type Plan, type PlanStatus, Planner, type Progress, type Provider, type Reflection, Reflector, type Role, type RunConfig, type RunContext, type RunEvent, type RunResult, type RunStatus, type RunSummary, ScriptedProvider, type ScriptedResponse, type SerializableConfig, type Step, type StepStatus, type StopReason, type Store, type Tool, type ToolCall, ToolRegistry, type ToolResult, type ToolSchema, checkBudget, claudeCode, codexCli, createContext, defineTool, estimateTokens, fsTools, genId, nextPendingStep, ollama, reply, resolveSerializable, resume, run, runLoop, runWith, withRetry };
package/dist/index.js CHANGED
@@ -876,6 +876,164 @@ var AnthropicProvider = class {
876
876
  }
877
877
  };
878
878
 
879
+ // src/providers/openai.ts
880
+ var FINISH = { stop: "end", tool_calls: "tool_use", function_call: "tool_use", length: "max_tokens" };
881
+ var OpenAICompatProvider = class {
882
+ name;
883
+ baseUrl;
884
+ apiKey;
885
+ model;
886
+ maxRetries;
887
+ defaultMaxTokens;
888
+ constructor(opts) {
889
+ if (!opts.baseUrl) throw new Error("OpenAICompatProvider needs a baseUrl.");
890
+ if (!opts.model) throw new Error("OpenAICompatProvider needs a model.");
891
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
892
+ this.apiKey = opts.apiKey;
893
+ this.model = opts.model;
894
+ this.name = opts.label ?? "openai-compatible";
895
+ this.maxRetries = opts.maxRetries ?? 4;
896
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? 4096;
897
+ }
898
+ estimateTokens(messages) {
899
+ return estimateTokens(messages);
900
+ }
901
+ async complete(req) {
902
+ const body = {
903
+ model: this.model,
904
+ messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
905
+ max_tokens: req.maxTokens ?? this.defaultMaxTokens,
906
+ temperature: req.temperature ?? 1
907
+ };
908
+ if (req.tools?.length) {
909
+ body["tools"] = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
910
+ }
911
+ const data = await withRetry(
912
+ async () => {
913
+ const headers = { "content-type": "application/json" };
914
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
915
+ const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers, body: JSON.stringify(body) });
916
+ if (!res.ok) {
917
+ const text = await res.text().catch(() => "");
918
+ const err = new Error(`${this.name} ${res.status}: ${text.slice(0, 300)}`);
919
+ err.status = res.status;
920
+ throw err;
921
+ }
922
+ return await res.json();
923
+ },
924
+ {
925
+ retries: this.maxRetries,
926
+ isRetryable: (e) => {
927
+ const s = e.status;
928
+ return s === void 0 || s === 429 || s >= 500 && s < 600;
929
+ }
930
+ }
931
+ );
932
+ const choice = data.choices?.[0];
933
+ const content = typeof choice?.message?.content === "string" ? choice.message.content : "";
934
+ const toolCalls = [];
935
+ for (const tc of choice?.message?.tool_calls ?? []) {
936
+ if (!tc.function?.name) continue;
937
+ let input = {};
938
+ try {
939
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
940
+ } catch {
941
+ input = tc.function.arguments ?? {};
942
+ }
943
+ toolCalls.push({ id: tc.id ?? tc.function.name, name: tc.function.name, input });
944
+ }
945
+ return {
946
+ content,
947
+ toolCalls: toolCalls.length ? toolCalls : void 0,
948
+ tokensIn: data.usage?.prompt_tokens ?? 0,
949
+ tokensOut: data.usage?.completion_tokens ?? 0,
950
+ stopReason: FINISH[choice?.finish_reason ?? ""] ?? "end"
951
+ };
952
+ }
953
+ };
954
+ function ollama(model, opts = {}) {
955
+ return new OpenAICompatProvider({ baseUrl: "http://localhost:11434/v1", model, label: "ollama", ...opts });
956
+ }
957
+
958
+ // src/providers/cli.ts
959
+ import { spawn } from "child_process";
960
+ function flatten(messages) {
961
+ const parts = [];
962
+ for (const m of messages) {
963
+ if (m.role === "assistant") parts.push(`Assistant: ${m.content}`);
964
+ else parts.push(m.content);
965
+ }
966
+ return parts.join("\n\n");
967
+ }
968
+ function runCli(command, args, input, timeoutMs, env) {
969
+ return new Promise((resolve2, reject) => {
970
+ let child;
971
+ try {
972
+ child = spawn(command, args, { env: { ...process.env, ...env } });
973
+ } catch (e) {
974
+ return reject(e);
975
+ }
976
+ let out = "";
977
+ let err = "";
978
+ const timer = setTimeout(() => {
979
+ child.kill("SIGKILL");
980
+ reject(new Error(`${command} timed out after ${timeoutMs} ms`));
981
+ }, timeoutMs);
982
+ child.stdout.on("data", (d) => out += d);
983
+ child.stderr.on("data", (d) => err += d);
984
+ child.on("error", (e) => {
985
+ clearTimeout(timer);
986
+ reject(e.code === "ENOENT" ? new Error(`"${command}" is not installed or not on your PATH.`) : e);
987
+ });
988
+ child.on("close", (code) => {
989
+ clearTimeout(timer);
990
+ if (code === 0) resolve2(out);
991
+ else reject(new Error(`${command} exited ${code}: ${err.trim().slice(0, 300)}`));
992
+ });
993
+ if (input !== null) child.stdin.write(input);
994
+ child.stdin.end();
995
+ });
996
+ }
997
+ var CliProvider = class {
998
+ name;
999
+ command;
1000
+ args;
1001
+ promptVia;
1002
+ parse;
1003
+ env;
1004
+ timeoutMs;
1005
+ constructor(opts) {
1006
+ this.command = opts.command;
1007
+ this.args = opts.args ?? [];
1008
+ this.promptVia = opts.promptVia ?? "arg";
1009
+ this.parse = opts.parse ?? ((s) => s.trim());
1010
+ this.env = opts.env;
1011
+ this.timeoutMs = opts.timeoutMs ?? 12e4;
1012
+ this.name = opts.label ?? `cli:${opts.command}`;
1013
+ }
1014
+ estimateTokens(messages) {
1015
+ return estimateTokens(messages);
1016
+ }
1017
+ async complete(req) {
1018
+ const prompt = flatten(req.messages);
1019
+ const args = this.promptVia === "arg" ? [...this.args, prompt] : this.args;
1020
+ const stdout = await runCli(this.command, args, this.promptVia === "stdin" ? prompt : null, this.timeoutMs, this.env);
1021
+ const content = this.parse(stdout);
1022
+ return {
1023
+ content,
1024
+ tokensIn: estimateTokens(req.messages),
1025
+ tokensOut: Math.ceil(content.length / 4),
1026
+ stopReason: "end"
1027
+ };
1028
+ }
1029
+ };
1030
+ function claudeCode(opts = {}) {
1031
+ return new CliProvider({ command: "claude", args: ["-p"], promptVia: "arg", label: "claude-code", ...opts });
1032
+ }
1033
+ function codexCli(opts = {}) {
1034
+ return new CliProvider({ command: "codex", args: ["exec"], promptVia: "arg", label: "codex", ...opts });
1035
+ }
1036
+
879
1037
  // src/index.ts
880
1038
  function buildDeps(config, serializable) {
881
1039
  const provider = config.provider;
@@ -908,22 +1066,27 @@ async function runWith(ctx, config) {
908
1066
  }
909
1067
  export {
910
1068
  AnthropicProvider,
1069
+ CliProvider,
911
1070
  ContextManager,
912
1071
  DEFAULT_CONFIG,
913
1072
  Executor,
914
1073
  FileStore,
915
1074
  MemoryStore,
1075
+ OpenAICompatProvider,
916
1076
  Planner,
917
1077
  Reflector,
918
1078
  ScriptedProvider,
919
1079
  ToolRegistry,
920
1080
  checkBudget,
1081
+ claudeCode,
1082
+ codexCli,
921
1083
  createContext,
922
1084
  defineTool,
923
1085
  estimateTokens,
924
1086
  fsTools,
925
1087
  genId,
926
1088
  nextPendingStep,
1089
+ ollama,
927
1090
  reply,
928
1091
  resolveSerializable,
929
1092
  resume,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-fable",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "The autonomous-agent harness that actually finishes long tasks — because it plans first, self-corrects every step, and survives crashes. The whole run lives in one serializable RunContext, checkpointed after every step, so it resumes exactly where it died. Model-agnostic, zero dependencies, deterministically testable.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",