juno-code 1.0.35 → 1.0.36

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.
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Gemini Service Script for juno-code
4
+ Headless wrapper around the Gemini CLI with JSON streaming and shorthand model support.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from datetime import datetime
13
+ from typing import List, Optional, Tuple
14
+
15
+
16
+ class GeminiService:
17
+ """Service wrapper for Gemini CLI headless mode."""
18
+
19
+ DEFAULT_MODEL = "gemini-2.5-pro"
20
+ DEFAULT_OUTPUT_FORMAT = "stream-json"
21
+ VALID_OUTPUT_FORMATS = ["stream-json", "json", "text"]
22
+
23
+ # Common shorthand mappings (extendable as models evolve)
24
+ MODEL_SHORTHANDS = {
25
+ ":pro": "gemini-2.5-pro",
26
+ ":flash": "gemini-2.5-flash",
27
+ ":pro-2.5": "gemini-2.5-pro",
28
+ ":flash-2.5": "gemini-2.5-flash",
29
+ ":pro-3": "gemini-3.0-pro",
30
+ ":flash-3": "gemini-3.0-flash",
31
+ }
32
+
33
+ def __init__(self):
34
+ self.model_name = self.DEFAULT_MODEL
35
+ self.output_format = self.DEFAULT_OUTPUT_FORMAT
36
+ self.project_path = os.getcwd()
37
+ self.prompt = ""
38
+ self.include_dirs: List[str] = []
39
+ self.approval_mode: Optional[str] = None
40
+ self.yolo: bool = False
41
+ self.debug = False
42
+ self.verbose = False
43
+
44
+ def expand_model_shorthand(self, model: str) -> str:
45
+ """Expand shorthand model names (colon-prefixed) to full identifiers."""
46
+ if model.startswith(":"):
47
+ return self.MODEL_SHORTHANDS.get(model, model)
48
+ return model
49
+
50
+ def check_gemini_installed(self) -> bool:
51
+ """Check if gemini CLI is installed and available."""
52
+ try:
53
+ result = subprocess.run(
54
+ ["which", "gemini"],
55
+ capture_output=True,
56
+ text=True,
57
+ check=False,
58
+ )
59
+ return result.returncode == 0
60
+ except Exception:
61
+ return False
62
+
63
+ def ensure_api_key_present(self) -> bool:
64
+ """Validate that GEMINI_API_KEY is set for headless execution."""
65
+ api_key = os.environ.get("GEMINI_API_KEY", "")
66
+ if isinstance(api_key, str) and api_key.strip():
67
+ return True
68
+
69
+ print(
70
+ "Error: GEMINI_API_KEY is not set. Export GEMINI_API_KEY before running gemini headless CLI.",
71
+ file=sys.stderr,
72
+ )
73
+ print("Example: export GEMINI_API_KEY=\"your-api-key\"", file=sys.stderr)
74
+ return False
75
+
76
+ def parse_arguments(self) -> argparse.Namespace:
77
+ """Parse command line arguments for the Gemini service."""
78
+ parser = argparse.ArgumentParser(
79
+ description="Gemini Service - Wrapper for Gemini CLI headless mode",
80
+ formatter_class=argparse.RawDescriptionHelpFormatter,
81
+ epilog="""
82
+ Examples:
83
+ %(prog)s -p "Quick summary of README" --output-format stream-json
84
+ %(prog)s -pp prompt.txt --model :pro-3 --yolo
85
+ %(prog)s -p "Refactor module" --include-directories src,docs
86
+ %(prog)s -p "Audit code" --approval-mode auto_edit --debug
87
+ """,
88
+ )
89
+
90
+ prompt_group = parser.add_mutually_exclusive_group(required=False)
91
+ prompt_group.add_argument("-p", "--prompt", type=str, help="Prompt text to send to Gemini")
92
+ prompt_group.add_argument("-pp", "--prompt-file", type=str, help="Path to file containing the prompt")
93
+
94
+ parser.add_argument(
95
+ "--cd",
96
+ type=str,
97
+ default=os.environ.get("GEMINI_PROJECT_PATH", os.getcwd()),
98
+ help="Project path (absolute). Default: current directory (env: GEMINI_PROJECT_PATH)",
99
+ )
100
+
101
+ parser.add_argument(
102
+ "-m",
103
+ "--model",
104
+ type=str,
105
+ default=os.environ.get("GEMINI_MODEL", self.DEFAULT_MODEL),
106
+ help=(
107
+ "Gemini model. Supports shorthands (:pro, :flash, :pro-3, :flash-3, :pro-2.5, :flash-2.5) "
108
+ f"or full IDs. Default: {self.DEFAULT_MODEL} (env: GEMINI_MODEL)"
109
+ ),
110
+ )
111
+
112
+ parser.add_argument(
113
+ "--output-format",
114
+ type=str,
115
+ choices=self.VALID_OUTPUT_FORMATS,
116
+ default=os.environ.get("GEMINI_OUTPUT_FORMAT", self.DEFAULT_OUTPUT_FORMAT),
117
+ help="Gemini output format (stream-json/json/text). Default: stream-json (env: GEMINI_OUTPUT_FORMAT)",
118
+ )
119
+
120
+ parser.add_argument(
121
+ "--include-directories",
122
+ type=str,
123
+ help="Comma-separated list of directories to include for Gemini context (forwarded to CLI).",
124
+ )
125
+
126
+ parser.add_argument(
127
+ "--approval-mode",
128
+ type=str,
129
+ help="Set approval mode (e.g., auto_edit). Defaults to --yolo for headless mode when not provided.",
130
+ )
131
+
132
+ parser.add_argument(
133
+ "--yolo",
134
+ action="store_true",
135
+ help="Auto-approve all actions (non-interactive). Enabled by default when no approval mode is supplied.",
136
+ )
137
+
138
+ parser.add_argument(
139
+ "--debug",
140
+ action="store_true",
141
+ help="Enable Gemini CLI debug output.",
142
+ )
143
+
144
+ parser.add_argument(
145
+ "--verbose",
146
+ action="store_true",
147
+ help="Print the constructed command before execution.",
148
+ )
149
+
150
+ return parser.parse_args()
151
+
152
+ def _first_nonempty_str(self, *values: Optional[str]) -> str:
153
+ """Return the first non-empty string from provided values."""
154
+ for val in values:
155
+ if isinstance(val, str) and val.strip() != "":
156
+ return val
157
+ return ""
158
+
159
+ def _extract_content_text(self, payload: dict) -> str:
160
+ """Extract human-readable content from various Gemini event payload shapes."""
161
+ if not isinstance(payload, dict):
162
+ return ""
163
+
164
+ content_val = payload.get("content")
165
+ if isinstance(content_val, list):
166
+ parts: List[str] = []
167
+ for entry in content_val:
168
+ if isinstance(entry, dict):
169
+ text_val = entry.get("text") or entry.get("response") or entry.get("output")
170
+ if isinstance(text_val, str) and text_val.strip():
171
+ parts.append(text_val)
172
+ elif isinstance(entry, str) and entry.strip():
173
+ parts.append(entry)
174
+ if parts:
175
+ return "\n".join(parts)
176
+ elif isinstance(content_val, str):
177
+ return content_val
178
+
179
+ # Fall back to common fields
180
+ return self._first_nonempty_str(
181
+ payload.get("response"),
182
+ payload.get("message"),
183
+ payload.get("output"),
184
+ payload.get("result") if isinstance(payload.get("result"), str) else "",
185
+ payload.get("text"),
186
+ )
187
+
188
+ def _format_event_pretty(self, payload: dict) -> str:
189
+ """
190
+ Normalize Gemini CLI JSON output into a compact JSON header plus optional multi-line block.
191
+ Ensures a `type` and `content` field exist so shell-backend can stream progress events.
192
+ """
193
+ try:
194
+ raw_type = payload.get("type") or payload.get("event") or "message"
195
+ msg_type = str(raw_type).strip() or "message"
196
+ now = datetime.now().strftime("%I:%M:%S %p")
197
+
198
+ content = self._extract_content_text(payload)
199
+ # If still empty, serialize result/output objects as JSON to keep content non-undefined
200
+ header = {
201
+ "type": msg_type,
202
+ "datetime": now,
203
+ }
204
+
205
+ def copy_if_present(key: str, dest: Optional[str] = None):
206
+ val = payload.get(key)
207
+ if val not in (None, ""):
208
+ header[dest or key] = val
209
+
210
+ copy_if_present("role")
211
+ copy_if_present("status")
212
+ copy_if_present("tool_name", "tool")
213
+ copy_if_present("tool_id", "tool_id")
214
+ copy_if_present("timestamp")
215
+ copy_if_present("session_id")
216
+ copy_if_present("model")
217
+ if payload.get("delta"):
218
+ header["delta"] = True
219
+
220
+ if msg_type == "tool_use" and not content:
221
+ tool_params = payload.get("parameters") or payload.get("tool_use") or payload.get("input")
222
+ if isinstance(tool_params, (dict, list)):
223
+ header["parameters"] = tool_params
224
+ content = json.dumps(tool_params, ensure_ascii=False)
225
+ elif tool_params:
226
+ content = str(tool_params)
227
+
228
+ if msg_type == "tool_result" and not content:
229
+ tool_output = self._first_nonempty_str(payload.get("output"), payload.get("result"))
230
+ if tool_output:
231
+ content = tool_output
232
+
233
+ if msg_type == "init" and not content:
234
+ init_summary = {k: payload.get(k) for k in ["session_id", "model"] if payload.get(k)}
235
+ if init_summary:
236
+ content = json.dumps(init_summary, ensure_ascii=False)
237
+
238
+ if msg_type == "result" and not content:
239
+ if isinstance(payload.get("stats"), (dict, list)):
240
+ content = json.dumps(payload.get("stats"), ensure_ascii=False)
241
+
242
+ if content and "\n" in content:
243
+ return json.dumps(header, ensure_ascii=False) + "\ncontent:\n" + content
244
+
245
+ if content != "":
246
+ header["content"] = content if content is not None else ""
247
+
248
+ return json.dumps(header, ensure_ascii=False)
249
+ except Exception:
250
+ return json.dumps(payload, ensure_ascii=False)
251
+
252
+ def _split_json_stream(self, text: str) -> Tuple[List[str], str]:
253
+ """
254
+ Split a stream of concatenated JSON objects based on top-level brace balance.
255
+ Returns (complete_objects, remainder).
256
+ """
257
+ objs: List[str] = []
258
+ buf: List[str] = []
259
+ depth = 0
260
+ in_str = False
261
+ esc = False
262
+ started = False
263
+
264
+ for ch in text:
265
+ if in_str:
266
+ buf.append(ch)
267
+ if esc:
268
+ esc = False
269
+ elif ch == "\\":
270
+ esc = True
271
+ elif ch == '"':
272
+ in_str = False
273
+ continue
274
+
275
+ if ch == '"':
276
+ in_str = True
277
+ buf.append(ch)
278
+ continue
279
+
280
+ if ch == "{":
281
+ depth += 1
282
+ started = True
283
+ buf.append(ch)
284
+ continue
285
+
286
+ if ch == "}":
287
+ depth -= 1
288
+ buf.append(ch)
289
+ if started and depth == 0:
290
+ candidate = "".join(buf).strip().strip("'\"")
291
+ if candidate:
292
+ objs.append(candidate)
293
+ buf = []
294
+ started = False
295
+ continue
296
+
297
+ if started:
298
+ buf.append(ch)
299
+ else:
300
+ # Treat delimiter separators (e.g., ASCII 0x7f) as whitespace
301
+ if ch == "\x7f":
302
+ continue
303
+ buf.append(ch)
304
+
305
+ remainder = "".join(buf) if buf else ""
306
+ return objs, remainder
307
+
308
+ def read_prompt_file(self, file_path: str) -> str:
309
+ """Read prompt content from a file."""
310
+ try:
311
+ with open(file_path, "r", encoding="utf-8") as f:
312
+ return f.read().strip()
313
+ except FileNotFoundError:
314
+ print(f"Error: Prompt file not found: {file_path}", file=sys.stderr)
315
+ sys.exit(1)
316
+ except Exception as e:
317
+ print(f"Error reading prompt file: {e}", file=sys.stderr)
318
+ sys.exit(1)
319
+
320
+ def build_gemini_command(self, args: argparse.Namespace) -> List[str]:
321
+ """Construct the Gemini CLI command for headless execution."""
322
+ cmd = ["gemini"]
323
+
324
+ if self.prompt:
325
+ cmd.extend(["--prompt", self.prompt])
326
+
327
+ cmd.extend(["--output-format", self.output_format])
328
+ cmd.extend(["--model", self.model_name])
329
+
330
+ include_dirs = []
331
+ if args.include_directories:
332
+ include_dirs = [part.strip() for part in args.include_directories.split(",") if part.strip()]
333
+ self.include_dirs = include_dirs
334
+ if include_dirs:
335
+ cmd.extend(["--include-directories", ",".join(include_dirs)])
336
+
337
+ if args.approval_mode:
338
+ cmd.extend(["--approval-mode", args.approval_mode])
339
+ else:
340
+ # Default to yolo for headless automation when approval mode is not provided
341
+ cmd.append("--yolo")
342
+
343
+ if args.yolo:
344
+ if "--yolo" not in cmd:
345
+ cmd.append("--yolo")
346
+
347
+ if args.debug:
348
+ cmd.append("--debug")
349
+
350
+ return cmd
351
+
352
+ def run_gemini(self, cmd: List[str], verbose: bool = False) -> int:
353
+ """Execute the Gemini CLI and normalize streaming output for shell-backend consumption."""
354
+ if verbose:
355
+ print(f"Executing: {' '.join(cmd)}", file=sys.stderr)
356
+ print("-" * 80, file=sys.stderr)
357
+
358
+ try:
359
+ process = subprocess.Popen(
360
+ cmd,
361
+ stdout=subprocess.PIPE,
362
+ stderr=subprocess.PIPE,
363
+ text=True,
364
+ bufsize=1,
365
+ universal_newlines=True,
366
+ cwd=self.project_path,
367
+ )
368
+
369
+ pending = ""
370
+
371
+ if process.stdout:
372
+ for raw_line in process.stdout:
373
+ combined = (pending + raw_line).replace("\x7f", "\n")
374
+ pending = ""
375
+
376
+ if not combined.strip():
377
+ continue
378
+
379
+ parts, pending = self._split_json_stream(combined)
380
+
381
+ if parts:
382
+ for part in parts:
383
+ try:
384
+ parsed = json.loads(part)
385
+ formatted = self._format_event_pretty(parsed)
386
+ print(formatted, flush=True)
387
+ except Exception:
388
+ print(part, flush=True)
389
+ continue
390
+
391
+ # Fallback for non-JSON lines or partial content
392
+ if pending:
393
+ continue
394
+
395
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
396
+
397
+ if pending.strip():
398
+ try:
399
+ parsed_tail = json.loads(pending)
400
+ print(self._format_event_pretty(parsed_tail), flush=True)
401
+ except Exception:
402
+ print(pending, flush=True)
403
+
404
+ process.wait()
405
+
406
+ if process.stderr and process.returncode != 0:
407
+ stderr_output = process.stderr.read()
408
+ if stderr_output:
409
+ print(stderr_output, file=sys.stderr)
410
+
411
+ return process.returncode
412
+
413
+ except KeyboardInterrupt:
414
+ print("\nInterrupted by user", file=sys.stderr)
415
+ try:
416
+ process.terminate()
417
+ process.wait()
418
+ except Exception:
419
+ pass
420
+ return 130
421
+ except Exception as e:
422
+ print(f"Error executing gemini: {e}", file=sys.stderr)
423
+ return 1
424
+
425
+ def run(self) -> int:
426
+ """Main execution flow."""
427
+ args = self.parse_arguments()
428
+
429
+ # Prompt handling: allow env override for shell backend compatibility
430
+ prompt_value = args.prompt or os.environ.get("JUNO_INSTRUCTION")
431
+ if not prompt_value and not args.prompt_file:
432
+ print("Error: Either -p/--prompt or -pp/--prompt-file is required.", file=sys.stderr)
433
+ print("\nRun 'gemini.py --help' for usage information.", file=sys.stderr)
434
+ return 1
435
+
436
+ if not self.check_gemini_installed():
437
+ print("Error: Gemini CLI is not available. Please install it: https://geminicli.com/docs/get-started/installation/", file=sys.stderr)
438
+ return 1
439
+
440
+ if not self.ensure_api_key_present():
441
+ return 1
442
+
443
+ self.project_path = os.path.abspath(args.cd)
444
+ if not os.path.isdir(self.project_path):
445
+ print(f"Error: Project path does not exist: {self.project_path}", file=sys.stderr)
446
+ return 1
447
+
448
+ self.model_name = self.expand_model_shorthand(args.model)
449
+ self.output_format = args.output_format or self.DEFAULT_OUTPUT_FORMAT
450
+ if self.output_format not in self.VALID_OUTPUT_FORMATS:
451
+ print(f"Warning: Unsupported output format '{self.output_format}'. Falling back to {self.DEFAULT_OUTPUT_FORMAT}.", file=sys.stderr)
452
+ self.output_format = self.DEFAULT_OUTPUT_FORMAT
453
+ self.debug = args.debug
454
+ self.verbose = args.verbose
455
+ self.approval_mode = args.approval_mode
456
+ self.yolo = args.yolo
457
+
458
+ if args.prompt_file:
459
+ self.prompt = self.read_prompt_file(args.prompt_file)
460
+ else:
461
+ self.prompt = prompt_value
462
+
463
+ cmd = self.build_gemini_command(args)
464
+ return self.run_gemini(cmd, verbose=args.verbose)
465
+
466
+
467
+ def main():
468
+ service = GeminiService()
469
+ sys.exit(service.run())
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juno-code",
3
- "version": "1.0.35",
3
+ "version": "1.0.36",
4
4
  "description": "TypeScript CLI tool for AI subagent orchestration with code automation",
