glaip-sdk 0.5.0__py3-none-any.whl → 0.5.2__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.
glaip_sdk/cli/auth.py CHANGED
@@ -109,6 +109,24 @@ def _get_token_value(prompt_for_secrets: bool, placeholder: str, console: Consol
109
109
  return placeholder
110
110
 
111
111
 
112
+ def _normalize_header_keys(
113
+ header_keys: Iterable[str] | str | None,
114
+ *,
115
+ default: Iterable[str] | None = None,
116
+ ) -> list[str]:
117
+ """Normalize header_keys to a list, handling strings and None safely."""
118
+ if header_keys is None:
119
+ return list(default or [])
120
+ if isinstance(header_keys, str):
121
+ return [header_keys] if header_keys else list(default or [])
122
+ try:
123
+ return list(header_keys)
124
+ except TypeError:
125
+ raise click.ClickException(
126
+ f"Invalid header_keys type: expected string or iterable, got {type(header_keys).__name__}"
127
+ ) from None
128
+
129
+
112
130
  def _build_bearer_headers(auth: dict[str, Any], token_value: str) -> dict[str, str]:
113
131
  """Build headers for bearer token authentication.
114
132
 
@@ -120,7 +138,10 @@ def _build_bearer_headers(auth: dict[str, Any], token_value: str) -> dict[str, s
120
138
  A dictionary of HTTP headers including the Authorization header when
121
139
  applicable.
122
140
  """
123
- header_keys = auth.get("header_keys", ["Authorization"])
141
+ default_header_keys = ["Authorization"]
142
+ has_header_keys = "header_keys" in auth
143
+ header_keys_raw = auth.get("header_keys") if has_header_keys else default_header_keys
144
+ header_keys = _normalize_header_keys(header_keys_raw, default=None if has_header_keys else default_header_keys)
124
145
  headers = {}
125
146
  for key in header_keys:
126
147
  # Prepend "Bearer " if this is Authorization header
@@ -179,8 +200,8 @@ def _extract_api_key_name(auth: dict[str, Any]) -> str | None:
179
200
  """
180
201
  key_name = auth.get("key")
181
202
  if not key_name and "header_keys" in auth:
182
- header_keys = auth["header_keys"]
183
- if isinstance(header_keys, list) and header_keys:
203
+ header_keys = _normalize_header_keys(auth["header_keys"])
204
+ if header_keys:
184
205
  key_name = header_keys[0]
185
206
  return key_name
186
207
 
@@ -228,8 +249,12 @@ def _build_api_key_headers(auth: dict[str, Any], key_name: str | None, key_value
228
249
  Returns:
229
250
  A dictionary of HTTP headers for API key authentication.
230
251
  """
231
- header_keys = [k for k in auth.get("header_keys", [key_name] if key_name else []) if k]
232
- return dict.fromkeys(header_keys, key_value)
252
+ default_header_keys = [key_name] if key_name else []
253
+ has_header_keys = "header_keys" in auth
254
+ header_keys_raw = auth.get("header_keys") if has_header_keys else default_header_keys
255
+ header_keys_list = _normalize_header_keys(header_keys_raw, default=None if has_header_keys else default_header_keys)
256
+ filtered_keys = [k for k in header_keys_list if k]
257
+ return dict.fromkeys(filtered_keys, key_value)
233
258
 
234
259
 
235
260
  def _prepare_api_key_auth(
@@ -315,9 +340,7 @@ def _extract_header_names(existing_headers: Mapping[str, Any] | None, header_key
315
340
  """
316
341
  if existing_headers:
317
342
  return list(existing_headers.keys())
318
- if header_keys:
319
- return list(header_keys)
320
- return []
343
+ return _normalize_header_keys(header_keys)
321
344
 
322
345
 
323
346
  def _is_valid_secret(value: Any) -> bool:
@@ -106,6 +106,9 @@ def list_accounts(output_json: bool) -> None:
106
106
  if active_account:
107
107
  console.print(f"\n[{SUCCESS_STYLE}]Active account[/]: {active_account}")
108
108
 
109
+ # Show hint for updating accounts
110
+ console.print(f"\n[{INFO}]💡 Tip[/]: To update an account's URL or key, use: [bold]aip accounts edit <name>[/bold]")
111
+
109
112
 
110
113
  def _check_account_overwrite(name: str, store: AccountStore, overwrite: bool) -> dict[str, str] | None:
111
114
  """Check if account exists and handle overwrite logic.
@@ -128,13 +131,19 @@ def _check_account_overwrite(name: str, store: AccountStore, overwrite: bool) ->
128
131
  return existing
129
132
 
130
133
 
131
- def _get_credentials_non_interactive(url: str, read_key_from_stdin: bool, name: str) -> tuple[str, str]:
134
+ def _get_credentials_non_interactive(
135
+ url: str,
136
+ read_key_from_stdin: bool,
137
+ name: str,
138
+ command_name: str = "aip accounts add",
139
+ ) -> tuple[str, str]:
132
140
  """Get credentials in non-interactive mode.
133
141
 
134
142
  Args:
135
143
  url: API URL from flag.
136
144
  read_key_from_stdin: Whether to read key from stdin.
137
145
  name: Account name (for error messages).
146
+ command_name: Command name for guidance text.
138
147
 
139
148
  Returns:
140
149
  Tuple of (api_url, api_key).
@@ -147,7 +156,7 @@ def _get_credentials_non_interactive(url: str, read_key_from_stdin: bool, name:
147
156
  return url, sys.stdin.read().strip()
148
157
  console.print(
149
158
  f"[{ERROR_STYLE}]Error: --key requires stdin input. "
150
- f"Use: cat key.txt | aip accounts add {name} --url {url} --key[/]",
159
+ f"Use: cat key.txt | {command_name} {name} --url {url} --key[/]",
151
160
  )
152
161
  raise click.Abort()
153
162
  # URL provided, prompt for key
@@ -178,6 +187,88 @@ def _get_credentials_interactive(read_key_from_stdin: bool, existing: dict[str,
178
187
  return _prompt_account_inputs(existing)
179
188
 
180
189
 
190
+ def _handle_key_rotation(
191
+ name: str,
192
+ existing_url: str,
193
+ command_name: str,
194
+ ) -> tuple[str, str]:
195
+ """Handle key rotation using stored URL.
196
+
197
+ Args:
198
+ name: Account name (for error messages).
199
+ existing_url: Existing account URL.
200
+ command_name: Command name for error messages.
201
+
202
+ Returns:
203
+ Tuple of (api_url, api_key).
204
+
205
+ Raises:
206
+ click.Abort: If existing URL is missing.
207
+ """
208
+ if not existing_url:
209
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' is missing an API URL. Provide --url to set it.[/]")
210
+ raise click.Abort()
211
+ return _get_credentials_non_interactive(existing_url, True, name, command_name)
212
+
213
+
214
+ def _preserve_existing_values(
215
+ api_url: str,
216
+ api_key: str,
217
+ existing_url: str,
218
+ existing_key: str,
219
+ ) -> tuple[str, str]:
220
+ """Preserve stored values when blank input is provided during edit.
221
+
222
+ Args:
223
+ api_url: Collected API URL.
224
+ api_key: Collected API key.
225
+ existing_url: Existing account URL.
226
+ existing_key: Existing account key.
227
+
228
+ Returns:
229
+ Tuple of (api_url, api_key) with preserved values.
230
+ """
231
+ if not api_url and existing_url:
232
+ api_url = existing_url
233
+ if not api_key and existing_key:
234
+ api_key = existing_key
235
+ return api_url, api_key
236
+
237
+
238
+ def _collect_credentials_from_inputs(
239
+ url: str | None,
240
+ read_key_from_stdin: bool,
241
+ name: str,
242
+ existing: dict[str, str] | None,
243
+ command_name: str,
244
+ existing_url: str,
245
+ ) -> tuple[str, str]:
246
+ """Collect credentials based on input flags and existing data.
247
+
248
+ Args:
249
+ url: Optional URL from flag.
250
+ read_key_from_stdin: Whether to read key from stdin.
251
+ name: Account name (for error messages).
252
+ existing: Existing account data.
253
+ command_name: Command name for error messages.
254
+ existing_url: Existing account URL.
255
+
256
+ Returns:
257
+ Tuple of (api_url, api_key).
258
+ """
259
+ if url and read_key_from_stdin:
260
+ # Non-interactive: URL from flag, key from stdin
261
+ return _get_credentials_non_interactive(url, True, name, command_name)
262
+ if url:
263
+ # URL provided, prompt for key
264
+ return _get_credentials_non_interactive(url, False, name, command_name)
265
+ if read_key_from_stdin and existing:
266
+ # Key rotation using stored URL
267
+ return _handle_key_rotation(name, existing_url, command_name)
268
+ # Fully interactive or error case
269
+ return _get_credentials_interactive(read_key_from_stdin, existing)
270
+
271
+
181
272
  def _collect_account_credentials(
182
273
  url: str | None,
183
274
  read_key_from_stdin: bool,
@@ -198,15 +289,16 @@ def _collect_account_credentials(
198
289
  Raises:
199
290
  click.Abort: If credentials cannot be collected or are invalid.
200
291
  """
201
- if url and read_key_from_stdin:
202
- # Non-interactive: URL from flag, key from stdin
203
- api_url, api_key = _get_credentials_non_interactive(url, True, name)
204
- elif url:
205
- # URL provided, prompt for key
206
- api_url, api_key = _get_credentials_non_interactive(url, False, name)
207
- else:
208
- # Fully interactive or error case
209
- api_url, api_key = _get_credentials_interactive(read_key_from_stdin, existing)
292
+ command_name = "aip accounts edit" if existing else "aip accounts add"
293
+ existing_url = existing.get("api_url", "") if existing else ""
294
+ existing_key = existing.get("api_key", "") if existing else ""
295
+
296
+ api_url, api_key = _collect_credentials_from_inputs(
297
+ url, read_key_from_stdin, name, existing, command_name, existing_url
298
+ )
299
+
300
+ # Preserve stored values when blank input is provided during edit
301
+ api_url, api_key = _preserve_existing_values(api_url, api_key, existing_url, existing_key)
210
302
 
211
303
  if not api_url or not api_key:
212
304
  console.print(f"[{ERROR_STYLE}]Error: Both API URL and API key are required.[/]")
@@ -235,12 +327,15 @@ def add_account(
235
327
  read_key_from_stdin: bool,
236
328
  overwrite: bool,
237
329
  ) -> None:
238
- """Add or update an account profile.
330
+ """Add a new account profile.
239
331
 
240
332
  NAME is the account name (1-32 chars, alphanumeric, dash, underscore).
241
333
 
242
334
  By default, this command runs interactively, prompting for API URL and key.
243
335
  For non-interactive use, both --url and --key (stdin) are required.
336
+
337
+ If the account already exists, use --yes to overwrite without prompting.
338
+ To update an existing account, use [bold]aip accounts edit <name>[/bold] instead.
244
339
  """
245
340
  store = get_account_store()
246
341
 
@@ -263,6 +358,55 @@ def add_account(
263
358
  raise click.Abort() from e
264
359
 
265
360
 
361
+ @accounts_group.command("edit")
362
+ @click.argument("name")
363
+ @click.option("--url", help="API URL (optional, leave blank to keep current)")
364
+ @click.option(
365
+ "--key",
366
+ "read_key_from_stdin",
367
+ is_flag=True,
368
+ help="Read API key from stdin (secure, for scripts). Uses stored URL unless --url is provided.",
369
+ )
370
+ def edit_account(
371
+ name: str,
372
+ url: str | None,
373
+ read_key_from_stdin: bool,
374
+ ) -> None:
375
+ """Edit an existing account profile's URL or key.
376
+
377
+ NAME is the account name to edit.
378
+
379
+ By default, this command runs interactively, showing current values and
380
+ prompting for new ones. Leave fields blank to keep current values.
381
+
382
+ For non-interactive use, provide --url to change the URL, --key (stdin) to rotate the key,
383
+ or both. Stored values are reused for any fields not provided.
384
+ """
385
+ store = get_account_store()
386
+
387
+ # Account must exist for edit
388
+ existing = store.get_account(name)
389
+ if not existing:
390
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' not found.[/]")
391
+ console.print(f"Use [bold]aip accounts add {name}[/bold] to create a new account.")
392
+ raise click.Abort()
393
+
394
+ # Collect credentials (will pre-fill existing values in interactive mode)
395
+ api_url, api_key = _collect_account_credentials(url, read_key_from_stdin, name, existing)
396
+
397
+ # Save account
398
+ try:
399
+ store.add_account(name, api_url, api_key, overwrite=True)
400
+ console.print(Text(f"✅ Account '{name}' updated successfully", style=SUCCESS_STYLE))
401
+ _print_active_account_footer(store)
402
+ except InvalidAccountNameError as e:
403
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
404
+ raise click.Abort() from e
405
+ except AccountStoreError as e:
406
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
407
+ raise click.Abort() from e
408
+
409
+
266
410
  @accounts_group.command("use")
267
411
  @click.argument("name")
268
412
  def use_account(name: str) -> None:
@@ -281,7 +425,8 @@ def use_account(name: str) -> None:
281
425
 
282
426
  if not url or not api_key:
283
427
  console.print(
284
- f"[{ERROR_STYLE}]Error: Account '{name}' is missing credentials. Re-run 'aip accounts add {name}'.[/]"
428
+ f"[{ERROR_STYLE}]Error: Account '{name}' is missing credentials. "
429
+ f"Use [bold]aip accounts edit {name}[/bold] to update credentials.[/]"
285
430
  )
286
431
  raise click.Abort()
287
432
 
@@ -576,7 +576,10 @@ def list_agents(
576
576
  and len(agents) > 0
577
577
  )
578
578
 
579
+ # Track picker attempt so the fallback table doesn't re-open the palette
580
+ picker_attempted = False
579
581
  if interactive_enabled:
582
+ picker_attempted = True
580
583
  picked_agent = _fuzzy_pick_for_resources(agents, "agent", "")
581
584
  if picked_agent:
582
585
  _display_agent_details(ctx, client, picked_agent)
@@ -591,7 +594,12 @@ def list_agents(
591
594
  f"{ICON_AGENT} Available Agents",
592
595
  columns,
593
596
  transform_agent,
594
- skip_picker=simple or any(param is not None for param in (agent_type, framework, name, version)),
597
+ skip_picker=(
598
+ not interactive_enabled
599
+ or picker_attempted
600
+ or simple
601
+ or any(param is not None for param in (agent_type, framework, name, version))
602
+ ),
595
603
  use_pager=False,
596
604
  )
597
605
 
glaip_sdk/cli/utils.py CHANGED
@@ -12,6 +12,7 @@ import importlib
12
12
  import json
13
13
  import logging
14
14
  import os
15
+ import re
15
16
  import sys
16
17
  from collections.abc import Callable, Iterable
17
18
  from contextlib import AbstractContextManager, contextmanager, nullcontext
@@ -787,56 +788,60 @@ class _FuzzyCompleter:
787
788
  self.words = words
788
789
 
789
790
  def get_completions(self, document: Any, _complete_event: Any) -> Any: # pragma: no cover
790
- """Get fuzzy completions for the current word.
791
+ """Get fuzzy completions for the current word, ranked by score.
791
792
 
792
793
  Args:
793
794
  document: Document object from prompt_toolkit.
794
795
  _complete_event: Completion event (unused).
795
796
 
796
797
  Yields:
797
- Completion objects matching the current word.
798
+ Completion objects matching the current word, in ranked order.
798
799
  """
799
- word = document.get_word_before_cursor()
800
- if not word:
800
+ # Get the entire buffer text (not just word before cursor)
801
+ buffer_text = document.text_before_cursor
802
+ if not buffer_text or not isinstance(buffer_text, str):
801
803
  return
802
804
 
803
- word_lower = word.lower()
804
- for label in self.words:
805
- label_lower = label.lower()
806
- if self._fuzzy_match(word_lower, label_lower):
807
- yield Completion(label, start_position=-len(word))
808
-
809
- def _fuzzy_match(self, search: str, target: str) -> bool: # pragma: no cover
810
- """True fuzzy matching: checks if all characters in search appear in order in target."""
811
- if not search:
812
- return True
813
-
814
- search_idx = 0
815
- for char in target:
816
- if search_idx < len(search) and search[search_idx] == char:
817
- search_idx += 1
818
- if search_idx == len(search):
819
- return True
820
- return False
805
+ # Rank labels by fuzzy score
806
+ ranked_labels = _rank_labels(self.words, buffer_text)
807
+
808
+ # Yield ranked completions
809
+ for label in ranked_labels:
810
+ # Replace entire buffer text, not just the word before cursor
811
+ # This prevents concatenation issues with hyphenated names
812
+ yield Completion(label, start_position=-len(buffer_text))
821
813
 
822
814
 
823
815
  def _perform_fuzzy_search(answer: str, labels: list[str], by_label: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
824
- """Perform fuzzy search fallback and return best match."""
816
+ """Perform fuzzy search fallback and return best match.
817
+
818
+ Returns:
819
+ Selected resource dict or None if cancelled/no match.
820
+ """
825
821
  # Exact label match
826
822
  if answer in by_label:
827
823
  return by_label[answer]
828
824
 
829
- # Fuzzy search fallback
830
- best_match = None
831
- best_score = -1
832
-
825
+ # Fuzzy search fallback using ranked labels
826
+ # Check if query actually matches anything before ranking
827
+ query_lower = answer.lower()
828
+ has_match = False
833
829
  for label in labels:
834
- score = _fuzzy_score(answer.lower(), label.lower())
835
- if score > best_score:
836
- best_score = score
837
- best_match = label
830
+ if _fuzzy_score(query_lower, label.lower()) >= 0:
831
+ has_match = True
832
+ break
838
833
 
839
- return by_label[best_match] if best_match and best_score > 0 else None
834
+ if not has_match:
835
+ return None
836
+
837
+ ranked_labels = _rank_labels(labels, answer)
838
+ if ranked_labels:
839
+ # Return the top-ranked match
840
+ best_match = ranked_labels[0]
841
+ if best_match in by_label:
842
+ return by_label[best_match]
843
+
844
+ return None
840
845
 
841
846
 
842
847
  def _fuzzy_pick(
@@ -865,33 +870,61 @@ def _fuzzy_pick(
865
870
  return _perform_fuzzy_search(answer, labels, by_label) if answer else None
866
871
 
867
872
 
868
- def _is_fuzzy_match(search: str, target: str) -> bool:
869
- """Check if search string is a fuzzy match for target."""
873
+ def _strip_spaces_for_matching(value: str) -> str:
874
+ """Remove whitespace from a query for consistent fuzzy matching."""
875
+ return re.sub(r"\s+", "", value)
876
+
877
+
878
+ def _is_fuzzy_match(search: Any, target: Any) -> bool:
879
+ """Case-insensitive fuzzy match with optional spaces; returns False for non-string inputs."""
880
+ # Ensure search is a string
881
+ if not isinstance(search, str) or not isinstance(target, str):
882
+ return False
883
+
870
884
  if not search:
871
885
  return True
872
886
 
887
+ # Strip spaces from search query - treat them as optional separators
888
+ # This allows "test agent" to match "test-agent", "test_agent", etc.
889
+ search_no_spaces = _strip_spaces_for_matching(search).lower()
890
+ if not search_no_spaces:
891
+ # If search is only spaces, match everything
892
+ return True
893
+
873
894
  search_idx = 0
874
- for char in target:
875
- if search_idx < len(search) and search[search_idx] == char:
895
+ for char in target.lower():
896
+ if search_idx < len(search_no_spaces) and search_no_spaces[search_idx] == char:
876
897
  search_idx += 1
877
- if search_idx == len(search):
898
+ if search_idx == len(search_no_spaces):
878
899
  return True
879
900
  return False
880
901
 
881
902
 
882
903
  def _calculate_exact_match_bonus(search: str, target: str) -> int:
883
- """Calculate bonus for exact substring matches."""
884
- return 100 if search.lower() in target.lower() else 0
904
+ """Calculate bonus for exact substring matches.
905
+
906
+ Spaces in search are treated as optional separators (stripped before matching).
907
+ """
908
+ # Strip spaces from search - treat them as optional separators
909
+ search_no_spaces = _strip_spaces_for_matching(search).lower()
910
+ if not search_no_spaces:
911
+ return 0
912
+ return 100 if search_no_spaces in target.lower() else 0
885
913
 
886
914
 
887
915
  def _calculate_consecutive_bonus(search: str, target: str) -> int:
888
- """Calculate bonus for consecutive character matches."""
916
+ """Case-insensitive consecutive-character bonus."""
917
+ # Strip spaces from search - treat them as optional separators
918
+ search_no_spaces = _strip_spaces_for_matching(search).lower()
919
+ if not search_no_spaces:
920
+ return 0
921
+
889
922
  consecutive = 0
890
923
  max_consecutive = 0
891
924
  search_idx = 0
892
925
 
893
- for char in target:
894
- if search_idx < len(search) and search[search_idx] == char:
926
+ for char in target.lower():
927
+ if search_idx < len(search_no_spaces) and search_no_spaces[search_idx] == char:
895
928
  consecutive += 1
896
929
  max_consecutive = max(max_consecutive, consecutive)
897
930
  search_idx += 1
@@ -902,16 +935,31 @@ def _calculate_consecutive_bonus(search: str, target: str) -> int:
902
935
 
903
936
 
904
937
  def _calculate_length_bonus(search: str, target: str) -> int:
905
- """Calculate bonus for shorter search terms."""
906
- return (len(target) - len(search)) * 2
938
+ """Calculate bonus for shorter search terms.
939
+
940
+ Spaces in search are treated as optional separators (stripped before calculation).
941
+ """
942
+ # Strip spaces from search - treat them as optional separators
943
+ search_no_spaces = _strip_spaces_for_matching(search)
944
+ if not search_no_spaces:
945
+ return 0
946
+ return max(0, (len(target) - len(search_no_spaces)) * 2)
907
947
 
908
948
 
909
- def _fuzzy_score(search: str, target: str) -> int:
949
+ def _fuzzy_score(search: Any, target: str) -> int:
910
950
  """Calculate fuzzy match score.
911
951
 
912
952
  Higher score = better match.
913
953
  Returns -1 if no match possible.
954
+
955
+ Args:
956
+ search: Search string (or any type - non-strings return -1)
957
+ target: Target string to match against
914
958
  """
959
+ # Ensure search is a string first
960
+ if not isinstance(search, str):
961
+ return -1
962
+
915
963
  if not search:
916
964
  return 0
917
965
 
@@ -927,6 +975,61 @@ def _fuzzy_score(search: str, target: str) -> int:
927
975
  return score
928
976
 
929
977
 
978
+ def _extract_id_suffix(label: str) -> str:
979
+ """Extract ID suffix from label for tie-breaking.
980
+
981
+ Args:
982
+ label: Display label (e.g., "name • [abc123...]")
983
+
984
+ Returns:
985
+ ID suffix string (e.g., "abc123") or empty string if not found
986
+ """
987
+ # Look for pattern like "[abc123...]" or "[abc123]"
988
+ match = re.search(r"\[([^\]]+)\]", label)
989
+ return match.group(1) if match else ""
990
+
991
+
992
+ def _rank_labels(labels: list[str], query: Any) -> list[str]:
993
+ """Rank labels by fuzzy score with deterministic tie-breaks.
994
+
995
+ Args:
996
+ labels: List of display labels to rank
997
+ query: Search query string (or any type - non-strings return sorted labels)
998
+
999
+ Returns:
1000
+ Labels sorted by fuzzy score (descending), then case-insensitive label,
1001
+ then id suffix for deterministic ordering.
1002
+ """
1003
+ suffix_cache = {label: _extract_id_suffix(label) for label in labels}
1004
+
1005
+ if not query:
1006
+ # No query: sort by case-insensitive label, then id suffix
1007
+ return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
1008
+
1009
+ # Ensure query is a string
1010
+ if not isinstance(query, str):
1011
+ return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
1012
+
1013
+ query_lower = query.lower()
1014
+
1015
+ # Calculate scores and create tuples for sorting
1016
+ scored_labels = []
1017
+ for label in labels:
1018
+ label_lower = label.lower()
1019
+ score = _fuzzy_score(query_lower, label_lower)
1020
+ if score >= 0: # Only include matches
1021
+ scored_labels.append((score, label_lower, suffix_cache[label], label))
1022
+
1023
+ if not scored_labels:
1024
+ # No fuzzy matches: fall back to deterministic label sorting
1025
+ return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
1026
+
1027
+ # Sort by: score (desc), label (case-insensitive), id suffix, original label
1028
+ scored_labels.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
1029
+
1030
+ return [label for _score, _label_lower, _id_suffix, label in scored_labels]
1031
+
1032
+
930
1033
  # ----------------------- Structured renderer helpers -------------------- #
931
1034
 
932
1035
 
@@ -1172,7 +1275,12 @@ def _print_selection_tip(title: str) -> None:
1172
1275
 
1173
1276
 
1174
1277
  def _handle_fuzzy_pick_selection(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> bool:
1175
- """Handle fuzzy picker selection, returns True if selection was made."""
1278
+ """Handle fuzzy picker selection.
1279
+
1280
+ Returns:
1281
+ True if a resource was selected and displayed,
1282
+ False if cancelled/no selection.
1283
+ """
1176
1284
  picked = _try_fuzzy_pick(rows, columns, title)
1177
1285
  if picked is None:
1178
1286
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: glaip-sdk
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Python SDK for GL AIP (GDP Labs AI Agent Package) - Simplified CLI Design
5
5
  License: MIT
6
6
  Author: Raymond Christopher
@@ -4,10 +4,10 @@ glaip_sdk/branding.py,sha256=tLqYCIHMkUf8p2smpuAGNptwaKUN38G4mlh0A0DOl_w,7823
4
4
  glaip_sdk/cli/__init__.py,sha256=xCCfuF1Yc7mpCDcfhHZTX0vizvtrDSLeT8MJ3V7m5A0,156
5
5
  glaip_sdk/cli/account_store.py,sha256=NXuAVPaJS_32Aw1VTaZCNwIID-gADw4F_UMieoWmg3g,17336
6
6
  glaip_sdk/cli/agent_config.py,sha256=YAbFKrTNTRqNA6b0i0Q3pH-01rhHDRi5v8dxSFwGSwM,2401
7
- glaip_sdk/cli/auth.py,sha256=ycT6CepbJDfUqRT0KBYXfBhnJYd-zJ_np1-DSiY3EWs,23040
7
+ glaip_sdk/cli/auth.py,sha256=9hfjZyd4cx2_mImqykJ1sWQsuVTR2gy6D4hFqAQNKL4,24129
8
8
  glaip_sdk/cli/commands/__init__.py,sha256=6Z3ASXDut0lAbUX_umBFtxPzzFyqoiZfVeTahThFu1A,219
9
- glaip_sdk/cli/commands/accounts.py,sha256=VCG-JZGY86DlWO5bAfDZ70RuyKQ5q-Rh4U0iM8NwO6M,13755
10
- glaip_sdk/cli/commands/agents.py,sha256=CGYWTPpcFXUms_3SMB5Mr-YRpBh7c8iu-Mj245XhSCc,47686
9
+ glaip_sdk/cli/commands/accounts.py,sha256=B5itsUzqoH_hBRYOVd2m4nPoIuBbPDIoK974zKMm9NE,18635
10
+ glaip_sdk/cli/commands/agents.py,sha256=35Ra1PLZYiSainYTtMBB40Iio5gDY_tyaDpeujoVdHw,47963
11
11
  glaip_sdk/cli/commands/common_config.py,sha256=IY13gPkeifXxSdpzRFUvfRin8J7s38p6Y7TYjdGw7w4,2474
12
12
  glaip_sdk/cli/commands/configure.py,sha256=8vfgtNEMK2lnEk3i6H1ZevsjxnYA6jAj4evhWmsHi6w,14494
13
13
  glaip_sdk/cli/commands/mcps.py,sha256=tttqQnfM89iI9Pm94u8YRhiHMQNYNouecFX0brsT4cQ,42551
@@ -44,7 +44,7 @@ glaip_sdk/cli/transcript/history.py,sha256=2FBjawxP8CX9gRPMUMP8bDjG50BGM2j2zk6If
44
44
  glaip_sdk/cli/transcript/launcher.py,sha256=z5ivkPXDQJpATIqtRLUK8jH3p3WIZ72PvOPqYRDMJvw,2327
45
45
  glaip_sdk/cli/transcript/viewer.py,sha256=ar1SzRkhKIf3_DgFz1EG1RZGDmd2w2wogAe038DLL_M,13037
46
46
  glaip_sdk/cli/update_notifier.py,sha256=qv-GfwTYZdrhlSbC_71I1AvKY9cV4QVBmtees16S2Xg,9807
47
- glaip_sdk/cli/utils.py,sha256=Dtvi8mkrU3v88ZO2HwHL2iuzY8Ic3PTDL0YySWsvy0Q,53510
47
+ glaip_sdk/cli/utils.py,sha256=fV6PZlQ7K5zckpFWvwh3yLmETGrVylK9AXtN7zKBp-A,57374
48
48
  glaip_sdk/cli/validators.py,sha256=d-kq4y7HWMo6Gc7wLXWUsCt8JwFvJX_roZqRm1Nko1I,5622
49
49
  glaip_sdk/client/__init__.py,sha256=F-eE_dRSzA0cc1it06oi0tZetZBHmSUjWSHGhJMLCls,263
50
50
  glaip_sdk/client/_agent_payloads.py,sha256=VfBHoijuoqUOixGBf2SA2vlQIXQmBsjB3sXHZhXYiec,17681
@@ -107,7 +107,7 @@ glaip_sdk/utils/resource_refs.py,sha256=vF34kyAtFBLnaKnQVrsr2st1JiSxVbIZ4yq0DelJ
107
107
  glaip_sdk/utils/run_renderer.py,sha256=d_VMI6LbvHPUUeRmGqh5wK_lHqDEIAcym2iqpbtDad0,1365
108
108
  glaip_sdk/utils/serialization.py,sha256=z-qpvWLSBrGK3wbUclcA1UIKLXJedTnMSwPdq-FF4lo,13308
109
109
  glaip_sdk/utils/validation.py,sha256=Vt8oSnn7OM6ns5vjOl5FwGIMWBPb0yI6RD5XL_L5_4M,6826
110
- glaip_sdk-0.5.0.dist-info/METADATA,sha256=869wDTHn4xaN_DBTqmlZG7bQ7UXzKuhqGp1Mx_umkV8,7053
111
- glaip_sdk-0.5.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
112
- glaip_sdk-0.5.0.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
113
- glaip_sdk-0.5.0.dist-info/RECORD,,
110
+ glaip_sdk-0.5.2.dist-info/METADATA,sha256=yYVEtAsyIJBd3p6bgZxlvSIzUeOwSK-tI3DQVPAP0tI,7053
111
+ glaip_sdk-0.5.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
112
+ glaip_sdk-0.5.2.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
113
+ glaip_sdk-0.5.2.dist-info/RECORD,,