@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 +58 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/package.json +27 -0
- package/src/index.ts +104 -0
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`).
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|