glaip-sdk 0.0.1b10__py3-none-any.whl → 0.0.3__py3-none-any.whl

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 (39) hide show
  1. glaip_sdk/__init__.py +2 -2
  2. glaip_sdk/_version.py +51 -0
  3. glaip_sdk/cli/commands/agents.py +201 -109
  4. glaip_sdk/cli/commands/configure.py +29 -87
  5. glaip_sdk/cli/commands/init.py +16 -7
  6. glaip_sdk/cli/commands/mcps.py +73 -153
  7. glaip_sdk/cli/commands/tools.py +185 -49
  8. glaip_sdk/cli/main.py +30 -27
  9. glaip_sdk/cli/utils.py +126 -13
  10. glaip_sdk/client/__init__.py +54 -2
  11. glaip_sdk/client/agents.py +175 -237
  12. glaip_sdk/client/base.py +62 -2
  13. glaip_sdk/client/mcps.py +63 -20
  14. glaip_sdk/client/tools.py +95 -28
  15. glaip_sdk/config/constants.py +10 -3
  16. glaip_sdk/exceptions.py +13 -0
  17. glaip_sdk/models.py +20 -4
  18. glaip_sdk/utils/__init__.py +116 -18
  19. glaip_sdk/utils/client_utils.py +284 -0
  20. glaip_sdk/utils/rendering/__init__.py +1 -0
  21. glaip_sdk/utils/rendering/formatting.py +211 -0
  22. glaip_sdk/utils/rendering/models.py +53 -0
  23. glaip_sdk/utils/rendering/renderer/__init__.py +38 -0
  24. glaip_sdk/utils/rendering/renderer/base.py +827 -0
  25. glaip_sdk/utils/rendering/renderer/config.py +33 -0
  26. glaip_sdk/utils/rendering/renderer/console.py +54 -0
  27. glaip_sdk/utils/rendering/renderer/debug.py +82 -0
  28. glaip_sdk/utils/rendering/renderer/panels.py +123 -0
  29. glaip_sdk/utils/rendering/renderer/progress.py +118 -0
  30. glaip_sdk/utils/rendering/renderer/stream.py +198 -0
  31. glaip_sdk/utils/rendering/steps.py +168 -0
  32. glaip_sdk/utils/run_renderer.py +22 -1086
  33. {glaip_sdk-0.0.1b10.dist-info → glaip_sdk-0.0.3.dist-info}/METADATA +9 -37
  34. glaip_sdk-0.0.3.dist-info/RECORD +40 -0
  35. glaip_sdk/cli/config.py +0 -592
  36. glaip_sdk/utils.py +0 -167
  37. glaip_sdk-0.0.1b10.dist-info/RECORD +0 -28
  38. {glaip_sdk-0.0.1b10.dist-info → glaip_sdk-0.0.3.dist-info}/WHEEL +0 -0
  39. {glaip_sdk-0.0.1b10.dist-info → glaip_sdk-0.0.3.dist-info}/entry_points.txt +0 -0
