langstage-cli 0.6.2__tar.gz → 0.6.4__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 (32) hide show
  1. {langstage_cli-0.6.2/langstage_cli.egg-info → langstage_cli-0.6.4}/PKG-INFO +17 -2
  2. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/README.md +16 -1
  3. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli/cli.py +124 -42
  4. {langstage_cli-0.6.2 → langstage_cli-0.6.4/langstage_cli.egg-info}/PKG-INFO +17 -2
  5. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli.egg-info/SOURCES.txt +1 -0
  6. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/pyproject.toml +1 -1
  7. langstage_cli-0.6.4/tests/test_quiet_output.py +79 -0
  8. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_streaming_marker.py +7 -3
  9. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/LICENSE +0 -0
  10. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/deepagent_code/__init__.py +0 -0
  11. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli/__init__.py +0 -0
  12. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli/agui_stream.py +0 -0
  13. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli/config.py +0 -0
  14. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli.egg-info/dependency_links.txt +0 -0
  15. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli.egg-info/entry_points.txt +0 -0
  16. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli.egg-info/requires.txt +0 -0
  17. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/langstage_cli.egg-info/top_level.txt +0 -0
  18. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/setup.cfg +0 -0
  19. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_agui_stream.py +0 -0
  20. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_checkpointer.py +0 -0
  21. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_cli.py +0 -0
  22. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_cli_help.py +0 -0
  23. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_codeconfig.py +0 -0
  24. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_config.py +0 -0
  25. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_help_render.py +0 -0
  26. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_no_interactive_approve.py +0 -0
  27. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_rename_shim.py +0 -0
  28. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_show_config.py +0 -0
  29. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_spec_resolution.py +0 -0
  30. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_stream_mode.py +0 -0
  31. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/tests/test_turn_exit_and_render.py +0 -0
  32. {langstage_cli-0.6.2 → langstage_cli-0.6.4}/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.2
3
+ Version: 0.6.4
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
@@ -39,7 +39,7 @@ Dynamic: license-file
39
39
 
40
40
  **The terminal stage for your LangGraph agent.** A Claude Code-style CLI that runs *any* LangGraph `CompiledGraph` — yours, not a bundled one — with streaming, tool-call rendering, and human-in-the-loop approval.
41
41
 
42
- > Renamed from **deepagent-code** (the old package name now just installs this one, and the `deepagent-code` command still works).
42
+ > Renamed from **deepagent-code** (the old package name now just installs this one, and the `deepagent-code` command still works). Not to be confused with LangChain's **`deepagents-code`** (`dcode`) — that's a separate project; `langstage-cli` is the terminal stage of the [LangStage family](#every-stage-for-your-langgraph-agent).
43
43
 
44
44
  ![langstage-cli](examples/image.png)
45
45
 
@@ -203,9 +203,24 @@ 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
206
207
  --version Show the version and exit
207
208
  ```
208
209
 
210
+ ### Scriptable output
211
+
212
+ A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
213
+ reply — no header, spinner, tool chatter, timing, or color — as soon as its output
214
+ is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and the
215
+ process exits non-zero if the turn failed, so a run is safe to capture:
216
+
217
+ ```bash
218
+ answer=$(langstage-cli --demo "say hi") || echo "run failed" >&2
219
+ echo "$answer" # -> (demo agent) You said: say hi
220
+ ```
221
+
222
+ Pass `-q/--quiet` to force the same clean output in a terminal.
223
+
209
224
  ## Creating Your Own Agent
210
225
 
211
226
  Your agent file just needs to export a compiled LangGraph graph — `langstage-cli`
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The terminal stage for your LangGraph agent.** A Claude Code-style CLI that runs *any* LangGraph `CompiledGraph` — yours, not a bundled one — with streaming, tool-call rendering, and human-in-the-loop approval.
4
4
 
5
- > Renamed from **deepagent-code** (the old package name now just installs this one, and the `deepagent-code` command still works).
5
+ > Renamed from **deepagent-code** (the old package name now just installs this one, and the `deepagent-code` command still works). Not to be confused with LangChain's **`deepagents-code`** (`dcode`) — that's a separate project; `langstage-cli` is the terminal stage of the [LangStage family](#every-stage-for-your-langgraph-agent).
6
6
 
7
7
  ![langstage-cli](examples/image.png)
8
8
 
@@ -166,9 +166,24 @@ 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
169
170
  --version Show the version and exit
170
171
  ```
171
172
 
173
+ ### Scriptable output
174
+
175
+ A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
176
+ reply — no header, spinner, tool chatter, timing, or color — as soon as its output
177
+ is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and the
178
+ process exits non-zero if the turn failed, so a run is safe to capture:
179
+
180
+ ```bash
181
+ answer=$(langstage-cli --demo "say hi") || echo "run failed" >&2
182
+ echo "$answer" # -> (demo agent) You said: say hi
183
+ ```
184
+
185
+ Pass `-q/--quiet` to force the same clean output in a terminal.
186
+
172
187
  ## Creating Your Own Agent
173
188
 
174
189
  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
@@ -260,7 +288,7 @@ def print_goodbye():
260
288
 
261
289
 
262
290
  def get_agent_name(graph) -> str:
263
- """Extract agent name from graph object, defaulting to 'AgentCode'."""
291
+ """Extract agent name from graph object, defaulting to 'Agent'."""
264
292
  # Try common attribute names for agent/graph name
265
293
  for attr in ("name", "agent_name", "_name", "__name__"):
266
294
  if hasattr(graph, attr):
@@ -272,7 +300,7 @@ def get_agent_name(graph) -> str:
272
300
  name = graph.builder.name
273
301
  if name and isinstance(name, str):
274
302
  return name
275
- return "AgentCode"
303
+ return "Agent"
276
304
 
277
305
 
278
306
  def get_agent_description(graph) -> Optional[str]:
@@ -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
  )
