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.
- {langstage_cli-0.6.3/langstage_cli.egg-info → langstage_cli-0.6.5}/PKG-INFO +31 -3
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/README.md +28 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/cli.py +148 -39
- {langstage_cli-0.6.3 → langstage_cli-0.6.5/langstage_cli.egg-info}/PKG-INFO +31 -3
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/SOURCES.txt +3 -1
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/requires.txt +2 -2
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/pyproject.toml +4 -3
- langstage_cli-0.6.5/tests/test_quiet_output.py +79 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_streaming_marker.py +7 -3
- langstage_cli-0.6.5/tests/test_verify_flag.py +45 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/LICENSE +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/deepagent_code/__init__.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/__init__.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/agui_stream.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli/config.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/dependency_links.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/entry_points.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/langstage_cli.egg-info/top_level.txt +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/setup.cfg +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_agui_stream.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_checkpointer.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_cli.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_cli_help.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_codeconfig.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_config.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_help_render.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_no_interactive_approve.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_rename_shim.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_show_config.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_spec_resolution.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_stream_mode.py +0 -0
- {langstage_cli-0.6.3 → langstage_cli-0.6.5}/tests/test_turn_exit_and_render.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
+
_status(f"{RED}⏺ Error: File '{prompt_file}' is empty{RESET}")
|
|
1552
1643
|
sys.exit(1)
|
|
1553
1644
|
except Exception as e:
|
|
1554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1669
|
+
_status(
|
|
1579
1670
|
f"{RED}⏺ Error: unsupported stream mode {final_stream_mode!r} "
|
|
1580
|
-
f"(use 'auto', 'updates', or 'messages')
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
1766
|
+
_status(f"{RED}⏺ Error: {e}{RESET}")
|
|
1658
1767
|
sys.exit(1)
|
|
1659
1768
|
except AttributeError as e:
|
|
1660
|
-
|
|
1769
|
+
_status(f"{RED}⏺ Error: {e}{RESET}")
|
|
1661
1770
|
sys.exit(1)
|
|
1662
1771
|
except ModuleNotFoundError as e:
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
-
|
|
1776
|
+
_status(f"{RED}⏺ Error: {e}{RESET}")
|
|
1668
1777
|
if verbose:
|
|
1669
1778
|
import traceback
|
|
1670
1779
|
|
|
1671
|
-
|
|
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
|
+
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.
|
|
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.
|
|
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
|
|
@@ -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.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
|
-
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|