@@ -5,19 +5,19 @@ Authors:
5
5
  """
6
6
 
7
7
  import json
8
+ import re
8
9
 
9
10
  import click
10
11
  from rich.console import Console
11
12
  from rich.panel import Panel
12
13
 
13
- from glaip_sdk.utils import is_uuid
14
-
15
14
  from ..utils import (
15
+ coerce_to_row,
16
16
  get_client,
17
- handle_ambiguous_resource,
18
17
  output_flags,
19
18
  output_list,
20
19
  output_result,
20
+ resolve_resource,
21
21
  )
22
22
 
23
23
  console = Console()
@@ -31,25 +31,61 @@ def tools_group():
31
31
 
32
32
  def _resolve_tool(ctx, client, ref, select=None):
33
33
  """Resolve tool reference (ID or name) with ambiguity handling."""
34
- if is_uuid(ref):
35
- return client.get_tool(ref)
36
-
37
- # Use find_tools for name-based resolution
38
- matches = client.find_tools(name=ref)
39
- if not matches:
40
- raise click.ClickException(f"Tool not found: {ref}")
41
-
42
- if len(matches) == 1:
43
- return matches[0]
44
- elif len(matches) > 1:
45
- if select:
46
- idx = int(select) - 1
47
- if not (0 <= idx < len(matches)):
48
- raise click.ClickException(f"--select must be 1..{len(matches)}")
49
- return matches[idx]
50
- return handle_ambiguous_resource(ctx, "tool", ref, matches)
51
- else:
52
- raise click.ClickException(f"Tool not found: {ref}")
34
+ return resolve_resource(
35
+ ctx,
36
+ ref,
37
+ get_by_id=client.get_tool,
38
+ find_by_name=client.find_tools,
39
+ label="Tool",
40
+ select=select,
41
+ )
42
+
43
+
44
+ # ----------------------------- Helpers --------------------------------- #
45
+
46
+
47
+ def _extract_internal_name(code: str) -> str:
48
+ """Extract plugin class name attribute from tool code."""
49
+ m = re.search(r'^\s*name\s*:\s*str\s*=\s*"([^"]+)"', code, re.M)
50
+ if not m:
51
+ m = re.search(r'^\s*name\s*=\s*"([^"]+)"', code, re.M)
52
+ if not m:
53
+ raise click.ClickException(
54
+ "Could not find plugin 'name' attribute in the tool file. "
55
+ 'Ensure your plugin class defines e.g. name: str = "my_tool".'
56
+ )
57
+ return m.group(1)
58
+
59
+
60
+ def _validate_name_match(provided: str | None, internal: str) -> str:
61
+ """Validate provided --name against internal name; return effective name."""
62
+ if provided and provided != internal:
63
+ raise click.ClickException(
64
+ f"--name '{provided}' does not match plugin internal name '{internal}'. "
65
+ "Either update the code or pass a matching --name."
66
+ )
67
+ return provided or internal
68
+
69
+
70
+ def _check_duplicate_name(client, tool_name: str) -> None:
71
+ """Raise if a tool with the same name already exists."""
72
+ try:
73
+ existing = client.find_tools(name=tool_name)
74
+ if existing:
75
+ raise click.ClickException(
76
+ f"A tool named '{tool_name}' already exists. "
77
+ "Please change your plugin's 'name' to a unique value, then re-run."
78
+ )
79
+ except click.ClickException:
80
+ # Re-raise ClickException (intended error)
81
+ raise
82
+ except Exception:
83
+ # Non-fatal: best-effort duplicate check for other errors
84
+ pass
85
+
86
+
87
+ def _parse_tags(tags: str | None) -> list[str]:
88
+ return [t.strip() for t in (tags.split(",") if tags else []) if t.strip()]
53
89
 
54
90
 
55
91
  @tools_group.command(name="list")
@@ -70,20 +106,10 @@ def list_tools(ctx):
70
106
 
71
107
  # Transform function for safe dictionary access
72
108
  def transform_tool(tool):
73
- # Handle both dict and object formats
74
- if isinstance(tool, dict):
75
- return {
76
- "id": str(tool.get("id", "N/A")),
77
- "name": tool.get("name", "N/A"),
78
- "framework": tool.get("framework", "N/A"),
79
- }
80
- else:
81
- # Fallback to attribute access
82
- return {
83
- "id": str(getattr(tool, "id", "N/A")),
84
- "name": getattr(tool, "name", "N/A"),
85
- "framework": getattr(tool, "framework", "N/A"),
86
- }
109
+ row = coerce_to_row(tool, ["id", "name", "framework"])
110
+ # Ensure id is always a string
111
+ row["id"] = str(row["id"])
112
+ return row
87
113
 
88
114
  output_list(ctx, tools, "🔧 Available Tools", columns, transform_tool)
89
115
 
@@ -92,6 +118,7 @@ def list_tools(ctx):
92
118
 
93
119
 
94
120
  @tools_group.command()
121
+ @click.argument("file_arg", required=False, type=click.Path(exists=True))
95
122
  @click.option(
96
123
  "--file",
97
124
  type=click.Path(exists=True),
@@ -111,11 +138,15 @@ def list_tools(ctx):
111
138
  )
112
139
  @output_flags()
113
140
  @click.pass_context
114
- def create(ctx, file, name, description, tags):
141
+ def create(ctx, file_arg, file, name, description, tags):
115
142
  """Create a new tool."""
116
143
  try:
117
144
  client = get_client(ctx)
118
145
 
146
+ # Allow positional file argument for better DX (matches examples)
147
+ if not file and file_arg:
148
+ file = file_arg
149
+
119
150
  # Validate required parameters based on creation method
120
151
  if not file:
121
152
  # Metadata-only tool creation
@@ -126,27 +157,33 @@ def create(ctx, file, name, description, tags):
126
157
 
127
158
  # Create tool based on whether file is provided
128
159
  if file:
129
- # File-based tool creation - use create_tool_from_code for proper plugin processing
160
+ # File-based tool creation validate internal plugin name, no rewriting
130
161
  with open(file, encoding="utf-8") as f:
131
162
  code_content = f.read()
132
163
 
133
- # Extract name from file if not provided
134
- if not name:
135
- import os
136
-
137
- name = os.path.splitext(os.path.basename(file))[0]
138
-
139
- # Create tool plugin using the upload endpoint
140
- tool = client.create_tool_from_code(name, code_content)
164
+ internal_name = _extract_internal_name(code_content)
165
+ tool_name = _validate_name_match(name, internal_name)
166
+ _check_duplicate_name(client, tool_name)
167
+
168
+ # Upload the plugin code as-is (no rewrite)
169
+ tool = client.create_tool_from_code(
170
+ tool_name,
171
+ code_content,
172
+ framework="langchain", # Always langchain
173
+ description=description,
174
+ tags=_parse_tags(tags),
175
+ )
141
176
  else:
142
177
  # Metadata-only tool creation
143
178
  tool_kwargs = {}
144
179
  if name:
145
180
  tool_kwargs["name"] = name
181
+ tool_kwargs["tool_type"] = "custom" # Always custom
182
+ tool_kwargs["framework"] = "langchain" # Always langchain
146
183
  if description:
147
184
  tool_kwargs["description"] = description
148
185
  if tags:
149
- tool_kwargs["tags"] = [tag.strip() for tag in tags.split(",")]
186
+ tool_kwargs["tags"] = _parse_tags(tags)
150
187
 
151
188
  tool = client.create_tool(**tool_kwargs)
152
189
 
@@ -160,8 +197,8 @@ def create(ctx, file, name, description, tags):
160
197
  panel = Panel(
161
198
  f"[green]✅ Tool '{tool.name}' created successfully via {creation_method}![/green]\n\n"
162
199
  f"ID: {tool.id}\n"
163
- f"Framework: langchain (default)\n"
164
- f"Type: {'custom' if file else 'native'} (auto-detected)\n"
200
+ f"Framework: {getattr(tool, 'framework', 'N/A')} (default)\n"
201
+ f"Type: {getattr(tool, 'tool_type', 'N/A')} (auto-detected)\n"
165
202
  f"Description: {getattr(tool, 'description', 'No description')}",
166
203
  title="🔧 Tool Created",
167
204
  border_style="green",
@@ -307,3 +344,102 @@ def delete(ctx, tool_id, yes):
307
344
  else:
308
345
  console.print(f"[red]Error deleting tool: {e}[/red]")
309
346
  raise click.ClickException(str(e))
347
+
348
+
349
+ @tools_group.command()
350
+ @click.argument("tool_id")
351
+ @output_flags()
352
+ @click.pass_context
353
+ def script(ctx, tool_id):
354
+ """Get tool script content."""
355
+ try:
356
+ client = get_client(ctx)
357
+
358
+ # Get tool by ID (no ambiguity handling needed)
359
+ try:
360
+ tool = client.get_tool_by_id(tool_id)
361
+ except Exception as e:
362
+ raise click.ClickException(f"Tool with ID '{tool_id}' not found: {e}")
363
+
364
+ # Get tool script content
365
+ script_content = client.tools.get_tool_script(tool_id)
366
+
367
+ if ctx.obj.get("view") == "json":
368
+ click.echo(
369
+ json.dumps(
370
+ {
371
+ "tool_id": tool_id,
372
+ "tool_name": tool.name,
373
+ "script": script_content,
374
+ },
375
+ indent=2,
376
+ )
377
+ )
378
+ elif ctx.obj.get("output"):
379
+ # Save to file
380
+ output_file = ctx.obj.get("output")
381
+ with open(output_file, "w", encoding="utf-8") as f:
382
+ f.write(script_content)
383
+ console.print(f"[green]✅ Tool script saved to {output_file}[/green]")
384
+ else:
385
+ # Display in terminal
386
+ console.print(
387
+ Panel(
388
+ script_content,
389
+ title=f"🔧 Tool Script: {tool.name}",
390
+ border_style="cyan",
391
+ )
392
+ )
393
+
394
+ except Exception as e:
395
+ if ctx.obj.get("view") == "json":
396
+ click.echo(json.dumps({"error": str(e)}, indent=2))
397
+ else:
398
+ console.print(f"[red]Error getting tool script: {e}[/red]")
399
+ raise click.ClickException(str(e))
400
+
401
+
402
+ @tools_group.command()
403
+ @click.argument("tool_id")
404
+ @click.option(
405
+ "--file",
406
+ type=click.Path(exists=True),
407
+ required=True,
408
+ help="New tool file for code update",
409
+ )
410
+ @click.option("--name", help="New tool name")
411
+ @click.option("--description", help="New description")
412
+ @click.option("--tags", help="Comma-separated tags")
413
+ @output_flags()
414
+ @click.pass_context
415
+ def upload_update(ctx, tool_id, file, name, description, tags):
416
+ """Update a tool plugin via file upload."""
417
+ try:
418
+ client = get_client(ctx)
419
+
420
+ # Prepare update data
421
+ update_data = {}
422
+ if name:
423
+ update_data["name"] = name
424
+ if description:
425
+ update_data["description"] = description
426
+ if tags:
427
+ update_data["tags"] = [tag.strip() for tag in tags.split(",")]
428
+
429
+ # Update tool via file upload
430
+ updated_tool = client.tools.update_tool_via_file(tool_id, file, **update_data)
431
+
432
+ if ctx.obj.get("view") == "json":
433
+ click.echo(json.dumps(updated_tool.model_dump(), indent=2))
434
+ else:
435
+ console.print(
436
+ f"[green]✅ Tool '{updated_tool.name}' updated successfully via file upload[/green]"
437
+ )
438
+ console.print(f"[blue]📁 File: {file}[/blue]")
439
+
440
+ except Exception as e:
441
+ if ctx.obj.get("view") == "json":
442
+ click.echo(json.dumps({"error": str(e)}, indent=2))
443
+ else:
444
+ console.print(f"[red]Error updating tool: {e}[/red]")
445
+ raise click.ClickException(str(e))
glaip_sdk/cli/main.py CHANGED
@@ -4,20 +4,32 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
+ import os
8
+ import subprocess
7
9
  import sys
8
10
 
9
11
  import click
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.table import Table
10
15
 
16
+ from glaip_sdk import Client
17
+ from glaip_sdk._version import __version__ as _SDK_VERSION
11
18
  from glaip_sdk.cli.commands.agents import agents_group
12
- from glaip_sdk.cli.commands.configure import config_group, configure_command
19
+ from glaip_sdk.cli.commands.configure import (
20
+ config_group,
21
+ configure_command,
22
+ load_config,
23
+ )
13
24
  from glaip_sdk.cli.commands.init import init_command
14
25
  from glaip_sdk.cli.commands.mcps import mcps_group
15
26
  from glaip_sdk.cli.commands.models import models_group
16
27
  from glaip_sdk.cli.commands.tools import tools_group
28
+ from glaip_sdk.config.constants import DEFAULT_AGENT_RUN_TIMEOUT
17
29
 
18
30
 
19
31
  @click.group()
20
- @click.version_option(version="0.1.1", prog_name="aip")
32
+ @click.version_option(version=_SDK_VERSION, prog_name="aip")
21
33
  @click.option("--api-url", envvar="AIP_API_URL", help="AIP API URL")
22
34
  @click.option("--api-key", envvar="AIP_API_KEY", help="AIP API Key")
23
35
  @click.option("--timeout", default=30.0, help="Request timeout in seconds")
@@ -75,13 +87,6 @@ def status(ctx):
75
87
  """Show connection status and basic info."""
76
88
  config = {}
77
89
  try:
78
- from rich.console import Console
79
- from rich.panel import Panel
80
- from rich.table import Table
81
-
82
- from glaip_sdk import Client
83
- from glaip_sdk.cli.commands.configure import load_config
84
-
85
90
  console = Console()
86
91
 
87
92
  # Load config from file and merge with context
@@ -89,7 +94,6 @@ def status(ctx):
89
94
  context_config = ctx.obj or {}
90
95
 
91
96
  # Load environment variables (middle priority)
92
- import os
93
97
 
94
98
  env_config = {}
95
99
  if os.getenv("AIP_API_URL"):
@@ -146,7 +150,7 @@ def status(ctx):
146
150
  Panel(
147
151
  f"[bold green]✅ Connected to AIP Platform[/bold green]\n"
148
152
  f"🔗 API URL: {client.api_url}\n"
149
- f"⏱️ Timeout: {config.get('timeout', 30.0)}s",
153
+ f"🤖 Agent Run Timeout: {DEFAULT_AGENT_RUN_TIMEOUT}s",
150
154
  title="🚀 Connection Status",
151
155
  border_style="green",
152
156
  )
@@ -191,9 +195,7 @@ def status(ctx):
191
195
  @main.command()
192
196
  def version():
193
197
  """Show version information."""
194
- from glaip_sdk.config.constants import SDK_VERSION
195
-
196
- click.echo(f"aip version {SDK_VERSION}")
198
+ click.echo(f"aip version {_SDK_VERSION}")
197
199
 
198
200
 
199
201
  @main.command()
@@ -201,17 +203,13 @@ def version():
201
203
  "--check-only", is_flag=True, help="Only check for updates without installing"
202
204
  )
203
205
  @click.option(
204
- "--force", is_flag=True, help="Force update even if no new version available"
206
+ "--force",
207
+ is_flag=True,
208
+ help="Force reinstall even if already up-to-date (adds --force-reinstall)",
205
209
  )
206
210
  def update(check_only: bool, force: bool):
207
211
  """Update AIP SDK to the latest version from PyPI."""
208
212
  try:
209
- import subprocess
210
- import sys
211
-
212
- from rich.console import Console
213
- from rich.panel import Panel
214
-
215
213
  console = Console()
216
214
 
217
215
  if check_only:
@@ -238,12 +236,17 @@ def update(check_only: bool, force: bool):
238
236
 
239
237
  # Update using pip
240
238
  try:
241
- subprocess.run(
242
- [sys.executable, "-m", "pip", "install", "--upgrade", "glaip-sdk"],
243
- capture_output=True,
244
- text=True,
245
- check=True,
246
- )
239
+ cmd = [
240
+ sys.executable,
241
+ "-m",
242
+ "pip",
243
+ "install",
244
+ "--upgrade",
245
+ "glaip-sdk",
246
+ ]
247
+ if force:
248
+ cmd.insert(5, "--force-reinstall")
249
+ subprocess.run(cmd, capture_output=True, text=True, check=True)
247
250
 
248
251
  console.print(
249
252
  Panel(
glaip_sdk/cli/utils.py CHANGED
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
19
19
  import click
20
20
  from rich import box
21
21
  from rich.console import Console, Group
22
+ from rich.markdown import Markdown
22
23
  from rich.panel import Panel
23
24
  from rich.pretty import Pretty
24
25
  from rich.table import Table
@@ -41,6 +42,15 @@ except Exception:
41
42
  if TYPE_CHECKING:
42
43
  from glaip_sdk import Client
43
44
 
45
+ from glaip_sdk import Client
46
+ from glaip_sdk.cli.commands.configure import load_config
47
+ from glaip_sdk.utils import is_uuid
48
+ from glaip_sdk.utils.rendering.renderer import (
49
+ CapturingConsole,
50
+ RendererConfig,
51
+ RichStreamRenderer,
52
+ )
53
+
44
54
  console = Console()
45
55
 
46
56
 
@@ -160,9 +170,6 @@ def _get_view(ctx) -> str:
160
170
 
161
171
  def get_client(ctx) -> Client:
162
172
  """Get configured client from context, env, and config file (ctx > env > file)."""
163
- from glaip_sdk import Client
164
- from glaip_sdk.cli.commands.configure import load_config
165
-
166
173
  file_config = load_config() or {}
167
174
  context_config = (ctx.obj or {}) if ctx else {}
168
175
 
@@ -481,8 +488,6 @@ def output_result(
481
488
 
482
489
  if fmt == "md":
483
490
  try:
484
- from rich.markdown import Markdown
485
-
486
491
  console.print(Markdown(str(data)))
487
492
  except ImportError:
488
493
  # Fallback to plain if markdown not available
@@ -532,6 +537,14 @@ def output_list(
532
537
  except Exception:
533
538
  rows = []
534
539
 
540
+ # Mask secrets (apply before any view)
541
+ mask_fields = _resolve_mask_fields()
542
+ if mask_fields:
543
+ try:
544
+ rows = [_maybe_mask_row(r, mask_fields) for r in rows]
545
+ except Exception:
546
+ pass
547
+
535
548
  # JSON view bypasses any UI
536
549
  if fmt == "json":
537
550
  data = rows or [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
@@ -578,14 +591,6 @@ def output_list(
578
591
  except Exception:
579
592
  pass
580
593
 
581
- # Mask secrets
582
- mask_fields = _resolve_mask_fields()
583
- if mask_fields:
584
- try:
585
- rows = [_maybe_mask_row(r, mask_fields) for r in rows]
586
- except Exception:
587
- pass
588
-
589
594
  # === Fuzzy palette is the default for TTY lists ===
590
595
  picked: dict[str, Any] | None = None
591
596
  if console.is_terminal and os.isatty(1):
@@ -685,6 +690,114 @@ def output_flags():
685
690
  # ------------------------- Ambiguity handling --------------------------- #
686
691
 
687
692
 
693
+ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
694
+ """Coerce an item (dict or object) to a row dict with specified keys.
695
+
696
+ Args:
697
+ item: The item to coerce (dict or object with attributes)
698
+ keys: List of keys/attribute names to extract
699
+
700
+ Returns:
701
+ Dict with the extracted values, "N/A" for missing values
702
+ """
703
+ result = {}
704
+ for key in keys:
705
+ if isinstance(item, dict):
706
+ value = item.get(key, "N/A")
707
+ else:
708
+ value = getattr(item, key, "N/A")
709
+ result[key] = str(value) if value is not None else "N/A"
710
+ return result
711
+
712
+
713
+ def build_renderer(
714
+ ctx,
715
+ *,
716
+ save_path,
717
+ theme="dark",
718
+ verbose=False,
719
+ tty_enabled=True,
720
+ live=None,
721
+ snapshots=None,
722
+ ):
723
+ """Build renderer and capturing console for CLI commands.
724
+
725
+ Args:
726
+ ctx: Click context
727
+ save_path: Path to save output to (enables capturing)
728
+ theme: Color theme ("dark" or "light")
729
+ verbose: Whether to enable verbose mode
730
+ tty_enabled: Whether TTY is available
731
+
732
+ Returns:
733
+ Tuple of (renderer, capturing_console)
734
+ """
735
+ # Use capturing console if saving output
736
+ working_console = console
737
+ if save_path:
738
+ working_console = CapturingConsole(console, capture=True)
739
+
740
+ # Decide live behavior: default is live unless verbose; allow explicit override
741
+ live_enabled = (not verbose) if live is None else bool(live)
742
+ renderer_cfg = RendererConfig(
743
+ theme=theme,
744
+ style="debug" if verbose else "pretty",
745
+ live=live_enabled,
746
+ show_delegate_tool_panels=True,
747
+ append_finished_snapshots=bool(snapshots)
748
+ if snapshots is not None
749
+ else RendererConfig.append_finished_snapshots,
750
+ )
751
+
752
+ # Create the renderer instance
753
+ renderer = RichStreamRenderer(
754
+ working_console.original_console
755
+ if isinstance(working_console, CapturingConsole)
756
+ else working_console,
757
+ cfg=renderer_cfg,
758
+ verbose=verbose,
759
+ )
760
+
761
+ return renderer, working_console
762
+
763
+
764
+ def resolve_resource(
765
+ ctx, ref: str, *, get_by_id, find_by_name, label: str, select: int | None = None
766
+ ):
767
+ """Resolve resource reference (ID or name) with ambiguity handling.
768
+
769
+ Args:
770
+ ctx: Click context
771
+ ref: Resource reference (ID or name)
772
+ get_by_id: Function to get resource by ID
773
+ find_by_name: Function to find resources by name
774
+ label: Resource type label for error messages
775
+ select: Optional selection index for ambiguity resolution
776
+
777
+ Returns:
778
+ Resolved resource object
779
+ """
780
+ if is_uuid(ref):
781
+ return get_by_id(ref)
782
+
783
+ # Find resources by name
784
+ matches = find_by_name(name=ref)
785
+ if not matches:
786
+ raise click.ClickException(f"{label} '{ref}' not found")
787
+
788
+ if len(matches) == 1:
789
+ return matches[0]
790
+
791
+ # Multiple matches - handle ambiguity
792
+ if select:
793
+ idx = int(select) - 1
794
+ if not (0 <= idx < len(matches)):
795
+ raise click.ClickException(f"--select must be 1..{len(matches)}")
796
+ return matches[idx]
797
+
798
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
799
+
800
+
688
801
  def handle_ambiguous_resource(
689
802
  ctx, resource_type: str, ref: str, matches: list[Any]
690
803
  ) -> Any: