glaip-sdk 0.0.14__py3-none-any.whl → 0.0.16__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 (44) hide show
  1. glaip_sdk/branding.py +27 -1
  2. glaip_sdk/cli/commands/agents.py +27 -20
  3. glaip_sdk/cli/commands/configure.py +39 -50
  4. glaip_sdk/cli/commands/mcps.py +2 -6
  5. glaip_sdk/cli/commands/models.py +1 -1
  6. glaip_sdk/cli/commands/tools.py +1 -3
  7. glaip_sdk/cli/config.py +42 -0
  8. glaip_sdk/cli/context.py +142 -0
  9. glaip_sdk/cli/display.py +92 -26
  10. glaip_sdk/cli/main.py +141 -124
  11. glaip_sdk/cli/masking.py +148 -0
  12. glaip_sdk/cli/mcp_validators.py +2 -2
  13. glaip_sdk/cli/pager.py +272 -0
  14. glaip_sdk/cli/parsers/json_input.py +2 -2
  15. glaip_sdk/cli/resolution.py +12 -10
  16. glaip_sdk/cli/slash/agent_session.py +7 -0
  17. glaip_sdk/cli/slash/prompt.py +21 -2
  18. glaip_sdk/cli/slash/session.py +15 -21
  19. glaip_sdk/cli/update_notifier.py +8 -2
  20. glaip_sdk/cli/utils.py +99 -369
  21. glaip_sdk/client/_agent_payloads.py +504 -0
  22. glaip_sdk/client/agents.py +194 -551
  23. glaip_sdk/client/base.py +92 -20
  24. glaip_sdk/client/main.py +6 -0
  25. glaip_sdk/client/run_rendering.py +275 -0
  26. glaip_sdk/config/constants.py +3 -0
  27. glaip_sdk/exceptions.py +15 -0
  28. glaip_sdk/models.py +5 -0
  29. glaip_sdk/payload_schemas/__init__.py +19 -0
  30. glaip_sdk/payload_schemas/agent.py +87 -0
  31. glaip_sdk/rich_components.py +12 -0
  32. glaip_sdk/utils/client_utils.py +12 -0
  33. glaip_sdk/utils/import_export.py +2 -2
  34. glaip_sdk/utils/rendering/formatting.py +5 -0
  35. glaip_sdk/utils/rendering/models.py +22 -0
  36. glaip_sdk/utils/rendering/renderer/base.py +9 -1
  37. glaip_sdk/utils/rendering/renderer/panels.py +0 -1
  38. glaip_sdk/utils/rendering/steps.py +59 -0
  39. glaip_sdk/utils/serialization.py +24 -3
  40. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/METADATA +1 -1
  41. glaip_sdk-0.0.16.dist-info/RECORD +72 -0
  42. glaip_sdk-0.0.14.dist-info/RECORD +0 -64
  43. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/WHEEL +0 -0
  44. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/entry_points.txt +0 -0
glaip_sdk/branding.py CHANGED
@@ -60,7 +60,8 @@ GDP Labs AI Agents Package
60
60
  version: str | None = None,
61
61
  package_name: str | None = None,
62
62
  ) -> None:
63
- """
63
+ """Initialize AIPBranding instance.
64
+
64
65
  Args:
65
66
  version: Explicit SDK version (overrides auto-detection).
66
67
  package_name: If set, attempt to read version from installed package.
@@ -105,6 +106,11 @@ GDP Labs AI Agents Package
105
106
  return banner
106
107
 
107
108
  def get_version_info(self) -> dict:
109
+ """Get comprehensive version information for the SDK.
110
+
111
+ Returns:
112
+ Dictionary containing version, Python version, platform, and architecture info
113
+ """
108
114
  return {
109
115
  "version": self.version,
110
116
  "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
@@ -113,6 +119,11 @@ GDP Labs AI Agents Package
113
119
  }
114
120
 
115
121
  def display_welcome_panel(self, title: str = "Welcome to AIP") -> None:
122
+ """Display a welcome panel with branding.
123
+
124
+ Args:
125
+ title: Custom title for the welcome panel
126
+ """
116
127
  banner = self.get_welcome_banner()
117
128
  panel = AIPPanel(
118
129
  banner,
@@ -123,6 +134,7 @@ GDP Labs AI Agents Package
123
134
  self.console.print(panel)
124
135
 
125
136
  def display_version_panel(self) -> None:
137
+ """Display a panel with comprehensive version information."""
126
138
  v = self.get_version_info()
127
139
  version_text = (
128
140
  f"[{TITLE_STYLE}]AIP SDK Version Information[/{TITLE_STYLE}]\n\n"
@@ -140,6 +152,11 @@ GDP Labs AI Agents Package
140
152
  self.console.print(panel)
141
153
 
142
154
  def display_status_banner(self, status: str = "ready") -> None:
155
+ """Display a status banner for the current state.
156
+
157
+ Args:
158
+ status: Current status to display
159
+ """
143
160
  # Keep it simple (no emoji); easy to parse in logs/CI
144
161
  banner = f"[{LABEL}]AIP[/{LABEL}] - {status.title()}"
145
162
  self.console.print(banner)
@@ -148,4 +165,13 @@ GDP Labs AI Agents Package
148
165
  def create_from_sdk(
149
166
  cls, sdk_version: str | None = None, package_name: str | None = None
150
167
  ) -> AIPBranding:
168
+ """Create AIPBranding instance from SDK package information.
169
+
170
+ Args:
171
+ sdk_version: Explicit SDK version override
172
+ package_name: Package name to read version from
173
+
174
+ Returns:
175
+ AIPBranding instance
176
+ """
151
177
  return cls(version=sdk_version, package_name=package_name)
@@ -23,6 +23,7 @@ from glaip_sdk.cli.agent_config import (
23
23
  from glaip_sdk.cli.agent_config import (
24
24
  sanitize_agent_config_for_cli as sanitize_agent_config,
25
25
  )
26
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
26
27
  from glaip_sdk.cli.display import (
27
28
  build_resource_result_data,
28
29
  display_agent_run_suggestions,
@@ -48,10 +49,7 @@ from glaip_sdk.cli.utils import (
48
49
  _fuzzy_pick_for_resources,
49
50
  build_renderer,
50
51
  coerce_to_row,
51
- detect_export_format,
52
52
  get_client,
53
- get_ctx_value,
54
- output_flags,
55
53
  output_list,
56
54
  output_result,
57
55
  spinner_context,
@@ -80,7 +78,6 @@ AGENT_NOT_FOUND_ERROR = "Agent not found"
80
78
 
81
79
  def _safe_agent_attribute(agent: Any, name: str) -> Any:
82
80
  """Return attribute value for ``name`` while filtering Mock sentinels."""
83
-
84
81
  try:
85
82
  value = getattr(agent, name)
86
83
  except Exception:
@@ -93,7 +90,6 @@ def _safe_agent_attribute(agent: Any, name: str) -> Any:
93
90
 
94
91
  def _coerce_mapping_candidate(candidate: Any) -> dict[str, Any] | None:
95
92
  """Convert a mapping-like candidate to a plain dict when possible."""
96
-
97
93
  if candidate is None:
98
94
  return None
99
95
  if isinstance(candidate, Mapping):
@@ -103,7 +99,6 @@ def _coerce_mapping_candidate(candidate: Any) -> dict[str, Any] | None:
103
99
 
104
100
  def _call_agent_method(agent: Any, method_name: str) -> dict[str, Any] | None:
105
101
  """Attempt to call the named method and coerce its output to a dict."""
106
-
107
102
  method = getattr(agent, method_name, None)
108
103
  if not callable(method):
109
104
  return None
@@ -116,7 +111,6 @@ def _call_agent_method(agent: Any, method_name: str) -> dict[str, Any] | None:
116
111
 
117
112
  def _coerce_agent_via_methods(agent: Any) -> dict[str, Any] | None:
118
113
  """Try standard serialisation helpers to produce a mapping."""
119
-
120
114
  for attr in ("model_dump", "dict", "to_dict"):
121
115
  mapping = _call_agent_method(agent, attr)
122
116
  if mapping is not None:
@@ -126,7 +120,6 @@ def _coerce_agent_via_methods(agent: Any) -> dict[str, Any] | None:
126
120
 
127
121
  def _build_fallback_agent_mapping(agent: Any) -> dict[str, Any]:
128
122
  """Construct a minimal mapping from well-known agent attributes."""
129
-
130
123
  fallback_fields = (
131
124
  "id",
132
125
  "name",
@@ -138,6 +131,7 @@ def _build_fallback_agent_mapping(agent: Any) -> dict[str, Any]:
138
131
  "agents",
139
132
  "mcps",
140
133
  "timeout",
134
+ "tool_configs",
141
135
  )
142
136
 
143
137
  fallback: dict[str, Any] = {}
@@ -151,7 +145,6 @@ def _build_fallback_agent_mapping(agent: Any) -> dict[str, Any]:
151
145
 
152
146
  def _prepare_agent_output(agent: Any) -> dict[str, Any]:
153
147
  """Build a JSON-serialisable mapping for CLI output."""
154
-
155
148
  method_mapping = _coerce_agent_via_methods(agent)
156
149
  if method_mapping is not None:
157
150
  return method_mapping
@@ -258,11 +251,11 @@ def _format_fallback_agent_data(client: Any, agent: Any) -> dict:
258
251
  "metadata",
259
252
  "language_model_id",
260
253
  "agent_config",
261
- "tool_configs",
262
254
  "tools",
263
255
  "agents",
264
256
  "mcps",
265
257
  "a2a_profile",
258
+ "tool_configs",
266
259
  ]
267
260
 
268
261
  result_data = build_resource_result_data(full_agent, fields)
@@ -299,8 +292,7 @@ def _display_agent_details(ctx: Any, client: Any, agent: Any) -> None:
299
292
  output_result(
300
293
  ctx,
301
294
  formatted_data,
302
- title="Agent Details",
303
- panel_title=panel_title,
295
+ title=panel_title,
304
296
  )
305
297
  else:
306
298
  # Fall back to Pydantic model data if raw fetch fails
@@ -320,7 +312,6 @@ def _display_agent_details(ctx: Any, client: Any, agent: Any) -> None:
320
312
  ctx,
321
313
  result_data,
322
314
  title="Agent Details",
323
- panel_title=f"🤖 {result_data.get('name', 'Unknown')}",
324
315
  )
325
316
 
326
317
 
@@ -340,7 +331,14 @@ def _resolve_agent(
340
331
  """Resolve agent reference (ID or name) with ambiguity handling.
341
332
 
342
333
  Args:
343
- interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
334
+ ctx: Click context object for CLI operations.
335
+ client: AIP client instance for API operations.
336
+ ref: Agent reference (ID or name) to resolve.
337
+ select: Pre-selected agent index for non-interactive mode.
338
+ interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list.
339
+
340
+ Returns:
341
+ Resolved agent object or None if not found.
344
342
  """
345
343
  return resolve_resource_reference(
346
344
  ctx,
@@ -880,7 +878,6 @@ def _add_import_file_attributes(
880
878
  "type",
881
879
  "framework",
882
880
  "version",
883
- "tool_configs",
884
881
  "mcps",
885
882
  "a2a_profile",
886
883
  }
@@ -1052,6 +1049,7 @@ def _handle_update_import_file(
1052
1049
  instruction: str | None,
1053
1050
  tools: tuple[str, ...] | None,
1054
1051
  agents: tuple[str, ...] | None,
1052
+ mcps: tuple[str, ...] | None,
1055
1053
  timeout: float | None,
1056
1054
  ) -> tuple[
1057
1055
  Any | None,
@@ -1059,11 +1057,12 @@ def _handle_update_import_file(
1059
1057
  str | None,
1060
1058
  tuple[str, ...] | None,
1061
1059
  tuple[str, ...] | None,
1060
+ tuple[str, ...] | None,
1062
1061
  float | None,
1063
1062
  ]:
1064
1063
  """Handle import file processing for agent update."""
1065
1064
  if not import_file:
1066
- return None, name, instruction, tools, agents, timeout
1065
+ return None, name, instruction, tools, agents, mcps, timeout
1067
1066
 
1068
1067
  import_data = load_resource_from_file(Path(import_file), "agent")
1069
1068
  import_data = convert_export_to_import_format(import_data)
@@ -1074,6 +1073,7 @@ def _handle_update_import_file(
1074
1073
  "instruction": instruction,
1075
1074
  "tools": tools or (),
1076
1075
  "agents": agents or (),
1076
+ "mcps": mcps or (),
1077
1077
  "timeout": timeout,
1078
1078
  }
1079
1079
 
@@ -1085,6 +1085,7 @@ def _handle_update_import_file(
1085
1085
  merged_data.get("instruction"),
1086
1086
  tuple(merged_data.get("tools", ())),
1087
1087
  tuple(merged_data.get("agents", ())),
1088
+ tuple(merged_data.get("mcps", ())),
1088
1089
  coerce_timeout(merged_data.get("timeout")),
1089
1090
  )
1090
1091
 
@@ -1094,6 +1095,7 @@ def _build_update_data(
1094
1095
  instruction: str | None,
1095
1096
  tools: tuple[str, ...] | None,
1096
1097
  agents: tuple[str, ...] | None,
1098
+ mcps: tuple[str, ...] | None,
1097
1099
  timeout: float | None,
1098
1100
  ) -> dict[str, Any]:
1099
1101
  """Build the update data dictionary from provided parameters."""
@@ -1106,6 +1108,8 @@ def _build_update_data(
1106
1108
  update_data["tools"] = list(tools)
1107
1109
  if agents:
1108
1110
  update_data["agents"] = list(agents)
1111
+ if mcps:
1112
+ update_data["mcps"] = list(mcps)
1109
1113
  if timeout is not None:
1110
1114
  update_data["timeout"] = timeout
1111
1115
  return update_data
@@ -1143,8 +1147,6 @@ def _handle_update_import_config(
1143
1147
  "type",
1144
1148
  "framework",
1145
1149
  "version",
1146
- "tool_configs",
1147
- "mcps",
1148
1150
  "a2a_profile",
1149
1151
  }
1150
1152
  for key, value in merged_data.items():
@@ -1158,6 +1160,7 @@ def _handle_update_import_config(
1158
1160
  @click.option("--instruction", help="New instruction")
1159
1161
  @click.option("--tools", multiple=True, help="New tool names or IDs")
1160
1162
  @click.option("--agents", multiple=True, help="New sub-agent names")
1163
+ @click.option("--mcps", multiple=True, help="New MCP names or IDs")
1161
1164
  @click.option("--timeout", type=int, help="New timeout value")
1162
1165
  @click.option(
1163
1166
  "--import",
@@ -1174,6 +1177,7 @@ def update(
1174
1177
  instruction: str | None,
1175
1178
  tools: tuple[str, ...] | None,
1176
1179
  agents: tuple[str, ...] | None,
1180
+ mcps: tuple[str, ...] | None,
1177
1181
  timeout: float | None,
1178
1182
  import_file: str | None,
1179
1183
  ) -> None:
@@ -1194,12 +1198,15 @@ def update(
1194
1198
  instruction,
1195
1199
  tools,
1196
1200
  agents,
1201
+ mcps,
1197
1202
  timeout,
1198
1203
  ) = _handle_update_import_file(
1199
- import_file, name, instruction, tools, agents, timeout
1204
+ import_file, name, instruction, tools, agents, mcps, timeout
1200
1205
  )
1201
1206
 
1202
- update_data = _build_update_data(name, instruction, tools, agents, timeout)
1207
+ update_data = _build_update_data(
1208
+ name, instruction, tools, agents, mcps, timeout
1209
+ )
1203
1210
 
1204
1211
  if merged_data:
1205
1212
  _handle_update_import_config(import_file, merged_data, update_data)
@@ -5,51 +5,20 @@ Authors:
5
5
  """
6
6
 
7
7
  import getpass
8
- import os
9
- from pathlib import Path
10
- from typing import Any
11
8
 
12
9
  import click
13
- import yaml
14
10
  from rich.console import Console
15
11
  from rich.text import Text
16
12
 
17
13
  from glaip_sdk import Client
18
14
  from glaip_sdk._version import __version__ as _SDK_VERSION
19
15
  from glaip_sdk.branding import AIPBranding
16
+ from glaip_sdk.cli.config import CONFIG_FILE, load_config, save_config
17
+ from glaip_sdk.cli.utils import command_hint
20
18
  from glaip_sdk.rich_components import AIPTable
21
19
 
22
20
  console = Console()
23
21
 
24
- CONFIG_DIR = Path.home() / ".aip"
25
- CONFIG_FILE = CONFIG_DIR / "config.yaml"
26
-
27
-
28
- def load_config() -> dict[str, Any]:
29
- """Load configuration from file."""
30
- if not CONFIG_FILE.exists():
31
- return {}
32
-
33
- try:
34
- with open(CONFIG_FILE) as f:
35
- return yaml.safe_load(f) or {}
36
- except yaml.YAMLError:
37
- return {}
38
-
39
-
40
- def save_config(config: dict[str, Any]) -> None:
41
- """Save configuration to file."""
42
- CONFIG_DIR.mkdir(exist_ok=True)
43
-
44
- with open(CONFIG_FILE, "w") as f:
45
- yaml.dump(config, f, default_flow_style=False)
46
-
47
- # Set secure file permissions
48
- try:
49
- os.chmod(CONFIG_FILE, 0o600)
50
- except Exception: # pragma: no cover - platform dependent best effort
51
- pass
52
-
53
22
 
54
23
  @click.group()
55
24
  def config_group() -> None:
@@ -60,13 +29,16 @@ def config_group() -> None:
60
29
  @config_group.command("list")
61
30
  def list_config() -> None:
62
31
  """List current configuration."""
63
-
64
32
  config = load_config()
65
33
 
66
34
  if not config:
67
- console.print(
68
- "[yellow]No configuration found. Run 'aip config configure' to set up.[/yellow]"
69
- )
35
+ hint = command_hint("config configure", slash_command="login")
36
+ if hint:
37
+ console.print(
38
+ f"[yellow]No configuration found. Run '{hint}' to set up.[/yellow]"
39
+ )
40
+ else:
41
+ console.print("[yellow]No configuration found.[/yellow]")
70
42
  return
71
43
 
72
44
  table = AIPTable(title="🔧 AIP Configuration")
@@ -90,7 +62,6 @@ def list_config() -> None:
90
62
  @click.argument("value")
91
63
  def set_config(key: str, value: str) -> None:
92
64
  """Set a configuration value."""
93
-
94
65
  valid_keys = ["api_url", "api_key"]
95
66
 
96
67
  if key not in valid_keys:
@@ -114,7 +85,6 @@ def set_config(key: str, value: str) -> None:
114
85
  @click.argument("key")
115
86
  def get_config(key: str) -> None:
116
87
  """Get a configuration value."""
117
-
118
88
  config = load_config()
119
89
 
120
90
  if key not in config:
@@ -135,7 +105,6 @@ def get_config(key: str) -> None:
135
105
  @click.argument("key")
136
106
  def unset_config(key: str) -> None:
137
107
  """Remove a configuration value."""
138
-
139
108
  config = load_config()
140
109
 
141
110
  if key not in config:
@@ -152,7 +121,6 @@ def unset_config(key: str) -> None:
152
121
  @click.option("--force", is_flag=True, help="Skip confirmation prompt")
153
122
  def reset_config(force: bool) -> None:
154
123
  """Reset all configuration to defaults."""
155
-
156
124
  if not force:
157
125
  console.print("[yellow]This will remove all AIP configuration.[/yellow]")
158
126
  confirm = input("Are you sure? (y/N): ").strip().lower()
@@ -160,13 +128,28 @@ def reset_config(force: bool) -> None:
160
128
  console.print("Cancelled.")
161
129
  return
162
130
 
163
- if CONFIG_FILE.exists():
164
- CONFIG_FILE.unlink()
165
- console.print(
166
- "✅ Configuration reset. Run 'aip config configure' to set up again."
167
- )
168
- else:
131
+ config_data = load_config()
132
+ file_exists = CONFIG_FILE.exists()
133
+
134
+ if not file_exists and not config_data:
169
135
  console.print("[yellow]No configuration found to reset.[/yellow]")
136
+ console.print("✅ Configuration reset (nothing to remove).")
137
+ return
138
+
139
+ if file_exists:
140
+ try:
141
+ CONFIG_FILE.unlink()
142
+ except FileNotFoundError: # pragma: no cover - defensive cleanup
143
+ pass
144
+ else:
145
+ # In-memory configuration (e.g., tests) needs explicit clearing
146
+ save_config({})
147
+
148
+ hint = command_hint("config configure", slash_command="login")
149
+ message = "✅ Configuration reset."
150
+ if hint:
151
+ message += f" Run '{hint}' to set up again."
152
+ console.print(message)
170
153
 
171
154
 
172
155
  def _configure_interactive() -> None:
@@ -232,11 +215,17 @@ def _configure_interactive() -> None:
232
215
  except Exception as e:
233
216
  console.print(Text(f"❌ Connection failed: {e}"))
234
217
  console.print(" Please check your API URL and key")
235
- console.print(" You can run 'aip status' later to test again")
218
+ hint_status = command_hint("status", slash_command="status")
219
+ if hint_status:
220
+ console.print(f" You can run '{hint_status}' later to test again")
236
221
 
237
222
  console.print("\n💡 You can now use AIP CLI commands!")
238
- console.print(" • Run 'aip status' to check connection")
239
- console.print(" • Run 'aip agents list' to see your agents")
223
+ hint_status = command_hint("status", slash_command="status")
224
+ if hint_status:
225
+ console.print(f" • Run '{hint_status}' to check connection")
226
+ hint_agents = command_hint("agents list", slash_command="agents")
227
+ if hint_agents:
228
+ console.print(f" • Run '{hint_agents}' to see your agents")
240
229
 
241
230
 
242
231
  @config_group.command()
@@ -13,6 +13,7 @@ import click
13
13
  from rich.console import Console
14
14
  from rich.text import Text
15
15
 
16
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
16
17
  from glaip_sdk.cli.display import (
17
18
  display_api_error,
18
19
  display_confirmation_prompt,
@@ -34,10 +35,7 @@ from glaip_sdk.cli.parsers.json_input import parse_json_input
34
35
  from glaip_sdk.cli.resolution import resolve_resource_reference
35
36
  from glaip_sdk.cli.utils import (
36
37
  coerce_to_row,
37
- detect_export_format,
38
38
  get_client,
39
- get_ctx_value,
40
- output_flags,
41
39
  output_list,
42
40
  output_result,
43
41
  spinner_context,
@@ -584,9 +582,7 @@ def _display_mcp_details(ctx: Any, client: Any, mcp: Any) -> None:
584
582
  "status": getattr(mcp, "status", "N/A"),
585
583
  "connection_status": getattr(mcp, "connection_status", "N/A"),
586
584
  }
587
- output_result(
588
- ctx, result_data, title="MCP Details", panel_title=f"🔌 {mcp.name}"
589
- )
585
+ output_result(ctx, result_data, title=f"🔌 {mcp.name}")
590
586
 
591
587
 
592
588
  @mcps_group.command()
@@ -9,9 +9,9 @@ from typing import Any
9
9
  import click
10
10
  from rich.console import Console
11
11
 
12
+ from glaip_sdk.cli.context import output_flags
12
13
  from glaip_sdk.cli.utils import (
13
14
  get_client,
14
- output_flags,
15
15
  output_list,
16
16
  spinner_context,
17
17
  )
@@ -13,6 +13,7 @@ import click
13
13
  from rich.console import Console
14
14
  from rich.text import Text
15
15
 
16
+ from glaip_sdk.cli.context import detect_export_format, get_ctx_value, output_flags
16
17
  from glaip_sdk.cli.display import (
17
18
  display_api_error,
18
19
  display_confirmation_prompt,
@@ -34,10 +35,7 @@ from glaip_sdk.cli.io import (
34
35
  from glaip_sdk.cli.resolution import resolve_resource_reference
35
36
  from glaip_sdk.cli.utils import (
36
37
  coerce_to_row,
37
- detect_export_format,
38
38
  get_client,
39
- get_ctx_value,
40
- output_flags,
41
39
  output_list,
42
40
  output_result,
43
41
  spinner_context,
@@ -0,0 +1,42 @@
1
+ """Configuration management utilities.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ CONFIG_DIR = Path.home() / ".aip"
14
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
15
+
16
+
17
+ def load_config() -> dict[str, Any]:
18
+ """Load configuration from file."""
19
+ if not CONFIG_FILE.exists():
20
+ return {}
21
+
22
+ try:
23
+ with open(CONFIG_FILE) as f:
24
+ return yaml.safe_load(f) or {}
25
+ except yaml.YAMLError:
26
+ return {}
27
+
28
+
29
+ def save_config(config: dict[str, Any]) -> None:
30
+ """Save configuration to file."""
31
+ CONFIG_DIR.mkdir(exist_ok=True)
32
+
33
+ with open(CONFIG_FILE, "w") as f:
34
+ yaml.dump(config, f, default_flow_style=False)
35
+
36
+ # Set secure file permissions
37
+ try:
38
+ os.chmod(CONFIG_FILE, 0o600)
39
+ except (
40
+ OSError
41
+ ): # pragma: no cover - permission errors are expected in some environments
42
+ pass
@@ -0,0 +1,142 @@
1
+ """Context-related helpers for the glaip CLI.
2
+
3
+ Authors:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ __all__ = [
16
+ "get_ctx_value",
17
+ "_get_view",
18
+ "_set_view",
19
+ "_set_json",
20
+ "output_flags",
21
+ "detect_export_format",
22
+ ]
23
+
24
+
25
+ def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
26
+ """Safely resolve a value from a click context object.
27
+
28
+ Args:
29
+ ctx: Click context object to extract value from
30
+ key: Key to retrieve from the context
31
+ default: Default value if key is not found
32
+
33
+ Returns:
34
+ The value associated with the key, or the default if not found
35
+ """
36
+ if ctx is None:
37
+ return default
38
+
39
+ obj = getattr(ctx, "obj", None)
40
+ if obj is None:
41
+ return default
42
+
43
+ if isinstance(obj, dict):
44
+ return obj.get(key, default)
45
+
46
+ getter = getattr(obj, "get", None)
47
+ if callable(getter):
48
+ try:
49
+ return getter(key, default)
50
+ except TypeError:
51
+ return default
52
+
53
+ return getattr(obj, key, default) if hasattr(obj, key) else default
54
+
55
+
56
+ def _get_view(ctx: Any) -> str:
57
+ """Resolve the active view preference from context.
58
+
59
+ Args:
60
+ ctx: Click context object containing view preferences
61
+
62
+ Returns:
63
+ The view format string (rich, plain, json, md), defaults to 'rich'
64
+ """
65
+ view = get_ctx_value(ctx, "view")
66
+ if view:
67
+ return view
68
+
69
+ fallback = get_ctx_value(ctx, "format")
70
+ return fallback or "rich"
71
+
72
+
73
+ def _set_view(ctx: Any, _param: Any, value: str) -> None:
74
+ """Click callback to persist the `--view/--output` option.
75
+
76
+ Args:
77
+ ctx: Click context object to store the view preference
78
+ _param: Click parameter object (unused)
79
+ value: The view format string to store
80
+ """
81
+ if not value:
82
+ return
83
+ ctx.ensure_object(dict)
84
+ ctx.obj["view"] = value
85
+
86
+
87
+ def _set_json(ctx: Any, _param: Any, value: bool) -> None:
88
+ """Click callback for the `--json` shorthand flag.
89
+
90
+ Args:
91
+ ctx: Click context object to store the view preference
92
+ _param: Click parameter object (unused)
93
+ value: Boolean flag indicating json mode
94
+ """
95
+ if not value:
96
+ return
97
+ ctx.ensure_object(dict)
98
+ ctx.obj["view"] = "json"
99
+
100
+
101
+ def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
102
+ """Decorator to add shared output flags (`--view`, `--json`) to commands.
103
+
104
+ Returns:
105
+ A decorator function that adds output format options to click commands
106
+ """
107
+
108
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
109
+ f = click.option(
110
+ "--json",
111
+ "json_mode",
112
+ is_flag=True,
113
+ expose_value=False,
114
+ help="Shortcut for --view json",
115
+ callback=_set_json,
116
+ )(f)
117
+ f = click.option(
118
+ "-o",
119
+ "--output",
120
+ "--view",
121
+ "view_opt",
122
+ type=click.Choice(["rich", "plain", "json", "md"]),
123
+ expose_value=False,
124
+ help="Output format",
125
+ callback=_set_view,
126
+ )(f)
127
+ return f
128
+
129
+ return decorator
130
+
131
+
132
+ def detect_export_format(file_path: str | Path) -> str:
133
+ """Detect the export format from the file extension.
134
+
135
+ Args:
136
+ file_path: Path to the file to analyze
137
+
138
+ Returns:
139
+ The format string ('yaml' or 'json') based on file extension
140
+ """
141
+ path = Path(file_path)
142
+ return "yaml" if path.suffix.lower() in {".yaml", ".yml"} else "json"