applied-cli 0.5.74__tar.gz → 0.6.0__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 (64) hide show
  1. {applied_cli-0.5.74 → applied_cli-0.6.0}/PKG-INFO +2 -1
  2. applied_cli-0.6.0/applied_cli/__init__.py +20 -0
  3. applied_cli-0.6.0/applied_cli/auth.py +111 -0
  4. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/cli.py +254 -6
  5. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/client.py +82 -0
  6. applied_cli-0.6.0/applied_cli/mcp.py +222 -0
  7. applied_cli-0.6.0/applied_cli/toolkit.py +255 -0
  8. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/tools.py +4 -7
  9. applied_cli-0.6.0/applied_cli/v2/__init__.py +11 -0
  10. applied_cli-0.6.0/applied_cli/v2/agents.py +448 -0
  11. applied_cli-0.6.0/applied_cli/v2/articles.py +364 -0
  12. applied_cli-0.6.0/applied_cli/v2/catalog.py +278 -0
  13. applied_cli-0.6.0/applied_cli/v2/connectors.py +122 -0
  14. applied_cli-0.6.0/applied_cli/v2/content.py +337 -0
  15. applied_cli-0.6.0/applied_cli/v2/conversations.py +709 -0
  16. applied_cli-0.6.0/applied_cli/v2/domains.py +141 -0
  17. applied_cli-0.6.0/applied_cli/v2/flows.py +1889 -0
  18. applied_cli-0.6.0/applied_cli/v2/knowledge.py +609 -0
  19. applied_cli-0.6.0/applied_cli/v2/manifest.py +129 -0
  20. applied_cli-0.6.0/applied_cli/v2/products.py +350 -0
  21. applied_cli-0.6.0/applied_cli/v2/scenarios.py +948 -0
  22. applied_cli-0.6.0/applied_cli/v2/taxonomy.py +261 -0
  23. applied_cli-0.6.0/applied_cli/v2/tickets.py +292 -0
  24. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli.egg-info/PKG-INFO +2 -1
  25. applied_cli-0.6.0/applied_cli.egg-info/SOURCES.txt +60 -0
  26. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli.egg-info/requires.txt +1 -0
  27. {applied_cli-0.5.74 → applied_cli-0.6.0}/pyproject.toml +2 -1
  28. applied_cli-0.6.0/tests/test_auth_context.py +51 -0
  29. applied_cli-0.6.0/tests/test_cli_v2.py +164 -0
  30. applied_cli-0.6.0/tests/test_client_v2.py +95 -0
  31. applied_cli-0.6.0/tests/test_toolkit_contract.py +230 -0
  32. applied_cli-0.6.0/tests/test_v2_agents.py +536 -0
  33. applied_cli-0.6.0/tests/test_v2_articles.py +387 -0
  34. applied_cli-0.6.0/tests/test_v2_catalog_and_mcp.py +327 -0
  35. applied_cli-0.6.0/tests/test_v2_connectors.py +100 -0
  36. applied_cli-0.6.0/tests/test_v2_content.py +253 -0
  37. applied_cli-0.6.0/tests/test_v2_conversations.py +426 -0
  38. applied_cli-0.6.0/tests/test_v2_flows.py +1740 -0
  39. applied_cli-0.6.0/tests/test_v2_knowledge.py +551 -0
  40. applied_cli-0.6.0/tests/test_v2_products.py +382 -0
  41. applied_cli-0.6.0/tests/test_v2_scenarios.py +876 -0
  42. applied_cli-0.6.0/tests/test_v2_taxonomy.py +284 -0
  43. applied_cli-0.6.0/tests/test_v2_tickets.py +328 -0
  44. applied_cli-0.5.74/applied_cli/__init__.py +0 -9
  45. applied_cli-0.5.74/applied_cli.egg-info/SOURCES.txt +0 -26
  46. {applied_cli-0.5.74 → applied_cli-0.6.0}/README.md +0 -0
  47. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/agent_scoped_flows.py +0 -0
  48. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/conversation_lookup.py +0 -0
  49. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/conversations.py +0 -0
  50. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/credentials.py +0 -0
  51. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/flow_helpers.py +0 -0
  52. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli/formatters.py +0 -0
  53. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli.egg-info/dependency_links.txt +0 -0
  54. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli.egg-info/entry_points.txt +0 -0
  55. {applied_cli-0.5.74 → applied_cli-0.6.0}/applied_cli.egg-info/top_level.txt +0 -0
  56. {applied_cli-0.5.74 → applied_cli-0.6.0}/setup.cfg +0 -0
  57. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_agent_scoped_flows.py +0 -0
  58. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_audit_tools.py +0 -0
  59. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_benchmark_scenario_tools.py +0 -0
  60. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_cli.py +0 -0
  61. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_client.py +0 -0
  62. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_conversation_tools.py +0 -0
  63. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_flow_tools.py +0 -0
  64. {applied_cli-0.5.74 → applied_cli-0.6.0}/tests/test_knowledge_content_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.74
