hcom 0.5.0__py3-none-any.whl → 0.6.1__py3-none-any.whl

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.

Potentially problematic release.


This version of hcom might be problematic. Click here for more details.

hcom/shared.py ADDED
@@ -0,0 +1,1206 @@
1
+ #!/usr/bin/env python3
2
+ """Shared constants and utilities for hcom"""
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ __version__ = "0.6.1"
9
+
10
+ # ===== Core ANSI Codes =====
11
+ RESET = "\033[0m"
12
+ DIM = "\033[2m"
13
+ BOLD = "\033[1m"
14
+ REVERSE = "\033[7m"
15
+
16
+ # Foreground colors
17
+ FG_GREEN = "\033[32m"
18
+ FG_CYAN = "\033[36m"
19
+ FG_WHITE = "\033[37m"
20
+ FG_BLACK = "\033[30m"
21
+ FG_GRAY = '\033[90m'
22
+ FG_YELLOW = '\033[33m'
23
+ FG_RED = '\033[31m'
24
+ FG_BLUE = '\033[34m'
25
+
26
+ # TUI-specific foreground
27
+ FG_ORANGE = '\033[38;5;208m'
28
+ FG_GOLD = '\033[38;5;220m'
29
+ FG_LIGHTGRAY = '\033[38;5;250m'
30
+
31
+ # Stale instance color (brownish-grey, distinct from exited)
32
+ FG_STALE = '\033[38;5;137m' # Tan/brownish-grey
33
+
34
+ # Background colors
35
+ BG_BLUE = "\033[44m"
36
+ BG_GREEN = "\033[42m"
37
+ BG_CYAN = "\033[46m"
38
+ BG_YELLOW = "\033[43m"
39
+ BG_RED = "\033[41m"
40
+ BG_GRAY = "\033[100m"
41
+
42
+ # Stale background (brownish-grey to match foreground)
43
+ BG_STALE = '\033[48;5;137m' # Tan/brownish-grey background
44
+
45
+ # TUI-specific background
46
+ BG_ORANGE = '\033[48;5;208m'
47
+ BG_CHARCOAL = '\033[48;5;236m'
48
+
49
+ # Terminal control
50
+ CLEAR_SCREEN = '\033[2J'
51
+ CURSOR_HOME = '\033[H'
52
+ HIDE_CURSOR = '\033[?25l'
53
+ SHOW_CURSOR = '\033[?25h'
54
+
55
+ # Box drawing
56
+ BOX_H = '─'
57
+
58
+ # ===== Default Config =====
59
+ DEFAULT_CONFIG_HEADER = [
60
+ "# HCOM Configuration",
61
+ "#",
62
+ "# All HCOM_* settings (and any env var ie. Claude Code settings)",
63
+ "# can be set here or via environment variables.",
64
+ "# Environment variables and cli args override config file values.",
65
+ "# Put each value on separate lines without comments.",
66
+ "#",
67
+ "# HCOM settings:",
68
+ "# HCOM_TIMEOUT - seconds before disconnecting idle instance (default: 1800)",
69
+ "# HCOM_SUBAGENT_TIMEOUT - seconds before disconnecting idle subagents (default: 30)",
70
+ "# HCOM_TERMINAL - Terminal mode: \"new\", \"here\", or custom command with {script}",
71
+ "# HCOM_HINTS - Text appended to all messages received by instances",
72
+ "# HCOM_TAG - Group tag for instances (creates tag-* instances)",
73
+ "# HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple",
74
+ "# HCOM_CLAUDE_ARGS - Default Claude args (e.g., '-p --model sonnet-4')",
75
+ "#",
76
+ "#",
77
+ "ANTHROPIC_MODEL=",
78
+ "CLAUDE_CODE_SUBAGENT_MODEL=",
79
+ ]
80
+
81
+ DEFAULT_CONFIG_DEFAULTS = [
82
+ 'HCOM_AGENT=',
83
+ 'HCOM_TAG=',
84
+ 'HCOM_HINTS=',
85
+ 'HCOM_TIMEOUT=1800',
86
+ 'HCOM_SUBAGENT_TIMEOUT=30',
87
+ 'HCOM_TERMINAL=new',
88
+ r'''HCOM_CLAUDE_ARGS="'say hi in hcom chat'"''',
89
+ ]
90
+
91
+ # ===== Status Configuration =====
92
+ # Status values stored directly in instance files (no event mapping)
93
+ # 'enabled' field is separate from status (participation vs activity)
94
+
95
+ # Valid status values
96
+ STATUS_VALUES = ['active', 'delivered', 'waiting', 'blocked', 'exited', 'stale', 'unknown']
97
+
98
+ # Status icons
99
+ STATUS_ICONS = {
100
+ 'active': '▶',
101
+ 'delivered': '▷',
102
+ 'waiting': '◉',
103
+ 'blocked': '■',
104
+ 'exited': '○',
105
+ 'stale': '⊙',
106
+ 'unknown': '◦'
107
+ }
108
+
109
+ # Status colors (foreground)
110
+ STATUS_COLORS = {
111
+ 'active': FG_GREEN,
112
+ 'delivered': FG_CYAN,
113
+ 'waiting': FG_BLUE,
114
+ 'blocked': FG_RED,
115
+ 'exited': FG_GRAY,
116
+ 'stale': FG_STALE,
117
+ 'unknown': FG_GRAY
118
+ }
119
+
120
+ # STATUS_MAP for watch command (foreground color, icon)
121
+ STATUS_MAP = {
122
+ status: (STATUS_COLORS[status], STATUS_ICONS[status])
123
+ for status in STATUS_VALUES
124
+ }
125
+
126
+ # Background colors for statusline display blocks
127
+ STATUS_BG_COLORS = {
128
+ 'active': BG_GREEN,
129
+ 'delivered': BG_CYAN,
130
+ 'waiting': BG_BLUE,
131
+ 'blocked': BG_RED,
132
+ 'exited': BG_GRAY,
133
+ 'stale': BG_STALE,
134
+ 'unknown': BG_GRAY
135
+ }
136
+
137
+ # Background color map for TUI statusline (background color, icon)
138
+ STATUS_BG_MAP = {
139
+ status: (STATUS_BG_COLORS[status], STATUS_ICONS[status])
140
+ for status in STATUS_VALUES
141
+ }
142
+
143
+ # Display order (priority-based sorting)
144
+ STATUS_ORDER = [
145
+ "active", "delivered", "waiting",
146
+ "blocked", "stale", "exited", "unknown"
147
+ ]
148
+
149
+ # TUI-specific (alias for STATUS_COLORS)
150
+ STATUS_FG = STATUS_COLORS
151
+
152
+ # ===== Pure Utility Functions =====
153
+ def format_timestamp(iso_str: str, fmt: str = '%H:%M') -> str:
154
+ """Format ISO timestamp for display - pure function"""
155
+ from datetime import datetime
156
+ try:
157
+ if 'T' in iso_str:
158
+ dt = datetime.fromisoformat(iso_str.replace('Z', '+00:00'))
159
+ return dt.strftime(fmt)
160
+ return iso_str
161
+ except Exception:
162
+ return iso_str[:5] if len(iso_str) >= 5 else iso_str
163
+
164
+ def format_age(seconds: float) -> str:
165
+ """Format time ago in human readable form - pure function"""
166
+ if seconds < 60:
167
+ return f"{int(seconds)}s"
168
+ elif seconds < 3600:
169
+ return f"{int(seconds/60)}m"
170
+ else:
171
+ return f"{int(seconds/3600)}h"
172
+
173
+ def get_status_counts(instances: dict[str, dict]) -> dict[str, int]:
174
+ """Count instances by status type - pure data transformation"""
175
+ counts = {s: 0 for s in STATUS_ORDER}
176
+ for info in instances.values():
177
+ status = info.get('status', 'unknown')
178
+ counts[status] = counts.get(status, 0) + 1
179
+ return counts
180
+
181
+
182
+ # ===== Config Parsing Utilities =====
183
+ def parse_env_value(value: str) -> str:
184
+ """Parse ENV file value with proper quote and escape handling"""
185
+ value = value.strip()
186
+
187
+ if not value:
188
+ return value
189
+
190
+ if value.startswith('"') and value.endswith('"') and len(value) >= 2:
191
+ inner = value[1:-1]
192
+ inner = inner.replace('\\\\', '\x00')
193
+ inner = inner.replace('\\n', '\n')
194
+ inner = inner.replace('\\t', '\t')
195
+ inner = inner.replace('\\r', '\r')
196
+ inner = inner.replace('\\"', '"')
197
+ inner = inner.replace('\x00', '\\')
198
+ return inner
199
+
200
+ if value.startswith("'") and value.endswith("'") and len(value) >= 2:
201
+ return value[1:-1]
202
+
203
+ return value
204
+
205
+
206
+ def format_env_value(value: str) -> str:
207
+ """Format value for ENV file with proper quoting (inverse of parse_env_value)"""
208
+ if not value:
209
+ return value
210
+
211
+ # Check if quoting needed for special characters
212
+ needs_quoting = any(c in value for c in ['\n', '\t', '"', "'", ' ', '\r'])
213
+
214
+ if needs_quoting:
215
+ # Use double quotes with proper escaping
216
+ escaped = value.replace('\\', '\\\\') # Escape backslashes first
217
+ escaped = escaped.replace('\n', '\\n') # Escape newlines
218
+ escaped = escaped.replace('\t', '\\t') # Escape tabs
219
+ escaped = escaped.replace('\r', '\\r') # Escape carriage returns
220
+ escaped = escaped.replace('"', '\\"') # Escape double quotes
221
+ return f'"{escaped}"'
222
+
223
+ return value
224
+
225
+
226
+ def parse_env_file(config_path: Path) -> dict[str, str]:
227
+ """Parse ENV file (KEY=VALUE format) with security validation"""
228
+ config: dict[str, str] = {}
229
+
230
+ dangerous_chars = ['`', '$', ';', '|', '&', '\n', '\r']
231
+
232
+ try:
233
+ content = config_path.read_text(encoding='utf-8')
234
+ for line in content.splitlines():
235
+ line = line.strip()
236
+ if not line or line.startswith('#'):
237
+ continue
238
+ if '=' in line:
239
+ key, _, value = line.partition('=')
240
+ key = key.strip()
241
+ value = value.strip()
242
+
243
+ if key == 'HCOM_TERMINAL':
244
+ if any(c in value for c in dangerous_chars):
245
+ print(
246
+ f"Warning: Unsafe characters in HCOM_TERMINAL "
247
+ f"({', '.join(repr(c) for c in dangerous_chars if c in value)}), "
248
+ f"ignoring custom terminal command",
249
+ file=sys.stderr
250
+ )
251
+ continue
252
+ if value not in ('new', 'here', 'print') and '{script}' not in value:
253
+ print(
254
+ "Warning: HCOM_TERMINAL custom command must include {script} placeholder, "
255
+ "ignoring",
256
+ file=sys.stderr
257
+ )
258
+ continue
259
+
260
+ parsed = parse_env_value(value)
261
+ if key:
262
+ config[key] = parsed
263
+ except (FileNotFoundError, PermissionError, UnicodeDecodeError):
264
+ pass
265
+ return config
266
+
267
+
268
+
269
+
270
+
271
+ # =====================CLAUDE ARGS============================
272
+ # Helpers for parsing and composing Claude CLI arguments.
273
+ # Used by hcom.py and ui.py for consistent arg parsing and launch command building.
274
+
275
+ import shlex
276
+ from dataclasses import dataclass
277
+ from typing import Iterable, Literal, Mapping, Sequence, Tuple
278
+
279
+
280
+ CanonicalFlag = Literal["--model", "--allowedTools"]
281
+
282
+ # All flag keys stored in lowercase (aside from short-form switches) for comparisons;
283
+ # values use canonical casing when recorded.
284
+ _FLAG_ALIASES: Mapping[str, CanonicalFlag] = {
285
+ "--model": "--model",
286
+ "--allowedtools": "--allowedTools",
287
+ "--allowed-tools": "--allowedTools",
288
+ }
289
+
290
+ _CANONICAL_PREFIXES: Mapping[str, CanonicalFlag] = {
291
+ "--model=": "--model",
292
+ "--allowedtools=": "--allowedTools",
293
+ "--allowed-tools=": "--allowedTools",
294
+ }
295
+
296
+ _BACKGROUND_SWITCHES = {"-p", "--print"}
297
+ _SYSTEM_FLAGS = {"--system-prompt", "--append-system-prompt"}
298
+ _BOOLEAN_FLAGS = {
299
+ "--verbose",
300
+ "--continue", "-c",
301
+ "--dangerously-skip-permissions",
302
+ "--include-partial-messages",
303
+ "--allow-dangerously-skip-permissions",
304
+ "--replay-user-messages",
305
+ "--mcp-debug",
306
+ "--fork-session",
307
+ "--ide",
308
+ "--strict-mcp-config",
309
+ "-v", "--version",
310
+ "-h", "--help",
311
+ }
312
+ _SYSTEM_PREFIXES: Mapping[str, str] = {
313
+ "--system-prompt=": "--system-prompt",
314
+ "--append-system-prompt=": "--append-system-prompt",
315
+ }
316
+
317
+ # Flags with optional values (lowercase).
318
+ _OPTIONAL_VALUE_FLAGS = {
319
+ "--resume", "-r",
320
+ "--debug", "-d",
321
+ }
322
+
323
+ _OPTIONAL_VALUE_FLAG_PREFIXES = {
324
+ "--resume=", "-r=",
325
+ "--debug=", "-d=",
326
+ }
327
+
328
+ _OPTIONAL_ALIAS_GROUPS = (
329
+ frozenset({"--resume", "-r"}),
330
+ frozenset({"--debug", "-d"}),
331
+ )
332
+ _OPTIONAL_ALIAS_LOOKUP: Mapping[str, set[str]] = {
333
+ alias: set(group)
334
+ for group in _OPTIONAL_ALIAS_GROUPS
335
+ for alias in group
336
+ }
337
+
338
+ # Flags that require a following value (lowercase).
339
+ _VALUE_FLAGS = {
340
+ "--add-dir",
341
+ "--agents",
342
+ "--allowed-tools",
343
+ "--allowedtools",
344
+ "--disallowedtools",
345
+ "--disallowed-tools",
346
+ "--fallback-model",
347
+ "--input-format",
348
+ "--max-turns",
349
+ "--mcp-config",
350
+ "--model",
351
+ "--output-format",
352
+ "--permission-mode",
353
+ "--permission-prompt-tool",
354
+ "--plugin-dir",
355
+ "--session-id",
356
+ "--setting-sources",
357
+ "--settings",
358
+ }
359
+
360
+ _VALUE_FLAG_PREFIXES = {
361
+ "--add-dir=",
362
+ "--agents=",
363
+ "--allowedtools=",
364
+ "--allowed-tools=",
365
+ "--disallowedtools=",
366
+ "--disallowed-tools=",
367
+ "--fallback-model=",
368
+ "--input-format=",
369
+ "--max-turns=",
370
+ "--mcp-config=",
371
+ "--model=",
372
+ "--output-format=",
373
+ "--permission-mode=",
374
+ "--permission-prompt-tool=",
375
+ "--plugin-dir=",
376
+ "--session-id=",
377
+ "--setting-sources=",
378
+ "--settings=",
379
+ }
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class ClaudeArgsSpec:
384
+ """Normalized representation of Claude CLI arguments."""
385
+
386
+ source: Literal["cli", "env", "none"]
387
+ raw_tokens: Tuple[str, ...]
388
+ clean_tokens: Tuple[str, ...]
389
+ positional_tokens: Tuple[str, ...]
390
+ positional_indexes: Tuple[int, ...]
391
+ system_entries: Tuple[Tuple[str, str], ...]
392
+ system_flag: str | None
393
+ system_value: str | None
394
+ user_system: str | None
395
+ user_append: str | None
396
+ is_background: bool
397
+ flag_values: Mapping[CanonicalFlag, str]
398
+ errors: Tuple[str, ...] = ()
399
+
400
+ def has_flag(
401
+ self,
402
+ names: Iterable[str] | None = None,
403
+ prefixes: Iterable[str] | None = None,
404
+ ) -> bool:
405
+ """Check for user-provided flags (only scans before -- separator)."""
406
+ name_set = {n.lower() for n in (names or ())}
407
+ prefix_tuple = tuple(p.lower() for p in (prefixes or ()))
408
+
409
+ # Only scan tokens before --
410
+ try:
411
+ dash_idx = self.clean_tokens.index('--')
412
+ tokens_to_scan = self.clean_tokens[:dash_idx]
413
+ except ValueError:
414
+ tokens_to_scan = self.clean_tokens
415
+
416
+ for token in tokens_to_scan:
417
+ lower = token.lower()
418
+ if lower in name_set:
419
+ return True
420
+ if any(lower.startswith(prefix) for prefix in prefix_tuple):
421
+ return True
422
+ return False
423
+
424
+ def rebuild_tokens(self, include_system: bool = True) -> list[str]:
425
+ """Return token list suitable for invoking Claude."""
426
+ tokens = list(self.clean_tokens)
427
+ if include_system and self.system_entries:
428
+ for flag, value in self.system_entries:
429
+ tokens.extend([flag, value])
430
+ return tokens
431
+
432
+ def to_env_string(self) -> str:
433
+ """Render tokens into a shell-safe env string."""
434
+ return shlex.join(self.rebuild_tokens())
435
+
436
+ def update(
437
+ self,
438
+ *,
439
+ background: bool | None = None,
440
+ system_flag: str | None = None,
441
+ system_value: str | None = None,
442
+ prompt: str | None = None,
443
+ ) -> "ClaudeArgsSpec":
444
+ """Return new spec with requested updates applied."""
445
+ tokens = list(self.clean_tokens)
446
+
447
+ if background is not None:
448
+ tokens = _toggle_background(tokens, self.positional_indexes, background)
449
+
450
+ if prompt is not None:
451
+ if prompt == "":
452
+ # Empty string = delete positional arg
453
+ tokens = _remove_positional(tokens)
454
+ else:
455
+ tokens = _set_prompt(tokens, prompt)
456
+
457
+ updated_entries = list(self.system_entries)
458
+
459
+ # Interpret explicit updates
460
+ if system_flag is not None or system_value is not None:
461
+ if system_value == "":
462
+ updated_entries.clear()
463
+ else:
464
+ current_flag = updated_entries[-1][0] if updated_entries else None
465
+ current_value = updated_entries[-1][1] if updated_entries else None
466
+
467
+ if system_flag is not None:
468
+ if system_flag:
469
+ current_flag = system_flag
470
+ else:
471
+ current_flag = None
472
+
473
+ if system_value is not None:
474
+ current_value = system_value
475
+
476
+ if current_flag is None or current_value is None:
477
+ updated_entries.clear()
478
+ else:
479
+ if updated_entries:
480
+ updated_entries[-1] = (current_flag, current_value)
481
+ else:
482
+ updated_entries.append((current_flag, current_value))
483
+
484
+ combined = list(tokens)
485
+ for flag, value in updated_entries:
486
+ combined.extend([flag, value])
487
+
488
+ return _parse_tokens(combined, self.source)
489
+
490
+ def has_errors(self) -> bool:
491
+ return bool(self.errors)
492
+
493
+ def get_flag_value(self, flag_name: str) -> str | None:
494
+ """Get value of any flag by searching clean_tokens.
495
+
496
+ Searches for both space-separated (--flag value) and equals-form (--flag=value).
497
+ Handles registered aliases (e.g., --allowed-tools and --allowedtools return same value).
498
+ Returns None if flag not found.
499
+
500
+ Examples:
501
+ spec.get_flag_value('--output-format')
502
+ spec.get_flag_value('--model')
503
+ spec.get_flag_value('-r') # Short form for --resume
504
+ """
505
+ flag_lower = flag_name.lower()
506
+
507
+ # Build list of possible flag names (original + aliases)
508
+ possible_flags = {flag_lower}
509
+
510
+ # Add canonical form if this is an alias
511
+ if flag_lower in _FLAG_ALIASES:
512
+ canonical = _FLAG_ALIASES[flag_lower]
513
+ possible_flags.add(canonical.lower())
514
+
515
+ # Add all aliases that map to same canonical
516
+ for alias, canonical in _FLAG_ALIASES.items():
517
+ if canonical.lower() == flag_lower or alias.lower() == flag_lower:
518
+ possible_flags.add(alias.lower())
519
+ possible_flags.add(canonical.lower())
520
+
521
+ # Include optional flag aliases (e.g., -r <-> --resume)
522
+ if flag_lower in _OPTIONAL_ALIAS_LOOKUP:
523
+ possible_flags.update(_OPTIONAL_ALIAS_LOOKUP[flag_lower])
524
+
525
+ # Check for --flag=value form in clean_tokens
526
+ for token in self.clean_tokens:
527
+ token_lower = token.lower()
528
+ for possible_flag in possible_flags:
529
+ if token_lower.startswith(possible_flag + '='):
530
+ return token[len(possible_flag) + 1:]
531
+
532
+ # Check for --flag value form (space-separated)
533
+ i = 0
534
+ while i < len(self.clean_tokens):
535
+ token_lower = self.clean_tokens[i].lower()
536
+ if token_lower in possible_flags:
537
+ # Found flag, check if next token is the value
538
+ if i + 1 < len(self.clean_tokens):
539
+ next_token = self.clean_tokens[i + 1]
540
+ # Ensure next token isn't another flag
541
+ if not _looks_like_new_flag(next_token.lower()):
542
+ return next_token
543
+ return None # Flag present but no value
544
+ i += 1
545
+
546
+ return None
547
+
548
+
549
+ def resolve_claude_args(
550
+ cli_args: Sequence[str] | None,
551
+ env_value: str | None,
552
+ ) -> ClaudeArgsSpec:
553
+ """Resolve Claude args from CLI (highest precedence) or env string."""
554
+ if cli_args:
555
+ return _parse_tokens(cli_args, "cli")
556
+
557
+ if env_value is not None:
558
+ try:
559
+ tokens = _split_env(env_value)
560
+ except ValueError as err:
561
+ return _parse_tokens([], "env", initial_errors=[f"invalid Claude args: {err}"])
562
+ return _parse_tokens(tokens, "env")
563
+
564
+ return _parse_tokens([], "none")
565
+
566
+
567
+ def merge_claude_args(env_spec: ClaudeArgsSpec, cli_spec: ClaudeArgsSpec) -> ClaudeArgsSpec:
568
+ """Merge env and CLI specs with smart precedence rules.
569
+
570
+ Rules:
571
+ 1. If CLI has positional args, they REPLACE all env positionals
572
+ - Empty string positional ("") explicitly deletes env positionals
573
+ - No CLI positional means inherit env positionals
574
+ 2. CLI flags override env flags (per-flag precedence)
575
+ 3. Duplicate boolean flags are deduped
576
+ 4. System prompts handled separately via system_entries
577
+
578
+ Args:
579
+ env_spec: Parsed spec from HCOM_CLAUDE_ARGS env
580
+ cli_spec: Parsed spec from CLI forwarded args
581
+
582
+ Returns:
583
+ Merged ClaudeArgsSpec with CLI taking precedence
584
+ """
585
+ # Handle positionals: CLI replaces env (if present), else inherit env
586
+ if cli_spec.positional_tokens:
587
+ # Check for empty string deletion marker
588
+ if cli_spec.positional_tokens == ("",):
589
+ final_positionals = []
590
+ else:
591
+ final_positionals = list(cli_spec.positional_tokens)
592
+ else:
593
+ # No CLI positional → inherit env positional
594
+ final_positionals = list(env_spec.positional_tokens)
595
+
596
+ # Extract flag names from CLI to know what to override
597
+ cli_flag_names = _extract_flag_names_from_tokens(cli_spec.clean_tokens)
598
+
599
+ # Filter out positionals from env and CLI clean_tokens to avoid duplication
600
+ env_positional_set = set(env_spec.positional_tokens)
601
+ cli_positional_set = set(cli_spec.positional_tokens)
602
+
603
+ # Build merged tokens: env flags (not overridden, not positionals) + CLI flags (not positionals)
604
+ merged_tokens = []
605
+ skip_next = False
606
+
607
+ for i, token in enumerate(env_spec.clean_tokens):
608
+ if skip_next:
609
+ skip_next = False
610
+ continue
611
+
612
+ # Skip positionals (will be added explicitly later)
613
+ if token in env_positional_set:
614
+ continue
615
+
616
+ # Check if this is a flag that CLI overrides
617
+ flag_name = _extract_flag_name_from_token(token)
618
+ if flag_name and flag_name in cli_flag_names:
619
+ # CLI overrides this flag, skip env version
620
+ # Check if next token is the value (space-separated syntax)
621
+ if '=' not in token and i + 1 < len(env_spec.clean_tokens):
622
+ next_token = env_spec.clean_tokens[i + 1]
623
+ # Only skip next if it's not a known flag (it's the value)
624
+ if not _looks_like_new_flag(next_token.lower()):
625
+ skip_next = True
626
+ continue
627
+
628
+ merged_tokens.append(token)
629
+
630
+ # Append all CLI tokens (excluding positionals)
631
+ for token in cli_spec.clean_tokens:
632
+ if token not in cli_positional_set:
633
+ merged_tokens.append(token)
634
+
635
+ # Deduplicate boolean flags
636
+ merged_tokens = _deduplicate_boolean_flags(merged_tokens)
637
+
638
+ # Handle system prompts: CLI wins if present, else env
639
+ if cli_spec.system_entries:
640
+ system_entries = cli_spec.system_entries
641
+ else:
642
+ system_entries = env_spec.system_entries
643
+
644
+ # Rebuild spec from merged tokens
645
+ # Need to combine tokens and positionals properly
646
+ combined_tokens = list(merged_tokens)
647
+
648
+ # Insert positionals at correct position
649
+ # Find where positionals should go (after flags, before --)
650
+ insert_idx = len(combined_tokens)
651
+ try:
652
+ dash_idx = combined_tokens.index('--')
653
+ insert_idx = dash_idx
654
+ except ValueError:
655
+ pass
656
+
657
+ # Insert positionals before -- (or at end)
658
+ for pos in reversed(final_positionals):
659
+ combined_tokens.insert(insert_idx, pos)
660
+
661
+ # Add system prompts back
662
+ for flag, value in system_entries:
663
+ combined_tokens.extend([flag, value])
664
+
665
+ # Re-parse to get proper ClaudeArgsSpec with all fields populated
666
+ return _parse_tokens(combined_tokens, "cli")
667
+
668
+
669
+ def _extract_flag_names_from_tokens(tokens: Sequence[str]) -> set[str]:
670
+ """Extract normalized flag names from token list.
671
+
672
+ Returns set of lowercase flag names (without values).
673
+ Examples: '--model' → '--model', '--model=opus' → '--model'
674
+ """
675
+ flag_names = set()
676
+ for token in tokens:
677
+ flag_name = _extract_flag_name_from_token(token)
678
+ if flag_name:
679
+ flag_names.add(flag_name)
680
+ return flag_names
681
+
682
+
683
+ def _extract_flag_name_from_token(token: str) -> str | None:
684
+ """Extract flag name from a token.
685
+
686
+ Examples:
687
+ '--model' → '--model'
688
+ '--model=opus' → '--model'
689
+ '-p' → '-p'
690
+ 'value' → None
691
+ """
692
+ token_lower = token.lower()
693
+
694
+ # Check if starts with - or --
695
+ if not token_lower.startswith('-'):
696
+ return None
697
+
698
+ # Extract name (before = if present)
699
+ if '=' in token_lower:
700
+ return token_lower.split('=')[0]
701
+
702
+ return token_lower
703
+
704
+
705
+ def _deduplicate_boolean_flags(tokens: Sequence[str]) -> list[str]:
706
+ """Remove duplicate boolean flags, keeping first occurrence.
707
+
708
+ Only deduplicates known boolean flags like --verbose, -p, etc.
709
+ Unknown flags and value flags are left as-is (Claude CLI handles them).
710
+ """
711
+ seen_flags = set()
712
+ result = []
713
+
714
+ for token in tokens:
715
+ token_lower = token.lower()
716
+
717
+ # Check if this is a known boolean flag
718
+ if token_lower in _BOOLEAN_FLAGS or token_lower in _BACKGROUND_SWITCHES:
719
+ if token_lower in seen_flags:
720
+ continue # Skip duplicate
721
+ seen_flags.add(token_lower)
722
+
723
+ result.append(token)
724
+
725
+ return result
726
+
727
+
728
+ def merge_system_prompts(
729
+ user_append: str | None,
730
+ user_system: str | None,
731
+ agent_content: str | None,
732
+ *,
733
+ prefer_system_flag: bool,
734
+ ) -> Tuple[str | None, str]:
735
+ """Merge user/agent system prompts, returning content and flag."""
736
+ if not agent_content:
737
+ if user_system:
738
+ return user_system, "--system-prompt"
739
+ if user_append:
740
+ return user_append, "--append-system-prompt"
741
+ return None, ""
742
+
743
+ blocks = []
744
+ if user_system:
745
+ blocks.append(user_system)
746
+ if user_append:
747
+ blocks.append(user_append)
748
+ blocks.append(agent_content)
749
+
750
+ merged = "\n\n".join(blocks)
751
+ if user_system or prefer_system_flag:
752
+ return merged, "--system-prompt"
753
+ return merged, "--append-system-prompt"
754
+
755
+
756
+ def extract_system_prompt_args(tokens: Sequence[str]) -> Tuple[list[str], str | None, str | None]:
757
+ """Public helper mirroring legacy behaviour."""
758
+ spec = _parse_tokens(tokens, "cli")
759
+ return list(spec.clean_tokens), spec.user_append, spec.user_system
760
+
761
+
762
+ def add_background_defaults(spec: ClaudeArgsSpec) -> ClaudeArgsSpec:
763
+ """Add HCOM-specific background mode defaults if missing.
764
+
765
+ When background mode is detected (-p/--print), adds:
766
+ - --output-format stream-json (if not already set)
767
+ - --verbose (if not already set)
768
+
769
+ Returns unchanged spec if not in background mode or flags already present.
770
+ """
771
+ if not spec.is_background:
772
+ return spec
773
+
774
+ tokens = list(spec.clean_tokens)
775
+ modified = False
776
+
777
+ # Find -- separator index if present
778
+ try:
779
+ dash_idx = tokens.index('--')
780
+ insert_idx = dash_idx
781
+ except ValueError:
782
+ insert_idx = len(tokens)
783
+
784
+ # Add --output-format stream-json if missing (insert before --)
785
+ if not spec.has_flag(['--output-format'], ('--output-format=',)):
786
+ tokens.insert(insert_idx, 'stream-json')
787
+ tokens.insert(insert_idx, '--output-format')
788
+ modified = True
789
+ insert_idx += 2 # Adjust insert position
790
+
791
+ # Add --verbose if missing (insert before --)
792
+ if not spec.has_flag(['--verbose']):
793
+ tokens.insert(insert_idx, '--verbose')
794
+ modified = True
795
+
796
+ if not modified:
797
+ return spec
798
+
799
+ # Re-parse to get updated spec with system entries preserved
800
+ combined = tokens[:]
801
+ for flag, value in spec.system_entries:
802
+ combined.extend([flag, value])
803
+
804
+ return _parse_tokens(combined, spec.source)
805
+
806
+
807
+ def validate_conflicts(spec: ClaudeArgsSpec) -> list[str]:
808
+ """Check for conflicting flag combinations.
809
+
810
+ Returns list of warning messages for:
811
+ - Multiple system prompts (informational, not an error)
812
+ - Other known conflicts
813
+
814
+ Empty list means no conflicts detected.
815
+ """
816
+ warnings = []
817
+
818
+ # Check for multiple system prompt entries
819
+ if len(spec.system_entries) > 1:
820
+ flags = [f for f, _ in spec.system_entries]
821
+ warnings.append(
822
+ f"Multiple system prompts detected: {', '.join(flags)}. "
823
+ f"All will be included in order."
824
+ )
825
+
826
+ # Could add more conflict checks here:
827
+ # - --print with interactive-only flags
828
+ # - Conflicting permission modes
829
+ # etc.
830
+
831
+ return warnings
832
+
833
+
834
+ def _parse_tokens(
835
+ tokens: Sequence[str],
836
+ source: Literal["cli", "env", "none"],
837
+ initial_errors: Sequence[str] | None = None,
838
+ ) -> ClaudeArgsSpec:
839
+ errors = list(initial_errors or [])
840
+ clean: list[str] = []
841
+ positional: list[str] = []
842
+ positional_indexes: list[int] = []
843
+ flag_values: dict[CanonicalFlag, str] = {}
844
+ system_entries: list[Tuple[str, str]] = []
845
+
846
+ pending_system: str | None = None
847
+ pending_canonical: CanonicalFlag | None = None
848
+ pending_canonical_token: str | None = None
849
+ pending_generic_flag: str | None = None
850
+ after_double_dash = False
851
+
852
+ system_flag: str | None = None
853
+ system_value: str | None = None
854
+ user_system: str | None = None
855
+ user_append: str | None = None
856
+ is_background = False
857
+
858
+ i = 0
859
+ raw_tokens = tuple(tokens)
860
+
861
+ while i < len(tokens):
862
+ token = tokens[i]
863
+ token_lower = token.lower()
864
+ advance = True
865
+
866
+ if pending_system:
867
+ if _looks_like_new_flag(token_lower):
868
+ errors.append(f"{pending_system} requires a value before '{token}'")
869
+ pending_system = None
870
+ advance = False
871
+ else:
872
+ system_entries.append((pending_system, token))
873
+ system_flag = pending_system
874
+ system_value = token
875
+ if pending_system == "--system-prompt":
876
+ user_system = token
877
+ else:
878
+ user_append = token
879
+ pending_system = None
880
+ if advance:
881
+ i += 1
882
+ continue
883
+
884
+ if pending_canonical:
885
+ if _looks_like_new_flag(token_lower):
886
+ display = pending_canonical_token or pending_canonical
887
+ errors.append(f"{display} requires a value before '{token}'")
888
+ pending_canonical = None
889
+ pending_canonical_token = None
890
+ advance = False
891
+ else:
892
+ idx = len(clean)
893
+ clean.append(token)
894
+ if after_double_dash:
895
+ positional.append(token)
896
+ positional_indexes.append(idx)
897
+ flag_values[pending_canonical] = token
898
+ pending_canonical = None
899
+ pending_canonical_token = None
900
+ if advance:
901
+ i += 1
902
+ continue
903
+
904
+ if pending_generic_flag:
905
+ if _looks_like_new_flag(token_lower):
906
+ errors.append(f"{pending_generic_flag} requires a value before '{token}'")
907
+ pending_generic_flag = None
908
+ advance = False
909
+ else:
910
+ idx = len(clean)
911
+ clean.append(token)
912
+ if after_double_dash:
913
+ positional.append(token)
914
+ positional_indexes.append(idx)
915
+ pending_generic_flag = None
916
+ if advance:
917
+ i += 1
918
+ continue
919
+
920
+ if after_double_dash:
921
+ idx = len(clean)
922
+ clean.append(token)
923
+ positional.append(token)
924
+ positional_indexes.append(idx)
925
+ i += 1
926
+ continue
927
+
928
+ if token_lower == "--":
929
+ clean.append(token)
930
+ after_double_dash = True
931
+ i += 1
932
+ continue
933
+
934
+ if token_lower in _BACKGROUND_SWITCHES:
935
+ is_background = True
936
+ clean.append(token)
937
+ i += 1
938
+ continue
939
+
940
+ if token_lower in _BOOLEAN_FLAGS:
941
+ clean.append(token)
942
+ i += 1
943
+ continue
944
+
945
+ system_assignment = _extract_system_assignment(token, token_lower)
946
+ if system_assignment:
947
+ assigned_flag, value = system_assignment
948
+ system_entries.append((assigned_flag, value))
949
+ system_flag = assigned_flag
950
+ system_value = value
951
+ if assigned_flag == "--system-prompt":
952
+ user_system = value
953
+ else:
954
+ user_append = value
955
+ i += 1
956
+ continue
957
+
958
+ canonical_assignment = _extract_canonical_prefixed(token, token_lower)
959
+ if canonical_assignment:
960
+ canonical_flag, value = canonical_assignment
961
+ clean.append(token)
962
+ flag_values[canonical_flag] = value
963
+ i += 1
964
+ continue
965
+
966
+ if any(token_lower.startswith(prefix) for prefix in _VALUE_FLAG_PREFIXES):
967
+ clean.append(token)
968
+ i += 1
969
+ continue
970
+
971
+ if token_lower in _FLAG_ALIASES:
972
+ pending_canonical = _FLAG_ALIASES[token_lower]
973
+ pending_canonical_token = token
974
+ clean.append(token)
975
+ i += 1
976
+ continue
977
+
978
+ # Handle optional value flags (--resume, --debug, etc.)
979
+ optional_assignment = None
980
+ for prefix in _OPTIONAL_VALUE_FLAG_PREFIXES:
981
+ if token_lower.startswith(prefix):
982
+ # --resume=value or --debug=filter form
983
+ optional_assignment = token
984
+ break
985
+
986
+ if optional_assignment:
987
+ clean.append(token)
988
+ i += 1
989
+ continue
990
+
991
+ if token_lower in _OPTIONAL_VALUE_FLAGS:
992
+ # Peek ahead - only consume value if it's not a flag
993
+ if i + 1 < len(tokens):
994
+ next_token = tokens[i + 1]
995
+ next_lower = next_token.lower()
996
+ if not _looks_like_new_flag(next_lower):
997
+ # Has a value, treat as value flag
998
+ pending_generic_flag = token
999
+ clean.append(token)
1000
+ i += 1
1001
+ continue
1002
+ # No value or next is a flag - just add the flag alone
1003
+ clean.append(token)
1004
+ i += 1
1005
+ continue
1006
+
1007
+ if token_lower in _VALUE_FLAGS:
1008
+ pending_generic_flag = token
1009
+ clean.append(token)
1010
+ i += 1
1011
+ continue
1012
+
1013
+ if token_lower in _SYSTEM_FLAGS:
1014
+ pending_system = "--system-prompt" if token_lower == "--system-prompt" else "--append-system-prompt"
1015
+ i += 1
1016
+ continue
1017
+
1018
+ idx = len(clean)
1019
+ clean.append(token)
1020
+ if not _looks_like_new_flag(token_lower):
1021
+ positional.append(token)
1022
+ positional_indexes.append(idx)
1023
+ i += 1
1024
+
1025
+ if pending_system:
1026
+ errors.append(f"{pending_system} requires a value at end of arguments")
1027
+ if pending_canonical:
1028
+ display = pending_canonical_token or pending_canonical
1029
+ errors.append(f"{display} requires a value at end of arguments")
1030
+ if pending_generic_flag:
1031
+ errors.append(f"{pending_generic_flag} requires a value at end of arguments")
1032
+
1033
+ last_flag = system_entries[-1][0] if system_entries else None
1034
+ last_value = system_entries[-1][1] if system_entries else None
1035
+
1036
+ return ClaudeArgsSpec(
1037
+ source=source,
1038
+ raw_tokens=raw_tokens,
1039
+ clean_tokens=tuple(clean),
1040
+ positional_tokens=tuple(positional),
1041
+ positional_indexes=tuple(positional_indexes),
1042
+ system_entries=tuple(system_entries),
1043
+ system_flag=last_flag,
1044
+ system_value=last_value,
1045
+ user_system=user_system,
1046
+ user_append=user_append,
1047
+ is_background=is_background,
1048
+ flag_values=dict(flag_values),
1049
+ errors=tuple(errors),
1050
+ )
1051
+
1052
+
1053
+ def _split_env(env_value: str) -> list[str]:
1054
+ return shlex.split(env_value)
1055
+
1056
+
1057
+ def _extract_system_assignment(token: str, token_lower: str) -> tuple[str, str] | None:
1058
+ for prefix, canonical in _SYSTEM_PREFIXES.items():
1059
+ if token_lower.startswith(prefix):
1060
+ value = token[len(prefix):]
1061
+ return canonical, value
1062
+ return None
1063
+
1064
+
1065
+ def _extract_canonical_prefixed(token: str, token_lower: str) -> tuple[CanonicalFlag, str] | None:
1066
+ for prefix, canonical in _CANONICAL_PREFIXES.items():
1067
+ if token_lower.startswith(prefix):
1068
+ return canonical, token[len(prefix):]
1069
+ return None
1070
+
1071
+
1072
+ def _looks_like_new_flag(token_lower: str) -> bool:
1073
+ """Check if token looks like a flag (not a value).
1074
+
1075
+ Used to detect when a flag is missing its value (next token is another flag).
1076
+ Recognizes known flags explicitly, no catch-all hyphen check.
1077
+ """
1078
+ if token_lower in _BACKGROUND_SWITCHES:
1079
+ return True
1080
+ if token_lower in _SYSTEM_FLAGS:
1081
+ return True
1082
+ if token_lower in _BOOLEAN_FLAGS:
1083
+ return True
1084
+ if token_lower in _FLAG_ALIASES:
1085
+ return True
1086
+ if token_lower in _OPTIONAL_VALUE_FLAGS:
1087
+ return True
1088
+ if token_lower in _VALUE_FLAGS:
1089
+ return True
1090
+ if token_lower == "--":
1091
+ return True
1092
+ if any(token_lower.startswith(prefix) for prefix in _OPTIONAL_VALUE_FLAG_PREFIXES):
1093
+ return True
1094
+ if any(token_lower.startswith(prefix) for prefix in _VALUE_FLAG_PREFIXES):
1095
+ return True
1096
+ if any(token_lower.startswith(prefix) for prefix in _SYSTEM_PREFIXES):
1097
+ return True
1098
+ if any(token_lower.startswith(prefix) for prefix in _CANONICAL_PREFIXES):
1099
+ return True
1100
+ # NOTE: No catch-all token_lower.startswith("-") check here!
1101
+ # That would reject valid values like "- check something" or "-1"
1102
+ # Instead, we explicitly list known boolean flags above
1103
+ return False
1104
+
1105
+
1106
+ def _toggle_background(tokens: Sequence[str], positional_indexes: Tuple[int, ...], desired: bool) -> list[str]:
1107
+ """Toggle background flag, preserving positional arguments.
1108
+
1109
+ Args:
1110
+ tokens: Token list to process
1111
+ positional_indexes: Indexes of positional arguments (not to be filtered)
1112
+ desired: True to enable background mode, False to disable
1113
+
1114
+ Returns:
1115
+ Modified token list with background flag toggled
1116
+ """
1117
+ tokens_list = list(tokens)
1118
+
1119
+ # Only filter tokens that are NOT positionals
1120
+ filtered = []
1121
+ for idx, token in enumerate(tokens_list):
1122
+ if idx in positional_indexes:
1123
+ # Keep positionals even if they look like flags
1124
+ filtered.append(token)
1125
+ elif token.lower() not in _BACKGROUND_SWITCHES:
1126
+ filtered.append(token)
1127
+
1128
+ has_background = len(filtered) != len(tokens_list)
1129
+
1130
+ if desired:
1131
+ if has_background:
1132
+ return tokens_list
1133
+ return ["-p"] + filtered
1134
+ return filtered
1135
+
1136
+
1137
+ def _set_prompt(tokens: Sequence[str], value: str) -> list[str]:
1138
+ tokens_list = list(tokens)
1139
+ index = _find_first_positional_index(tokens_list)
1140
+ if index is None:
1141
+ tokens_list.append(value)
1142
+ else:
1143
+ tokens_list[index] = value
1144
+ return tokens_list
1145
+
1146
+
1147
+ def _remove_positional(tokens: Sequence[str]) -> list[str]:
1148
+ """Remove first positional argument from tokens"""
1149
+ tokens_list = list(tokens)
1150
+ index = _find_first_positional_index(tokens_list)
1151
+ if index is not None:
1152
+ tokens_list.pop(index)
1153
+ return tokens_list
1154
+
1155
+
1156
+ def _find_first_positional_index(tokens: Sequence[str]) -> int | None:
1157
+ pending_system = False
1158
+ pending_canonical = False
1159
+ pending_generic = False
1160
+ after_double_dash = False
1161
+
1162
+ for idx, token in enumerate(tokens):
1163
+ token_lower = token.lower()
1164
+
1165
+ if after_double_dash:
1166
+ return idx
1167
+ if token_lower == "--":
1168
+ after_double_dash = True
1169
+ continue
1170
+ if pending_system:
1171
+ pending_system = False
1172
+ continue
1173
+ if pending_canonical:
1174
+ pending_canonical = False
1175
+ continue
1176
+ if pending_generic:
1177
+ pending_generic = False
1178
+ continue
1179
+ if token_lower in _BACKGROUND_SWITCHES:
1180
+ continue
1181
+ if token_lower in _BOOLEAN_FLAGS:
1182
+ continue
1183
+ if _extract_system_assignment(token, token_lower):
1184
+ continue
1185
+ if token_lower in _SYSTEM_FLAGS:
1186
+ pending_system = True
1187
+ continue
1188
+ if _extract_canonical_prefixed(token, token_lower):
1189
+ continue
1190
+ if any(token_lower.startswith(prefix) for prefix in _OPTIONAL_VALUE_FLAG_PREFIXES):
1191
+ continue
1192
+ if any(token_lower.startswith(prefix) for prefix in _VALUE_FLAG_PREFIXES):
1193
+ continue
1194
+ if token_lower in _FLAG_ALIASES:
1195
+ pending_canonical = True
1196
+ continue
1197
+ if token_lower in _OPTIONAL_VALUE_FLAGS:
1198
+ pending_generic = True
1199
+ continue
1200
+ if token_lower in _VALUE_FLAGS:
1201
+ pending_generic = True
1202
+ continue
1203
+ if _looks_like_new_flag(token_lower):
1204
+ continue
1205
+ return idx
1206
+ return None