@@ -1238,7 +1285,7 @@ async def run_single_turn_agui(
1238
1285
  def run_conversation_loop(
1239
1286
  graph,
1240
1287
  config: Dict[str, Any],
1241
- agent_name: str = "AgentCode",
1288
+ agent_name: str = "Agent",
1242
1289
  agent_description: Optional[str] = None,
1243
1290
  use_async: bool = False,
1244
1291
  use_agui: bool = False,
@@ -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,15 @@ 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
+ )
1452
1517
  def main(
1453
1518
  message: Optional[str],
1454
1519
  agent_spec: Optional[str],
@@ -1461,6 +1526,7 @@ def main(
1461
1526
  demo: bool,
1462
1527
  agui: bool,
1463
1528
  show_config: bool,
1529
+ quiet: bool,
1464
1530
  ):
1465
1531
  """
1466
1532
  Run a LangGraph agent from the command line.
@@ -1506,9 +1572,22 @@ def main(
1506
1572
  except (AttributeError, ValueError): # non-reconfigurable stream
1507
1573
  pass
1508
1574
 
1575
+ # Scriptable single-shot output (gh #53). A single-shot run (a MESSAGE arg or
1576
+ # -f/--file) that is piped — stdout is not a TTY — auto-enables quiet so the
1577
+ # consumer gets only the reply; --quiet forces it even in a terminal. Color is
1578
+ # additionally stripped whenever stdout is not a TTY, matching well-behaved CLIs.
1579
+ try:
1580
+ _is_tty = sys.stdout.isatty()
1581
+ except (AttributeError, ValueError):
1582
+ _is_tty = False
1583
+ global _QUIET
1584
+ _QUIET = quiet or (bool(message or prompt_file) and not _is_tty)
1585
+ if _QUIET or not _is_tty:
1586
+ _disable_ansi()
1587
+
1509
1588
  if demo:
1510
1589
  if agent_spec:
1511
- print(f"{RED}⏺ Error: --demo and -a/--agent are mutually exclusive{RESET}")
1590
+ _status(f"{RED}⏺ Error: --demo and -a/--agent are mutually exclusive{RESET}")
1512
1591
  sys.exit(1)
1513
1592
  # The keyless echo agent shipped with the shared core.
1514
1593
  agent_spec = "langstage_core.demo.stub:graph"
@@ -1540,7 +1619,7 @@ def main(
1540
1619
  try:
1541
1620
  # Handle -f/--file option: read message from file
1542
1621
  if prompt_file and message:
1543
- print(f"{RED}⏺ Error: Cannot use both MESSAGE argument and -f/--file option{RESET}")
1622
+ _status(f"{RED}⏺ Error: Cannot use both MESSAGE argument and -f/--file option{RESET}")
1544
1623
  sys.exit(1)
1545
1624
 
1546
1625
  if prompt_file:
@@ -1548,17 +1627,17 @@ def main(
1548
1627
  with open(prompt_file, "r", encoding="utf-8") as f:
1549
1628
  message = f.read().strip()
1550
1629
  if not message:
1551
- print(f"{RED}⏺ Error: File '{prompt_file}' is empty{RESET}")
1630
+ _status(f"{RED}⏺ Error: File '{prompt_file}' is empty{RESET}")
1552
1631
  sys.exit(1)
1553
1632
  except Exception as e:
1554
- print(f"{RED}⏺ Error reading file '{prompt_file}': {e}{RESET}")
1633
+ _status(f"{RED}⏺ Error reading file '{prompt_file}': {e}{RESET}")
1555
1634
  sys.exit(1)
1556
1635
 
1557
1636
  # Load TOML configuration (global + project, merged)
1558
1637
  try:
1559
1638
  toml_config, toml_sources = config_module.load_config()
1560
1639
  except config_module.ConfigError as e:
1561
- print(f"{RED}⏺ {e}{RESET}")
1640
+ _status(f"{RED}⏺ {e}{RESET}")
1562
1641
  sys.exit(1)
1563
1642
 
1564
1643
  # Resolve all standard settings through the shared chain in one shot:
@@ -1575,9 +1654,9 @@ def main(
1575
1654
  # else (e.g. LANGSTAGE_STREAM_MODE=values, which bypasses the flag's Choice)
1576
1655
  # with a clean error instead of a fatal crash deep in the stream worker.
1577
1656
  if final_stream_mode not in ("auto", "updates", "messages"):
1578
- print(
1657
+ _status(
1579
1658
  f"{RED}⏺ Error: unsupported stream mode {final_stream_mode!r} "
1580
- f"(use 'auto', 'updates', or 'messages'){RESET}"
1659
+ f"(use 'auto', 'updates', or 'messages')"
1581
1660
  )
1582
1661
  sys.exit(2)
1583
1662
  use_async = cfg.async_mode
@@ -1593,11 +1672,11 @@ def main(
1593
1672
  if default_agent_path.exists():
1594
1673
  final_spec = f"{default_agent_path}:agent"
1595
1674
  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}")
1675
+ _status(f"{RED}⏺ Error: No agent specified.{RESET}")
1676
+ _status(f"\n{DIM}Usage:{RESET}")
1677
+ _status(" langstage-cli path/to/agent.py:graph")
1678
+ _status(" langstage-cli mypackage.module:agent")
1679
+ _status(f"\n{DIM}Or set the LANGSTAGE_AGENT_SPEC environment variable{RESET}")
1601
1680
  sys.exit(1)
1602
1681
 
1603
1682
  # Resolve a relative file-path spec against the invocation cwd BEFORE we
@@ -1611,12 +1690,15 @@ def main(
1611
1690
  if workspace_path.exists():
1612
1691
  os.chdir(workspace_path)
1613
1692
 
1614
- # Load the graph with a spinner
1615
- spinner = Spinner("Loading agent")
1616
- spinner.start()
1693
+ # Load the graph with a spinner (both are chrome; quiet mode stays silent
1694
+ # until the reply). (gh #53)
1695
+ loading = None if _QUIET else Spinner("Loading agent")
1696
+ if loading:
1697
+ loading.start()
1617
1698
  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}")
1699
+ if loading:
1700
+ loading.stop()
1701
+ print(f"{GREEN}✓{RESET} {DIM}Loaded {final_spec}{RESET}")
1620
1702
 
1621
1703
  # Seed LangGraph RunnableConfig from TOML [configurable] table if present
1622
1704
  config_dict: Dict[str, Any] = {"configurable": {}}
@@ -1654,21 +1736,21 @@ def main(
1654
1736
  sys.exit(1)
1655
1737
 
1656
1738
  except FileNotFoundError as e:
1657
- print(f"{RED}⏺ Error: {e}{RESET}")
1739
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1658
1740
  sys.exit(1)
1659
1741
  except AttributeError as e:
1660
- print(f"{RED}⏺ Error: {e}{RESET}")
1742
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1661
1743
  sys.exit(1)
1662
1744
  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}")
1745
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1746
+ _status(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
1665
1747
  sys.exit(1)
1666
1748
  except Exception as e:
1667
- print(f"{RED}⏺ Error: {e}{RESET}")
1749
+ _status(f"{RED}⏺ Error: {e}{RESET}")
1668
1750
  if verbose:
1669
1751
  import traceback
1670
1752
 
1671
- print(traceback.format_exc())
1753
+ _status(traceback.format_exc())
1672
1754
  sys.exit(1)
1673
1755
 
1674
1756
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.2
3
+ Version: 0.6.4
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
@@ -39,7 +39,7 @@ Dynamic: license-file
39
39
 
40
40
  **The terminal stage for your LangGraph agent.** A Claude Code-style CLI that runs *any* LangGraph `CompiledGraph` — yours, not a bundled one — with streaming, tool-call rendering, and human-in-the-loop approval.
41
41
 
42
- > Renamed from **deepagent-code** (the old package name now just installs this one, and the `deepagent-code` command still works).
42
+ > Renamed from **deepagent-code** (the old package name now just installs this one, and the `deepagent-code` command still works). Not to be confused with LangChain's **`deepagents-code`** (`dcode`) — that's a separate project; `langstage-cli` is the terminal stage of the [LangStage family](#every-stage-for-your-langgraph-agent).
43
43
 
44
44
  ![langstage-cli](examples/image.png)
45
45
 
@@ -203,9 +203,24 @@ 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
206
207
  --version Show the version and exit
207
208
  ```
208
209
 
210
+ ### Scriptable output
211
+
212
+ A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
213
+ reply — no header, spinner, tool chatter, timing, or color — as soon as its output
214
+ is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and the
215
+ process exits non-zero if the turn failed, so a run is safe to capture:
216
+
217
+ ```bash
218
+ answer=$(langstage-cli --demo "say hi") || echo "run failed" >&2
219
+ echo "$answer" # -> (demo agent) You said: say hi
220
+ ```
221
+
222
+ Pass `-q/--quiet` to force the same clean output in a terminal.
223
+
209
224
  ## Creating Your Own Agent
210
225
 
211
226
  Your agent file just needs to export a compiled LangGraph graph — `langstage-cli`
@@ -20,6 +20,7 @@ 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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.6.2"
7
+ version = "0.6.4"
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"
@@ -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
 
File without changes
File without changes