3
+ Version: 0.6.0
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.11
16
16
  Description-Content-Type: text/markdown
17
17
  Requires-Dist: httpx>=0.27.0
18
+ Requires-Dist: pydantic>=2.0.0
18
19
  Requires-Dist: typer>=0.9.0
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,20 @@
1
+ """Applied Labs CLI - shared client library for CLI and MCP server."""
2
+
3
+ from applied_cli import tools
4
+ from applied_cli.auth import AuthContext
5
+ from applied_cli.client import AppliedClient
6
+ from applied_cli.formatters import to_csv, to_json
7
+ from applied_cli.toolkit import ToolResult, ToolSpec
8
+
9
+ __version__ = "0.6.0"
10
+
11
+ __all__ = [
12
+ "AppliedClient",
13
+ "AuthContext",
14
+ "ToolResult",
15
+ "ToolSpec",
16
+ "tools",
17
+ "to_csv",
18
+ "to_json",
19
+ "__version__",
20
+ ]
@@ -0,0 +1,111 @@
1
+ """Shared authentication context for local CLI and MCP-injected sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Protocol
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from applied_cli.client import AppliedAPIError, AppliedClient
11
+ from applied_cli.credentials import load_credentials
12
+
13
+ DEFAULT_BASE_URL = "https://api.appliedlabs.ai"
14
+
15
+
16
+ class AuthStatus(BaseModel):
17
+ ok: bool
18
+ shop_id: str | None = None
19
+ base_url: str = DEFAULT_BASE_URL
20
+ message: str
21
+ status_code: int | None = None
22
+
23
+
24
+ class AuthContext(BaseModel):
25
+ token: str
26
+ shop_id: str | None = None
27
+ base_url: str = DEFAULT_BASE_URL
28
+ access_mode: str | None = None
29
+
30
+ def to_client(self) -> AppliedClient:
31
+ return AppliedClient(
32
+ token=self.token,
33
+ shop_id=self.shop_id,
34
+ base_url=self.base_url,
35
+ access_mode=self.access_mode,
36
+ )
37
+
38
+ async def validate(self) -> AuthStatus:
39
+ client = self.to_client()
40
+ try:
41
+ await client.request(
42
+ "GET",
43
+ "/v1/agents/",
44
+ params={"limit": 1},
45
+ shop_id=self.shop_id,
46
+ )
47
+ except AppliedAPIError as exc:
48
+ return AuthStatus(
49
+ ok=False,
50
+ shop_id=self.shop_id,
51
+ base_url=self.base_url,
52
+ message=str(exc),
53
+ status_code=exc.status_code,
54
+ )
55
+ return AuthStatus(
56
+ ok=True,
57
+ shop_id=self.shop_id,
58
+ base_url=self.base_url,
59
+ message="Authenticated.",
60
+ )
61
+
62
+
63
+ class CredentialProvider(Protocol):
64
+ def load(self) -> AuthContext | None:
65
+ ...
66
+
67
+
68
+ class EnvCredentialProvider:
69
+ """Load credentials from environment variables for automation/MCP smoke tests."""
70
+
71
+ def load(self) -> AuthContext | None:
72
+ token = os.environ.get("APPLIED_TOKEN") or os.environ.get("APPLIED_API_TOKEN")
73
+ if not token:
74
+ return None
75
+ return AuthContext(
76
+ token=token,
77
+ shop_id=os.environ.get("APPLIED_SHOP_ID"),
78
+ base_url=os.environ.get("APPLIED_BASE_URL", DEFAULT_BASE_URL),
79
+ access_mode=os.environ.get("APPLIED_ASSISTANT_ACCESS_MODE"),
80
+ )
81
+
82
+
83
+ class StoredCredentialProvider:
84
+ """Load credentials written by `applied login`."""
85
+
86
+ def load(self) -> AuthContext | None:
87
+ credentials = load_credentials()
88
+ if not credentials or not credentials.get("api_token"):
89
+ return None
90
+ return AuthContext(
91
+ token=credentials["api_token"],
92
+ shop_id=credentials.get("shop_id"),
93
+ base_url=credentials.get("base_url", DEFAULT_BASE_URL),
94
+ access_mode=os.environ.get("APPLIED_ASSISTANT_ACCESS_MODE"),
95
+ )
96
+
97
+
98
+ class ChainedCredentialProvider:
99
+ def __init__(self, *providers: CredentialProvider):
100
+ self.providers = providers
101
+
102
+ def load(self) -> AuthContext | None:
103
+ for provider in self.providers:
104
+ context = provider.load()
105
+ if context:
106
+ return context
107
+ return None
108
+
109
+
110
+ def default_credential_provider() -> CredentialProvider:
111
+ return ChainedCredentialProvider(EnvCredentialProvider(), StoredCredentialProvider())
@@ -10,12 +10,21 @@ import typer
10
10
 
11
11
  from applied_cli import __version__, tools
12
12
  from applied_cli.agent_scoped_flows import run_agent_scoped_flow_command
13
+ from applied_cli.auth import AuthContext, default_credential_provider
13
14
  from applied_cli.client import AppliedClient
14
15
  from applied_cli.credentials import (
15
16
  clear_credentials,
16
17
  load_credentials,
17
18
  save_credentials,
18
19
  )
20
+ from applied_cli.toolkit import render_tool_result
21
+ from applied_cli.v2.catalog import (
22
+ execute_tool as execute_v2_tool,
23
+ )
24
+ from applied_cli.v2.catalog import (
25
+ get_tool_catalog,
26
+ )
27
+ from applied_cli.v2.manifest import build_doctor_report, build_tool_manifest
19
28
 
20
29
  app = typer.Typer(
21
30
  name="applied",
@@ -24,9 +33,19 @@ app = typer.Typer(
24
33
  )
25
34
  content_app = typer.Typer(help="Inspect synced content items.")
26
35
  app.add_typer(content_app, name="content")
36
+ v2_app = typer.Typer(help="Run v2 typed Applied tools.")
37
+ v2_tools_app = typer.Typer(help="Inspect v2 tool schemas.")
38
+ v2_conversations_app = typer.Typer(help="Conversation tools.")
39
+ v2_app.add_typer(v2_tools_app, name="tools")
40
+ v2_app.add_typer(v2_conversations_app, name="conversations")
41
+ app.add_typer(v2_app, name="v2")
27
42
 
28
43
  DEFAULT_BASE_URL = "https://api.appliedlabs.ai"
29
44
  DEFAULT_CLIENT_URL = "https://appliedlabs.ai"
45
+ AGENT_DEPLOY_AGENT_IDS_ARGUMENT = typer.Argument(
46
+ ...,
47
+ help="One or more Agent IDs to deploy",
48
+ )
30
49
 
31
50
 
32
51
  def _version_callback(value: bool) -> None:
@@ -144,6 +163,47 @@ def _build_conversation_filters(
144
163
  return filters or None
145
164
 
146
165
 
166
+ def _v2_auth_context() -> AuthContext | None:
167
+ return default_credential_provider().load()
168
+
169
+
170
+ def _parse_v2_input(value: str | None) -> dict:
171
+ if not value:
172
+ return {}
173
+ parsed = _parse_json_option(value, option_name="--input")
174
+ return parsed or {}
175
+
176
+
177
+ def _v2_tool_metadata(include_debug: bool = False) -> list[dict]:
178
+ return [
179
+ {
180
+ "name": spec.name,
181
+ "namespace": spec.namespace,
182
+ "description": spec.description,
183
+ "read_write_mode": spec.read_write_mode,
184
+ "tags": spec.tags,
185
+ }
186
+ for spec in get_tool_catalog(include_debug=include_debug)
187
+ ]
188
+
189
+
190
+ def _v2_tool_detail(tool_name: str, include_debug: bool = False) -> dict:
191
+ catalog = get_tool_catalog(include_debug=include_debug)
192
+ spec = catalog.get(tool_name)
193
+ if spec is None:
194
+ typer.echo(f"Unknown v2 tool: {tool_name}", err=True)
195
+ raise typer.Exit(1)
196
+ return {
197
+ "name": spec.name,
198
+ "namespace": spec.namespace,
199
+ "description": spec.description,
200
+ "read_write_mode": spec.read_write_mode,
201
+ "tags": spec.tags,
202
+ "examples": spec.examples,
203
+ "input_schema": spec.input_schema(),
204
+ }
205
+
206
+
147
207
  # -----------------------------------------------------------------------------
148
208
  # Auth commands
149
209
  # -----------------------------------------------------------------------------
@@ -230,7 +290,13 @@ def logout() -> None:
230
290
 
231
291
 
232
292
  @app.command()
233
- def whoami() -> None:
293
+ def whoami(
294
+ verify: bool = typer.Option(
295
+ True,
296
+ "--verify/--no-verify",
297
+ help="Verify the token against the Applied API.",
298
+ ),
299
+ ) -> None:
234
300
  """Show current authentication status."""
235
301
  creds = load_credentials()
236
302
  if not creds or not creds.get("api_token"):
@@ -243,6 +309,189 @@ def whoami() -> None:
243
309
  typer.echo(f"Base URL: {creds.get('base_url', DEFAULT_BASE_URL)}")
244
310
  if creds.get("shop_id"):
245
311
  typer.echo(f"Shop ID: {creds['shop_id']}")
312
+ if verify:
313
+ context = AuthContext(
314
+ token=creds["api_token"],
315
+ shop_id=creds.get("shop_id"),
316
+ base_url=creds.get("base_url", DEFAULT_BASE_URL),
317
+ )
318
+ status = asyncio.run(context.validate())
319
+ typer.echo(f"Verified: {'yes' if status.ok else 'no'}")
320
+ if not status.ok:
321
+ typer.echo(status.message, err=True)
322
+ raise typer.Exit(1)
323
+
324
+
325
+ @v2_conversations_app.command("search")
326
+ def v2_conversations_search(
327
+ search: str | None = typer.Option(None, "--search", help="Full-text search query"),
328
+ limit: int = typer.Option(10, "--limit", "-l", help="Max conversations"),
329
+ json_output: bool = typer.Option(
330
+ False,
331
+ "--json",
332
+ help="Render the structured tool result as JSON.",
333
+ ),
334
+ ) -> None:
335
+ """Search conversations through the v2 typed tool catalog."""
336
+
337
+ async def _run():
338
+ return await execute_v2_tool(
339
+ "conversations_search",
340
+ {"search": search, "limit": limit},
341
+ auth_context=_v2_auth_context(),
342
+ )
343
+
344
+ result = asyncio.run(_run())
345
+ typer.echo(render_tool_result(result, "json" if json_output else "text"))
346
+
347
+
348
+ @v2_tools_app.command("list")
349
+ def v2_tools_list(
350
+ include_debug: bool = typer.Option(
351
+ False,
352
+ "--include-debug",
353
+ help="Include gated debug tools.",
354
+ ),
355
+ json_output: bool = typer.Option(
356
+ False,
357
+ "--json",
358
+ help="Render tool metadata as JSON.",
359
+ ),
360
+ ) -> None:
361
+ """List registered v2 Applied tools."""
362
+ payload = {"tools": _v2_tool_metadata(include_debug=include_debug)}
363
+ if json_output:
364
+ typer.echo(json.dumps(payload, indent=2, default=str))
365
+ return
366
+
367
+ for row in payload["tools"]:
368
+ first_line = row["description"].splitlines()[0]
369
+ typer.echo(
370
+ f"{row['name']}\t{row['read_write_mode']}\t{row['namespace']}\t{first_line}"
371
+ )
372
+
373
+
374
+ @v2_tools_app.command("describe")
375
+ def v2_tools_describe(
376
+ tool_name: str = typer.Argument(..., help="v2 tool name"),
377
+ include_debug: bool = typer.Option(
378
+ False,
379
+ "--include-debug",
380
+ help="Include gated debug tools.",
381
+ ),
382
+ json_output: bool = typer.Option(
383
+ False,
384
+ "--json",
385
+ help="Render schema as JSON.",
386
+ ),
387
+ ) -> None:
388
+ """Describe one v2 Applied tool and its input schema."""
389
+ payload = _v2_tool_detail(tool_name, include_debug=include_debug)
390
+ if json_output:
391
+ typer.echo(json.dumps(payload, indent=2, default=str))
392
+ return
393
+
394
+ typer.echo(f"{payload['name']} ({payload['read_write_mode']})")
395
+ typer.echo(payload["description"])
396
+ typer.echo(json.dumps(payload["input_schema"], indent=2, default=str))
397
+
398
+
399
+ @v2_tools_app.command("manifest")
400
+ def v2_tools_manifest(
401
+ include_debug: bool = typer.Option(
402
+ False,
403
+ "--include-debug",
404
+ help="Include gated debug tools.",
405
+ ),
406
+ json_output: bool = typer.Option(
407
+ False,
408
+ "--json",
409
+ help="Render the complete manifest as JSON.",
410
+ ),
411
+ ) -> None:
412
+ """Emit the complete v2 tool manifest for agents and MCP packaging."""
413
+ payload = build_tool_manifest(include_debug=include_debug)
414
+ if json_output:
415
+ typer.echo(json.dumps(payload, indent=2, default=str))
416
+ return
417
+
418
+ typer.echo(
419
+ f"Applied CLI v2 manifest: {payload['tool_count']} tools "
420
+ f"across {len(payload['namespace_counts'])} namespaces"
421
+ )
422
+ for name, count in payload["namespace_counts"].items():
423
+ typer.echo(f"{name}\t{count}")
424
+
425
+
426
+ @v2_app.command("doctor")
427
+ def v2_doctor(
428
+ include_debug: bool = typer.Option(
429
+ False,
430
+ "--include-debug",
431
+ help="Include gated debug tools in the catalog check.",
432
+ ),
433
+ validate_auth: bool = typer.Option(
434
+ False,
435
+ "--validate-auth",
436
+ help="Verify configured credentials against the Applied API.",
437
+ ),
438
+ json_output: bool = typer.Option(
439
+ False,
440
+ "--json",
441
+ help="Render diagnostics as JSON.",
442
+ ),
443
+ ) -> None:
444
+ """Check v2 catalog, MCP registration, and optional auth readiness."""
445
+
446
+ async def _run():
447
+ return await build_doctor_report(
448
+ include_debug=include_debug,
449
+ validate_auth=validate_auth,
450
+ credential_provider=default_credential_provider(),
451
+ )
452
+
453
+ payload = asyncio.run(_run())
454
+ if json_output:
455
+ typer.echo(json.dumps(payload, indent=2, default=str))
456
+ return
457
+
458
+ auth = payload["auth"]
459
+ typer.echo(f"Applied CLI {payload['version']}")
460
+ typer.echo(f"Catalog tools: {payload['catalog']['tool_count']}")
461
+ typer.echo(f"MCP registrar: {payload['mcp']['registrar']}")
462
+ typer.echo(f"Auth configured: {'yes' if auth['configured'] else 'no'}")
463
+ if validate_auth:
464
+ typer.echo(f"Auth valid: {'yes' if auth['ok'] else 'no'}")
465
+ if auth["message"]:
466
+ typer.echo(auth["message"])
467
+
468
+
469
+ @v2_app.command("run")
470
+ def v2_run(
471
+ tool_name: str = typer.Argument(..., help="v2 tool name"),
472
+ input_json: str | None = typer.Option(
473
+ None,
474
+ "--input",
475
+ "-i",
476
+ help="JSON object input for the tool.",
477
+ ),
478
+ json_output: bool = typer.Option(
479
+ False,
480
+ "--json",
481
+ help="Render the structured tool result as JSON.",
482
+ ),
483
+ ) -> None:
484
+ """Run any v2 Applied tool by name with JSON input."""
485
+
486
+ async def _run():
487
+ return await execute_v2_tool(
488
+ tool_name,
489
+ _parse_v2_input(input_json),
490
+ auth_context=_v2_auth_context(),
491
+ )
492
+
493
+ result = asyncio.run(_run())
494
+ typer.echo(render_tool_result(result, "json" if json_output else "text"))
246
495
 
247
496
 
248
497
  # -----------------------------------------------------------------------------
@@ -2475,7 +2724,7 @@ def product_update(
2475
2724
 
2476
2725
  @app.command("agent-deploy")
2477
2726
  def agent_deploy(
2478
- agent_ids: list[str] = typer.Argument(..., help="One or more Agent IDs to deploy"),
2727
+ agent_ids: list[str] = AGENT_DEPLOY_AGENT_IDS_ARGUMENT,
2479
2728
  description: str = typer.Option("", "--description", "-d", help="Optional revision description"),
2480
2729
  shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
2481
2730
  ) -> None:
@@ -2580,7 +2829,6 @@ def agent_revision_diff(
2580
2829
  Example:
2581
2830
  applied agent-revision-diff <agent_id> 54 55
2582
2831
  """
2583
- import difflib
2584
2832
 
2585
2833
  client = get_client(shop_id=shop_id)
2586
2834
 
@@ -2661,7 +2909,7 @@ def agent_revision_diff(
2661
2909
  for field in ("model", "escalation_mode", "use_guardrails", "auto_reply", "response_delay_in_seconds"):
2662
2910
  typer.echo(f" {field}: {agent.get(field)}")
2663
2911
 
2664
- typer.echo(f"\n--- PROMPT (guardrail, current) ---")
2912
+ typer.echo("\n--- PROMPT (guardrail, current) ---")
2665
2913
  guardrail = agent.get("guardrail") or ""
2666
2914
  typer.echo(f" Length: {len(guardrail)} chars")
2667
2915
  typer.echo(f" First 300 chars:\n{guardrail[:300]}")
@@ -2673,8 +2921,8 @@ def agent_revision_diff(
2673
2921
  for f in active[:20]:
2674
2922
  typer.echo(f" [{f.get('trigger','?')}] {f.get('name')} ({f.get('status')})")
2675
2923
 
2676
- typer.echo(f"\nNote: Full prompt diff requires snapshot storage per revision (not yet available in API).")
2677
- typer.echo(f"To investigate: compare guardrail text above against the known-good version,")
2924
+ typer.echo("\nNote: Full prompt diff requires snapshot storage per revision (not yet available in API).")
2925
+ typer.echo("To investigate: compare guardrail text above against the known-good version,")
2678
2926
  typer.echo(f"and check if any flows were added/removed between {rev_a['created_at'][:10]} and {rev_b['created_at'][:10]}.")
2679
2927
 
2680
2928
 
@@ -4,6 +4,7 @@ import asyncio
4
4
  import json
5
5
  import os
6
6
  import uuid
7
+ from collections.abc import AsyncIterator
7
8
  from typing import Any
8
9
 
9
10
  import httpx
@@ -531,6 +532,25 @@ class AppliedClient:
531
532
  # Do not let proposal logging mask the access-mode decision.
532
533
  return
533
534
 
535
+ async def record_tool_access_mode_decision(
536
+ self,
537
+ *,
538
+ tool_name: str,
539
+ raw_input: dict[str, Any],
540
+ read_write_mode: str,
541
+ status: str,
542
+ ) -> None:
543
+ await self._record_access_mode_change(
544
+ method="POST",
545
+ path=f"/mcp/tools/{tool_name}/",
546
+ body={
547
+ "tool_name": tool_name,
548
+ "input": raw_input,
549
+ "read_write_mode": read_write_mode,
550
+ },
551
+ status=status,
552
+ )
553
+
534
554
  async def _fetch_current_object(
535
555
  self,
536
556
  *,
@@ -707,6 +727,68 @@ class AppliedClient:
707
727
  )
708
728
  return result
709
729
 
730
+ async def request(
731
+ self,
732
+ method: str,
733
+ path: str,
734
+ params: dict[str, Any] | None = None,
735
+ body: dict[str, Any] | None = None,
736
+ shop_id: str | None = None,
737
+ ) -> Any:
738
+ """Public authenticated request API for catalog tools and integrations."""
739
+ return await self._request(
740
+ method,
741
+ path,
742
+ params=params,
743
+ body=body,
744
+ shop_id=shop_id,
745
+ )
746
+
747
+ async def iter_pages(
748
+ self,
749
+ path: str,
750
+ *,
751
+ params: dict[str, Any] | None = None,
752
+ page_style: str = "page",
753
+ shop_id: str | None = None,
754
+ max_pages: int | None = None,
755
+ ) -> AsyncIterator[dict[str, Any]]:
756
+ """Yield rows from page- or offset-paginated list endpoints."""
757
+ base_params = dict(params or {})
758
+ limit = int(base_params.get("limit") or 100)
759
+ page = int(base_params.get("page") or 1)
760
+ offset = int(base_params.get("offset") or 0)
761
+ pages_seen = 0
762
+
763
+ while True:
764
+ request_params = dict(base_params)
765
+ if page_style == "offset":
766
+ request_params["offset"] = offset
767
+ elif page_style == "page":
768
+ request_params["page"] = page
769
+ else:
770
+ raise ValueError("page_style must be 'page' or 'offset'")
771
+
772
+ data = await self.request(
773
+ "GET",
774
+ path,
775
+ params=request_params,
776
+ shop_id=shop_id,
777
+ )
778
+ rows = self._normalize_response(data)
779
+ for row in rows:
780
+ yield row
781
+
782
+ pages_seen += 1
783
+ if max_pages is not None and pages_seen >= max_pages:
784
+ break
785
+ if not isinstance(data, dict) or not data.get("next"):
786
+ break
787
+ if page_style == "offset":
788
+ offset += limit
789
+ else:
790
+ page += 1
791
+
710
792
  async def _upload_request(
711
793
  self,
712
794
  method: str,