hcom 0.5.0__py3-none-any.whl → 0.6.0__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,1036 @@
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.0"
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('\\"', '"')
196
+ inner = inner.replace('\x00', '\\')
197
+ return inner
198
+
199
+ if value.startswith("'") and value.endswith("'") and len(value) >= 2:
200
+ return value[1:-1]
201
+
202
+ return value
203
+
204
+
205
+ def format_env_value(value: str) -> str:
206
+ """Format value for ENV file with proper quoting (inverse of parse_env_value)"""
207
+ if not value:
208
+ return value
209
+
210
+ if "'" in value:
211
+ escaped = value.replace('\\', '\\\\').replace('"', '\\"')
212
+ return f'"{escaped}"'
213
+
214
+ return value
215
+
216
+
217
+ def parse_env_file(config_path: Path) -> dict[str, str]:
218
+ """Parse ENV file (KEY=VALUE format) with security validation"""
219
+ config: dict[str, str] = {}
220
+
221
+ dangerous_chars = ['`', '$', ';', '|', '&', '\n', '\r']
222
+
223
+ try:
224
+ content = config_path.read_text(encoding='utf-8')
225
+ for line in content.splitlines():
226
+ line = line.strip()
227
+ if not line or line.startswith('#'):
228
+ continue
229
+ if '=' in line:
230
+ key, _, value = line.partition('=')
231
+ key = key.strip()
232
+ value = value.strip()
233
+
234
+ if key == 'HCOM_TERMINAL':
235
+ if any(c in value for c in dangerous_chars):
236
+ print(
237
+ f"Warning: Unsafe characters in HCOM_TERMINAL "
238
+ f"({', '.join(repr(c) for c in dangerous_chars if c in value)}), "
239
+ f"ignoring custom terminal command",
240
+ file=sys.stderr
241
+ )
242
+ continue
243
+ if value not in ('new', 'here', 'print') and '{script}' not in value:
244
+ print(
245
+ "Warning: HCOM_TERMINAL custom command must include {script} placeholder, "
246
+ "ignoring",
247
+ file=sys.stderr
248
+ )
249
+ continue
250
+
251
+ parsed = parse_env_value(value)
252
+ if key:
253
+ config[key] = parsed
254
+ except (FileNotFoundError, PermissionError, UnicodeDecodeError):
255
+ pass
256
+ return config
257
+
258
+
259
+
260
+
261
+
262
+ # =====================CLAUDE ARGS============================
263
+ # Helpers for parsing and composing Claude CLI arguments.
264
+ # Used by hcom.py and ui.py for consistent arg parsing and launch command building.
265
+
266
+ import shlex
267
+ from dataclasses import dataclass
268
+ from typing import Iterable, Literal, Mapping, Sequence, Tuple
269
+
270
+
271
+ CanonicalFlag = Literal["--model", "--allowedTools"]
272
+
273
+ # All flag keys stored in lowercase (aside from short-form switches) for comparisons;
274
+ # values use canonical casing when recorded.
275
+ _FLAG_ALIASES: Mapping[str, CanonicalFlag] = {
276
+ "--model": "--model",
277
+ "--allowedtools": "--allowedTools",
278
+ "--allowed-tools": "--allowedTools",
279
+ }
280
+
281
+ _CANONICAL_PREFIXES: Mapping[str, CanonicalFlag] = {
282
+ "--model=": "--model",
283
+ "--allowedtools=": "--allowedTools",
284
+ "--allowed-tools=": "--allowedTools",
285
+ }
286
+
287
+ _BACKGROUND_SWITCHES = {"-p", "--print"}
288
+ _SYSTEM_FLAGS = {"--system-prompt", "--append-system-prompt"}
289
+ _BOOLEAN_FLAGS = {
290
+ "--verbose",
291
+ "--continue", "-c",
292
+ "--dangerously-skip-permissions",
293
+ "--include-partial-messages",
294
+ "--allow-dangerously-skip-permissions",
295
+ "--replay-user-messages",
296
+ "--mcp-debug",
297
+ "--fork-session",
298
+ "--ide",
299
+ "--strict-mcp-config",
300
+ "-v", "--version",
301
+ "-h", "--help",
302
+ }
303
+ _SYSTEM_PREFIXES: Mapping[str, str] = {
304
+ "--system-prompt=": "--system-prompt",
305
+ "--append-system-prompt=": "--append-system-prompt",
306
+ }
307
+
308
+ # Flags with optional values (lowercase).
309
+ _OPTIONAL_VALUE_FLAGS = {
310
+ "--resume", "-r",
311
+ "--debug", "-d",
312
+ }
313
+
314
+ _OPTIONAL_VALUE_FLAG_PREFIXES = {
315
+ "--resume=", "-r=",
316
+ "--debug=", "-d=",
317
+ }
318
+
319
+ _OPTIONAL_ALIAS_GROUPS = (
320
+ frozenset({"--resume", "-r"}),
321
+ frozenset({"--debug", "-d"}),
322
+ )
323
+ _OPTIONAL_ALIAS_LOOKUP: Mapping[str, set[str]] = {
324
+ alias: set(group)
325
+ for group in _OPTIONAL_ALIAS_GROUPS
326
+ for alias in group
327
+ }
328
+
329
+ # Flags that require a following value (lowercase).
330
+ _VALUE_FLAGS = {
331
+ "--add-dir",
332
+ "--agents",
333
+ "--allowed-tools",
334
+ "--allowedtools",
335
+ "--disallowedtools",
336
+ "--disallowed-tools",
337
+ "--fallback-model",
338
+ "--input-format",
339
+ "--max-turns",
340
+ "--mcp-config",
341
+ "--model",
342
+ "--output-format",
343
+ "--permission-mode",
344
+ "--permission-prompt-tool",
345
+ "--plugin-dir",
346
+ "--session-id",
347
+ "--setting-sources",
348
+ "--settings",
349
+ }
350
+
351
+ _VALUE_FLAG_PREFIXES = {
352
+ "--add-dir=",
353
+ "--agents=",
354
+ "--allowedtools=",
355
+ "--allowed-tools=",
356
+ "--disallowedtools=",
357
+ "--disallowed-tools=",
358
+ "--fallback-model=",
359
+ "--input-format=",
360
+ "--max-turns=",
361
+ "--mcp-config=",
362
+ "--model=",
363
+ "--output-format=",
364
+ "--permission-mode=",
365
+ "--permission-prompt-tool=",
366
+ "--plugin-dir=",
367
+ "--session-id=",
368
+ "--setting-sources=",
369
+ "--settings=",
370
+ }
371
+
372
+
373
+ @dataclass(frozen=True)
374
+ class ClaudeArgsSpec:
375
+ """Normalized representation of Claude CLI arguments."""
376
+
377
+ source: Literal["cli", "env", "none"]
378
+ raw_tokens: Tuple[str, ...]
379
+ clean_tokens: Tuple[str, ...]
380
+ positional_tokens: Tuple[str, ...]
381
+ positional_indexes: Tuple[int, ...]
382
+ system_entries: Tuple[Tuple[str, str], ...]
383
+ system_flag: str | None
384
+ system_value: str | None
385
+ user_system: str | None
386
+ user_append: str | None
387
+ is_background: bool
388
+ flag_values: Mapping[CanonicalFlag, str]
389
+ errors: Tuple[str, ...] = ()
390
+
391
+ def has_flag(
392
+ self,
393
+ names: Iterable[str] | None = None,
394
+ prefixes: Iterable[str] | None = None,
395
+ ) -> bool:
396
+ """Check for user-provided flags (only scans before -- separator)."""
397
+ name_set = {n.lower() for n in (names or ())}
398
+ prefix_tuple = tuple(p.lower() for p in (prefixes or ()))
399
+
400
+ # Only scan tokens before --
401
+ try:
402
+ dash_idx = self.clean_tokens.index('--')
403
+ tokens_to_scan = self.clean_tokens[:dash_idx]
404
+ except ValueError:
405
+ tokens_to_scan = self.clean_tokens
406
+
407
+ for token in tokens_to_scan:
408
+ lower = token.lower()
409
+ if lower in name_set:
410
+ return True
411
+ if any(lower.startswith(prefix) for prefix in prefix_tuple):
412
+ return True
413
+ return False
414
+
415
+ def rebuild_tokens(self, include_system: bool = True) -> list[str]:
416
+ """Return token list suitable for invoking Claude."""
417
+ tokens = list(self.clean_tokens)
418
+ if include_system and self.system_entries:
419
+ for flag, value in self.system_entries:
420
+ tokens.extend([flag, value])
421
+ return tokens
422
+
423
+ def to_env_string(self) -> str:
424
+ """Render tokens into a shell-safe env string."""
425
+ return shlex.join(self.rebuild_tokens())
426
+
427
+ def update(
428
+ self,
429
+ *,
430
+ background: bool | None = None,
431
+ system_flag: str | None = None,
432
+ system_value: str | None = None,
433
+ prompt: str | None = None,
434
+ ) -> "ClaudeArgsSpec":
435
+ """Return new spec with requested updates applied."""
436
+ tokens = list(self.clean_tokens)
437
+
438
+ if background is not None:
439
+ tokens = _toggle_background(tokens, self.positional_indexes, background)
440
+
441
+ if prompt is not None:
442
+ if prompt == "":
443
+ # Empty string = delete positional arg
444
+ tokens = _remove_positional(tokens)
445
+ else:
446
+ tokens = _set_prompt(tokens, prompt)
447
+
448
+ updated_entries = list(self.system_entries)
449
+
450
+ # Interpret explicit updates
451
+ if system_flag is not None or system_value is not None:
452
+ if system_value == "":
453
+ updated_entries.clear()
454
+ else:
455
+ current_flag = updated_entries[-1][0] if updated_entries else None
456
+ current_value = updated_entries[-1][1] if updated_entries else None
457
+
458
+ if system_flag is not None:
459
+ if system_flag:
460
+ current_flag = system_flag
461
+ else:
462
+ current_flag = None
463
+
464
+ if system_value is not None:
465
+ current_value = system_value
466
+
467
+ if current_flag is None or current_value is None:
468
+ updated_entries.clear()
469
+ else:
470
+ if updated_entries:
471
+ updated_entries[-1] = (current_flag, current_value)
472
+ else:
473
+ updated_entries.append((current_flag, current_value))
474
+
475
+ combined = list(tokens)
476
+ for flag, value in updated_entries:
477
+ combined.extend([flag, value])
478
+
479
+ return _parse_tokens(combined, self.source)
480
+
481
+ def has_errors(self) -> bool:
482
+ return bool(self.errors)
483
+
484
+ def get_flag_value(self, flag_name: str) -> str | None:
485
+ """Get value of any flag by searching clean_tokens.
486
+
487
+ Searches for both space-separated (--flag value) and equals-form (--flag=value).
488
+ Handles registered aliases (e.g., --allowed-tools and --allowedtools return same value).
489
+ Returns None if flag not found.
490
+
491
+ Examples:
492
+ spec.get_flag_value('--output-format')
493
+ spec.get_flag_value('--model')
494
+ spec.get_flag_value('-r') # Short form for --resume
495
+ """
496
+ flag_lower = flag_name.lower()
497
+
498
+ # Build list of possible flag names (original + aliases)
499
+ possible_flags = {flag_lower}
500
+
501
+ # Add canonical form if this is an alias
502
+ if flag_lower in _FLAG_ALIASES:
503
+ canonical = _FLAG_ALIASES[flag_lower]
504
+ possible_flags.add(canonical.lower())
505
+
506
+ # Add all aliases that map to same canonical
507
+ for alias, canonical in _FLAG_ALIASES.items():
508
+ if canonical.lower() == flag_lower or alias.lower() == flag_lower:
509
+ possible_flags.add(alias.lower())
510
+ possible_flags.add(canonical.lower())
511
+
512
+ # Include optional flag aliases (e.g., -r <-> --resume)
513
+ if flag_lower in _OPTIONAL_ALIAS_LOOKUP:
514
+ possible_flags.update(_OPTIONAL_ALIAS_LOOKUP[flag_lower])
515
+
516
+ # Check for --flag=value form in clean_tokens
517
+ for token in self.clean_tokens:
518
+ token_lower = token.lower()
519
+ for possible_flag in possible_flags:
520
+ if token_lower.startswith(possible_flag + '='):
521
+ return token[len(possible_flag) + 1:]
522
+
523
+ # Check for --flag value form (space-separated)
524
+ i = 0
525
+ while i < len(self.clean_tokens):
526
+ token_lower = self.clean_tokens[i].lower()
527
+ if token_lower in possible_flags:
528
+ # Found flag, check if next token is the value
529
+ if i + 1 < len(self.clean_tokens):
530
+ next_token = self.clean_tokens[i + 1]
531
+ # Ensure next token isn't another flag
532
+ if not _looks_like_new_flag(next_token.lower()):
533
+ return next_token
534
+ return None # Flag present but no value
535
+ i += 1
536
+
537
+ return None
538
+
539
+
540
+ def resolve_claude_args(
541
+ cli_args: Sequence[str] | None,
542
+ env_value: str | None,
543
+ ) -> ClaudeArgsSpec:
544
+ """Resolve Claude args from CLI (highest precedence) or env string."""
545
+ if cli_args:
546
+ return _parse_tokens(cli_args, "cli")
547
+
548
+ if env_value is not None:
549
+ try:
550
+ tokens = _split_env(env_value)
551
+ except ValueError as err:
552
+ return _parse_tokens([], "env", initial_errors=[f"invalid Claude args: {err}"])
553
+ return _parse_tokens(tokens, "env")
554
+
555
+ return _parse_tokens([], "none")
556
+
557
+
558
+ def merge_system_prompts(
559
+ user_append: str | None,
560
+ user_system: str | None,
561
+ agent_content: str | None,
562
+ *,
563
+ prefer_system_flag: bool,
564
+ ) -> Tuple[str | None, str]:
565
+ """Merge user/agent system prompts, returning content and flag."""
566
+ if not agent_content:
567
+ if user_system:
568
+ return user_system, "--system-prompt"
569
+ if user_append:
570
+ return user_append, "--append-system-prompt"
571
+ return None, ""
572
+
573
+ blocks = []
574
+ if user_system:
575
+ blocks.append(user_system)
576
+ if user_append:
577
+ blocks.append(user_append)
578
+ blocks.append(agent_content)
579
+
580
+ merged = "\n\n".join(blocks)
581
+ if user_system or prefer_system_flag:
582
+ return merged, "--system-prompt"
583
+ return merged, "--append-system-prompt"
584
+
585
+
586
+ def extract_system_prompt_args(tokens: Sequence[str]) -> Tuple[list[str], str | None, str | None]:
587
+ """Public helper mirroring legacy behaviour."""
588
+ spec = _parse_tokens(tokens, "cli")
589
+ return list(spec.clean_tokens), spec.user_append, spec.user_system
590
+
591
+
592
+ def add_background_defaults(spec: ClaudeArgsSpec) -> ClaudeArgsSpec:
593
+ """Add HCOM-specific background mode defaults if missing.
594
+
595
+ When background mode is detected (-p/--print), adds:
596
+ - --output-format stream-json (if not already set)
597
+ - --verbose (if not already set)
598
+
599
+ Returns unchanged spec if not in background mode or flags already present.
600
+ """
601
+ if not spec.is_background:
602
+ return spec
603
+
604
+ tokens = list(spec.clean_tokens)
605
+ modified = False
606
+
607
+ # Find -- separator index if present
608
+ try:
609
+ dash_idx = tokens.index('--')
610
+ insert_idx = dash_idx
611
+ except ValueError:
612
+ insert_idx = len(tokens)
613
+
614
+ # Add --output-format stream-json if missing (insert before --)
615
+ if not spec.has_flag(['--output-format'], ('--output-format=',)):
616
+ tokens.insert(insert_idx, 'stream-json')
617
+ tokens.insert(insert_idx, '--output-format')
618
+ modified = True
619
+ insert_idx += 2 # Adjust insert position
620
+
621
+ # Add --verbose if missing (insert before --)
622
+ if not spec.has_flag(['--verbose']):
623
+ tokens.insert(insert_idx, '--verbose')
624
+ modified = True
625
+
626
+ if not modified:
627
+ return spec
628
+
629
+ # Re-parse to get updated spec with system entries preserved
630
+ combined = tokens[:]
631
+ for flag, value in spec.system_entries:
632
+ combined.extend([flag, value])
633
+
634
+ return _parse_tokens(combined, spec.source)
635
+
636
+
637
+ def validate_conflicts(spec: ClaudeArgsSpec) -> list[str]:
638
+ """Check for conflicting flag combinations.
639
+
640
+ Returns list of warning messages for:
641
+ - Multiple system prompts (informational, not an error)
642
+ - Other known conflicts
643
+
644
+ Empty list means no conflicts detected.
645
+ """
646
+ warnings = []
647
+
648
+ # Check for multiple system prompt entries
649
+ if len(spec.system_entries) > 1:
650
+ flags = [f for f, _ in spec.system_entries]
651
+ warnings.append(
652
+ f"Multiple system prompts detected: {', '.join(flags)}. "
653
+ f"All will be included in order."
654
+ )
655
+
656
+ # Could add more conflict checks here:
657
+ # - --print with interactive-only flags
658
+ # - Conflicting permission modes
659
+ # etc.
660
+
661
+ return warnings
662
+
663
+
664
+ def _parse_tokens(
665
+ tokens: Sequence[str],
666
+ source: Literal["cli", "env", "none"],
667
+ initial_errors: Sequence[str] | None = None,
668
+ ) -> ClaudeArgsSpec:
669
+ errors = list(initial_errors or [])
670
+ clean: list[str] = []
671
+ positional: list[str] = []
672
+ positional_indexes: list[int] = []
673
+ flag_values: dict[CanonicalFlag, str] = {}
674
+ system_entries: list[Tuple[str, str]] = []
675
+
676
+ pending_system: str | None = None
677
+ pending_canonical: CanonicalFlag | None = None
678
+ pending_canonical_token: str | None = None
679
+ pending_generic_flag: str | None = None
680
+ after_double_dash = False
681
+
682
+ system_flag: str | None = None
683
+ system_value: str | None = None
684
+ user_system: str | None = None
685
+ user_append: str | None = None
686
+ is_background = False
687
+
688
+ i = 0
689
+ raw_tokens = tuple(tokens)
690
+
691
+ while i < len(tokens):
692
+ token = tokens[i]
693
+ token_lower = token.lower()
694
+ advance = True
695
+
696
+ if pending_system:
697
+ if _looks_like_new_flag(token_lower):
698
+ errors.append(f"{pending_system} requires a value before '{token}'")
699
+ pending_system = None
700
+ advance = False
701
+ else:
702
+ system_entries.append((pending_system, token))
703
+ system_flag = pending_system
704
+ system_value = token
705
+ if pending_system == "--system-prompt":
706
+ user_system = token
707
+ else:
708
+ user_append = token
709
+ pending_system = None
710
+ if advance:
711
+ i += 1
712
+ continue
713
+
714
+ if pending_canonical:
715
+ if _looks_like_new_flag(token_lower):
716
+ display = pending_canonical_token or pending_canonical
717
+ errors.append(f"{display} requires a value before '{token}'")
718
+ pending_canonical = None
719
+ pending_canonical_token = None
720
+ advance = False
721
+ else:
722
+ idx = len(clean)
723
+ clean.append(token)
724
+ if after_double_dash:
725
+ positional.append(token)
726
+ positional_indexes.append(idx)
727
+ flag_values[pending_canonical] = token
728
+ pending_canonical = None
729
+ pending_canonical_token = None
730
+ if advance:
731
+ i += 1
732
+ continue
733
+
734
+ if pending_generic_flag:
735
+ if _looks_like_new_flag(token_lower):
736
+ errors.append(f"{pending_generic_flag} requires a value before '{token}'")
737
+ pending_generic_flag = None
738
+ advance = False
739
+ else:
740
+ idx = len(clean)
741
+ clean.append(token)
742
+ if after_double_dash:
743
+ positional.append(token)
744
+ positional_indexes.append(idx)
745
+ pending_generic_flag = None
746
+ if advance:
747
+ i += 1
748
+ continue
749
+
750
+ if after_double_dash:
751
+ idx = len(clean)
752
+ clean.append(token)
753
+ positional.append(token)
754
+ positional_indexes.append(idx)
755
+ i += 1
756
+ continue
757
+
758
+ if token_lower == "--":
759
+ clean.append(token)
760
+ after_double_dash = True
761
+ i += 1
762
+ continue
763
+
764
+ if token_lower in _BACKGROUND_SWITCHES:
765
+ is_background = True
766
+ clean.append(token)
767
+ i += 1
768
+ continue
769
+
770
+ if token_lower in _BOOLEAN_FLAGS:
771
+ clean.append(token)
772
+ i += 1
773
+ continue
774
+
775
+ system_assignment = _extract_system_assignment(token, token_lower)
776
+ if system_assignment:
777
+ assigned_flag, value = system_assignment
778
+ system_entries.append((assigned_flag, value))
779
+ system_flag = assigned_flag
780
+ system_value = value
781
+ if assigned_flag == "--system-prompt":
782
+ user_system = value
783
+ else:
784
+ user_append = value
785
+ i += 1
786
+ continue
787
+
788
+ canonical_assignment = _extract_canonical_prefixed(token, token_lower)
789
+ if canonical_assignment:
790
+ canonical_flag, value = canonical_assignment
791
+ clean.append(token)
792
+ flag_values[canonical_flag] = value
793
+ i += 1
794
+ continue
795
+
796
+ if any(token_lower.startswith(prefix) for prefix in _VALUE_FLAG_PREFIXES):
797
+ clean.append(token)
798
+ i += 1
799
+ continue
800
+
801
+ if token_lower in _FLAG_ALIASES:
802
+ pending_canonical = _FLAG_ALIASES[token_lower]
803
+ pending_canonical_token = token
804
+ clean.append(token)
805
+ i += 1
806
+ continue
807
+
808
+ # Handle optional value flags (--resume, --debug, etc.)
809
+ optional_assignment = None
810
+ for prefix in _OPTIONAL_VALUE_FLAG_PREFIXES:
811
+ if token_lower.startswith(prefix):
812
+ # --resume=value or --debug=filter form
813
+ optional_assignment = token
814
+ break
815
+
816
+ if optional_assignment:
817
+ clean.append(token)
818
+ i += 1
819
+ continue
820
+
821
+ if token_lower in _OPTIONAL_VALUE_FLAGS:
822
+ # Peek ahead - only consume value if it's not a flag
823
+ if i + 1 < len(tokens):
824
+ next_token = tokens[i + 1]
825
+ next_lower = next_token.lower()
826
+ if not _looks_like_new_flag(next_lower):
827
+ # Has a value, treat as value flag
828
+ pending_generic_flag = token
829
+ clean.append(token)
830
+ i += 1
831
+ continue
832
+ # No value or next is a flag - just add the flag alone
833
+ clean.append(token)
834
+ i += 1
835
+ continue
836
+
837
+ if token_lower in _VALUE_FLAGS:
838
+ pending_generic_flag = token
839
+ clean.append(token)
840
+ i += 1
841
+ continue
842
+
843
+ if token_lower in _SYSTEM_FLAGS:
844
+ pending_system = "--system-prompt" if token_lower == "--system-prompt" else "--append-system-prompt"
845
+ i += 1
846
+ continue
847
+
848
+ idx = len(clean)
849
+ clean.append(token)
850
+ if not _looks_like_new_flag(token_lower):
851
+ positional.append(token)
852
+ positional_indexes.append(idx)
853
+ i += 1
854
+
855
+ if pending_system:
856
+ errors.append(f"{pending_system} requires a value at end of arguments")
857
+ if pending_canonical:
858
+ display = pending_canonical_token or pending_canonical
859
+ errors.append(f"{display} requires a value at end of arguments")
860
+ if pending_generic_flag:
861
+ errors.append(f"{pending_generic_flag} requires a value at end of arguments")
862
+
863
+ last_flag = system_entries[-1][0] if system_entries else None
864
+ last_value = system_entries[-1][1] if system_entries else None
865
+
866
+ return ClaudeArgsSpec(
867
+ source=source,
868
+ raw_tokens=raw_tokens,
869
+ clean_tokens=tuple(clean),
870
+ positional_tokens=tuple(positional),
871
+ positional_indexes=tuple(positional_indexes),
872
+ system_entries=tuple(system_entries),
873
+ system_flag=last_flag,
874
+ system_value=last_value,
875
+ user_system=user_system,
876
+ user_append=user_append,
877
+ is_background=is_background,
878
+ flag_values=dict(flag_values),
879
+ errors=tuple(errors),
880
+ )
881
+
882
+
883
+ def _split_env(env_value: str) -> list[str]:
884
+ return shlex.split(env_value)
885
+
886
+
887
+ def _extract_system_assignment(token: str, token_lower: str) -> tuple[str, str] | None:
888
+ for prefix, canonical in _SYSTEM_PREFIXES.items():
889
+ if token_lower.startswith(prefix):
890
+ value = token[len(prefix):]
891
+ return canonical, value
892
+ return None
893
+
894
+
895
+ def _extract_canonical_prefixed(token: str, token_lower: str) -> tuple[CanonicalFlag, str] | None:
896
+ for prefix, canonical in _CANONICAL_PREFIXES.items():
897
+ if token_lower.startswith(prefix):
898
+ return canonical, token[len(prefix):]
899
+ return None
900
+
901
+
902
+ def _looks_like_new_flag(token_lower: str) -> bool:
903
+ """Check if token looks like a flag (not a value).
904
+
905
+ Used to detect when a flag is missing its value (next token is another flag).
906
+ Recognizes known flags explicitly, no catch-all hyphen check.
907
+ """
908
+ if token_lower in _BACKGROUND_SWITCHES:
909
+ return True
910
+ if token_lower in _SYSTEM_FLAGS:
911
+ return True
912
+ if token_lower in _BOOLEAN_FLAGS:
913
+ return True
914
+ if token_lower in _FLAG_ALIASES:
915
+ return True
916
+ if token_lower in _OPTIONAL_VALUE_FLAGS:
917
+ return True
918
+ if token_lower in _VALUE_FLAGS:
919
+ return True
920
+ if token_lower == "--":
921
+ return True
922
+ if any(token_lower.startswith(prefix) for prefix in _OPTIONAL_VALUE_FLAG_PREFIXES):
923
+ return True
924
+ if any(token_lower.startswith(prefix) for prefix in _VALUE_FLAG_PREFIXES):
925
+ return True
926
+ if any(token_lower.startswith(prefix) for prefix in _SYSTEM_PREFIXES):
927
+ return True
928
+ if any(token_lower.startswith(prefix) for prefix in _CANONICAL_PREFIXES):
929
+ return True
930
+ # NOTE: No catch-all token_lower.startswith("-") check here!
931
+ # That would reject valid values like "- check something" or "-1"
932
+ # Instead, we explicitly list known boolean flags above
933
+ return False
934
+
935
+
936
+ def _toggle_background(tokens: Sequence[str], positional_indexes: Tuple[int, ...], desired: bool) -> list[str]:
937
+ """Toggle background flag, preserving positional arguments.
938
+
939
+ Args:
940
+ tokens: Token list to process
941
+ positional_indexes: Indexes of positional arguments (not to be filtered)
942
+ desired: True to enable background mode, False to disable
943
+
944
+ Returns:
945
+ Modified token list with background flag toggled
946
+ """
947
+ tokens_list = list(tokens)
948
+
949
+ # Only filter tokens that are NOT positionals
950
+ filtered = []
951
+ for idx, token in enumerate(tokens_list):
952
+ if idx in positional_indexes:
953
+ # Keep positionals even if they look like flags
954
+ filtered.append(token)
955
+ elif token.lower() not in _BACKGROUND_SWITCHES:
956
+ filtered.append(token)
957
+
958
+ has_background = len(filtered) != len(tokens_list)
959
+
960
+ if desired:
961
+ if has_background:
962
+ return tokens_list
963
+ return ["-p"] + filtered
964
+ return filtered
965
+
966
+
967
+ def _set_prompt(tokens: Sequence[str], value: str) -> list[str]:
968
+ tokens_list = list(tokens)
969
+ index = _find_first_positional_index(tokens_list)
970
+ if index is None:
971
+ tokens_list.append(value)
972
+ else:
973
+ tokens_list[index] = value
974
+ return tokens_list
975
+
976
+
977
+ def _remove_positional(tokens: Sequence[str]) -> list[str]:
978
+ """Remove first positional argument from tokens"""
979
+ tokens_list = list(tokens)
980
+ index = _find_first_positional_index(tokens_list)
981
+ if index is not None:
982
+ tokens_list.pop(index)
983
+ return tokens_list
984
+
985
+
986
+ def _find_first_positional_index(tokens: Sequence[str]) -> int | None:
987
+ pending_system = False
988
+ pending_canonical = False
989
+ pending_generic = False
990
+ after_double_dash = False
991
+
992
+ for idx, token in enumerate(tokens):
993
+ token_lower = token.lower()
994
+
995
+ if after_double_dash:
996
+ return idx
997
+ if token_lower == "--":
998
+ after_double_dash = True
999
+ continue
1000
+ if pending_system:
1001
+ pending_system = False
1002
+ continue
1003
+ if pending_canonical:
1004
+ pending_canonical = False
1005
+ continue
1006
+ if pending_generic:
1007
+ pending_generic = False
1008
+ continue
1009
+ if token_lower in _BACKGROUND_SWITCHES:
1010
+ continue
1011
+ if token_lower in _BOOLEAN_FLAGS:
1012
+ continue
1013
+ if _extract_system_assignment(token, token_lower):
1014
+ continue
1015
+ if token_lower in _SYSTEM_FLAGS:
1016
+ pending_system = True
1017
+ continue
1018
+ if _extract_canonical_prefixed(token, token_lower):
1019
+ continue
1020
+ if any(token_lower.startswith(prefix) for prefix in _OPTIONAL_VALUE_FLAG_PREFIXES):
1021
+ continue
1022
+ if any(token_lower.startswith(prefix) for prefix in _VALUE_FLAG_PREFIXES):
1023
+ continue
1024
+ if token_lower in _FLAG_ALIASES:
1025
+ pending_canonical = True
1026
+ continue
1027
+ if token_lower in _OPTIONAL_VALUE_FLAGS:
1028
+ pending_generic = True
1029
+ continue
1030
+ if token_lower in _VALUE_FLAGS:
1031
+ pending_generic = True
1032
+ continue
1033
+ if _looks_like_new_flag(token_lower):
1034
+ continue
1035
+ return idx
1036
+ return None