langstage-cli 0.6.3__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.
- {langstage_cli-0.6.3/langstage_cli.egg-info → langstage_cli-0.6.4}/PKG-INFO +16 -1
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/README.md +15 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli/cli.py +121 -39
- {langstage_cli-0.6.3 → langstage_cli-0.6.4/langstage_cli.egg-info}/PKG-INFO +16 -1
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli.egg-info/SOURCES.txt +1 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/pyproject.toml +1 -1
- langstage_cli-0.6.4/tests/test_quiet_output.py +79 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_streaming_marker.py +7 -3
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/LICENSE +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/deepagent_code/__init__.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli/__init__.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli/agui_stream.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli/config.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli.egg-info/dependency_links.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli.egg-info/entry_points.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli.egg-info/requires.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/langstage_cli.egg-info/top_level.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/setup.cfg +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_agui_stream.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_checkpointer.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_cli.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_cli_help.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_codeconfig.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_config.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_help_render.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_no_interactive_approve.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_rename_shim.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_show_config.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_spec_resolution.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_stream_mode.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.4}/tests/test_turn_exit_and_render.py +0 -0
- {langstage_cli-0.6.3 → 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.
|
|
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
|
|
@@ -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`
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
1203
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
1261
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
1338
|
+
_status(f"{RED}⏺ {e}{RESET}")
|
|
1289
1339
|
return
|
|
1290
1340
|
|
|
1291
1341
|
# Process initial message if provided
|
|
1292
1342
|
if initial_message:
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1630
|
+
_status(f"{RED}⏺ Error: File '{prompt_file}' is empty{RESET}")
|
|
1552
1631
|
sys.exit(1)
|
|
1553
1632
|
except Exception as e:
|
|
1554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1657
|
+
_status(
|
|
1579
1658
|
f"{RED}⏺ Error: unsupported stream mode {final_stream_mode!r} "
|
|
1580
|
-
f"(use 'auto', 'updates', or 'messages')
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
1739
|
+
_status(f"{RED}⏺ Error: {e}{RESET}")
|
|
1658
1740
|
sys.exit(1)
|
|
1659
1741
|
except AttributeError as e:
|
|
1660
|
-
|
|
1742
|
+
_status(f"{RED}⏺ Error: {e}{RESET}")
|
|
1661
1743
|
sys.exit(1)
|
|
1662
1744
|
except ModuleNotFoundError as e:
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
-
|
|
1749
|
+
_status(f"{RED}⏺ Error: {e}{RESET}")
|
|
1668
1750
|
if verbose:
|
|
1669
1751
|
import traceback
|
|
1670
1752
|
|
|
1671
|
-
|
|
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.
|
|
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
|
|
@@ -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`
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langstage-cli"
|
|
7
|
-
version = "0.6.
|
|
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
|
|
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
|
-
|
|
60
|
-
assert 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|