bt-cli 0.4.13__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 (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +94 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +108 -0
  20. bt_cli/data/skills/entitle/SKILL.md +170 -0
  21. bt_cli/data/skills/epmw/SKILL.md +144 -0
  22. bt_cli/data/skills/pra/SKILL.md +150 -0
  23. bt_cli/data/skills/pws/SKILL.md +198 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.13.dist-info/METADATA +417 -0
  119. bt_cli-0.4.13.dist-info/RECORD +121 -0
  120. bt_cli-0.4.13.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1 @@
1
+ """Shared commands for bt-cli CLI."""
@@ -0,0 +1,415 @@
1
+ """Configure command for bt-cli CLI.
2
+
3
+ Provides interactive and non-interactive configuration management.
4
+ """
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Confirm, Prompt
10
+ from rich.table import Table
11
+ from typing import Optional
12
+
13
+ from ..core.config_file import (
14
+ CONFIG_FILE,
15
+ PRODUCTS,
16
+ ConfigFile,
17
+ ensure_config_dir,
18
+ load_config_file,
19
+ save_config_file,
20
+ set_secret_in_keyring,
21
+ _keyring_available,
22
+ )
23
+ from ..core.output import print_error, print_info, print_success, print_warning
24
+
25
+ app = typer.Typer(no_args_is_help=True, help="Configure bt-cli settings")
26
+ console = Console()
27
+
28
+
29
+ @app.callback(invoke_without_command=True)
30
+ def configure_callback(
31
+ ctx: typer.Context,
32
+ product: Optional[str] = typer.Option(
33
+ None,
34
+ "--product", "-p",
35
+ help="Product to configure (pws, entitle, pra, epmw)",
36
+ ),
37
+ profile: Optional[str] = typer.Option(
38
+ None,
39
+ "--profile",
40
+ help="Profile name (default: 'default')",
41
+ ),
42
+ api_url: Optional[str] = typer.Option(None, "--api-url", help="API URL"),
43
+ client_id: Optional[str] = typer.Option(None, "--client-id", help="OAuth Client ID"),
44
+ client_secret: Optional[str] = typer.Option(None, "--client-secret", help="OAuth Client Secret"),
45
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="API Key"),
46
+ ) -> None:
47
+ """Configure bt-cli interactively or via flags.
48
+
49
+ Examples:
50
+
51
+ # Interactive setup
52
+ bt configure
53
+
54
+ # Configure specific product
55
+ bt configure --product pws
56
+
57
+ # Configure with flags
58
+ bt configure --product pws --api-url https://example.com/api --client-id xxx
59
+
60
+ # Use a named profile
61
+ bt configure --product pws --profile production
62
+ """
63
+ # If subcommand was invoked, don't run main configure
64
+ if ctx.invoked_subcommand is not None:
65
+ return
66
+
67
+ # Check if any non-interactive flags were provided
68
+ has_flags = any([api_url, client_id, client_secret, api_key])
69
+
70
+ if has_flags and product:
71
+ # Non-interactive mode with flags
72
+ _configure_with_flags(product, profile, api_url, client_id, client_secret, api_key)
73
+ else:
74
+ # Interactive mode
75
+ _configure_interactive(product, profile)
76
+
77
+
78
+ def _configure_interactive(product: Optional[str] = None, profile: Optional[str] = None) -> None:
79
+ """Run interactive configuration wizard."""
80
+ console.print()
81
+ console.print(Panel.fit(
82
+ "[bold blue]BeyondTrust CLI Configuration[/bold blue]\n\n"
83
+ "This wizard will help you configure your BeyondTrust product connections.\n"
84
+ f"Configuration will be saved to: [cyan]{CONFIG_FILE}[/cyan]",
85
+ border_style="blue"
86
+ ))
87
+ console.print()
88
+
89
+ # Load existing config
90
+ config = load_config_file()
91
+
92
+ # Select profile
93
+ if not profile:
94
+ existing_profiles = config.list_profiles()
95
+ if existing_profiles:
96
+ console.print(f"[dim]Existing profiles: {', '.join(existing_profiles)}[/dim]")
97
+ profile = Prompt.ask(
98
+ "Profile name",
99
+ default=config.default_profile or "default"
100
+ )
101
+
102
+ # Select product
103
+ if not product:
104
+ console.print("\n[bold]Available products:[/bold]")
105
+ for key, info in PRODUCTS.items():
106
+ console.print(f" [cyan]{key}[/cyan] - {info['name']}")
107
+
108
+ product = Prompt.ask(
109
+ "\nSelect product",
110
+ choices=list(PRODUCTS.keys()),
111
+ )
112
+
113
+ if product not in PRODUCTS:
114
+ print_error(f"Unknown product: {product}")
115
+ raise typer.Exit(1)
116
+
117
+ product_info = PRODUCTS[product]
118
+ console.print(f"\n[bold]Configuring {product_info['name']}[/bold]\n")
119
+
120
+ # Get existing config for this product/profile
121
+ existing = config.get_product_config(product, profile)
122
+
123
+ # Collect new configuration
124
+ new_config: dict = {}
125
+ use_keyring = False
126
+
127
+ # Check keyring availability
128
+ if _keyring_available():
129
+ use_keyring = Confirm.ask(
130
+ "Store secrets in system keyring (more secure)?",
131
+ default=True
132
+ )
133
+
134
+ for field_name, field_info in product_info["fields"].items():
135
+ # Skip conditional fields that don't apply
136
+ if "if" in field_info:
137
+ condition = field_info["if"]
138
+ if "auth_method == oauth" in condition and new_config.get("auth_method") != "oauth":
139
+ continue
140
+ if "auth_method == apikey" in condition and new_config.get("auth_method") != "apikey":
141
+ continue
142
+
143
+ prompt_text = field_info["prompt"]
144
+ default = existing.get(field_name) or field_info.get("default", "")
145
+
146
+ # Show example if available
147
+ if "example" in field_info:
148
+ console.print(f" [dim]Example: {field_info['example']}[/dim]")
149
+
150
+ # Handle boolean fields
151
+ if isinstance(default, bool):
152
+ value = Confirm.ask(prompt_text, default=default)
153
+ # Handle choice fields
154
+ elif "choices" in field_info:
155
+ value = Prompt.ask(
156
+ prompt_text,
157
+ choices=field_info["choices"],
158
+ default=str(default) if default else None
159
+ )
160
+ # Handle secret fields - show the value (not hidden) for easier pasting verification
161
+ elif field_info.get("secret"):
162
+ if existing.get(field_name):
163
+ existing_val = str(existing[field_name])
164
+ if existing_val.startswith("keyring://"):
165
+ console.print(f" [dim](current: stored in keyring)[/dim]")
166
+ else:
167
+ console.print(f" [dim](current: {existing_val[:20]}...)[/dim]" if len(existing_val) > 20 else f" [dim](current: {existing_val})[/dim]")
168
+ # Don't use password=True so users can see what they paste
169
+ value = Prompt.ask(
170
+ prompt_text,
171
+ default="" if not default else None
172
+ )
173
+ if not value and default:
174
+ value = default
175
+ # Handle regular fields
176
+ else:
177
+ value = Prompt.ask(
178
+ prompt_text,
179
+ default=str(default) if default else ""
180
+ )
181
+
182
+ # Skip empty optional fields
183
+ if not value and not field_info.get("required"):
184
+ continue
185
+
186
+ # Store secrets in keyring if enabled
187
+ if field_info.get("secret") and use_keyring and value:
188
+ keyring_key = f"{product}-{profile}-{field_name}"
189
+ if set_secret_in_keyring("bt-cli", keyring_key, value):
190
+ new_config[field_name] = f"keyring://bt-cli/{keyring_key}"
191
+ console.print(f" [dim]Stored in keyring[/dim]")
192
+ else:
193
+ new_config[field_name] = value
194
+ else:
195
+ new_config[field_name] = value
196
+
197
+ # Save configuration
198
+ config.set_product_config(product, new_config, profile)
199
+
200
+ # Set as default profile if it's the first one
201
+ if not config.default_profile or profile == "default":
202
+ config.default_profile = profile
203
+
204
+ save_config_file(config)
205
+
206
+ console.print()
207
+ print_success(f"Configuration saved for {product_info['name']} (profile: {profile})")
208
+ console.print(f"[dim]Config file: {CONFIG_FILE}[/dim]")
209
+
210
+ # Offer to test connection
211
+ if Confirm.ask("\nTest connection now?", default=True):
212
+ _test_connection(product, profile)
213
+
214
+
215
+ def _configure_with_flags(
216
+ product: str,
217
+ profile: Optional[str],
218
+ api_url: Optional[str],
219
+ client_id: Optional[str],
220
+ client_secret: Optional[str],
221
+ api_key: Optional[str],
222
+ ) -> None:
223
+ """Configure using command-line flags (non-interactive)."""
224
+ if product not in PRODUCTS:
225
+ print_error(f"Unknown product: {product}. Available: {', '.join(PRODUCTS.keys())}")
226
+ raise typer.Exit(1)
227
+
228
+ profile = profile or "default"
229
+ config = load_config_file()
230
+ existing = config.get_product_config(product, profile)
231
+
232
+ # Merge with existing config
233
+ new_config = dict(existing)
234
+
235
+ if api_url:
236
+ new_config["api_url"] = api_url
237
+ if client_id:
238
+ new_config["client_id"] = client_id
239
+ if client_secret:
240
+ new_config["client_secret"] = client_secret
241
+ if api_key:
242
+ new_config["api_key"] = api_key
243
+
244
+ # Infer auth method
245
+ if api_key:
246
+ new_config["auth_method"] = "apikey"
247
+ elif client_id and client_secret:
248
+ new_config["auth_method"] = "oauth"
249
+
250
+ config.set_product_config(product, new_config, profile)
251
+ if not config.default_profile:
252
+ config.default_profile = profile
253
+
254
+ save_config_file(config)
255
+ print_success(f"Configuration saved for {product} (profile: {profile})")
256
+
257
+
258
+ def _test_connection(product: str, profile: str) -> None:
259
+ """Test connection for a product."""
260
+ console.print(f"\n[dim]Testing {product} connection...[/dim]")
261
+
262
+ try:
263
+ # Import and test based on product
264
+ if product == "pws":
265
+ from ..pws.client import get_client
266
+ with get_client() as client:
267
+ client.authenticate()
268
+ # Test with Platforms endpoint (always accessible)
269
+ platforms = client.get("/Platforms", params={"limit": 1})
270
+ count = len(platforms) if isinstance(platforms, list) else 1
271
+ print_success(f"Password Safe connection successful! ({count} platform(s) found)")
272
+
273
+ elif product == "entitle":
274
+ from ..entitle.client import get_client
275
+ with get_client() as client:
276
+ client.get("/integrations", params={"perPage": 1})
277
+ print_success("Entitle connection successful!")
278
+
279
+ elif product == "pra":
280
+ from ..pra.client import get_client
281
+ with get_client() as client:
282
+ client.get("/jumpoint")
283
+ print_success("PRA connection successful!")
284
+
285
+ elif product == "epmw":
286
+ from ..epmw.client import get_client
287
+ with get_client() as client:
288
+ client.authenticate()
289
+ client.get("/Computers", params={"pageSize": 1})
290
+ print_success("EPM Windows connection successful!")
291
+
292
+ except Exception as e:
293
+ print_error(f"Connection failed: {e}")
294
+
295
+
296
+ @app.command("show")
297
+ def show_config(
298
+ profile: Optional[str] = typer.Option(None, "--profile", help="Profile to show"),
299
+ show_secrets: bool = typer.Option(False, "--show-secrets", help="Show secret values"),
300
+ ) -> None:
301
+ """Show current configuration."""
302
+ config = load_config_file()
303
+
304
+ if not config.profiles:
305
+ print_warning("No configuration found. Run 'bt configure' to set up.")
306
+ raise typer.Exit(0)
307
+
308
+ profiles_to_show = [profile] if profile else config.list_profiles()
309
+
310
+ for prof in profiles_to_show:
311
+ if prof not in config.profiles:
312
+ print_warning(f"Profile '{prof}' not found")
313
+ continue
314
+
315
+ is_default = prof == config.default_profile
316
+ title = f"Profile: {prof}" + (" (default)" if is_default else "")
317
+
318
+ table = Table(title=title, show_header=True)
319
+ table.add_column("Product", style="cyan")
320
+ table.add_column("Setting", style="green")
321
+ table.add_column("Value")
322
+
323
+ for product, settings in config.profiles[prof].items():
324
+ first = True
325
+ for key, value in settings.items():
326
+ # Mask secrets
327
+ if not show_secrets and any(s in key for s in ["secret", "key", "password"]):
328
+ if isinstance(value, str) and value.startswith("keyring://"):
329
+ display_value = "[dim]<stored in keyring>[/dim]"
330
+ else:
331
+ display_value = "****" + str(value)[-4:] if value else ""
332
+ else:
333
+ display_value = str(value)
334
+
335
+ table.add_row(
336
+ product if first else "",
337
+ key,
338
+ display_value
339
+ )
340
+ first = False
341
+
342
+ console.print(table)
343
+ console.print()
344
+
345
+
346
+ @app.command("profiles")
347
+ def list_profiles() -> None:
348
+ """List all configured profiles."""
349
+ config = load_config_file()
350
+
351
+ if not config.profiles:
352
+ print_warning("No profiles configured. Run 'bt configure' to set up.")
353
+ raise typer.Exit(0)
354
+
355
+ table = Table(title="Configured Profiles", show_header=True)
356
+ table.add_column("Profile", style="cyan")
357
+ table.add_column("Products", style="green")
358
+ table.add_column("Default", style="yellow")
359
+
360
+ for profile_name in config.list_profiles():
361
+ products = list(config.profiles[profile_name].keys())
362
+ is_default = "Yes" if profile_name == config.default_profile else ""
363
+ table.add_row(profile_name, ", ".join(products), is_default)
364
+
365
+ console.print(table)
366
+
367
+
368
+ @app.command("set-default")
369
+ def set_default_profile(
370
+ profile: str = typer.Argument(..., help="Profile name to set as default"),
371
+ ) -> None:
372
+ """Set the default profile."""
373
+ config = load_config_file()
374
+
375
+ if profile not in config.profiles:
376
+ print_error(f"Profile '{profile}' not found")
377
+ raise typer.Exit(1)
378
+
379
+ config.default_profile = profile
380
+ save_config_file(config)
381
+ print_success(f"Default profile set to '{profile}'")
382
+
383
+
384
+ @app.command("delete")
385
+ def delete_profile(
386
+ profile: str = typer.Argument(..., help="Profile name to delete"),
387
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
388
+ ) -> None:
389
+ """Delete a profile."""
390
+ config = load_config_file()
391
+
392
+ if profile not in config.profiles:
393
+ print_error(f"Profile '{profile}' not found")
394
+ raise typer.Exit(1)
395
+
396
+ if not force:
397
+ if not Confirm.ask(f"Delete profile '{profile}'?", default=False):
398
+ print_info("Cancelled")
399
+ raise typer.Exit(0)
400
+
401
+ config.delete_profile(profile)
402
+ save_config_file(config)
403
+ print_success(f"Profile '{profile}' deleted")
404
+
405
+
406
+ @app.command("path")
407
+ def show_path() -> None:
408
+ """Show configuration file path."""
409
+ console.print(f"Config directory: [cyan]{ensure_config_dir()}[/cyan]")
410
+ console.print(f"Config file: [cyan]{CONFIG_FILE}[/cyan]")
411
+
412
+ if CONFIG_FILE.exists():
413
+ console.print("[green]Config file exists[/green]")
414
+ else:
415
+ console.print("[yellow]Config file does not exist yet[/yellow]")
@@ -0,0 +1,229 @@
1
+ """Learning log for capturing workflows, patterns, and insights.
2
+
3
+ This module provides commands to log learnings from complex tasks,
4
+ which can later be reviewed to create quick commands or improve AI context.
5
+ """
6
+
7
+ import os
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import typer
13
+ import yaml
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+
18
+ app = typer.Typer(no_args_is_help=True, help="Learning log - capture workflows and insights for future quick commands")
19
+ console = Console()
20
+
21
+
22
+ def _get_learnings_path() -> Path:
23
+ """Get path to learnings file."""
24
+ config_dir = Path.home() / ".bt-cli"
25
+ config_dir.mkdir(parents=True, exist_ok=True)
26
+ return config_dir / "learnings.yaml"
27
+
28
+
29
+ def _load_learnings() -> list[dict]:
30
+ """Load learnings from file."""
31
+ path = _get_learnings_path()
32
+ if not path.exists():
33
+ return []
34
+ try:
35
+ with open(path) as f:
36
+ data = yaml.safe_load(f)
37
+ return data if data else []
38
+ except Exception:
39
+ return []
40
+
41
+
42
+ def _save_learnings(learnings: list[dict]) -> None:
43
+ """Save learnings to file."""
44
+ path = _get_learnings_path()
45
+ with open(path, "w") as f:
46
+ yaml.dump(learnings, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
47
+
48
+
49
+ @app.command("add")
50
+ def add_learning(
51
+ task: str = typer.Argument(..., help="Task or workflow description"),
52
+ workflow: Optional[str] = typer.Option(None, "--workflow", "-w", help="Commands used (separate with ;)"),
53
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes or gotchas"),
54
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Tags for categorization (comma-separated)"),
55
+ ) -> None:
56
+ """Add a learning from a completed task.
57
+
58
+ Examples:
59
+ bt learn add "Onboard EC2 to PWS + PRA"
60
+ bt learn add "Onboard EC2" -w "bt pws quick onboard; bt pra jump-items shell create" -n "Use jumpoint 3 for AWS"
61
+ bt learn add "EPMW stale cleanup" -t "epmw,maintenance"
62
+ """
63
+ learnings = _load_learnings()
64
+
65
+ entry = {
66
+ "id": len(learnings) + 1,
67
+ "date": datetime.now().isoformat(),
68
+ "task": task,
69
+ }
70
+
71
+ if workflow:
72
+ entry["workflow"] = [cmd.strip() for cmd in workflow.split(";") if cmd.strip()]
73
+
74
+ if notes:
75
+ entry["notes"] = notes
76
+
77
+ if tags:
78
+ entry["tags"] = [tag.strip() for tag in tags.split(",") if tag.strip()]
79
+
80
+ learnings.append(entry)
81
+ _save_learnings(learnings)
82
+
83
+ console.print(f"[green]Added learning #{entry['id']}:[/green] {task}")
84
+
85
+
86
+ @app.command("list")
87
+ def list_learnings(
88
+ limit: int = typer.Option(20, "--limit", "-l", help="Max entries to show"),
89
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"),
90
+ ) -> None:
91
+ """List recorded learnings.
92
+
93
+ Examples:
94
+ bt learn list
95
+ bt learn list --tag pws
96
+ bt learn list -l 5
97
+ """
98
+ learnings = _load_learnings()
99
+
100
+ if not learnings:
101
+ console.print("[yellow]No learnings recorded yet.[/yellow]")
102
+ console.print("Use [cyan]bt learn add \"description\"[/cyan] to add one.")
103
+ return
104
+
105
+ # Filter by tag if specified
106
+ if tag:
107
+ learnings = [l for l in learnings if tag.lower() in [t.lower() for t in l.get("tags", [])]]
108
+
109
+ # Show most recent first, limited
110
+ learnings = list(reversed(learnings))[:limit]
111
+
112
+ table = Table(title="Learnings")
113
+ table.add_column("#", style="cyan", justify="right")
114
+ table.add_column("Date", style="dim")
115
+ table.add_column("Task", style="green")
116
+ table.add_column("Tags", style="yellow")
117
+
118
+ for entry in learnings:
119
+ date_str = entry.get("date", "")[:10] # Just the date part
120
+ tags_str = ", ".join(entry.get("tags", [])) or "-"
121
+ table.add_row(
122
+ str(entry.get("id", "")),
123
+ date_str,
124
+ entry.get("task", "")[:50] + ("..." if len(entry.get("task", "")) > 50 else ""),
125
+ tags_str,
126
+ )
127
+
128
+ console.print(table)
129
+ console.print(f"\n[dim]Showing {len(learnings)} of {len(_load_learnings())} total learnings[/dim]")
130
+
131
+
132
+ @app.command("show")
133
+ def show_learning(
134
+ learning_id: int = typer.Argument(..., help="Learning ID to show"),
135
+ ) -> None:
136
+ """Show details of a specific learning.
137
+
138
+ Examples:
139
+ bt learn show 3
140
+ """
141
+ learnings = _load_learnings()
142
+
143
+ entry = next((l for l in learnings if l.get("id") == learning_id), None)
144
+ if not entry:
145
+ console.print(f"[red]Learning #{learning_id} not found[/red]")
146
+ raise typer.Exit(1)
147
+
148
+ output = f"[bold]Task:[/bold] {entry.get('task', '')}\n"
149
+ output += f"[bold]Date:[/bold] {entry.get('date', '')[:19]}\n"
150
+
151
+ if entry.get("tags"):
152
+ output += f"[bold]Tags:[/bold] {', '.join(entry['tags'])}\n"
153
+
154
+ if entry.get("workflow"):
155
+ output += f"\n[bold]Workflow:[/bold]\n"
156
+ for i, cmd in enumerate(entry["workflow"], 1):
157
+ output += f" {i}. [cyan]{cmd}[/cyan]\n"
158
+
159
+ if entry.get("notes"):
160
+ output += f"\n[bold]Notes:[/bold]\n {entry['notes']}"
161
+
162
+ console.print(Panel(output, title=f"Learning #{learning_id}"))
163
+
164
+
165
+ @app.command("export")
166
+ def export_learnings(
167
+ format: str = typer.Option("yaml", "--format", "-f", help="Export format: yaml, markdown"),
168
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"),
169
+ ) -> None:
170
+ """Export learnings for review or documentation.
171
+
172
+ Examples:
173
+ bt learn export
174
+ bt learn export --format markdown
175
+ bt learn export --tag pws --format markdown
176
+ """
177
+ learnings = _load_learnings()
178
+
179
+ if not learnings:
180
+ console.print("[yellow]No learnings to export[/yellow]")
181
+ return
182
+
183
+ # Filter by tag if specified
184
+ if tag:
185
+ learnings = [l for l in learnings if tag.lower() in [t.lower() for t in l.get("tags", [])]]
186
+
187
+ if format == "markdown":
188
+ output = "# BT-Admin Learnings\n\n"
189
+ for entry in learnings:
190
+ output += f"## {entry.get('task', 'Untitled')}\n\n"
191
+ output += f"**Date:** {entry.get('date', '')[:10]}\n"
192
+ if entry.get("tags"):
193
+ output += f"**Tags:** {', '.join(entry['tags'])}\n"
194
+ output += "\n"
195
+
196
+ if entry.get("workflow"):
197
+ output += "**Workflow:**\n```bash\n"
198
+ for cmd in entry["workflow"]:
199
+ output += f"{cmd}\n"
200
+ output += "```\n\n"
201
+
202
+ if entry.get("notes"):
203
+ output += f"**Notes:** {entry['notes']}\n\n"
204
+
205
+ output += "---\n\n"
206
+
207
+ typer.echo(output)
208
+ else:
209
+ # YAML format
210
+ typer.echo(yaml.dump(learnings, default_flow_style=False, sort_keys=False, allow_unicode=True))
211
+
212
+
213
+ @app.command("clear")
214
+ def clear_learnings(
215
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
216
+ ) -> None:
217
+ """Clear all learnings.
218
+
219
+ Examples:
220
+ bt learn clear
221
+ bt learn clear --force
222
+ """
223
+ if not force:
224
+ if not typer.confirm("Clear all learnings?"):
225
+ console.print("[yellow]Cancelled[/yellow]")
226
+ raise typer.Exit(0)
227
+
228
+ _save_learnings([])
229
+ console.print("[green]Learnings cleared[/green]")