cloding 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/bin/cloding.js +137 -67
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,7 +27,7 @@ cloding -m haiku # Use Claude Haiku 4.5
27
27
  cloding -m sonnet # Use Claude Sonnet 4
28
28
  cloding -m opus # Use Claude Opus 4.6
29
29
  cloding -m deepseek # Use DeepSeek Coder V3
30
- cloding -p "fix the login bug" # Non-interactive, single prompt
30
+ cloding -p "add dark mode" # Non-interactive, single prompt
31
31
  cloding --list-models # Show all models with pricing
32
32
  cloding -m meta-llama/llama-4-scout # Any OpenRouter model ID works
33
33
  ```
package/bin/cloding.js CHANGED
@@ -16,9 +16,36 @@
16
16
  * cloding docker run "prompt" # Run in a Docker container
17
17
  */
18
18
 
19
- const { spawn, execSync } = require("child_process");
19
+ const { spawn, spawnSync } = require("child_process");
20
20
  const path = require("path");
21
21
  const fs = require("fs");
22
+ const os = require("os");
23
+
24
+ // ──────────────────────────────────────────────
25
+ // Constants
26
+ // ──────────────────────────────────────────────
27
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
28
+ const DEFAULT_MODEL = "qwen";
29
+ const DOCKER_IMAGE = "cloding:latest";
30
+ const DOCKER_NETWORK = "cloding-net";
31
+
32
+ // ──────────────────────────────────────────────
33
+ // Signal forwarding — relay SIGINT/SIGTERM to child processes
34
+ // ──────────────────────────────────────────────
35
+ function forwardSignals(child) {
36
+ const handler = (signal) => {
37
+ if (child && !child.killed) {
38
+ child.kill(signal);
39
+ }
40
+ };
41
+ process.on("SIGINT", handler);
42
+ process.on("SIGTERM", handler);
43
+ // Clean up listeners when child exits to avoid leaks
44
+ child.on("exit", () => {
45
+ process.removeListener("SIGINT", handler);
46
+ process.removeListener("SIGTERM", handler);
47
+ });
48
+ }
22
49
 
23
50
  // ──────────────────────────────────────────────
24
51
  // .env loader (no dependencies)
@@ -47,8 +74,9 @@ function loadEnvFile() {
47
74
  ) {
48
75
  val = val.slice(1, -1);
49
76
  }
50
- // Don't override existing env vars
51
- if (!process.env[key]) {
77
+ // Don't override existing env vars (check existence, not truthiness —
78
+ // empty string values like ANTHROPIC_API_KEY="" must be preserved)
79
+ if (!(key in process.env)) {
52
80
  process.env[key] = val;
53
81
  }
54
82
  }
@@ -63,12 +91,35 @@ function loadEnvFile() {
63
91
  // ──────────────────────────────────────────────
64
92
  function loadModels() {
65
93
  const modelsPath = path.join(__dirname, "..", "models.json");
94
+ let models;
66
95
  try {
67
- return JSON.parse(fs.readFileSync(modelsPath, "utf8"));
96
+ models = JSON.parse(fs.readFileSync(modelsPath, "utf8"));
68
97
  } catch {
69
98
  console.error("Error: Could not load models.json");
70
99
  process.exit(1);
71
100
  }
101
+
102
+ // Validate structure
103
+ if (!models || typeof models !== "object" || Array.isArray(models)) {
104
+ console.error("Error: models.json must be a JSON object");
105
+ process.exit(1);
106
+ }
107
+ for (const [shortcut, m] of Object.entries(models)) {
108
+ if (!m.id || typeof m.id !== "string") {
109
+ console.error(`Error: models.json: "${shortcut}" missing required "id" (string)`);
110
+ process.exit(1);
111
+ }
112
+ if (!m.name || typeof m.name !== "string") {
113
+ console.error(`Error: models.json: "${shortcut}" missing required "name" (string)`);
114
+ process.exit(1);
115
+ }
116
+ if (typeof m.in !== "number" || typeof m.out !== "number") {
117
+ console.error(`Error: models.json: "${shortcut}" requires numeric "in" and "out" costs`);
118
+ process.exit(1);
119
+ }
120
+ }
121
+
122
+ return models;
72
123
  }
73
124
 
74
125
  // ──────────────────────────────────────────────
@@ -148,10 +199,14 @@ function parseArgs(argv) {
148
199
  // Display helpers
149
200
  // ──────────────────────────────────────────────
150
201
  function printVersion() {
151
- const pkg = JSON.parse(
152
- fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
153
- );
154
- console.log(`cloding v${pkg.version}`);
202
+ try {
203
+ const pkg = JSON.parse(
204
+ fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
205
+ );
206
+ console.log(`cloding v${pkg.version}`);
207
+ } catch {
208
+ console.log("cloding (unknown version)");
209
+ }
155
210
  }
156
211
 
157
212
  function printHelp() {
@@ -183,7 +238,7 @@ DOCKER COMMANDS:
183
238
  cloding docker help Show Docker help
184
239
 
185
240
  MODELS (shortcuts):
186
- qwen Qwen 3 Coder $0.07/$0.30 per Mtok (default, ~250x cheaper)
241
+ qwen Qwen 3 Coder $0.07/$0.30 per Mtok (default, ~71x cheaper)
187
242
  haiku Claude Haiku 4.5 $0.80/$4.00 per Mtok
188
243
  sonnet Claude Sonnet 4 $3.00/$15.00 per Mtok
189
244
  opus Claude Opus 4.6 $15.00/$75.00 per Mtok
@@ -223,9 +278,10 @@ function printModels(models) {
223
278
  const opusOut = models.opus ? models.opus.out : 75.0;
224
279
 
225
280
  for (const [shortcut, m] of Object.entries(models)) {
226
- const savings = opusOut / m.out;
281
+ const savings = m.out > 0 ? Math.round(opusOut / m.out) : 0;
227
282
  const savingsStr =
228
- shortcut === "opus" ? " baseline" : ` ${savings.toFixed(0)}x cheaper`;
283
+ shortcut === "opus" ? " baseline" :
284
+ savings > 0 ? ` ${savings}x cheaper` : " n/a";
229
285
  console.log(
230
286
  ` ${shortcut.padEnd(11)} ${m.name.padEnd(24)} $${m.in.toFixed(2).padStart(6)} $${m.out.toFixed(2).padStart(6)}${savingsStr}`
231
287
  );
@@ -242,7 +298,7 @@ function printModels(models) {
242
298
  function resolveModel(modelArg, models) {
243
299
  if (!modelArg) {
244
300
  // Use default from env, or fall back to qwen
245
- const defaultModel = process.env.CLODING_DEFAULT_MODEL || "qwen";
301
+ const defaultModel = process.env.CLODING_DEFAULT_MODEL || DEFAULT_MODEL;
246
302
  return resolveModel(defaultModel, models);
247
303
  }
248
304
 
@@ -261,16 +317,10 @@ function resolveModel(modelArg, models) {
261
317
  };
262
318
  }
263
319
 
264
- // ──────────────────────────────────────────────
265
- // Docker helpers
266
- // ──────────────────────────────────────────────
267
- const DOCKER_IMAGE = "cloding:latest";
268
- const DOCKER_NETWORK = "cloding-net";
269
-
270
320
  function dockerAvailable() {
271
321
  try {
272
- execSync("docker --version", { stdio: "ignore" });
273
- return true;
322
+ const result = spawnSync("docker", ["--version"], { stdio: "ignore" });
323
+ return result.status === 0;
274
324
  } catch {
275
325
  return false;
276
326
  }
@@ -278,11 +328,10 @@ function dockerAvailable() {
278
328
 
279
329
  function dockerImageExists() {
280
330
  try {
281
- // Safe: no user input in this command, just a constant image name
282
- const result = execSync(`docker images -q ${DOCKER_IMAGE}`, {
331
+ const result = spawnSync("docker", ["images", "-q", DOCKER_IMAGE], {
283
332
  encoding: "utf8",
284
333
  });
285
- return result.trim().length > 0;
334
+ return (result.stdout || "").trim().length > 0;
286
335
  } catch {
287
336
  return false;
288
337
  }
@@ -305,10 +354,7 @@ function getDockerfilePath() {
305
354
  function ensureNetwork() {
306
355
  try {
307
356
  // 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
- );
357
+ spawnSync("docker", ["network", "create", DOCKER_NETWORK], { stdio: "ignore" });
312
358
  // Ignore errors — network may already exist
313
359
  } catch {
314
360
  // Network already exists, that's fine
@@ -332,6 +378,7 @@ COMMANDS:
332
378
 
333
379
  OPTIONS (for run/shell):
334
380
  -m, --model <name> Model shortcut or OpenRouter ID (default: qwen)
381
+ -p, --prompt <text> Prompt text (alternative to positional argument)
335
382
  -w, --workspace <path> Mount a local directory as /workspace (default: cwd)
336
383
  --memory <limit> Container memory limit (default: 2g)
337
384
  --cpus <limit> Container CPU limit (default: 1.0)
@@ -372,6 +419,7 @@ function dockerBuild() {
372
419
  const child = spawn("docker", ["build", "-t", DOCKER_IMAGE, dockerDir], {
373
420
  stdio: "inherit",
374
421
  });
422
+ forwardSignals(child);
375
423
 
376
424
  child.on("exit", (code) => {
377
425
  if (code === 0) {
@@ -412,6 +460,11 @@ function dockerRun(dockerArgs, models, interactive) {
412
460
  if (i + 1 >= dockerArgs.length) { console.error("Error: --model requires a value."); process.exit(1); }
413
461
  modelArg = dockerArgs[++i];
414
462
  break;
463
+ case "-p":
464
+ case "--prompt":
465
+ if (i + 1 >= dockerArgs.length) { console.error("Error: --prompt requires a value."); process.exit(1); }
466
+ prompt = dockerArgs[++i];
467
+ break;
415
468
  case "-w":
416
469
  case "--workspace":
417
470
  if (i + 1 >= dockerArgs.length) { console.error("Error: --workspace requires a path."); process.exit(1); }
@@ -474,9 +527,13 @@ function dockerRun(dockerArgs, models, interactive) {
474
527
  // Resolve model
475
528
  const model = resolveModel(modelArg, models);
476
529
 
477
- // Validate workspace exists
530
+ // Validate workspace exists and is a directory
478
531
  if (!fs.existsSync(workspace)) {
479
- console.error(`Error: Workspace directory not found: ${workspace}`);
532
+ console.error(`Error: Workspace not found: ${workspace}`);
533
+ process.exit(1);
534
+ }
535
+ if (!fs.statSync(workspace).isDirectory()) {
536
+ console.error(`Error: Workspace path is not a directory: ${workspace}`);
480
537
  process.exit(1);
481
538
  }
482
539
 
@@ -489,6 +546,17 @@ function dockerRun(dockerArgs, models, interactive) {
489
546
  containerName = `cloding-${interactive ? "shell" : "run"}-${suffix}`;
490
547
  }
491
548
 
549
+ // Write env vars to a temp file so the API key doesn't leak in `ps aux`
550
+ const envFileContent = [
551
+ `ANTHROPIC_BASE_URL=${OPENROUTER_BASE_URL}`,
552
+ `ANTHROPIC_AUTH_TOKEN=${apiKey}`,
553
+ `ANTHROPIC_API_KEY=`,
554
+ `ANTHROPIC_MODEL=${model.id}`,
555
+ `CLAUDECODE=`,
556
+ ].join("\n") + "\n";
557
+ const envFilePath = path.join(os.tmpdir(), `cloding-env-${Date.now()}.tmp`);
558
+ fs.writeFileSync(envFilePath, envFileContent, { mode: 0o600 });
559
+
492
560
  // Build docker command — uses spawn with argument array (safe, no shell injection)
493
561
  const cmd = ["docker", "run"];
494
562
 
@@ -506,11 +574,7 @@ function dockerRun(dockerArgs, models, interactive) {
506
574
  "--memory", memory,
507
575
  "--cpus", cpus,
508
576
  "-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=`,
