cloding 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Carlos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # ⚡ cloding
2
+
3
+ **Claude Code with any model via OpenRouter.**
4
+
5
+ Claude Code costs $5/$25 per Mtok. Qwen 3 Coder costs $0.07/$0.30. That's 71x cheaper on input, 83x cheaper on output. You'd be an idiot not to use it at scale.
6
+
7
+ Cloding lets you run Claude Code — tools, file editing, terminal access, the whole thing — with any OpenRouter model. Same experience, fraction of the cost. Zero dependencies, zero overhead. It sets 4 env vars and spawns `claude`. That's it.
8
+
9
+ ```
10
+ npm install -g cloding
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ export OPENROUTER_API_KEY=sk-or-v1-your-key-here
17
+ cloding
18
+ ```
19
+
20
+ You're now running Claude Code with Qwen 3 Coder at **$0.07/Mtok input** instead of $5/Mtok.
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ cloding # Interactive session with Qwen (default)
26
+ cloding -m haiku # Use Claude Haiku 4.5
27
+ cloding -m sonnet # Use Claude Sonnet 4
28
+ cloding -m opus # Use Claude Opus 4.6
29
+ cloding -m deepseek # Use DeepSeek Coder V3
30
+ cloding -p "fix the login bug" # Non-interactive, single prompt
31
+ cloding --list-models # Show all models with pricing
32
+ cloding -m meta-llama/llama-4-scout # Any OpenRouter model ID works
33
+ ```
34
+
35
+ All Claude Code flags pass through:
36
+ ```bash
37
+ cloding --allowedTools Read,Write,Bash
38
+ ```
39
+
40
+ ## Models & Cost
41
+
42
+ | Shortcut | Model | Input $/Mtok | Output $/Mtok | vs Claude Code |
43
+ |----------|-------|-------------|---------------|----------------|
44
+ | `qwen` | Qwen 3 Coder | $0.07 | $0.30 | **71x cheaper** |
45
+ | `deepseek` | DeepSeek Coder V3 | $0.14 | $0.28 | **36x cheaper** |
46
+ | `haiku` | Claude Haiku 4.5 | $0.80 | $4.00 | 6x cheaper |
47
+ | `gemini` | Gemini 2.5 Pro | $1.25 | $10.00 | 4x cheaper |
48
+ | `sonnet` | Claude Sonnet 4 | $3.00 | $15.00 | 1.7x cheaper |
49
+ | `opus` | Claude Opus 4.6 | $15.00 | $75.00 | 3x more expensive |
50
+
51
+ > A 30-minute coding session that costs ~$5 with Claude Code costs ~$0.07 with Qwen. Same tools, same workflow.
52
+
53
+ ## Docker Mode
54
+
55
+ When you run Claude Code, it has full access to your machine — your files, your terminal, your `.env`, your SSH keys, everything. It's an LLM agent with root-level power and nothing about that is secure. Nobody seems to care that these models are looking at all your stuff and running wild.
56
+
57
+ Docker mode puts it in a box. The model can only touch the workspace you mount and nothing else. It can't read your secrets, wreck your system, or do anything outside the container. Non-root user, no access to your host filesystem, network isolated.
58
+
59
+ ```bash
60
+ cloding docker build # Build image (one-time)
61
+ cloding docker shell # Interactive session
62
+ cloding docker run "fix the bug" # Run a prompt
63
+ cloding docker run -m haiku "prompt" # Specific model
64
+ cloding docker run -w ./myproject # Mount workspace
65
+ cloding docker run --memory 4g --cpus 2 # Resource limits
66
+ cloding docker status # Show running containers
67
+ cloding docker stop # Stop all containers
68
+ cloding docker clean # Remove stopped containers
69
+ ```
70
+
71
+ Your workspace gets mounted read-write at `/workspace` inside the container. That's the only thing the model can touch.
72
+
73
+ ## Pipeline Mode
74
+
75
+ Multi-stage coding pipeline: Plan → Explore → Code → Review, with parallel fan-out. Assign different models to different stages — Opus for planning, Qwen for coding.
76
+
77
+ ```bash
78
+ cd pipeline && pip install -e . # Requires Python 3.11+
79
+ cloding pipeline "Add auth" --workspace ./myapp --no-docker
80
+ cloding pipeline -c configs/qwen-fanout.yaml "Refactor the DB layer"
81
+ ```
82
+
83
+ 8 pipeline configs included: default, quick, fan-out, opus-plan+qwen-code, human-in-the-loop, and more.
84
+
85
+ ## Configuration
86
+
87
+ ```bash
88
+ export OPENROUTER_API_KEY=sk-or-v1-your-key-here # Required
89
+ export CLODING_DEFAULT_MODEL=qwen # Optional (default: qwen)
90
+ ```
91
+
92
+ Add custom model shortcuts by editing `models.json`.
93
+
94
+ ## Prerequisites
95
+
96
+ - **Node.js 18+**
97
+ - **Claude Code**: `npm install -g @anthropic-ai/claude-code`
98
+ - **OpenRouter API key**: [openrouter.ai/keys](https://openrouter.ai/keys)
99
+
100
+ ## License
101
+
102
+ MIT
package/bin/cloding.js ADDED
@@ -0,0 +1,871 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cloding — Claude Code with any model via OpenRouter.
5
+ *
6
+ * Sets the right env vars and spawns `claude` so you can use
7
+ * Qwen, Haiku, Sonnet, or any OpenRouter model at a fraction of the cost.
8
+ *
9
+ * Usage:
10
+ * cloding # Interactive with default model (Qwen)
11
+ * cloding -m haiku # Use a different model
12
+ * cloding -p "fix the bug" # Non-interactive single prompt
13
+ * cloding --list-models # Show available models
14
+ * cloding pipeline "Add auth" # Run the full pipeline
15
+ * cloding docker build # Build Docker image
16
+ * cloding docker run "prompt" # Run in a Docker container
17
+ */
18
+
19
+ const { spawn, execSync } = require("child_process");
20
+ const path = require("path");
21
+ const fs = require("fs");
22
+
23
+ // ──────────────────────────────────────────────
24
+ // .env loader (no dependencies)
25
+ // ──────────────────────────────────────────────
26
+ function loadEnvFile() {
27
+ // Search for .env in: cwd, then cloding package root
28
+ const candidates = [
29
+ path.join(process.cwd(), ".env"),
30
+ path.join(__dirname, "..", ".env"),
31
+ ];
32
+
33
+ for (const envPath of candidates) {
34
+ if (fs.existsSync(envPath)) {
35
+ const lines = fs.readFileSync(envPath, "utf8").split("\n");
36
+ for (const line of lines) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed || trimmed.startsWith("#")) continue;
39
+ const eqIdx = trimmed.indexOf("=");
40
+ if (eqIdx === -1) continue;
41
+ const key = trimmed.slice(0, eqIdx).trim();
42
+ let val = trimmed.slice(eqIdx + 1).trim();
43
+ // Strip surrounding quotes
44
+ if (
45
+ (val.startsWith('"') && val.endsWith('"')) ||
46
+ (val.startsWith("'") && val.endsWith("'"))
47
+ ) {
48
+ val = val.slice(1, -1);
49
+ }
50
+ // Don't override existing env vars
51
+ if (!process.env[key]) {
52
+ process.env[key] = val;
53
+ }
54
+ }
55
+ return envPath;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // ──────────────────────────────────────────────
62
+ // Model registry
63
+ // ──────────────────────────────────────────────
64
+ function loadModels() {
65
+ const modelsPath = path.join(__dirname, "..", "models.json");
66
+ try {
67
+ return JSON.parse(fs.readFileSync(modelsPath, "utf8"));
68
+ } catch {
69
+ console.error("Error: Could not load models.json");
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ // ──────────────────────────────────────────────
75
+ // Arg parsing (lightweight, no dependencies)
76
+ // ──────────────────────────────────────────────
77
+ function parseArgs(argv) {
78
+ const args = {
79
+ model: null,
80
+ prompt: null,
81
+ listModels: false,
82
+ version: false,
83
+ help: false,
84
+ pipeline: false,
85
+ pipelineArgs: [],
86
+ docker: false,
87
+ dockerSubcommand: null,
88
+ dockerArgs: [],
89
+ claudeArgs: [], // passthrough args for claude CLI
90
+ };
91
+
92
+ let i = 0;
93
+ while (i < argv.length) {
94
+ const arg = argv[i];
95
+
96
+ if (arg === "pipeline") {
97
+ args.pipeline = true;
98
+ args.pipelineArgs = argv.slice(i + 1);
99
+ break;
100
+ }
101
+
102
+ if (arg === "docker") {
103
+ args.docker = true;
104
+ args.dockerSubcommand = argv[i + 1] || "help";
105
+ args.dockerArgs = argv.slice(i + 2);
106
+ break;
107
+ }
108
+
109
+ switch (arg) {
110
+ case "-m":
111
+ case "--model":
112
+ if (i + 1 >= argv.length) {
113
+ console.error("Error: --model requires a value.\n Usage: cloding -m <model>");
114
+ process.exit(1);
115
+ }
116
+ args.model = argv[++i];
117
+ break;
118
+ case "-p":
119
+ case "--prompt":
120
+ if (i + 1 >= argv.length) {
121
+ console.error("Error: --prompt requires a value.\n Usage: cloding -p \"your prompt\"");
122
+ process.exit(1);
123
+ }
124
+ args.prompt = argv[++i];
125
+ break;
126
+ case "--list-models":
127
+ args.listModels = true;
128
+ break;
129
+ case "-v":
130
+ case "--version":
131
+ args.version = true;
132
+ break;
133
+ case "-h":
134
+ case "--help":
135
+ args.help = true;
136
+ break;
137
+ default:
138
+ args.claudeArgs.push(arg);
139
+ break;
140
+ }
141
+ i++;
142
+ }
143
+
144
+ return args;
145
+ }
146
+
147
+ // ──────────────────────────────────────────────
148
+ // Display helpers
149
+ // ──────────────────────────────────────────────
150
+ function printVersion() {
151
+ const pkg = JSON.parse(
152
+ fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
153
+ );
154
+ console.log(`cloding v${pkg.version}`);
155
+ }
156
+
157
+ function printHelp() {
158
+ console.log(`
159
+ cloding — Claude Code with any model via OpenRouter
160
+
161
+ USAGE:
162
+ cloding Interactive session (default: Qwen)
163
+ cloding -m haiku Use a specific model
164
+ cloding -p "fix the bug" Non-interactive single prompt
165
+ cloding --list-models Show available models and costs
166
+ cloding pipeline "Add auth" Run the full pipeline (requires Python)
167
+ cloding docker <command> Docker container management
168
+
169
+ OPTIONS:
170
+ -m, --model <name|id> Model shortcut or full OpenRouter model ID
171
+ -p, --prompt <text> Run non-interactively with a single prompt
172
+ --list-models Show available models with pricing
173
+ -v, --version Show version
174
+ -h, --help Show this help
175
+
176
+ DOCKER COMMANDS:
177
+ cloding docker build Build the cloding Docker image
178
+ cloding docker run "prompt" Run a prompt in a container
179
+ cloding docker shell Interactive claude session in a container
180
+ cloding docker status Show running cloding containers
181
+ cloding docker stop Stop all cloding containers
182
+ cloding docker clean Remove stopped cloding containers
183
+ cloding docker help Show Docker help
184
+
185
+ MODELS (shortcuts):
186
+ qwen Qwen 3 Coder $0.07/$0.30 per Mtok (default, ~250x cheaper)
187
+ haiku Claude Haiku 4.5 $0.80/$4.00 per Mtok
188
+ sonnet Claude Sonnet 4 $3.00/$15.00 per Mtok
189
+ opus Claude Opus 4.6 $15.00/$75.00 per Mtok
190
+ deepseek DeepSeek Coder V3 $0.14/$0.28 per Mtok
191
+ gemini Gemini 2.5 Pro $1.25/$10.00 per Mtok
192
+
193
+ Or pass any OpenRouter model ID:
194
+ cloding -m meta-llama/llama-4-scout
195
+
196
+ ENVIRONMENT:
197
+ OPENROUTER_API_KEY Required. Your OpenRouter API key.
198
+ CLODING_DEFAULT_MODEL Optional. Default model shortcut (default: qwen).
199
+
200
+ EXAMPLES:
201
+ cloding # Start coding with Qwen ($0.07/Mtok)
202
+ cloding -m haiku # Quick task with Haiku
203
+ cloding -m opus -p "Review arch" # One-shot with Opus
204
+ cloding docker build # Build Docker image first
205
+ cloding docker run "Fix the bug" # Run isolated in Docker
206
+ cloding docker shell # Interactive Docker session
207
+ cloding pipeline "Add auth" -c configs/qwen-fanout.yaml
208
+
209
+ All other arguments are passed through to claude.
210
+ `);
211
+ }
212
+
213
+ function printModels(models) {
214
+ console.log("\nAvailable models:\n");
215
+ console.log(
216
+ " Shortcut Model Input $/Mtok Output $/Mtok vs Opus"
217
+ );
218
+ console.log(
219
+ " ─────────── ──────────────────────── ────────────── ────────────── ───────"
220
+ );
221
+
222
+ // Opus cost for comparison
223
+ const opusOut = models.opus ? models.opus.out : 75.0;
224
+
225
+ for (const [shortcut, m] of Object.entries(models)) {
226
+ const savings = opusOut / m.out;
227
+ const savingsStr =
228
+ shortcut === "opus" ? " baseline" : ` ${savings.toFixed(0)}x cheaper`;
229
+ console.log(
230
+ ` ${shortcut.padEnd(11)} ${m.name.padEnd(24)} $${m.in.toFixed(2).padStart(6)} $${m.out.toFixed(2).padStart(6)}${savingsStr}`
231
+ );
232
+ }
233
+
234
+ console.log(
235
+ "\n Use: cloding -m <shortcut> or cloding -m <openrouter-model-id>\n"
236
+ );
237
+ }
238
+
239
+ // ──────────────────────────────────────────────
240
+ // Resolve model to OpenRouter model ID
241
+ // ──────────────────────────────────────────────
242
+ function resolveModel(modelArg, models) {
243
+ if (!modelArg) {
244
+ // Use default from env, or fall back to qwen
245
+ const defaultModel = process.env.CLODING_DEFAULT_MODEL || "qwen";
246
+ return resolveModel(defaultModel, models);
247
+ }
248
+
249
+ // Check shortcuts first
250
+ if (models[modelArg.toLowerCase()]) {
251
+ return models[modelArg.toLowerCase()];
252
+ }
253
+
254
+ // Assume it's a full OpenRouter model ID (e.g. "meta-llama/llama-4-scout")
255
+ return {
256
+ id: modelArg,
257
+ name: modelArg,
258
+ in: 0,
259
+ out: 0,
260
+ description: "Custom model",
261
+ };
262
+ }
263
+
264
+ // ──────────────────────────────────────────────
265
+ // Docker helpers
266
+ // ──────────────────────────────────────────────
267
+ const DOCKER_IMAGE = "cloding:latest";
268
+ const DOCKER_NETWORK = "cloding-net";
269
+
270
+ function dockerAvailable() {
271
+ try {
272
+ execSync("docker --version", { stdio: "ignore" });
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ function dockerImageExists() {
280
+ try {
281
+ // Safe: no user input in this command, just a constant image name
282
+ const result = execSync(`docker images -q ${DOCKER_IMAGE}`, {
283
+ encoding: "utf8",
284
+ });
285
+ return result.trim().length > 0;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ function getDockerfilePath() {
292
+ // Check relative to package: pipeline/docker/Dockerfile
293
+ const bundled = path.join(__dirname, "..", "pipeline", "docker");
294
+ if (fs.existsSync(path.join(bundled, "Dockerfile"))) {
295
+ return bundled;
296
+ }
297
+ // Check cwd
298
+ const cwdDocker = path.join(process.cwd(), "pipeline", "docker");
299
+ if (fs.existsSync(path.join(cwdDocker, "Dockerfile"))) {
300
+ return cwdDocker;
301
+ }
302
+ return null;
303
+ }
304
+
305
+ function ensureNetwork() {
306
+ try {
307
+ // Safe: constant network name, no user input. Uses spawnSync for safety.
308
+ const result = require("child_process").spawnSync(
309
+ "docker", ["network", "create", DOCKER_NETWORK],
310
+ { stdio: "ignore" }
311
+ );
312
+ // Ignore errors — network may already exist
313
+ } catch {
314
+ // Network already exists, that's fine
315
+ }
316
+ }
317
+
318
+ function printDockerHelp() {
319
+ console.log(`
320
+ cloding docker — Run Claude Code in isolated Docker containers
321
+
322
+ COMMANDS:
323
+ cloding docker build Build the cloding Docker image
324
+ cloding docker run "your prompt here" Run a prompt in a fresh container
325
+ cloding docker run -m haiku "prompt" Run with a specific model
326
+ cloding docker shell Interactive claude session in Docker
327
+ cloding docker shell -m sonnet Interactive with specific model
328
+ cloding docker status Show running cloding containers
329
+ cloding docker stop Stop all running cloding containers
330
+ cloding docker clean Remove all stopped cloding containers
331
+ cloding docker help Show this help
332
+
333
+ OPTIONS (for run/shell):
334
+ -m, --model <name> Model shortcut or OpenRouter ID (default: qwen)
335
+ -w, --workspace <path> Mount a local directory as /workspace (default: cwd)
336
+ --memory <limit> Container memory limit (default: 2g)
337
+ --cpus <limit> Container CPU limit (default: 1.0)
338
+ --name <name> Custom container name
339
+ --no-rm Don't auto-remove container on exit
340
+
341
+ EXAMPLES:
342
+ cloding docker build
343
+ cloding docker run "Add error handling to src/api.js"
344
+ cloding docker run -m haiku -w ./myproject "Fix the tests"
345
+ cloding docker shell -w /home/user/code
346
+ cloding docker status
347
+ cloding docker stop
348
+
349
+ NOTES:
350
+ - Each container gets its own isolated environment
351
+ - Workspace is mounted at /workspace inside the container
352
+ - Containers auto-remove on exit (use --no-rm to keep them)
353
+ - Containers run as non-root user 'coder' for security
354
+ - Resource limits: 2GB RAM, 1 CPU core (configurable)
355
+ `);
356
+ }
357
+
358
+ function dockerBuild() {
359
+ const dockerDir = getDockerfilePath();
360
+ if (!dockerDir) {
361
+ console.error(
362
+ "Error: Dockerfile not found.\n\n" +
363
+ "Expected at: pipeline/docker/Dockerfile\n" +
364
+ "Make sure you have the full cloding package with Docker support.\n"
365
+ );
366
+ process.exit(1);
367
+ }
368
+
369
+ console.log(`\x1b[36m⚡ cloding\x1b[0m Building Docker image: ${DOCKER_IMAGE}`);
370
+ console.log(` Dockerfile: ${path.join(dockerDir, "Dockerfile")}\n`);
371
+
372
+ const child = spawn("docker", ["build", "-t", DOCKER_IMAGE, dockerDir], {
373
+ stdio: "inherit",
374
+ });
375
+
376
+ child.on("exit", (code) => {
377
+ if (code === 0) {
378
+ console.log(
379
+ `\n\x1b[32m✓ Image ${DOCKER_IMAGE} built successfully!\x1b[0m`
380
+ );
381
+ console.log(" Run: cloding docker shell");
382
+ console.log(' Run: cloding docker run "your prompt here"\n');
383
+ } else {
384
+ console.error(`\n\x1b[31m✗ Build failed (exit code ${code})\x1b[0m`);
385
+ }
386
+ process.exit(code ?? 0);
387
+ });
388
+
389
+ child.on("error", (err) => {
390
+ console.error(`Error: ${err.message}`);
391
+ process.exit(1);
392
+ });
393
+ }
394
+
395
+ function dockerRun(dockerArgs, models, interactive) {
396
+ // Parse docker run/shell args
397
+ let modelArg = null;
398
+ let workspace = process.cwd();
399
+ let memory = "2g";
400
+ let cpus = "1.0";
401
+ let containerName = null;
402
+ let autoRemove = true;
403
+ let prompt = null;
404
+ const extraClaudeArgs = [];
405
+
406
+ let i = 0;
407
+ while (i < dockerArgs.length) {
408
+ const arg = dockerArgs[i];
409
+ switch (arg) {
410
+ case "-m":
411
+ case "--model":
412
+ if (i + 1 >= dockerArgs.length) { console.error("Error: --model requires a value."); process.exit(1); }
413
+ modelArg = dockerArgs[++i];
414
+ break;
415
+ case "-w":
416
+ case "--workspace":
417
+ if (i + 1 >= dockerArgs.length) { console.error("Error: --workspace requires a path."); process.exit(1); }
418
+ workspace = path.resolve(dockerArgs[++i]);
419
+ break;
420
+ case "--memory":
421
+ if (i + 1 >= dockerArgs.length) { console.error("Error: --memory requires a value (e.g. 2g)."); process.exit(1); }
422
+ memory = dockerArgs[++i];
423
+ break;
424
+ case "--cpus":
425
+ if (i + 1 >= dockerArgs.length) { console.error("Error: --cpus requires a value (e.g. 1.0)."); process.exit(1); }
426
+ cpus = dockerArgs[++i];
427
+ break;
428
+ case "--name":
429
+ if (i + 1 >= dockerArgs.length) { console.error("Error: --name requires a value."); process.exit(1); }
430
+ containerName = dockerArgs[++i];
431
+ break;
432
+ case "--no-rm":
433
+ autoRemove = false;
434
+ break;
435
+ default:
436
+ // First unrecognized non-flag arg is the prompt (for 'run' mode)
437
+ if (!interactive && !prompt && !arg.startsWith("-")) {
438
+ prompt = arg;
439
+ } else {
440
+ extraClaudeArgs.push(arg);
441
+ }
442
+ break;
443
+ }
444
+ i++;
445
+ }
446
+
447
+ // Validate
448
+ if (!interactive && !prompt) {
449
+ console.error(
450
+ 'Error: No prompt provided.\n\n' +
451
+ ' Usage: cloding docker run "your prompt here"\n'
452
+ );
453
+ process.exit(1);
454
+ }
455
+
456
+ if (!dockerImageExists()) {
457
+ console.error(
458
+ `Error: Docker image '${DOCKER_IMAGE}' not found.\n\n` +
459
+ " Build it first: cloding docker build\n"
460
+ );
461
+ process.exit(1);
462
+ }
463
+
464
+ const apiKey = process.env.OPENROUTER_API_KEY;
465
+ if (!apiKey) {
466
+ console.error(
467
+ "Error: OPENROUTER_API_KEY not set.\n\n" +
468
+ "Get your key at https://openrouter.ai/keys\n" +
469
+ "Then: export OPENROUTER_API_KEY=sk-or-v1-...\n"
470
+ );
471
+ process.exit(1);
472
+ }
473
+
474
+ // Resolve model
475
+ const model = resolveModel(modelArg, models);
476
+
477
+ // Validate workspace exists
478
+ if (!fs.existsSync(workspace)) {
479
+ console.error(`Error: Workspace directory not found: ${workspace}`);
480
+ process.exit(1);
481
+ }
482
+
483
+ // Ensure network exists
484
+ ensureNetwork();
485
+
486
+ // Generate container name if not provided
487
+ if (!containerName) {
488
+ const suffix = Date.now().toString(36);
489
+ containerName = `cloding-${interactive ? "shell" : "run"}-${suffix}`;
490
+ }
491
+
492
+ // Build docker command — uses spawn with argument array (safe, no shell injection)
493
+ const cmd = ["docker", "run"];
494
+
495
+ if (interactive) {
496
+ cmd.push("-it");
497
+ }
498
+
499
+ if (autoRemove) {
500
+ cmd.push("--rm");
501
+ }
502
+
503
+ cmd.push(
504
+ "--name", containerName,
505
+ "--network", DOCKER_NETWORK,
506
+ "--memory", memory,
507
+ "--cpus", cpus,
508
+ "-v", `${workspace}:/workspace`,
509
+ "-e", `ANTHROPIC_BASE_URL=https://openrouter.ai/api`,
510
+ "-e", `ANTHROPIC_AUTH_TOKEN=${apiKey}`,
511
+ "-e", `ANTHROPIC_API_KEY=`,
512
+ "-e", `ANTHROPIC_MODEL=${model.id}`,
513
+ "-e", `CLAUDECODE=`,
514
+ DOCKER_IMAGE
515
+ );
516
+
517
+ // Add claude args
518
+ if (!interactive && prompt) {
519
+ cmd.push("-p", prompt);
520
+ }
521
+ cmd.push(...extraClaudeArgs);
522
+
523
+ // Print banner
524
+ const costInfo =
525
+ model.in > 0 ? ` ($${model.in}/$${model.out} per Mtok)` : "";
526
+ console.log(
527
+ `\x1b[36m⚡ cloding docker\x1b[0m → ${model.name}${costInfo}`
528
+ );
529
+ console.log(` Container: ${containerName}`);
530
+ console.log(` Workspace: ${workspace} → /workspace`);
531
+ console.log(` Resources: ${memory} RAM, ${cpus} CPUs`);
532
+
533
+ if (model.in > 0 && models.opus) {
534
+ const savings = (models.opus.out / model.out).toFixed(0);
535
+ if (savings > 1) {
536
+ console.log(` \x1b[32m${savings}x cheaper than Opus\x1b[0m`);
537
+ }
538
+ }
539
+ console.log("");
540
+
541
+ // Spawn docker — uses argument array (no shell interpretation)
542
+ const child = spawn(cmd[0], cmd.slice(1), {
543
+ stdio: "inherit",
544
+ });
545
+
546
+ child.on("exit", (code) => process.exit(code ?? 0));
547
+ child.on("error", (err) => {
548
+ console.error(`Error launching Docker: ${err.message}`);
549
+ console.error("Make sure Docker is installed and running.");
550
+ process.exit(1);
551
+ });
552
+ }
553
+
554
+ function dockerStatus() {
555
+ console.log("\x1b[36m⚡ cloding\x1b[0m Running containers:\n");
556
+
557
+ try {
558
+ // Safe: uses spawnSync with argument array, no shell injection possible
559
+ const result = require("child_process").spawnSync(
560
+ "docker",
561
+ ["ps", "--filter", "name=cloding", "--format", "table {{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}"],
562
+ { encoding: "utf8" }
563
+ );
564
+
565
+ if (result.stdout && result.stdout.trim()) {
566
+ console.log(result.stdout);
567
+ } else {
568
+ console.log(" No running cloding containers.\n");
569
+ console.log(" Start one with:");
570
+ console.log(' cloding docker run "your prompt"');
571
+ console.log(" cloding docker shell\n");
572
+ }
573
+ } catch {
574
+ console.error(
575
+ "Error: Could not list containers. Is Docker running?"
576
+ );
577
+ process.exit(1);
578
+ }
579
+ }
580
+
581
+ function dockerStop() {
582
+ console.log("\x1b[36m⚡ cloding\x1b[0m Stopping all cloding containers...\n");
583
+
584
+ try {
585
+ // Safe: uses spawnSync with argument array
586
+ const result = require("child_process").spawnSync(
587
+ "docker",
588
+ ["ps", "-q", "--filter", "name=cloding"],
589
+ { encoding: "utf8" }
590
+ );
591
+
592
+ const ids = (result.stdout || "")
593
+ .trim()
594
+ .split("\n")
595
+ .filter((id) => id);
596
+
597
+ if (ids.length === 0) {
598
+ console.log(" No running cloding containers to stop.\n");
599
+ return;
600
+ }
601
+
602
+ for (const id of ids) {
603
+ try {
604
+ const inspect = require("child_process").spawnSync(
605
+ "docker", ["inspect", "--format", "{{.Name}}", id],
606
+ { encoding: "utf8" }
607
+ );
608
+ const name = (inspect.stdout || id).trim().replace(/^\//, "");
609
+
610
+ require("child_process").spawnSync(
611
+ "docker", ["stop", "-t", "5", id],
612
+ { stdio: "ignore" }
613
+ );
614
+ console.log(` \x1b[32m✓\x1b[0m Stopped: ${name}`);
615
+ } catch {
616
+ console.log(` \x1b[31m✗\x1b[0m Failed to stop: ${id}`);
617
+ }
618
+ }
619
+ console.log(`\n Stopped ${ids.length} container(s).\n`);
620
+ } catch {
621
+ console.error("Error: Could not stop containers. Is Docker running?");
622
+ process.exit(1);
623
+ }
624
+ }
625
+
626
+ function dockerClean() {
627
+ console.log(
628
+ "\x1b[36m⚡ cloding\x1b[0m Cleaning up stopped cloding containers...\n"
629
+ );
630
+
631
+ try {
632
+ // Safe: uses spawnSync with argument array
633
+ const result = require("child_process").spawnSync(
634
+ "docker",
635
+ ["ps", "-aq", "--filter", "name=cloding", "--filter", "status=exited"],
636
+ { encoding: "utf8" }
637
+ );
638
+
639
+ const ids = (result.stdout || "")
640
+ .trim()
641
+ .split("\n")
642
+ .filter((id) => id);
643
+
644
+ if (ids.length === 0) {
645
+ console.log(" No stopped cloding containers to clean.\n");
646
+ return;
647
+ }
648
+
649
+ for (const id of ids) {
650
+ try {
651
+ const inspect = require("child_process").spawnSync(
652
+ "docker", ["inspect", "--format", "{{.Name}}", id],
653
+ { encoding: "utf8" }
654
+ );
655
+ const name = (inspect.stdout || id).trim().replace(/^\//, "");
656
+
657
+ require("child_process").spawnSync(
658
+ "docker", ["rm", id],
659
+ { stdio: "ignore" }
660
+ );
661
+ console.log(` \x1b[32m✓\x1b[0m Removed: ${name}`);
662
+ } catch {
663
+ console.log(` \x1b[31m✗\x1b[0m Failed to remove: ${id}`);
664
+ }
665
+ }
666
+ console.log(`\n Cleaned ${ids.length} container(s).\n`);
667
+ } catch {
668
+ console.error(
669
+ "Error: Could not clean containers. Is Docker running?"
670
+ );
671
+ process.exit(1);
672
+ }
673
+ }
674
+
675
+ // ──────────────────────────────────────────────
676
+ // Docker dispatcher
677
+ // ──────────────────────────────────────────────
678
+ function handleDocker(args) {
679
+ if (!dockerAvailable()) {
680
+ console.error(
681
+ "Error: Docker not found.\n\n" +
682
+ "Install Docker Desktop:\n" +
683
+ " https://docs.docker.com/get-docker/\n"
684
+ );
685
+ process.exit(1);
686
+ }
687
+
688
+ const models = loadModels();
689
+
690
+ switch (args.dockerSubcommand) {
691
+ case "build":
692
+ dockerBuild();
693
+ break;
694
+ case "run":
695
+ dockerRun(args.dockerArgs, models, false);
696
+ break;
697
+ case "shell":
698
+ dockerRun(args.dockerArgs, models, true);
699
+ break;
700
+ case "status":
701
+ case "ps":
702
+ dockerStatus();
703
+ break;
704
+ case "stop":
705
+ dockerStop();
706
+ break;
707
+ case "clean":
708
+ case "cleanup":
709
+ case "prune":
710
+ dockerClean();
711
+ break;
712
+ case "help":
713
+ case "--help":
714
+ case "-h":
715
+ printDockerHelp();
716
+ break;
717
+ default:
718
+ console.error(
719
+ `Unknown docker command: ${args.dockerSubcommand}\n`
720
+ );
721
+ printDockerHelp();
722
+ process.exit(1);
723
+ }
724
+ }
725
+
726
+ // ──────────────────────────────────────────────
727
+ // Main
728
+ // ──────────────────────────────────────────────
729
+ function main() {
730
+ // Load .env
731
+ loadEnvFile();
732
+
733
+ // Parse args (skip node and script path)
734
+ const args = parseArgs(process.argv.slice(2));
735
+
736
+ if (args.version) {
737
+ printVersion();
738
+ process.exit(0);
739
+ }
740
+
741
+ if (args.help) {
742
+ printHelp();
743
+ process.exit(0);
744
+ }
745
+
746
+ const models = loadModels();
747
+
748
+ if (args.listModels) {
749
+ printModels(models);
750
+ process.exit(0);
751
+ }
752
+
753
+ // ── Docker mode ──
754
+ if (args.docker) {
755
+ handleDocker(args);
756
+ return;
757
+ }
758
+
759
+ // Validate API key
760
+ const apiKey = process.env.OPENROUTER_API_KEY;
761
+ if (!apiKey) {
762
+ console.error(
763
+ "Error: OPENROUTER_API_KEY not set.\n\n" +
764
+ "Get your key at https://openrouter.ai/keys\n" +
765
+ "Then either:\n" +
766
+ " 1. Create a .env file: OPENROUTER_API_KEY=sk-or-v1-...\n" +
767
+ " 2. Set it: export OPENROUTER_API_KEY=sk-or-v1-...\n"
768
+ );
769
+ process.exit(1);
770
+ }
771
+
772
+ // ── Pipeline mode ──
773
+ if (args.pipeline) {
774
+ const pipelineDir = path.join(__dirname, "..", "pipeline");
775
+ if (!fs.existsSync(pipelineDir)) {
776
+ console.error(
777
+ "Error: Pipeline not found. Install the full cloding package with Python support."
778
+ );
779
+ process.exit(1);
780
+ }
781
+
782
+ const pythonArgs = ["-m", "osq", ...args.pipelineArgs];
783
+ console.log(`Running pipeline: python ${pythonArgs.join(" ")}`);
784
+
785
+ const child = spawn("python", pythonArgs, {
786
+ cwd: pipelineDir,
787
+ stdio: "inherit",
788
+ env: { ...process.env },
789
+ });
790
+
791
+ child.on("exit", (code) => process.exit(code ?? 0));
792
+ child.on("error", (err) => {
793
+ console.error(`Error launching pipeline: ${err.message}`);
794
+ console.error("Make sure Python 3.11+ is installed.");
795
+ process.exit(1);
796
+ });
797
+ return;
798
+ }
799
+
800
+ // ── Simple mode: launch claude with OpenRouter ──
801
+ const model = resolveModel(args.model, models);
802
+
803
+ // Build env for claude
804
+ const claudeEnv = { ...process.env };
805
+ claudeEnv.ANTHROPIC_BASE_URL = "https://openrouter.ai/api";
806
+ claudeEnv.ANTHROPIC_AUTH_TOKEN = apiKey;
807
+ claudeEnv.ANTHROPIC_API_KEY = "";
808
+ claudeEnv.ANTHROPIC_MODEL = model.id;
809
+
810
+ // Don't inherit CLAUDECODE — prevents "cannot launch inside another session" error
811
+ delete claudeEnv.CLAUDECODE;
812
+
813
+ // Build claude args
814
+ const claudeArgs = [...args.claudeArgs];
815
+ if (args.prompt) {
816
+ claudeArgs.push("-p", args.prompt);
817
+ }
818
+
819
+ // Print banner
820
+ const shortcut = args.model || process.env.CLODING_DEFAULT_MODEL || "qwen";
821
+ const costInfo =
822
+ model.in > 0
823
+ ? ` ($${model.in}/$${model.out} per Mtok)`
824
+ : "";
825
+ console.log(`\x1b[36m⚡ cloding\x1b[0m → ${model.name}${costInfo}`);
826
+
827
+ if (model.in > 0 && models.opus) {
828
+ const savings = (models.opus.out / model.out).toFixed(0);
829
+ if (savings > 1) {
830
+ console.log(`\x1b[32m ${savings}x cheaper than Opus\x1b[0m`);
831
+ }
832
+ }
833
+ console.log("");
834
+
835
+ // Launch claude
836
+ // On Windows, npm globals are .cmd shims that need shell resolution.
837
+ // We use spawn with shell:true but pass empty args array to avoid the
838
+ // DEP0190 deprecation — instead the args are baked into the command string.
839
+ const isWin = process.platform === "win32";
840
+ let child;
841
+ if (isWin) {
842
+ // Build full command string for shell execution
843
+ const parts = ["claude", ...claudeArgs.map((a) => `"${a}"`)];
844
+ child = spawn(parts.join(" "), {
845
+ stdio: "inherit",
846
+ env: claudeEnv,
847
+ shell: true,
848
+ });
849
+ } else {
850
+ child = spawn("claude", claudeArgs, {
851
+ stdio: "inherit",
852
+ env: claudeEnv,
853
+ });
854
+ }
855
+
856
+ child.on("exit", (code) => process.exit(code ?? 0));
857
+ child.on("error", (err) => {
858
+ if (err.code === "ENOENT") {
859
+ console.error(
860
+ "Error: 'claude' command not found.\n\n" +
861
+ "Install Claude Code first:\n" +
862
+ " npm install -g @anthropic-ai/claude-code\n"
863
+ );
864
+ } else {
865
+ console.error(`Error launching claude: ${err.message}`);
866
+ }
867
+ process.exit(1);
868
+ });
869
+ }
870
+
871
+ main();
package/models.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "qwen": {
3
+ "id": "qwen/qwen3-coder-next",
4
+ "name": "Qwen 3 Coder",
5
+ "in": 0.07,
6
+ "out": 0.30,
7
+ "description": "Best value. Handles all Claude Code tools perfectly."
8
+ },
9
+ "haiku": {
10
+ "id": "anthropic/claude-haiku-4.5",
11
+ "name": "Claude Haiku 4.5",
12
+ "in": 0.80,
13
+ "out": 4.00,
14
+ "description": "Fast and capable. Good for exploration tasks."
15
+ },
16
+ "sonnet": {
17
+ "id": "anthropic/claude-sonnet-4",
18
+ "name": "Claude Sonnet 4",
19
+ "in": 3.00,
20
+ "out": 15.00,
21
+ "description": "Strong all-rounder. Good for complex coding."
22
+ },
23
+ "opus": {
24
+ "id": "anthropic/claude-opus-4.6",
25
+ "name": "Claude Opus 4.6",
26
+ "in": 15.00,
27
+ "out": 75.00,
28
+ "description": "Most capable. Use for architecture and planning."
29
+ },
30
+ "deepseek": {
31
+ "id": "deepseek/deepseek-coder-v3",
32
+ "name": "DeepSeek Coder V3",
33
+ "in": 0.14,
34
+ "out": 0.28,
35
+ "description": "Ultra-cheap alternative. Good for simple tasks."
36
+ },
37
+ "gemini": {
38
+ "id": "google/gemini-2.5-pro",
39
+ "name": "Gemini 2.5 Pro",
40
+ "in": 1.25,
41
+ "out": 10.00,
42
+ "description": "Google's flagship. Large context window."
43
+ }
44
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "cloding",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code with any model via OpenRouter. Use Qwen, Haiku, Sonnet, or Opus at a fraction of the cost.",
5
+ "bin": {
6
+ "cloding": "./bin/cloding.js"
7
+ },
8
+ "keywords": [
9
+ "claude",
10
+ "claude-code",
11
+ "openrouter",
12
+ "qwen",
13
+ "ai",
14
+ "coding",
15
+ "llm",
16
+ "cheap",
17
+ "cost-effective"
18
+ ],
19
+ "author": "Carlos",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/claudlos/cloding"
24
+ },
25
+ "homepage": "https://github.com/claudlos/cloding#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/claudlos/cloding/issues"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "files": [
33
+ "bin/",
34
+ "models.json",
35
+ "README.md",
36
+ "LICENSE"
37
+ ]
38
+ }