@loopyjs/claude-code 0.1.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.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @loopyjs/claude-code
2
+
3
+ A loopy `ModelClient` backed by the **Claude Code CLI** in headless mode
4
+ (`claude -p`), authenticated by your Claude Pro/Max **subscription** instead of
5
+ an `ANTHROPIC_API_KEY`.
6
+
7
+ `claude -p` is constrained to a single tool-less turn, so it behaves like a
8
+ plain text completion. loopy keeps ownership of the reducer graph, the tool
9
+ loop, and replay — this client only fills the `complete()` contract, exactly
10
+ like `@loopyjs/anthropic`.
11
+
12
+ ```ts
13
+ import { defineLoopy, agent, io } from "@loopyjs/core";
14
+ import { claudeCode } from "@loopyjs/claude-code";
15
+
16
+ const sentiment = agent({
17
+ name: "sentiment",
18
+ model: "sub",
19
+ instructions:
20
+ 'Classify the sentiment of the user message. ' +
21
+ 'Respond with a single JSON object: {"label": "positive" | "negative" | "neutral"}.',
22
+ input: io<{ text: string }>(),
23
+ output: io<{ label: "positive" | "negative" | "neutral" }>(),
24
+ });
25
+
26
+ const rt = defineLoopy({
27
+ agents: { sentiment },
28
+ workflows: {},
29
+ deps: {},
30
+ models: { sub: claudeCode("opus") }, // runs on the subscription
31
+ });
32
+
33
+ await rt.run("sentiment", { text: "I love this API!" }); // → { label: "positive" }
34
+ ```
35
+
36
+ ## Scope & caveats
37
+
38
+ - **Tool-less nodes only.** Tools are disabled, so `complete()` never returns
39
+ `toolCalls`. Attach this model only to agents without tools (classifiers,
40
+ judges, extractors…). Pointing a tool-using agent at it stalls the loop.
41
+ - **Requires the `claude` CLI on PATH**, logged in via a Pro/Max subscription
42
+ (`claude` → `/status` should show a subscription login, not an API key). The
43
+ client strips `ANTHROPIC_API_KEY` from the child env so the subscription wins.
44
+ - **Terms of service.** A personal subscription is fine for local dev,
45
+ dogfooding, and internal tooling. Backing a service you offer to others
46
+ violates Anthropic's consumer terms — use organization API keys there.
47
+
48
+ ## API
49
+
50
+ ```ts
51
+ claudeCode(cliModel: string, opts?: { bin?: string; forceSubscription?: boolean }): ModelClient
52
+ ```
53
+
54
+ - `cliModel` — the `--model` value: an alias (`"opus"` | `"sonnet"` |
55
+ `"fable"` | `"haiku"`) or a full model ID.
56
+ - `opts.bin` — CLI binary path (default `"claude"`).
57
+ - `opts.forceSubscription` — strip `ANTHROPIC_API_KEY` from the child env
58
+ (default `true`).
@@ -0,0 +1,15 @@
1
+ import type { ModelClient } from "@loopyjs/core";
2
+ export interface ClaudeCodeOpts {
3
+ /** CLI binary; override if `claude` isn't on PATH. */
4
+ readonly bin?: string;
5
+ /** Strip ANTHROPIC_API_KEY from the child env so the subscription login wins. Default true. */
6
+ readonly forceSubscription?: boolean;
7
+ }
8
+ /**
9
+ * @param cliModel the `--model` value passed to the CLI: an alias ("opus" |
10
+ * "sonnet" | "fable" | "haiku") or a full ID. This is separate
11
+ * from `req.model`, which is loopy's registry key used to route
12
+ * to this client — same split as anthropic(modelId, …).
13
+ */
14
+ export declare function claudeCode(cliModel: string, opts?: ClaudeCodeOpts): ModelClient;
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,WAAW,EAAyC,MAAM,eAAe,CAAC;AAYxF,MAAM,WAAW,cAAc;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,+FAA+F;IAC/F,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;CACtC;AAgCD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,GAAE,cAAmB,GAAG,WAAW,CA6BnF"}
package/dist/index.js ADDED
@@ -0,0 +1,87 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // claudeCode() — a ModelClient backed by the Claude Code CLI in headless mode
3
+ // (`claude -p`), authenticated by the user's Pro/Max SUBSCRIPTION instead of an
4
+ // external ANTHROPIC_API_KEY.
5
+ //
6
+ // `claude -p` is constrained to a SINGLE tool-less turn, so it behaves like a
7
+ // plain text completion. loopy still owns the reducer graph, the tool loop, and
8
+ // replay — this client only fills the `complete()` contract, exactly like the
9
+ // @loopyjs/anthropic client does.
10
+ //
11
+ // ⚠️ Scope: because tools are disabled here, `complete()` never returns
12
+ // `toolCalls`. Attach this model ONLY to tool-less nodes (classifiers,
13
+ // judges, extractors…). A tool-using agent pointed at it stalls the loop.
14
+ //
15
+ // ⚠️ ToS: a personal subscription is fine for local dev / dogfooding / internal
16
+ // tooling. Backing a service you offer to others violates Anthropic's
17
+ // consumer terms — use org API keys there.
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ import { spawn } from "node:child_process";
20
+ // Model A takes a single prompt, so a multi-turn history is flattened with role
21
+ // labels. For the tool-less nodes this client is meant for, `messages` is
22
+ // usually just one user turn — the assistant/tool branches are defensive.
23
+ function flattenPrompt(msgs) {
24
+ return msgs
25
+ .map((m) => {
26
+ if (m.role === "user")
27
+ return m.content;
28
+ if (m.role === "assistant")
29
+ return `[assistant]\n${m.content}`;
30
+ return `[tool result]\n${m.content}`;
31
+ })
32
+ .join("\n\n");
33
+ }
34
+ function runClaude(prompt, args, opts) {
35
+ return new Promise((resolve, reject) => {
36
+ const env = { ...process.env };
37
+ if (opts.forceSubscription ?? true)
38
+ delete env.ANTHROPIC_API_KEY;
39
+ const child = spawn(opts.bin ?? "claude", [...args], { env });
40
+ let out = "";
41
+ let err = "";
42
+ child.stdout.on("data", (d) => (out += d.toString()));
43
+ child.stderr.on("data", (d) => (err += d.toString()));
44
+ child.on("error", reject);
45
+ child.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(`claude exited ${code}: ${err}`))));
46
+ // Prompt via stdin (not argv) — avoids ARG_MAX limits on long histories.
47
+ child.stdin.write(prompt);
48
+ child.stdin.end();
49
+ });
50
+ }
51
+ /**
52
+ * @param cliModel the `--model` value passed to the CLI: an alias ("opus" |
53
+ * "sonnet" | "fable" | "haiku") or a full ID. This is separate
54
+ * from `req.model`, which is loopy's registry key used to route
55
+ * to this client — same split as anthropic(modelId, …).
56
+ */
57
+ export function claudeCode(cliModel, opts = {}) {
58
+ return {
59
+ async complete(req) {
60
+ const args = [
61
+ "-p",
62
+ "--output-format",
63
+ "json",
64
+ "--model",
65
+ cliModel,
66
+ // Replace (not append) the system prompt so Claude Code's own coding-
67
+ // oriented system prompt doesn't leak into a plain completion.
68
+ ...(req.system ? ["--system-prompt", req.system] : []),
69
+ // Disable all tools so it's one turn, no agentic loop.
70
+ // req.tools is intentionally ignored: this model never runs tools.
71
+ "--allowedTools",
72
+ "",
73
+ ];
74
+ const stdout = await runClaude(flattenPrompt(req.messages), args, opts);
75
+ const raw = JSON.parse(stdout);
76
+ if (raw.is_error)
77
+ throw new Error(`claude -p returned error: ${raw.result}`);
78
+ return {
79
+ text: raw.result || undefined,
80
+ stopReason: raw.stop_reason ?? "end_turn",
81
+ usage: raw.usage
82
+ ? { inputTokens: raw.usage.input_tokens, outputTokens: raw.usage.output_tokens }
83
+ : undefined,
84
+ };
85
+ },
86
+ };
87
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@loopyjs/claude-code",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "loopy model adapter backed by the Claude Code CLI (headless `claude -p`, subscription auth)",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kmjnnhyk/loopy.git",
10
+ "directory": "packages/claude-code"
11
+ },
12
+ "homepage": "https://loopy.js.org",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "bun": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": ["dist", "src"],
24
+ "peerDependencies": {
25
+ "@loopyjs/core": "^0.1.0"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,104 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // claudeCode() — a ModelClient backed by the Claude Code CLI in headless mode
3
+ // (`claude -p`), authenticated by the user's Pro/Max SUBSCRIPTION instead of an
4
+ // external ANTHROPIC_API_KEY.
5
+ //
6
+ // `claude -p` is constrained to a SINGLE tool-less turn, so it behaves like a
7
+ // plain text completion. loopy still owns the reducer graph, the tool loop, and
8
+ // replay — this client only fills the `complete()` contract, exactly like the
9
+ // @loopyjs/anthropic client does.
10
+ //
11
+ // ⚠️ Scope: because tools are disabled here, `complete()` never returns
12
+ // `toolCalls`. Attach this model ONLY to tool-less nodes (classifiers,
13
+ // judges, extractors…). A tool-using agent pointed at it stalls the loop.
14
+ //
15
+ // ⚠️ ToS: a personal subscription is fine for local dev / dogfooding / internal
16
+ // tooling. Backing a service you offer to others violates Anthropic's
17
+ // consumer terms — use org API keys there.
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ import { spawn } from "node:child_process";
20
+ import type { ModelClient, ModelMsg, ModelRequest, ModelResponse } from "@loopyjs/core";
21
+
22
+ // The subset of `claude -p --output-format json` we consume. The CLI emits more
23
+ // fields (session_id, total_cost_usd, modelUsage, num_turns, …) — we only read
24
+ // what maps onto ModelResponse.
25
+ interface ClaudeCliResult {
26
+ readonly result: string;
27
+ readonly stop_reason?: string;
28
+ readonly is_error?: boolean;
29
+ readonly usage?: { readonly input_tokens: number; readonly output_tokens: number };
30
+ }
31
+
32
+ export interface ClaudeCodeOpts {
33
+ /** CLI binary; override if `claude` isn't on PATH. */
34
+ readonly bin?: string;
35
+ /** Strip ANTHROPIC_API_KEY from the child env so the subscription login wins. Default true. */
36
+ readonly forceSubscription?: boolean;
37
+ }
38
+
39
+ // Model A takes a single prompt, so a multi-turn history is flattened with role
40
+ // labels. For the tool-less nodes this client is meant for, `messages` is
41
+ // usually just one user turn — the assistant/tool branches are defensive.
42
+ function flattenPrompt(msgs: readonly ModelMsg[]): string {
43
+ return msgs
44
+ .map((m) => {
45
+ if (m.role === "user") return m.content;
46
+ if (m.role === "assistant") return `[assistant]\n${m.content}`;
47
+ return `[tool result]\n${m.content}`;
48
+ })
49
+ .join("\n\n");
50
+ }
51
+
52
+ function runClaude(prompt: string, args: readonly string[], opts: ClaudeCodeOpts): Promise<string> {
53
+ return new Promise((resolve, reject) => {
54
+ const env = { ...process.env };
55
+ if (opts.forceSubscription ?? true) delete env.ANTHROPIC_API_KEY;
56
+ const child = spawn(opts.bin ?? "claude", [...args], { env });
57
+ let out = "";
58
+ let err = "";
59
+ child.stdout.on("data", (d: Buffer) => (out += d.toString()));
60
+ child.stderr.on("data", (d: Buffer) => (err += d.toString()));
61
+ child.on("error", reject);
62
+ child.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(`claude exited ${code}: ${err}`))));
63
+ // Prompt via stdin (not argv) — avoids ARG_MAX limits on long histories.
64
+ child.stdin.write(prompt);
65
+ child.stdin.end();
66
+ });
67
+ }
68
+
69
+ /**
70
+ * @param cliModel the `--model` value passed to the CLI: an alias ("opus" |
71
+ * "sonnet" | "fable" | "haiku") or a full ID. This is separate
72
+ * from `req.model`, which is loopy's registry key used to route
73
+ * to this client — same split as anthropic(modelId, …).
74
+ */
75
+ export function claudeCode(cliModel: string, opts: ClaudeCodeOpts = {}): ModelClient {
76
+ return {
77
+ async complete(req: ModelRequest): Promise<ModelResponse> {
78
+ const args: string[] = [
79
+ "-p",
80
+ "--output-format",
81
+ "json",
82
+ "--model",
83
+ cliModel,
84
+ // Replace (not append) the system prompt so Claude Code's own coding-
85
+ // oriented system prompt doesn't leak into a plain completion.
86
+ ...(req.system ? ["--system-prompt", req.system] : []),
87
+ // Disable all tools so it's one turn, no agentic loop.
88
+ // req.tools is intentionally ignored: this model never runs tools.
89
+ "--allowedTools",
90
+ "",
91
+ ];
92
+ const stdout = await runClaude(flattenPrompt(req.messages), args, opts);
93
+ const raw = JSON.parse(stdout) as ClaudeCliResult;
94
+ if (raw.is_error) throw new Error(`claude -p returned error: ${raw.result}`);
95
+ return {
96
+ text: raw.result || undefined,
97
+ stopReason: raw.stop_reason ?? "end_turn",
98
+ usage: raw.usage
99
+ ? { inputTokens: raw.usage.input_tokens, outputTokens: raw.usage.output_tokens }
100
+ : undefined,
101
+ };
102
+ },
103
+ };
104
+ }