5
5
  "keywords": [
6
6
  "ai",
@@ -36,8 +36,8 @@
36
36
  "scripts": {
37
37
  "dev": "tsx src/bin/cli.ts",
38
38
  "build": "tsup && npm run build:copy-templates && npm run build:copy-services && npm run build:copy-wrapper",
39
- "build:copy-templates": "node -e \"require('fs-extra').copySync('src/templates/scripts', 'dist/templates/scripts', { recursive: true })\"",
40
- "build:copy-services": "node -e \"const fs = require('fs-extra'); fs.copySync('src/templates/services', 'dist/templates/services', { recursive: true }); fs.chmodSync('dist/templates/services/codex.py', 0o755); fs.chmodSync('dist/templates/services/claude.py', 0o755);\"",
39
+ "build:copy-templates": "node -e \"const fs = require('fs-extra'); const path = require('path'); fs.copySync('src/templates/scripts', 'dist/templates/scripts', { recursive: true }); fs.readdirSync('dist/templates/scripts').filter(f => f.endsWith('.sh')).forEach(f => fs.chmodSync(path.join('dist/templates/scripts', f), 0o755));\"",
40
+ "build:copy-services": "node -e \"const fs = require('fs-extra'); const path = require('path'); const src = 'src/templates/services'; const dest = 'dist/templates/services'; fs.copySync(src, dest, { recursive: true }); const required = ['codex.py','claude.py','gemini.py']; const missing = required.filter(file => !fs.existsSync(path.join(dest, file))); if (missing.length) { throw new Error('Missing required service scripts in dist: ' + missing.join(', ')); } required.forEach(file => fs.chmodSync(path.join(dest, file), 0o755));\"",
41
41
  "build:copy-wrapper": "node -e \"const fs = require('fs-extra'); fs.copySync('src/bin/juno-code.sh', 'dist/bin/juno-code.sh'); fs.chmodSync('dist/bin/juno-code.sh', 0o755);\"",
42
42
  "build:watch": "tsup --watch",
43
43
  "test": "vitest",