langstage-cli 0.6.3__tar.gz → 0.6.5__tar.gz

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 (33) hide show
  1. {langstage_cli-0.6.3/langstage_cli.egg-info → langstage_cli-0.6.5}/PKG-INFO +31 -3
  2. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/README.md +28 -0
  3. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/cli.py +148 -39
  4. {langstage_cli-0.6.3 → langstage_cli-0.6.5/langstage_cli.egg-info}/PKG-INFO +31 -3
  5. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/SOURCES.txt +3 -1
  6. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/requires.txt +2 -2
  7. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/pyproject.toml +4 -3
  8. langstage_cli-0.6.5/tests/test_quiet_output.py +79 -0
  9. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_streaming_marker.py +7 -3
  10. langstage_cli-0.6.5/tests/test_verify_flag.py +45 -0
  11. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/LICENSE +0 -0
  12. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/deepagent_code/__init__.py +0 -0
  13. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/__init__.py +0 -0
  14. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/agui_stream.py +0 -0
  15. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/config.py +0 -0
  16. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/dependency_links.txt +0 -0
  17. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/entry_points.txt +0 -0
  18. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/top_level.txt +0 -0
  19. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/setup.cfg +0 -0
  20. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_agui_stream.py +0 -0
  21. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_checkpointer.py +0 -0
  22. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_cli.py +0 -0
  23. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_cli_help.py +0 -0
  24. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_codeconfig.py +0 -0
  25. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_config.py +0 -0
  26. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_help_render.py +0 -0
  27. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_no_interactive_approve.py +0 -0
  28. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_rename_shim.py +0 -0
  29. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_show_config.py +0 -0
  30. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_spec_resolution.py +0 -0
  31. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_stream_mode.py +0 -0
  32. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_turn_exit_and_render.py +0 -0
  33. {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_unicode_console.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.3
3
+ Version: 0.6.5
4
4
  Summary: The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph
5
5
  Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: langgraph>=0.2.0
22
- Requires-Dist: langstage-core[agui]>=1.0.4
22
+ Requires-Dist: langstage-core[agui]>=1.0.6
23
23
  Requires-Dist: click>=8.0.0
24
24
  Requires-Dist: python-dotenv
25
25
  Provides-Extra: dev
@@ -32,7 +32,7 @@ Requires-Dist: deepagents>=0.3; extra == "dev"
32
32
  Requires-Dist: ag-ui-langgraph>=0.0.41; extra == "dev"
33
33
  Requires-Dist: fastapi; extra == "dev"
34
34
  Provides-Extra: agui
35
- Requires-Dist: langstage-core[agui]>=1.0.4; extra == "agui"
35
+ Requires-Dist: langstage-core[agui]>=1.0.6; extra == "agui"
36
36
  Dynamic: license-file
37
37
 
38
38
  # langstage-cli
@@ -203,9 +203,37 @@ Options:
203
203
  -v, --verbose Verbose output
204
204
  --demo Run with the built-in keyless demo agent
205
205
  --show-config Print the resolved configuration and exit
206
+ -q, --quiet Scriptable single-shot output: only the reply
207
+ --verify Preflight the agent (one real turn); exit 0/1
206
208
  --version Show the version and exit
207
209
  ```
208
210
 
211
+ ### Verifying an agent (CI gate)
212
+
213
+ `--verify` loads the configured agent and runs **one real turn**, exiting `0` if it
214
+ completed cleanly and non-zero otherwise — so you can gate on it in CI before
215
+ trusting an agent. It catches a missing key, a broken tool, or a non-runnable graph
216
+ that a static "it imports" check would wave through (it runs the same
217
+ `langstage-core` preflight every LangStage surface uses):
218
+
219
+ ```bash
220
+ langstage-cli --verify -a my_agent.py:graph || { echo "agent broken" >&2; exit 1; }
221
+ ```
222
+
223
+ ### Scriptable output
224
+
225
+ A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
226
+ reply — no header, spinner, tool chatter, timing, or color — as soon as its output
227
+ is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and the
228
+ process exits non-zero if the turn failed, so a run is safe to capture:
229
+
230
+ ```bash
231
+ answer=$(langstage-cli --demo "say hi") || echo "run failed" >&2
232
+ echo "$answer" # -> (demo agent) You said: say hi
233
+ ```
234
+
235
+ Pass `-q/--quiet` to force the same clean output in a terminal.
236
+
209
237
  ## Creating Your Own Agent
210
238
 
211
239
  Your agent file just needs to export a compiled LangGraph graph — `langstage-cli`
@@ -166,9 +166,37 @@ Options:
166
166
  -v, --verbose Verbose output
167
167
  --demo Run with the built-in keyless demo agent
168
168
  --show-config Print the resolved configuration and exit
169
+ -q, --quiet Scriptable single-shot output: only the reply
170
+ --verify Preflight the agent (one real turn); exit 0/1
169
171
  --version Show the version and exit
170
172
  ```
171
173
 
174
+ ### Verifying an agent (CI gate)
175
+
176
+ `--verify` loads the configured agent and runs **one real turn**, exiting `0` if it
177
+ completed cleanly and non-zero otherwise — so you can gate on it in CI before
178
+ trusting an agent. It catches a missing key, a broken tool, or a non-runnable graph
179
+ that a static "it imports" check would wave through (it runs the same
180
+ `langstage-core` preflight every LangStage surface uses):
181
+
182
+ ```bash
183
+ langstage-cli --verify -a my_agent.py:graph || { echo "agent broken" >&2; exit 1; }
184
+ ```
185
+
186
+ ### Scriptable output
187
+
188
+ A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
189
+ reply — no header, spinner, tool chatter, timing, or color — as soon as its output
190
+ is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and the
191
+ process exits non-zero if the turn failed, so a run is safe to capture:
192
+
193
+ ```bash
194
+ answer=$(langstage-cli --demo "say hi") || echo "run failed" >&2
195
+ echo "$answer" # -> (demo agent) You said: say hi
196
+ ```
197
+
198
+ Pass `-q/--quiet` to force the same clean output in a terminal.
199
+
172
200
  ## Creating Your Own Agent
173
201
 
174
202
  Your agent file just needs to export a compiled LangGraph graph — `langstage-cli`
@@ -48,6 +48,34 @@ MAGENTA, WHITE, GRAY = "\033[35m", "\033[37m", "\033[90m"
48
48
  BRIGHT_CYAN, BRIGHT_BLUE = "\033[96m", "\033[94m"
49
49
  BRIGHT_GREEN, BRIGHT_YELLOW = "\033[92m", "\033[93m"
50
50
 
51
+ # Scriptable single-shot output (gh #53). When a single-shot run is piped (stdout
52
+ # is not a TTY) or --quiet is passed, we suppress every decoration — the header
53
+ # box, welcome text, "Loaded" line, spinner, tool-call chatter, and timing — and
54
+ # strip ANSI, so the pipe/file receives ONLY the agent's reply. Toggled once in
55
+ # main(); the render helpers below read it as a module global.
56
+ _QUIET = False
57
+
58
+
59
+ def _disable_ansi() -> None:
60
+ """Blank every ANSI constant so nothing colorized reaches a pipe or file.
61
+
62
+ The render helpers reference these as module globals at call time, so
63
+ reassigning them here strips color everywhere without threading a flag
64
+ through every ``print``. ``render_markdown`` then also drops its ``**``/`` ` ``
65
+ markers cleanly (empty wrappers), leaving plain text.
66
+ """
67
+ global RESET, BOLD, DIM, ITALIC, UNDERLINE, BLUE, CYAN, GREEN, YELLOW, RED
68
+ global MAGENTA, WHITE, GRAY, BRIGHT_CYAN, BRIGHT_BLUE, BRIGHT_GREEN, BRIGHT_YELLOW
69
+ RESET = BOLD = DIM = ITALIC = UNDERLINE = BLUE = CYAN = GREEN = YELLOW = RED = ""
70
+ MAGENTA = WHITE = GRAY = BRIGHT_CYAN = BRIGHT_BLUE = BRIGHT_GREEN = BRIGHT_YELLOW = ""
71
+
72
+
73
+ def _status(msg: str) -> None:
74
+ """Emit a status/diagnostic line off the reply stream: to stderr in quiet
75
+ mode (so it never pollutes the piped answer), to stdout otherwise."""
76
+ print(msg, file=sys.stderr if _QUIET else sys.stdout)
77
+
78
+
51
79
  # Inherited HostConfig keys the terminal CLI never reads: it starts no server
52
80
  # (host/port/debug are inert) and the header box uses the loaded graph's name,
53
81
  # not `title`. `--show-config` / `/config` omit these so the diagnostic only
@@ -604,6 +632,12 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
604
632
  if "chunk" in chunk:
605
633
  text = chunk["chunk"]
606
634
  node = chunk.get("node", "unknown")
635
+ if _QUIET:
636
+ # Scriptable path (gh #53): emit the raw reply text only — no cyan
637
+ # bullet, no [node] label, and no markdown re-rendering, so a pipe
638
+ # gets exactly what the model produced.
639
+ print(text, end="", flush=True)
640
+ return
607
641
  if verbose:
608
642
  # Print the [node] label ONCE per streamed run — when a text run
609
643
  # starts, or the node changes mid-stream — then append subsequent
@@ -635,6 +669,8 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
635
669
 
636
670
  # Handle tool calls - green tool name
637
671
  elif "tool_calls" in chunk:
672
+ if _QUIET:
673
+ return # tool chatter is decoration; scriptable output omits it
638
674
  print_chunk._streaming_text = False # a non-text event ends the text run
639
675
  for tool_call in chunk["tool_calls"]:
640
676
  tool_name = tool_call["name"]
@@ -647,6 +683,8 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
647
683
 
648
684
  # Handle tool results - indented with result preview
649
685
  elif "tool_result" in chunk:
686
+ if _QUIET:
687
+ return # tool chatter is decoration; scriptable output omits it
650
688
  print_chunk._streaming_text = False
651
689
  result = chunk.get("tool_result", "")
652
690
  preview = format_result_preview(str(result))
@@ -672,7 +710,11 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
672
710
  elif status == "error":
673
711
  print_chunk._streaming_text = False
674
712
  error_msg = chunk.get("error", "Unknown error")
675
- print(f"\n{RED}✗ Error: {error_msg}{RESET}")
713
+ if _QUIET:
714
+ # Keep stdout clean for the pipe; errors go to stderr. (gh #53)
715
+ print(f"Error: {error_msg}", file=sys.stderr)
716
+ else:
717
+ print(f"\n{RED}✗ Error: {error_msg}{RESET}")
676
718
 
677
719
 
678
720
  # Whether the current AI turn has already emitted its leading cyan bullet. Tracked
@@ -1199,12 +1241,16 @@ async def run_single_turn_agui(
1199
1241
  has_interrupt = False
1200
1242
  num_pending_actions = 0
1201
1243
  first_chunk = True
1202
- spinner = Spinner("Thinking")
1203
- spinner.start()
1244
+ # No spinner in quiet mode — its \r animation is terminal-only chrome that
1245
+ # would corrupt a piped reply. (gh #53)
1246
+ spinner = None if _QUIET else Spinner("Thinking")
1247
+ if spinner:
1248
+ spinner.start()
1204
1249
  try:
1205
1250
  async for chunk in agui_stream_updates(agent, message, thread_id, resume=resume):
1206
1251
  if first_chunk:
1207
- spinner.stop()
1252
+ if spinner:
1253
+ spinner.stop()
1208
1254
  first_chunk = False
1209
1255
  print_chunk(chunk, verbose=verbose)
1210
1256
  if chunk.get("status") == "error":
@@ -1215,7 +1261,8 @@ async def run_single_turn_agui(
1215
1261
  action_requests = interrupt_data.get("action_requests", [])
1216
1262
  num_pending_actions = len(action_requests) if action_requests else 1
1217
1263
  finally:
1218
- spinner.stop()
1264
+ if spinner:
1265
+ spinner.stop()
1219
1266
 
1220
1267
  if has_interrupt and interactive:
1221
1268
  decisions = handle_interrupt_input(num_pending_actions)
@@ -1223,7 +1270,7 @@ async def run_single_turn_agui(
1223
1270
  elif has_interrupt:
1224
1271
  # --no-interactive: auto-approve and resume so the agent runs to
1225
1272
  # completion (same behavior as the default path, gh #32).
1226
- print(
1273
+ _status(
1227
1274
  f"{DIM}Auto-approving {num_pending_actions} pending action(s) "
1228
1275
  f"(--no-interactive){RESET}"
1229
1276
  )
@@ -1257,11 +1304,14 @@ def run_conversation_loop(
1257
1304
  # Set up tab completion for slash commands
1258
1305
  setup_readline_completion()
1259
1306
 
1260
- # Print box-drawn header with agent name and description
1261
- print_header_box(agent_name, os.getcwd(), agent_description)
1307
+ # Header + welcome are interactive chrome; a scriptable single-shot run omits
1308
+ # them so the pipe gets only the reply. (gh #53)
1309
+ if not _QUIET:
1310
+ # Print box-drawn header with agent name and description
1311
+ print_header_box(agent_name, os.getcwd(), agent_description)
1262
1312
 
1263
- # Print welcome message with tips
1264
- print_welcome()
1313
+ # Print welcome message with tips
1314
+ print_welcome()
1265
1315
 
1266
1316
  # Create command context (mutable dict that commands can modify)
1267
1317
  command_context = {
@@ -1285,20 +1335,26 @@ def run_conversation_loop(
1285
1335
  try:
1286
1336
  agui_agent = build_session_agent(graph, name=agent_name)
1287
1337
  except RuntimeError as e:
1288
- print(f"{RED}⏺ {e}{RESET}")
1338
+ _status(f"{RED}⏺ {e}{RESET}")
1289
1339
  return
1290
1340
 
1291
1341
  # Process initial message if provided
1292
1342
  if initial_message:
1293
- print(f"\n{BOLD}{BRIGHT_BLUE}You{RESET}")
1294
- print(f"{initial_message}")
1295
- print()
1343
+ if not _QUIET:
1344
+ print(f"\n{BOLD}{BRIGHT_BLUE}You{RESET}")
1345
+ print(f"{initial_message}")
1346
+ print()
1296
1347
 
1297
1348
  duration, had_error = asyncio.run(
1298
1349
  run_single_turn_agui(agui_agent, initial_message, thread_id, interactive, verbose)
1299
1350
  )
1300
- print_timing(duration, verbose)
1301
- print()
1351
+ if _QUIET:
1352
+ # Only the reply reached stdout (streamed with end=""); cap it with a
1353
+ # single newline so the piped output ends cleanly. No timing line. (gh #53)
1354
+ print()
1355
+ else:
1356
+ print_timing(duration, verbose)
1357
+ print()
1302
1358
 
1303
1359
  # Exit after single-shot execution. Propagate the turn's error status so
1304
1360
  # main() can exit non-zero — a single-shot/piped caller must be able to tell
@@ -1449,6 +1505,24 @@ def run_conversation_loop(
1449
1505
  default=False,
1450
1506
  help="Print the resolved configuration (defaults < langstage.toml < env < CLI) and exit",
1451
1507
  )
1508
+ @click.option(
1509
+ "--quiet",
1510
+ "-q",
1511
+ is_flag=True,
1512
+ default=False,
1513
+ help="Scriptable single-shot output: suppress the header, spinner, tool "
1514
+ "chatter, timing, and color, and emit only the agent's reply. Auto-enabled "
1515
+ "when a single-shot run is piped (stdout is not a TTY).",
1516
+ )
1517
+ @click.option(
1518
+ "--verify",
1519
+ "verify_agent",
1520
+ is_flag=True,
1521
+ default=False,
1522
+ help="Preflight the configured agent: run ONE real turn and exit 0 if it "
1523
+ "completed cleanly, non-zero otherwise. Catches a missing key / broken tool "
1524
+ "/ bad graph before you rely on it (e.g. in CI).",
1525
+ )
1452
1526
  def main(
1453
1527
  message: Optional[str],
1454
1528
  agent_spec: Optional[str],
@@ -1461,6 +1535,8 @@ def main(
1461
1535
  demo: bool,
1462
1536
  agui: bool,
1463
1537
  show_config: bool,
1538
+ quiet: bool,
1539
+ verify_agent: bool,
1464
1540
  ):
1465
1541
  """
1466
1542
  Run a LangGraph agent from the command line.
@@ -1495,6 +1571,7 @@ def main(
1495
1571
  langstage-cli -f ./prompt.md
1496
1572
  langstage-cli --demo "try it with no API key"
1497
1573
  langstage-cli --show-config
1574
+ langstage-cli --verify -a my_agent.py # preflight one real turn; exit 0/1
1498
1575
  """
1499
1576
  # Windows consoles default to cp1252, where the spinner (Braille frames) and
1500
1577
  # status glyphs (✓ ⏺ —) raise UnicodeEncodeError — the documented
@@ -1506,9 +1583,23 @@ def main(
1506
1583
  except (AttributeError, ValueError): # non-reconfigurable stream
1507
1584
  pass
1508
1585
 
1586
+ # Scriptable output (gh #53). A single-shot run (a MESSAGE arg or -f/--file) or
1587
+ # a --verify preflight that is piped — stdout is not a TTY — auto-enables quiet
1588
+ # so the consumer gets only the reply/verdict (no spinner or "Loaded" line);
1589
+ # --quiet forces it in a terminal. Color is additionally stripped whenever stdout
1590
+ # is not a TTY, matching well-behaved CLIs.
1591
+ try:
1592
+ _is_tty = sys.stdout.isatty()
1593
+ except (AttributeError, ValueError):
1594
+ _is_tty = False
1595
+ global _QUIET
1596
+ _QUIET = quiet or ((bool(message or prompt_file) or verify_agent) and not _is_tty)
1597
+ if _QUIET or not _is_tty:
1598
+ _disable_ansi()
1599
+
1509
1600
  if demo:
1510
1601
  if agent_spec:
1511
- print(f"{RED}⏺ Error: --demo and -a/--agent are mutually exclusive{RESET}")
1602
+ _status(f"{RED}⏺ Error: --demo and -a/--agent are mutually exclusive{RESET}")
1512
1603
  sys.exit(1)
1513
1604
  # The keyless echo agent shipped with the shared core.
1514
1605
  agent_spec = "langstage_core.demo.stub:graph"
@@ -1540,7 +1631,7 @@ def main(
1540
1631
  try:
1541
1632
  # Handle -f/--file option: read message from file
1542
1633
  if prompt_file and message:
1543
- print(f"{RED}⏺ Error: Cannot use both MESSAGE argument and -f/--file option{RESET}")
1634
+ _status(f"{RED}⏺ Error: Cannot use both MESSAGE argument and -f/--file option{RESET}")
1544
1635
  sys.exit(1)
1545
1636
 
1546
1637
  if prompt_file:
@@ -1548,17 +1639,17 @@ def main(
1548
1639
  with open(prompt_file, "r", encoding="utf-8") as f:
1549
1640
  message = f.read().strip()
1550
1641
  if not message:
1551
- print(f"{RED}⏺ Error: File '{prompt_file}' is empty{RESET}")
1642
+ _status(f"{RED}⏺ Error: File '{prompt_file}' is empty{RESET}")
1552
1643
  sys.exit(1)
1553
1644
  except Exception as e:
1554
- print(f"{RED}⏺ Error reading file '{prompt_file}': {e}{RESET}")
1645
+ _status(f"{RED}⏺ Error reading file '{prompt_file}': {e}{RESET}")
1555
1646
  sys.exit(1)
1556
1647
 
1557
1648
  # Load TOML configuration (global + project, merged)
1558
1649
  try:
1559
1650
  toml_config, toml_sources = config_module.load_config()
1560
1651
  except config_module.ConfigError as e:
1561
- print(f"{RED}⏺ {e}{RESET}")
1652
+ _status(f"{RED}⏺ {e}{RESET}")
1562
1653
  sys.exit(1)
1563
1654
 
1564
1655
  # Resolve all standard settings through the shared chain in one shot:
@@ -1575,9 +1666,9 @@ def main(
1575
1666
  # else (e.g. LANGSTAGE_STREAM_MODE=values, which bypasses the flag's Choice)
1576
1667
  # with a clean error instead of a fatal crash deep in the stream worker.
1577
1668
  if final_stream_mode not in ("auto", "updates", "messages"):
1578
- print(
1669
+ _status(
1579
1670
  f"{RED}⏺ Error: unsupported stream mode {final_stream_mode!r} "
1580
- f"(use 'auto', 'updates', or 'messages'){RESET}"
1671
+ f"(use 'auto', 'updates', or 'messages')"
1581
1672
  )
1582
1673
  sys.exit(2)
1583
1674
  use_async = cfg.async_mode
@@ -1593,11 +1684,11 @@ def main(
1593
1684
  if default_agent_path.exists():
1594
1685
  final_spec = f"{default_agent_path}:agent"
1595
1686
  else:
1596
- print(f"{RED}⏺ Error: No agent specified.{RESET}")
1597
- print(f"\n{DIM}Usage:{RESET}")
1598
- print(" langstage-cli path/to/agent.py:graph")
1599
- print(" langstage-cli mypackage.module:agent")
1600
- print(f"\n{DIM}Or set the LANGSTAGE_AGENT_SPEC environment variable{RESET}")
1687
+ _status(f"{RED}⏺ Error: No agent specified.{RESET}")
1688
+ _status(f"\n{DIM}Usage:{RESET}")
1689
+ _status(" langstage-cli path/to/agent.py:graph")
1690
+ _status(" langstage-cli mypackage.module:agent")
1691
+ _status(f"\n{DIM}Or set the LANGSTAGE_AGENT_SPEC environment variable{RESET}")
1601
1692
  sys.exit(1)
1602
1693
 
1603
1694
  # Resolve a relative file-path spec against the invocation cwd BEFORE we
@@ -1611,12 +1702,30 @@ def main(
1611
1702
  if workspace_path.exists():
1612
1703
  os.chdir(workspace_path)
1613
1704
 
1614
- # Load the graph with a spinner
1615
- spinner = Spinner("Loading agent")
1616
- spinner.start()
1705
+ # Load the graph with a spinner (both are chrome; quiet mode stays silent
1706
+ # until the reply). (gh #53)
1707
+ loading = None if _QUIET else Spinner("Loading agent")
1708
+ if loading:
1709
+ loading.start()
1617
1710
  graph, final_graph_name = load_graph(final_spec, final_graph_name_default)
1618
- spinner.stop()
1619
- print(f"{GREEN}✓{RESET} {DIM}Loaded {final_spec}{RESET}")
1711
+ if loading:
1712
+ loading.stop()
1713
+ print(f"{GREEN}✓{RESET} {DIM}Loaded {final_spec}{RESET}")
1714
+
1715
+ # --verify: preflight the configured agent by running ONE real turn through
1716
+ # the shared core primitive (langstage-core >= 1.0.6), then exit on its
1717
+ # verdict. A green here means the agent actually completed a turn — not just
1718
+ # that it imported — so `langstage-cli --verify -a my_agent.py` is a real CI
1719
+ # gate. Delegates to core.verify so "healthy" means the same across surfaces.
1720
+ if verify_agent:
1721
+ from langstage_core.agui import verify as _core_verify
1722
+
1723
+ result = _core_verify(graph)
1724
+ if result.ok:
1725
+ print(f"{GREEN}✓{RESET} agent verified: {result.reason}")
1726
+ sys.exit(0)
1727
+ _status(f"{RED}✗ agent verification failed: {result.reason}{RESET}")
1728
+ sys.exit(1)
1620
1729
 
1621
1730
  # Seed LangGraph RunnableConfig from TOML [configurable] table if present
1622
1731
  config_dict: Dict[str, Any] = {"configurable": {}}
@@ -1654,21 +1763,21 @@ def main(
1654
1763
  sys.exit(1)
1655
1764
 
1656
1765
  except FileNotFoundError as e:
1657
- print(f"{RED}⏺ Error: {e}{RESET}")
1766
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1658
1767
  sys.exit(1)
1659
1768
  except AttributeError as e:
1660
- print(f"{RED}⏺ Error: {e}{RESET}")
1769
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1661
1770
  sys.exit(1)
1662
1771
  except ModuleNotFoundError as e:
1663
- print(f"{RED}⏺ Error: {e}{RESET}")
1664
- print(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
1772
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1773
+ _status(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
1665
1774
  sys.exit(1)
1666
1775
  except Exception as e:
1667
- print(f"{RED}⏺ Error: {e}{RESET}")
1776
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1668
1777
  if verbose:
1669
1778
  import traceback
1670
1779
 
1671
- print(traceback.format_exc())
1780
+ _status(traceback.format_exc())
1672
1781
  sys.exit(1)
1673
1782
 
1674
1783
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.3
3
+ Version: 0.6.5
4
4
  Summary: The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph
5
5
  Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: langgraph>=0.2.0
22
- Requires-Dist: langstage-core[agui]>=1.0.4
22
+ Requires-Dist: langstage-core[agui]>=1.0.6
23
23
  Requires-Dist: click>=8.0.0
24
24
  Requires-Dist: python-dotenv
25
25
  Provides-Extra: dev
@@ -32,7 +32,7 @@ Requires-Dist: deepagents>=0.3; extra == "dev"
32
32
  Requires-Dist: ag-ui-langgraph>=0.0.41; extra == "dev"
33
33
  Requires-Dist: fastapi; extra == "dev"
34
34
  Provides-Extra: agui
35
- Requires-Dist: langstage-core[agui]>=1.0.4; extra == "agui"
35
+ Requires-Dist: langstage-core[agui]>=1.0.6; extra == "agui"
36
36
  Dynamic: license-file
37
37
 
38
38
  # langstage-cli
@@ -203,9 +203,37 @@ Options:
203
203
  -v, --verbose Verbose output
204
204
  --demo Run with the built-in keyless demo agent
205
205
  --show-config Print the resolved configuration and exit
206
+ -q, --quiet Scriptable single-shot output: only the reply
207
+ --verify Preflight the agent (one real turn); exit 0/1
206
208
  --version Show the version and exit
207
209
  ```
208
210
 
211
+ ### Verifying an agent (CI gate)
212
+
213
+ `--verify` loads the configured agent and runs **one real turn**, exiting `0` if it
214
+ completed cleanly and non-zero otherwise — so you can gate on it in CI before
215
+ trusting an agent. It catches a missing key, a broken tool, or a non-runnable graph
216
+ that a static "it imports" check would wave through (it runs the same
217
+ `langstage-core` preflight every LangStage surface uses):
218
+
219
+ ```bash
220
+ langstage-cli --verify -a my_agent.py:graph || { echo "agent broken" >&2; exit 1; }
221
+ ```
222
+
223
+ ### Scriptable output
224
+
225
+ A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
226
+ reply — no header, spinner, tool chatter, timing, or color — as soon as its output
227
+ is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and the
228
+ process exits non-zero if the turn failed, so a run is safe to capture:
229
+
230
+ ```bash
231
+ answer=$(langstage-cli --demo "say hi") || echo "run failed" >&2
232
+ echo "$answer" # -> (demo agent) You said: say hi
233
+ ```
234
+
235
+ Pass `-q/--quiet` to force the same clean output in a terminal.
236
+
209
237
  ## Creating Your Own Agent
210
238
 
211
239
  Your agent file just needs to export a compiled LangGraph graph — `langstage-cli`
@@ -20,10 +20,12 @@ tests/test_codeconfig.py
20
20
  tests/test_config.py
21
21
  tests/test_help_render.py
22
22
  tests/test_no_interactive_approve.py
23
+ tests/test_quiet_output.py
23
24
  tests/test_rename_shim.py
24
25
  tests/test_show_config.py
25
26
  tests/test_spec_resolution.py
26
27
  tests/test_stream_mode.py
27
28
  tests/test_streaming_marker.py
28
29
  tests/test_turn_exit_and_render.py
29
- tests/test_unicode_console.py
30
+ tests/test_unicode_console.py
31
+ tests/test_verify_flag.py
@@ -1,10 +1,10 @@
1
1
  langgraph>=0.2.0
2
- langstage-core[agui]>=1.0.4
2
+ langstage-core[agui]>=1.0.6
3
3
  click>=8.0.0
4
4
  python-dotenv
5
5
 
6
6
  [agui]
7
- langstage-core[agui]>=1.0.4
7
+ langstage-core[agui]>=1.0.6
8
8
 
9
9
  [dev]
10
10
  pytest>=7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.6.3"
7
+ version = "0.6.5"
8
8
  description = "The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -29,7 +29,8 @@ dependencies = [
29
29
  # in-process AG-UI adapter — there is no built-in parser fallback — so the
30
30
  # AG-UI runtime (ag-ui-langgraph[fastapi] + uvicorn, via core's [agui] extra)
31
31
  # is a HARD dep. A bare `pip install langstage-cli` must be able to run a turn.
32
- "langstage-core[agui]>=1.0.4",
32
+ # >=1.0.6 for the shared preflight primitive core.verify() used by --verify.
33
+ "langstage-core[agui]>=1.0.6",
33
34
  "click>=8.0.0",
34
35
  "python-dotenv",
35
36
  ]
@@ -53,7 +54,7 @@ dev = [
53
54
  ]
54
55
  # Redundant since AG-UI moved into base deps (core 1.0); kept as a no-op alias so
55
56
  # existing `pip install langstage-cli[agui]` scripts/READMEs still resolve.
56
- agui = ["langstage-core[agui]>=1.0.4"]
57
+ agui = ["langstage-core[agui]>=1.0.6"]
57
58
 
58
59
  [project.scripts]
59
60
  langstage-cli = "langstage_cli.cli:main"
@@ -0,0 +1,79 @@
1
+ """Scriptable single-shot output (gh #53).
2
+
3
+ A single-shot run that is piped (stdout is not a TTY) auto-enables quiet output,
4
+ and ``--quiet`` forces it in a terminal: the reply is emitted with no header box,
5
+ welcome text, "Loaded" line, spinner, tool chatter, timing, or color — just the
6
+ agent's text on stdout, with diagnostics and errors routed to stderr so a pipe
7
+ never sees them.
8
+
9
+ CliRunner's stdout is not a TTY, so ``invoke(main, [...])`` exercises exactly the
10
+ piped path these tests care about.
11
+ """
12
+
13
+ from click.testing import CliRunner
14
+
15
+ from langstage_cli import cli as c
16
+ from langstage_cli.cli import main, print_chunk
17
+
18
+
19
+ def test_piped_single_shot_is_only_the_reply(tmp_path, monkeypatch):
20
+ monkeypatch.chdir(tmp_path)
21
+ r = CliRunner().invoke(main, ["--demo", "hello world"])
22
+ assert r.exit_code == 0, r.output
23
+ assert "hello world" in r.output # the echo stub replies with the message
24
+ # None of the interactive chrome or ANSI leaks into the pipe.
25
+ assert "\x1b[" not in r.output, r.output # color stripped
26
+ assert "⏺" not in r.output, r.output # no streaming marker
27
+ assert "Loaded" not in r.output, r.output # no load line
28
+ assert "Thinking" not in r.output, r.output # no spinner
29
+
30
+
31
+ def test_quiet_flag_forces_clean_output(tmp_path, monkeypatch):
32
+ monkeypatch.chdir(tmp_path)
33
+ r = CliRunner().invoke(main, ["-q", "--demo", "ping"])
34
+ assert r.exit_code == 0, r.output
35
+ assert "ping" in r.output
36
+ assert "\x1b[" not in r.output and "⏺" not in r.output, r.output
37
+
38
+
39
+ def test_errors_go_to_stderr_not_stdout(tmp_path, monkeypatch):
40
+ # A failed single-shot run must keep stdout clean so a scripted caller can
41
+ # consume the (empty) reply and read the diagnostic off stderr / the exit code.
42
+ monkeypatch.chdir(tmp_path)
43
+ r = CliRunner().invoke(main, ["-a", "nope_missing.module:graph", "hi"])
44
+ assert r.exit_code != 0
45
+ assert r.stdout.strip() == "", r.stdout # nothing on the reply stream
46
+ assert "nope_missing" in r.stderr or "Error" in r.stderr, r.stderr
47
+
48
+
49
+ def test_print_chunk_quiet_emits_raw_text_only(capsys):
50
+ c._QUIET = True
51
+ print_chunk({"status": "streaming", "chunk": "**bold** and `code`", "node": "n"})
52
+ out = capsys.readouterr().out
53
+ # Verbatim: no cyan bullet, no [node] label, and no markdown rewrite — a script
54
+ # gets exactly what the model produced.
55
+ assert out == "**bold** and `code`", repr(out)
56
+
57
+
58
+ def test_print_chunk_quiet_suppresses_tool_chatter(capsys):
59
+ c._QUIET = True
60
+ print_chunk({"status": "streaming", "tool_calls": [{"name": "t", "args": {"x": 1}}]})
61
+ print_chunk({"status": "streaming", "tool_result": "big result"})
62
+ assert capsys.readouterr().out == "" # tool decoration omitted in quiet mode
63
+
64
+
65
+ def test_print_chunk_quiet_routes_error_to_stderr(capsys):
66
+ c._QUIET = True
67
+ print_chunk({"status": "error", "error": "boom"})
68
+ captured = capsys.readouterr()
69
+ assert captured.out == "" # the reply stream stays clean
70
+ assert "boom" in captured.err
71
+
72
+
73
+ def test_interactive_default_keeps_decoration(capsys):
74
+ # The default (TTY / not quiet) path is unchanged: the marker still renders.
75
+ c._QUIET = False
76
+ print_chunk._streaming_text = False
77
+ print_chunk._streaming_node = None
78
+ print_chunk({"status": "streaming", "chunk": "hi", "node": "n"})
79
+ assert "⏺" in capsys.readouterr().out
@@ -50,14 +50,18 @@ _TOK_AGENT = textwrap.dedent(
50
50
  )
51
51
 
52
52
 
53
- def test_token_stream_renders_one_marker_end_to_end(tmp_path, monkeypatch):
53
+ def test_token_stream_scriptable_when_piped(tmp_path, monkeypatch):
54
+ # gh #53: a single-shot run under CliRunner (stdout is not a TTY) auto-enables
55
+ # quiet output, so the piped reply is ONLY the streamed tokens — no `⏺` marker,
56
+ # header box, "Loaded" line, or timing. (Per-turn marker dedup, gh #34, is still
57
+ # covered by test_marker_printed_once_per_text_run driving print_chunk directly.)
54
58
  (tmp_path / "tok.py").write_text(_TOK_AGENT)
55
59
  monkeypatch.chdir(tmp_path)
56
60
 
57
61
  r = CliRunner().invoke(main, ["-a", "tok.py:graph", "hi", "--no-interactive"])
58
62
  assert r.exit_code == 0, r.output
59
- # Exactly one bullet for the whole streamed turn, not one per token.
60
- assert r.output.count("") == 1, r.output
63
+ assert r.output.count("⏺") == 0, r.output # no decoration on the scriptable path
64
+ assert "Loaded" not in r.output and "You" not in r.output, r.output
61
65
  for word in ("alpha", "beta", "gamma"):
62
66
  assert word in r.output, r.output
63
67
 
@@ -0,0 +1,45 @@
1
+ """`--verify` preflights the configured agent with a real turn (ADR 0004 adoption).
2
+
3
+ It delegates to `langstage_core.agui.verify`, so a green here means the agent
4
+ actually completed a turn — a real CI gate — not just that it imported. Exit 0 on
5
+ success, non-zero on failure, with the diagnostic on stderr so stdout stays clean.
6
+ """
7
+
8
+ import textwrap
9
+
10
+ from click.testing import CliRunner
11
+
12
+ from langstage_cli.cli import main
13
+
14
+ _BROKEN_AGENT = textwrap.dedent(
15
+ """
16
+ from langgraph.graph import StateGraph, START, END, MessagesState
17
+
18
+ def boom(state):
19
+ raise RuntimeError("tool exploded")
20
+
21
+ b = StateGraph(MessagesState)
22
+ b.add_node("boom", boom)
23
+ b.add_edge(START, "boom")
24
+ b.add_edge("boom", END)
25
+ graph = b.compile()
26
+ """
27
+ )
28
+
29
+
30
+ def test_verify_demo_passes_exit_zero(tmp_path, monkeypatch):
31
+ monkeypatch.chdir(tmp_path)
32
+ r = CliRunner().invoke(main, ["--demo", "--verify"])
33
+ assert r.exit_code == 0, r.output
34
+ assert "verified" in r.output # the pass verdict, on stdout
35
+ # A preflight is not a chat: no header/marker leaks.
36
+ assert "⏺" not in r.output and "\x1b[" not in r.output, r.output
37
+
38
+
39
+ def test_verify_broken_agent_fails_exit_one(tmp_path, monkeypatch):
40
+ (tmp_path / "broken.py").write_text(_BROKEN_AGENT)
41
+ monkeypatch.chdir(tmp_path)
42
+ r = CliRunner().invoke(main, ["-a", "broken.py:graph", "--verify"])
43
+ assert r.exit_code == 1
44
+ assert r.stdout.strip() == "", r.stdout # stdout stays clean for scripting
45
+ assert "verification failed" in r.stderr, r.stderr
File without changes
File without changes