577
+ "--env-file", envFilePath,
514
578
  DOCKER_IMAGE
515
579
  );
516
580
 
@@ -530,21 +594,31 @@ function dockerRun(dockerArgs, models, interactive) {
530
594
  console.log(` Workspace: ${workspace} → /workspace`);
531
595
  console.log(` Resources: ${memory} RAM, ${cpus} CPUs`);
532
596
 
533
- if (model.in > 0 && models.opus) {
534
- const savings = (models.opus.out / model.out).toFixed(0);
597
+ if (model.in > 0 && model.out > 0 && models.opus) {
598
+ const savings = Math.round(models.opus.out / model.out);
535
599
  if (savings > 1) {
536
600
  console.log(` \x1b[32m${savings}x cheaper than Opus\x1b[0m`);
537
601
  }
538
602
  }
539
603
  console.log("");
540
604
 
605
+ // Clean up env file on exit (contains API key)
606
+ function cleanupEnvFile() {
607
+ try { fs.unlinkSync(envFilePath); } catch {}
608
+ }
609
+
541
610
  // Spawn docker — uses argument array (no shell interpretation)
542
611
  const child = spawn(cmd[0], cmd.slice(1), {
543
612
  stdio: "inherit",
544
613
  });
614
+ forwardSignals(child);
545
615
 
546
- child.on("exit", (code) => process.exit(code ?? 0));
616
+ child.on("exit", (code) => {
617
+ cleanupEnvFile();
618
+ process.exit(code ?? 0);
619
+ });
547
620
  child.on("error", (err) => {
621
+ cleanupEnvFile();
548
622
  console.error(`Error launching Docker: ${err.message}`);
549
623
  console.error("Make sure Docker is installed and running.");
550
624
  process.exit(1);
@@ -556,7 +630,7 @@ function dockerStatus() {
556
630
 
557
631
  try {
558
632
  // Safe: uses spawnSync with argument array, no shell injection possible
559
- const result = require("child_process").spawnSync(
633
+ const result = spawnSync(
560
634
  "docker",
561
635
  ["ps", "--filter", "name=cloding", "--format", "table {{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}"],
562
636
  { encoding: "utf8" }
@@ -583,7 +657,7 @@ function dockerStop() {
583
657
 
584
658
  try {
585
659
  // Safe: uses spawnSync with argument array
586
- const result = require("child_process").spawnSync(
660
+ const result = spawnSync(
587
661
  "docker",
588
662
  ["ps", "-q", "--filter", "name=cloding"],
589
663
  { encoding: "utf8" }
@@ -601,13 +675,13 @@ function dockerStop() {
601
675
 
602
676
  for (const id of ids) {
603
677
  try {
604
- const inspect = require("child_process").spawnSync(
678
+ const inspect = spawnSync(
605
679
  "docker", ["inspect", "--format", "{{.Name}}", id],
606
680
  { encoding: "utf8" }
607
681
  );
608
682
  const name = (inspect.stdout || id).trim().replace(/^\//, "");
609
683
 
610
- require("child_process").spawnSync(
684
+ spawnSync(
611
685
  "docker", ["stop", "-t", "5", id],
612
686
  { stdio: "ignore" }
613
687
  );
@@ -630,7 +704,7 @@ function dockerClean() {
630
704
 
631
705
  try {
632
706
  // Safe: uses spawnSync with argument array
633
- const result = require("child_process").spawnSync(
707
+ const result = spawnSync(
634
708
  "docker",
635
709
  ["ps", "-aq", "--filter", "name=cloding", "--filter", "status=exited"],
636
710
  { encoding: "utf8" }
@@ -648,13 +722,13 @@ function dockerClean() {
648
722
 
649
723
  for (const id of ids) {
650
724
  try {
651
- const inspect = require("child_process").spawnSync(
725
+ const inspect = spawnSync(
652
726
  "docker", ["inspect", "--format", "{{.Name}}", id],
653
727
  { encoding: "utf8" }
654
728
  );
655
729
  const name = (inspect.stdout || id).trim().replace(/^\//, "");
656
730
 
657
- require("child_process").spawnSync(
731
+ spawnSync(
658
732
  "docker", ["rm", id],
659
733
  { stdio: "ignore" }
660
734
  );
@@ -782,11 +856,17 @@ function main() {
782
856
  const pythonArgs = ["-m", "osq", ...args.pipelineArgs];
783
857
  console.log(`Running pipeline: python ${pythonArgs.join(" ")}`);
784
858
 
859
+ // Pipeline inherits env but needs OpenRouter vars and CLAUDECODE stripped
860
+ const pipelineEnv = { ...process.env };
861
+ pipelineEnv.OPENROUTER_API_KEY = apiKey;
862
+ delete pipelineEnv.CLAUDECODE;
863
+
785
864
  const child = spawn("python", pythonArgs, {
786
865
  cwd: pipelineDir,
787
866
  stdio: "inherit",
788
- env: { ...process.env },
867
+ env: pipelineEnv,
789
868
  });
869
+ forwardSignals(child);
790
870
 
791
871
  child.on("exit", (code) => process.exit(code ?? 0));
792
872
  child.on("error", (err) => {
@@ -802,7 +882,7 @@ function main() {
802
882
 
803
883
  // Build env for claude
804
884
  const claudeEnv = { ...process.env };
805
- claudeEnv.ANTHROPIC_BASE_URL = "https://openrouter.ai/api";
885
+ claudeEnv.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL;
806
886
  claudeEnv.ANTHROPIC_AUTH_TOKEN = apiKey;
807
887
  claudeEnv.ANTHROPIC_API_KEY = "";
808
888
  claudeEnv.ANTHROPIC_MODEL = model.id;
@@ -817,15 +897,15 @@ function main() {
817
897
  }
818
898
 
819
899
  // Print banner
820
- const shortcut = args.model || process.env.CLODING_DEFAULT_MODEL || "qwen";
900
+ const shortcut = args.model || process.env.CLODING_DEFAULT_MODEL || DEFAULT_MODEL;
821
901
  const costInfo =
822
902
  model.in > 0
823
903
  ? ` ($${model.in}/$${model.out} per Mtok)`
824
904
  : "";
825
905
  console.log(`\x1b[36m⚡ cloding\x1b[0m → ${model.name}${costInfo}`);
826
906
 
827
- if (model.in > 0 && models.opus) {
828
- const savings = (models.opus.out / model.out).toFixed(0);
907
+ if (model.in > 0 && model.out > 0 && models.opus) {
908
+ const savings = Math.round(models.opus.out / model.out);
829
909
  if (savings > 1) {
830
910
  console.log(`\x1b[32m ${savings}x cheaper than Opus\x1b[0m`);
831
911
  }
@@ -833,25 +913,15 @@ function main() {
833
913
  console.log("");
834
914
 
835
915
  // 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.
916
+ // On Windows, npm globals are .cmd shims that need shell:true for resolution.
917
+ // We pass args as an array and let Node handle escaping never build a command string.
839
918
  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
- }
919
+ const child = spawn("claude", claudeArgs, {
920
+ stdio: "inherit",
921
+ env: claudeEnv,
922
+ shell: isWin,
923
+ });
924
+ forwardSignals(child);
855
925
 
856
926
  child.on("exit", (code) => process.exit(code ?? 0));
857
927
  child.on("error", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloding",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Claude Code with any model via OpenRouter. Use Qwen, Haiku, Sonnet, or Opus at a fraction of the cost.",
5
5
  "bin": {
6
6
  "cloding": "./bin/cloding.js"