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.
- bt_cli/__init__.py +3 -0
- bt_cli/cli.py +830 -0
- bt_cli/commands/__init__.py +1 -0
- bt_cli/commands/configure.py +415 -0
- bt_cli/commands/learn.py +229 -0
- bt_cli/commands/quick.py +784 -0
- bt_cli/core/__init__.py +1 -0
- bt_cli/core/auth.py +213 -0
- bt_cli/core/client.py +313 -0
- bt_cli/core/config.py +393 -0
- bt_cli/core/config_file.py +420 -0
- bt_cli/core/csv_utils.py +91 -0
- bt_cli/core/errors.py +247 -0
- bt_cli/core/output.py +205 -0
- bt_cli/core/prompts.py +87 -0
- bt_cli/core/rest_debug.py +221 -0
- bt_cli/data/CLAUDE.md +94 -0
- bt_cli/data/__init__.py +0 -0
- bt_cli/data/skills/bt/SKILL.md +108 -0
- bt_cli/data/skills/entitle/SKILL.md +170 -0
- bt_cli/data/skills/epmw/SKILL.md +144 -0
- bt_cli/data/skills/pra/SKILL.md +150 -0
- bt_cli/data/skills/pws/SKILL.md +198 -0
- bt_cli/entitle/__init__.py +1 -0
- bt_cli/entitle/client/__init__.py +5 -0
- bt_cli/entitle/client/base.py +443 -0
- bt_cli/entitle/commands/__init__.py +24 -0
- bt_cli/entitle/commands/accounts.py +53 -0
- bt_cli/entitle/commands/applications.py +39 -0
- bt_cli/entitle/commands/auth.py +68 -0
- bt_cli/entitle/commands/bundles.py +218 -0
- bt_cli/entitle/commands/integrations.py +60 -0
- bt_cli/entitle/commands/permissions.py +70 -0
- bt_cli/entitle/commands/policies.py +97 -0
- bt_cli/entitle/commands/resources.py +131 -0
- bt_cli/entitle/commands/roles.py +74 -0
- bt_cli/entitle/commands/users.py +123 -0
- bt_cli/entitle/commands/workflows.py +187 -0
- bt_cli/entitle/models/__init__.py +31 -0
- bt_cli/entitle/models/bundle.py +28 -0
- bt_cli/entitle/models/common.py +37 -0
- bt_cli/entitle/models/integration.py +30 -0
- bt_cli/entitle/models/permission.py +27 -0
- bt_cli/entitle/models/policy.py +25 -0
- bt_cli/entitle/models/resource.py +29 -0
- bt_cli/entitle/models/role.py +28 -0
- bt_cli/entitle/models/user.py +24 -0
- bt_cli/entitle/models/workflow.py +55 -0
- bt_cli/epmw/__init__.py +1 -0
- bt_cli/epmw/client/__init__.py +5 -0
- bt_cli/epmw/client/base.py +848 -0
- bt_cli/epmw/commands/__init__.py +33 -0
- bt_cli/epmw/commands/audits.py +250 -0
- bt_cli/epmw/commands/auth.py +55 -0
- bt_cli/epmw/commands/computers.py +140 -0
- bt_cli/epmw/commands/events.py +233 -0
- bt_cli/epmw/commands/groups.py +215 -0
- bt_cli/epmw/commands/policies.py +673 -0
- bt_cli/epmw/commands/quick.py +348 -0
- bt_cli/epmw/commands/requests.py +224 -0
- bt_cli/epmw/commands/roles.py +78 -0
- bt_cli/epmw/commands/tasks.py +38 -0
- bt_cli/epmw/commands/users.py +219 -0
- bt_cli/epmw/models/__init__.py +1 -0
- bt_cli/pra/__init__.py +1 -0
- bt_cli/pra/client/__init__.py +5 -0
- bt_cli/pra/client/base.py +618 -0
- bt_cli/pra/commands/__init__.py +30 -0
- bt_cli/pra/commands/auth.py +55 -0
- bt_cli/pra/commands/import_export.py +442 -0
- bt_cli/pra/commands/jump_clients.py +139 -0
- bt_cli/pra/commands/jump_groups.py +146 -0
- bt_cli/pra/commands/jump_items.py +638 -0
- bt_cli/pra/commands/jumpoints.py +95 -0
- bt_cli/pra/commands/policies.py +197 -0
- bt_cli/pra/commands/quick.py +470 -0
- bt_cli/pra/commands/teams.py +81 -0
- bt_cli/pra/commands/users.py +87 -0
- bt_cli/pra/commands/vault.py +564 -0
- bt_cli/pra/models/__init__.py +27 -0
- bt_cli/pra/models/common.py +12 -0
- bt_cli/pra/models/jump_client.py +25 -0
- bt_cli/pra/models/jump_group.py +15 -0
- bt_cli/pra/models/jump_item.py +72 -0
- bt_cli/pra/models/jumpoint.py +19 -0
- bt_cli/pra/models/team.py +14 -0
- bt_cli/pra/models/user.py +17 -0
- bt_cli/pra/models/vault.py +45 -0
- bt_cli/pws/__init__.py +1 -0
- bt_cli/pws/client/__init__.py +5 -0
- bt_cli/pws/client/base.py +356 -0
- bt_cli/pws/client/beyondinsight.py +869 -0
- bt_cli/pws/client/passwordsafe.py +1786 -0
- bt_cli/pws/commands/__init__.py +33 -0
- bt_cli/pws/commands/accounts.py +372 -0
- bt_cli/pws/commands/assets.py +311 -0
- bt_cli/pws/commands/auth.py +166 -0
- bt_cli/pws/commands/clouds.py +221 -0
- bt_cli/pws/commands/config.py +344 -0
- bt_cli/pws/commands/credentials.py +347 -0
- bt_cli/pws/commands/databases.py +306 -0
- bt_cli/pws/commands/directories.py +199 -0
- bt_cli/pws/commands/functional.py +298 -0
- bt_cli/pws/commands/import_export.py +452 -0
- bt_cli/pws/commands/platforms.py +118 -0
- bt_cli/pws/commands/quick.py +1646 -0
- bt_cli/pws/commands/search.py +256 -0
- bt_cli/pws/commands/secrets.py +1343 -0
- bt_cli/pws/commands/systems.py +389 -0
- bt_cli/pws/commands/users.py +415 -0
- bt_cli/pws/commands/workgroups.py +166 -0
- bt_cli/pws/config.py +18 -0
- bt_cli/pws/models/__init__.py +19 -0
- bt_cli/pws/models/account.py +186 -0
- bt_cli/pws/models/asset.py +102 -0
- bt_cli/pws/models/common.py +132 -0
- bt_cli/pws/models/system.py +121 -0
- bt_cli-0.4.13.dist-info/METADATA +417 -0
- bt_cli-0.4.13.dist-info/RECORD +121 -0
- bt_cli-0.4.13.dist-info/WHEEL +4 -0
- 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]")
|
bt_cli/commands/learn.py
ADDED
|
@@ -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]")
|