asec 0.1.0__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 (42) hide show
  1. asec/__init__.py +1 -0
  2. asec/api_client.py +186 -0
  3. asec/cli.py +38 -0
  4. asec/cli_api_key.py +64 -0
  5. asec/cli_auth.py +220 -0
  6. asec/cli_devsecops/__init__.py +28 -0
  7. asec/cli_devsecops/asset.py +103 -0
  8. asec/cli_devsecops/issue.py +59 -0
  9. asec/cli_devsecops/product.py +89 -0
  10. asec/cli_devsecops/revision.py +113 -0
  11. asec/cli_devsecops/upload.py +73 -0
  12. asec/cli_pentest/__init__.py +32 -0
  13. asec/cli_pentest/run.py +189 -0
  14. asec/cli_pentest/run_profile.py +284 -0
  15. asec/cli_pentest/run_record.py +49 -0
  16. asec/cli_pentest/run_record_decisions.py +40 -0
  17. asec/cli_pentest/run_record_findings.py +83 -0
  18. asec/cli_pentest/run_record_target_analysis.py +58 -0
  19. asec/cli_pentest/run_record_task_board.py +50 -0
  20. asec/cli_pentest/run_report.py +176 -0
  21. asec/cli_pentest/supervisor.py +29 -0
  22. asec/cli_pentest/target.py +103 -0
  23. asec/cli_pentest/vpn.py +56 -0
  24. asec/cli_pentest/web_login.py +111 -0
  25. asec/cli_profile.py +45 -0
  26. asec/cli_scanners.py +27 -0
  27. asec/config.py +175 -0
  28. asec/oidc_auth.py +220 -0
  29. asec/output.py +10 -0
  30. asec/templates/__init__.py +0 -0
  31. asec/templates/callback_error.html +105 -0
  32. asec/templates/callback_success.html +107 -0
  33. asec/templates/svg/icon-check.svg +3 -0
  34. asec/templates/svg/icon-cross.svg +4 -0
  35. asec/templates/svg/icon-info.svg +5 -0
  36. asec/templates/svg/icon-warning.svg +5 -0
  37. asec/templates/svg/logo.svg +20 -0
  38. asec-0.1.0.dist-info/METADATA +80 -0
  39. asec-0.1.0.dist-info/RECORD +42 -0
  40. asec-0.1.0.dist-info/WHEEL +4 -0
  41. asec-0.1.0.dist-info/entry_points.txt +2 -0
  42. asec-0.1.0.dist-info/licenses/LICENSE +31 -0
asec/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """asec - AgenticSec DevSecOps CLI."""
asec/api_client.py ADDED
@@ -0,0 +1,186 @@
1
+ """HTTP client for API communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ from asec.config import Credentials, load_credentials
11
+
12
+
13
+ class ApiClientError(Exception):
14
+ """API client error."""
15
+
16
+ def __init__(self, status_code: int, message: str):
17
+ self.status_code = status_code
18
+ self.message = message
19
+ super().__init__(f"HTTP {status_code}: {message}")
20
+
21
+
22
+ class LicenseError(ApiClientError):
23
+ """Raised when the organization lacks a required license (AUTH_LICENSE_REQUIRED)."""
24
+
25
+ def __init__(self, detail: str):
26
+ self.detail = detail
27
+ message = f"{detail}\nPlease contact your organization administrator."
28
+ super().__init__(400, message)
29
+
30
+
31
+ def resolve_credentials(
32
+ credentials: Credentials | None = None,
33
+ profile: str | None = None,
34
+ ) -> Credentials:
35
+ """Resolve credentials from profile, then apply environment variable overrides.
36
+
37
+ Resolution order:
38
+ 1. Explicit credentials argument (if provided)
39
+ 2. Load from profile (saved credentials file)
40
+ 3. ASEC_API_KEY / ASEC_ORG_ID / ASEC_API_URL env vars override profile values
41
+
42
+ Args:
43
+ credentials: Explicit credentials (takes priority over profile)
44
+ profile: Profile name to load from
45
+
46
+ Returns:
47
+ Resolved Credentials
48
+
49
+ Raises:
50
+ ApiClientError: If resolved credentials are not valid
51
+ """
52
+ creds = credentials or load_credentials(profile)
53
+
54
+ # Environment variables override profile values. ASEC_API_KEY additionally switches to
55
+ # api-key auth, but ASEC_ORG_ID / ASEC_API_URL apply in ANY auth mode (incl. OIDC) so
56
+ # callers can pin the target Hub/org without mutating the shared credentials.toml
57
+ # profile (e.g. multi-worktree dev / E2E, where the global `dev` profile is racy).
58
+ env_api_key = os.environ.get("ASEC_API_KEY", "")
59
+ if env_api_key:
60
+ creds.api_key = env_api_key
61
+ env_org = os.environ.get("ASEC_ORG_ID", "")
62
+ if env_org:
63
+ creds.org_id = env_org
64
+ env_url = os.environ.get("ASEC_API_URL", "")
65
+ if env_url:
66
+ creds.api_url = env_url
67
+
68
+ if not creds.is_valid():
69
+ raise ApiClientError(0, "Not authenticated. Run 'asec auth login' or set ASEC_API_KEY.")
70
+
71
+ return creds
72
+
73
+
74
+ class ApiClient:
75
+ """HTTP client for API."""
76
+
77
+ def __init__(self, credentials: Credentials | None = None, profile: str | None = None):
78
+ self._creds = resolve_credentials(credentials, profile)
79
+ self.base_url = self._creds.api_url.rstrip("/")
80
+
81
+ def _headers(self) -> dict[str, str]:
82
+ if self._creds.api_key:
83
+ headers: dict[str, str] = {"X-API-Key": self._creds.api_key}
84
+ if self._creds.org_id:
85
+ headers["X-Org-Id"] = self._creds.org_id
86
+ return headers
87
+ headers = {"Authorization": f"Bearer {self._creds.id_token}"}
88
+ if self._creds.org_id:
89
+ headers["X-Org-Id"] = self._creds.org_id
90
+ if self._creds.dev_oidc_sub:
91
+ headers["X-Oidc-Sub"] = self._creds.dev_oidc_sub
92
+ return headers
93
+
94
+ def _api_url(self, path: str) -> str:
95
+ if not path.startswith("/"):
96
+ path = "/" + path
97
+ prefix = "/api/cli-apikey/v1" if self._creds.api_key else "/api/cli-oidc/v1"
98
+ return f"{self.base_url}{prefix}{path}"
99
+
100
+ @staticmethod
101
+ def _parse_json(response: httpx.Response) -> dict:
102
+ """Return parsed JSON body, or {} for empty bodies (e.g. 204 No Content)."""
103
+ if response.status_code == 204 or not response.content:
104
+ return {}
105
+ return response.json()
106
+
107
+ @staticmethod
108
+ def _raise_for_status(response: httpx.Response) -> None:
109
+ """Raise an appropriate error for non-2xx responses."""
110
+ if response.status_code < 400:
111
+ return
112
+
113
+ detail = response.text
114
+ error_code = ""
115
+ try:
116
+ body = response.json()
117
+ if isinstance(body, dict):
118
+ if "error_code" in body:
119
+ error_code = body["error_code"]
120
+ detail = body.get("message", detail)
121
+ elif "detail" in body:
122
+ detail = body["detail"]
123
+ except Exception:
124
+ pass
125
+
126
+ if error_code == "AUTH_LICENSE_REQUIRED":
127
+ raise LicenseError(detail)
128
+
129
+ raise ApiClientError(response.status_code, detail)
130
+
131
+ def get(self, path: str, params: dict | None = None) -> dict:
132
+ with httpx.Client(timeout=30) as client:
133
+ response = client.get(self._api_url(path), headers=self._headers(), params=params)
134
+ self._raise_for_status(response)
135
+ return self._parse_json(response)
136
+
137
+ def patch(self, path: str, json_data: dict | None = None) -> dict:
138
+ with httpx.Client(timeout=30) as client:
139
+ response = client.patch(self._api_url(path), headers=self._headers(), json=json_data)
140
+ self._raise_for_status(response)
141
+ return self._parse_json(response)
142
+
143
+ def put(self, path: str, json_data: dict | None = None) -> dict:
144
+ with httpx.Client(timeout=30) as client:
145
+ response = client.put(self._api_url(path), headers=self._headers(), json=json_data)
146
+ self._raise_for_status(response)
147
+ return self._parse_json(response)
148
+
149
+ def post(self, path: str, json_data: dict | None = None) -> dict:
150
+ with httpx.Client(timeout=30) as client:
151
+ response = client.post(self._api_url(path), headers=self._headers(), json=json_data)
152
+ self._raise_for_status(response)
153
+ return self._parse_json(response)
154
+
155
+ def upload_file(self, path: str, file_path: Path, form_data: dict) -> dict:
156
+ with httpx.Client(timeout=120) as client:
157
+ with open(file_path, "rb") as f:
158
+ files = {"file": (file_path.name, f, "application/json")}
159
+ response = client.post(self._api_url(path), headers=self._headers(), data=form_data, files=files)
160
+ self._raise_for_status(response)
161
+ return self._parse_json(response)
162
+
163
+ def download_from_url(self, url: str, output_path: Path) -> None:
164
+ """Stream a binary response from an arbitrary URL to a local file.
165
+
166
+ Used for two-step download flows (e.g. Run Report PDF): the API
167
+ first returns a short-lived signed URL via ``GET /reports/.../download``,
168
+ and the caller follows up with this method to fetch the bytes
169
+ directly from object storage.
170
+
171
+ No auth headers are sent — the URL itself is the authorization
172
+ token, which keeps API credentials out of the cross-origin hop.
173
+
174
+ Raises:
175
+ ApiClientError: For non-2xx responses from the storage backend.
176
+ """
177
+ output_path.parent.mkdir(parents=True, exist_ok=True)
178
+ with httpx.Client(timeout=300) as client:
179
+ with client.stream("GET", url) as response:
180
+ if response.status_code >= 400:
181
+ # ``stream`` mode requires explicit read before .text/.json
182
+ response.read()
183
+ raise ApiClientError(response.status_code, response.text)
184
+ with output_path.open("wb") as f:
185
+ for chunk in response.iter_bytes():
186
+ f.write(chunk)
asec/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ """asec CLI - AgenticSec command line interface."""
2
+
3
+ import click
4
+
5
+ from asec.cli_auth import auth
6
+ from asec.cli_devsecops import devsecops
7
+ from asec.cli_pentest import pentest
8
+ from asec.cli_profile import profile
9
+
10
+
11
+ @click.group()
12
+ @click.version_option(package_name="asec", prog_name="asec")
13
+ @click.option(
14
+ "--profile",
15
+ envvar="ASEC_PROFILE",
16
+ default=None,
17
+ help="Profile name (default: active profile)",
18
+ )
19
+ @click.pass_context
20
+ def cli(ctx, profile):
21
+ """asec - AgenticSec CLI."""
22
+ ctx.ensure_object(dict)
23
+ ctx.obj["profile"] = profile
24
+
25
+
26
+ cli.add_command(auth)
27
+ cli.add_command(profile)
28
+ cli.add_command(devsecops)
29
+ cli.add_command(pentest)
30
+
31
+
32
+ def main():
33
+ """Main entry point."""
34
+ cli()
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
asec/cli_api_key.py ADDED
@@ -0,0 +1,64 @@
1
+ """API Key configuration CLI commands."""
2
+
3
+ import click
4
+
5
+ from asec.config import (
6
+ Credentials,
7
+ save_credentials,
8
+ set_active_profile,
9
+ )
10
+ from asec.output import output_json
11
+
12
+
13
+ @click.group("api-key")
14
+ def api_key():
15
+ """Configure API key for non-interactive authentication."""
16
+ pass
17
+
18
+
19
+ @api_key.command("configure")
20
+ @click.argument("api_url")
21
+ @click.argument("key_value", required=False)
22
+ @click.option("--org", required=True, help="Organization ID")
23
+ @click.pass_context
24
+ def configure(ctx, api_url: str, key_value: str | None, org: str):
25
+ """Configure API key for non-interactive authentication.
26
+
27
+ API_URL: API base URL (e.g., https://api.agenticsec.tech)
28
+ KEY_VALUE: API Key value (obtain from organization admin).
29
+ If omitted, you will be prompted securely.
30
+
31
+ Examples:
32
+
33
+ # Interactive (key is hidden):
34
+ asec auth api-key configure https://api.agenticsec.tech --org org-acme
35
+
36
+ # Non-interactive (argument):
37
+ asec auth api-key configure https://api.agenticsec.tech KEY --org org-acme
38
+
39
+ # Non-interactive (pipe):
40
+ echo $ASEC_API_KEY | asec auth api-key configure https://api.agenticsec.tech --org org-acme
41
+ """
42
+ if not key_value:
43
+ # Prompt goes to stderr so stdout stays pure JSON.
44
+ key_value = click.prompt("API Key", hide_input=True, err=True)
45
+
46
+ api_url = api_url.rstrip("/")
47
+ profile_name = ctx.obj.get("profile") if ctx.obj else None
48
+ if not profile_name:
49
+ profile_name = "ci"
50
+
51
+ creds = Credentials(
52
+ api_url=api_url,
53
+ api_key=key_value,
54
+ org_id=org,
55
+ )
56
+ save_credentials(creds, profile_name)
57
+ set_active_profile(profile_name)
58
+ output_json(
59
+ {
60
+ "profile": profile_name,
61
+ "api_url": api_url,
62
+ "org_id": org,
63
+ }
64
+ )
asec/cli_auth.py ADDED
@@ -0,0 +1,220 @@
1
+ """Auth CLI commands."""
2
+
3
+ import os
4
+ import time
5
+
6
+ import click
7
+ import httpx
8
+
9
+ from asec.cli_api_key import api_key
10
+ from asec.config import (
11
+ Credentials,
12
+ clear_credentials,
13
+ get_active_profile,
14
+ load_credentials,
15
+ resolve_profile_for_login,
16
+ save_credentials,
17
+ set_active_profile,
18
+ )
19
+ from asec.oidc_auth import AuthorizationError, perform_oidc_login
20
+ from asec.output import output_json
21
+
22
+
23
+ def _fetch_orgs(api_url: str, id_token: str) -> list[dict]:
24
+ """Fetch organizations the user belongs to from API.
25
+
26
+ Returns a list of org dicts with keys: org_id, org_name, role, status, licenses.
27
+ Returns empty list on error.
28
+ """
29
+ url = f"{api_url}/api/cli-oidc/v1/user/organizations"
30
+ try:
31
+ with httpx.Client(timeout=10) as client:
32
+ res = client.get(url, headers={"Authorization": f"Bearer {id_token}"})
33
+ if res.status_code != 200:
34
+ click.echo(f"Warning: Could not fetch organizations (HTTP {res.status_code})", err=True)
35
+ return []
36
+ return res.json().get("organizations", [])
37
+ except httpx.RequestError as e:
38
+ click.echo(f"Warning: Could not fetch organizations: {e}", err=True)
39
+ return []
40
+
41
+
42
+ def _resolve_org_id(orgs: list[dict], explicit_org: str | None) -> str | None:
43
+ """Resolve org_id from explicit flag or org list.
44
+
45
+ Returns:
46
+ org_id string, or None if no org could be resolved.
47
+
48
+ Raises:
49
+ SystemExit: If multiple orgs exist and --org is not specified.
50
+ """
51
+ if explicit_org:
52
+ return explicit_org
53
+
54
+ if not orgs:
55
+ return None
56
+
57
+ # Filter to active orgs
58
+ active_orgs = [o for o in orgs if o.get("status") == "active"]
59
+ if not active_orgs:
60
+ active_orgs = orgs
61
+
62
+ if len(active_orgs) == 1:
63
+ org = active_orgs[0]
64
+ click.echo(f"Organization: {org['org_name']} ({org['org_id']})", err=True)
65
+ return org["org_id"]
66
+
67
+ # Multiple orgs: show list and exit
68
+ click.echo("Multiple organizations found. Specify one with --org:", err=True)
69
+ for org in active_orgs:
70
+ licenses = org.get("licenses", {})
71
+ flags = []
72
+ if licenses.get("pentest"):
73
+ flags.append("pentest")
74
+ if licenses.get("devsecops"):
75
+ flags.append("devsecops")
76
+ license_info = f" [{', '.join(flags)}]" if flags else ""
77
+ click.echo(f" {org['org_id']} {org['org_name']}{license_info}", err=True)
78
+ raise SystemExit(1)
79
+
80
+
81
+ @click.group()
82
+ def auth():
83
+ """Manage authentication."""
84
+ pass
85
+
86
+
87
+ auth.add_command(api_key)
88
+
89
+
90
+ @auth.command()
91
+ @click.argument("api_url", required=False)
92
+ @click.option("--org", "explicit_org", default=None, help="Organization ID to use for this profile")
93
+ @click.pass_context
94
+ def login(ctx, api_url: str | None, explicit_org: str | None):
95
+ """Log in to AgenticSec.
96
+
97
+ API_URL: API base URL (e.g., https://api-stg.agenticsec.tech).
98
+ If omitted, uses the saved URL from the current profile.
99
+ """
100
+ explicit_profile = ctx.obj.get("profile") if ctx.obj else None
101
+
102
+ # Resolve api_url: argument > env > saved profile > default (prd)
103
+ if not api_url:
104
+ api_url = os.environ.get("ASEC_API_URL")
105
+ if not api_url:
106
+ existing_creds = load_credentials(explicit_profile)
107
+ if existing_creds.api_url:
108
+ api_url = existing_creds.api_url
109
+ if not api_url:
110
+ api_url = "https://api.agenticsec.tech"
111
+
112
+ api_url = api_url.rstrip("/")
113
+
114
+ # Resolve profile: explicit > existing match by URL > auto-derived from URL
115
+ profile_name = resolve_profile_for_login(explicit_profile, api_url)
116
+
117
+ # Dev environment shortcut
118
+ dev_oidc_sub = os.environ.get("ASEC_DEV_OIDC_SUB")
119
+ if dev_oidc_sub:
120
+ dev_org_id = os.environ.get("ASEC_DEV_ORG_ID", "")
121
+ creds = Credentials(
122
+ api_url=api_url,
123
+ id_token="dev-token",
124
+ refresh_token="",
125
+ expires_at=time.time() + 86400 * 365,
126
+ user_email="dev@agenticsec.local",
127
+ org_id=dev_org_id,
128
+ dev_oidc_sub=dev_oidc_sub,
129
+ )
130
+ save_credentials(creds, profile_name)
131
+ set_active_profile(profile_name)
132
+ output_json(
133
+ {
134
+ "profile": profile_name,
135
+ "api_url": api_url,
136
+ "user_email": creds.user_email,
137
+ "org_id": creds.org_id or None,
138
+ }
139
+ )
140
+ return
141
+
142
+ # Discover auth config from server
143
+ auth_config_url = f"{api_url}/api/cli-oidc/v1/auth-config"
144
+ click.echo(f"Fetching auth config from {auth_config_url}...", err=True)
145
+ try:
146
+ with httpx.Client(timeout=10) as client:
147
+ res = client.get(auth_config_url)
148
+ if res.status_code != 200:
149
+ click.echo(f"Error: Failed to fetch auth config (HTTP {res.status_code})", err=True)
150
+ click.echo("The server may not be configured for CLI authentication.", err=True)
151
+ raise SystemExit(1)
152
+ auth_config = res.json()
153
+ except httpx.RequestError as e:
154
+ click.echo(f"Error: Cannot connect to {auth_config_url}: {e}", err=True)
155
+ raise SystemExit(1) from None
156
+
157
+ cognito_domain = auth_config["cognito_domain"]
158
+ client_id = auth_config["client_id"]
159
+
160
+ try:
161
+ tokens = perform_oidc_login(cognito_domain=cognito_domain, client_id=client_id)
162
+ creds = Credentials(
163
+ api_url=api_url,
164
+ id_token=tokens["id_token"],
165
+ refresh_token=tokens.get("refresh_token", ""),
166
+ expires_at=time.time() + tokens.get("expires_in", 3600),
167
+ cognito_domain=cognito_domain,
168
+ client_id=client_id,
169
+ )
170
+
171
+ # Resolve organization
172
+ orgs = _fetch_orgs(api_url, creds.id_token)
173
+ org_id = _resolve_org_id(orgs, explicit_org)
174
+ if org_id:
175
+ creds.org_id = org_id
176
+
177
+ save_credentials(creds, profile_name)
178
+ set_active_profile(profile_name)
179
+ output_json(
180
+ {
181
+ "profile": profile_name,
182
+ "api_url": api_url,
183
+ "user_email": creds.user_email or None,
184
+ "org_id": creds.org_id or None,
185
+ }
186
+ )
187
+ except AuthorizationError as e:
188
+ click.echo(f"Login failed: {e}", err=True)
189
+ raise SystemExit(1) from None
190
+
191
+
192
+ @auth.command()
193
+ @click.pass_context
194
+ def status(ctx):
195
+ """Show current authentication status."""
196
+ profile_name = ctx.obj.get("profile") if ctx.obj else None
197
+ resolved = get_active_profile(profile_name)
198
+ creds = load_credentials(profile_name)
199
+ authenticated = creds.is_valid()
200
+
201
+ output_json(
202
+ {
203
+ "profile": resolved,
204
+ "authenticated": authenticated,
205
+ "api_url": creds.api_url or None,
206
+ "user_email": creds.user_email or None,
207
+ "org_id": creds.org_id or None,
208
+ "expires_at": creds.expires_at if creds.expires_at > 0 else None,
209
+ }
210
+ )
211
+
212
+
213
+ @auth.command()
214
+ @click.pass_context
215
+ def logout(ctx):
216
+ """Clear stored credentials."""
217
+ profile_name = ctx.obj.get("profile") if ctx.obj else None
218
+ resolved = get_active_profile(profile_name)
219
+ clear_credentials(profile_name)
220
+ output_json({"profile": resolved})
@@ -0,0 +1,28 @@
1
+ """DevSecOps CLI command group."""
2
+
3
+ import click
4
+
5
+ from asec.cli_devsecops.asset import asset
6
+ from asec.cli_devsecops.issue import issue
7
+ from asec.cli_devsecops.product import product
8
+ from asec.cli_devsecops.revision import revision
9
+ from asec.cli_devsecops.upload import upload
10
+ from asec.cli_scanners import scanners
11
+
12
+
13
+ @click.group()
14
+ @click.pass_context
15
+ def devsecops(ctx):
16
+ """Manage DevSecOps products, assets, and security issues."""
17
+ ctx.ensure_object(dict)
18
+
19
+
20
+ devsecops.add_command(product)
21
+ devsecops.add_command(asset)
22
+ devsecops.add_command(upload)
23
+ devsecops.add_command(issue)
24
+ devsecops.add_command(revision)
25
+ devsecops.add_command(scanners)
26
+
27
+
28
+ __all__ = ["devsecops"]
@@ -0,0 +1,103 @@
1
+ """Asset CLI commands."""
2
+
3
+ import click
4
+
5
+ from asec.api_client import ApiClient, ApiClientError
6
+ from asec.output import output_json
7
+
8
+
9
+ def _get_profile(ctx: click.Context) -> str | None:
10
+ return ctx.obj.get("profile") if ctx.obj else None
11
+
12
+
13
+ @click.group()
14
+ def asset():
15
+ """Manage assets."""
16
+ pass
17
+
18
+
19
+ @asset.command()
20
+ @click.option("--product", "product_id", required=True, help="Product ID")
21
+ @click.option("--type", "asset_type", required=True, help="Asset type (repository, container_image, cloud_account)")
22
+ @click.option("--name", required=True, help="Asset name")
23
+ @click.option("--description", default=None, help="Description (also used as context for security analysis)")
24
+ @click.option("--version", default=None, help="Asset version")
25
+ @click.pass_context
26
+ def create(ctx, product_id: str, asset_type: str, name: str, description: str | None, version: str | None):
27
+ """Create a new asset."""
28
+ try:
29
+ client = ApiClient(profile=_get_profile(ctx))
30
+ result = client.post(
31
+ "/assets",
32
+ {
33
+ "product_id": product_id,
34
+ "asset_type": asset_type,
35
+ "name": name,
36
+ "description": description,
37
+ "version": version,
38
+ },
39
+ )
40
+ output_json(result)
41
+ except ApiClientError as e:
42
+ click.echo(f"Error: {e.message}", err=True)
43
+ raise SystemExit(1) from None
44
+
45
+
46
+ @asset.command("list")
47
+ @click.option("--product", "product_id", required=True, help="Product ID")
48
+ @click.option("--include-inactive", is_flag=True, default=False, help="Include inactive assets")
49
+ @click.pass_context
50
+ def list_assets(ctx, product_id: str, include_inactive: bool):
51
+ """List assets for a product."""
52
+ try:
53
+ client = ApiClient(profile=_get_profile(ctx))
54
+ params: dict = {"product_id": product_id}
55
+ if include_inactive:
56
+ params["include_inactive"] = "true"
57
+ result = client.get("/assets", params=params)
58
+ output_json(result.get("assets", []))
59
+ except ApiClientError as e:
60
+ click.echo(f"Error: {e.message}", err=True)
61
+ raise SystemExit(1) from None
62
+
63
+
64
+ @asset.command()
65
+ @click.argument("asset_id")
66
+ @click.pass_context
67
+ def show(ctx, asset_id: str):
68
+ """Show asset details."""
69
+ try:
70
+ client = ApiClient(profile=_get_profile(ctx))
71
+ result = client.get(f"/assets/{asset_id}")
72
+ output_json(result)
73
+ except ApiClientError as e:
74
+ click.echo(f"Error: {e.message}", err=True)
75
+ raise SystemExit(1) from None
76
+
77
+
78
+ @asset.command()
79
+ @click.argument("asset_id")
80
+ @click.pass_context
81
+ def deactivate(ctx, asset_id: str):
82
+ """Deactivate an asset."""
83
+ try:
84
+ client = ApiClient(profile=_get_profile(ctx))
85
+ client.patch(f"/assets/{asset_id}/deactivate")
86
+ output_json({"asset_id": asset_id})
87
+ except ApiClientError as e:
88
+ click.echo(f"Error: {e.message}", err=True)
89
+ raise SystemExit(1) from None
90
+
91
+
92
+ @asset.command()
93
+ @click.argument("asset_id")
94
+ @click.pass_context
95
+ def activate(ctx, asset_id: str):
96
+ """Activate an asset."""
97
+ try:
98
+ client = ApiClient(profile=_get_profile(ctx))
99
+ client.patch(f"/assets/{asset_id}/activate")
100
+ output_json({"asset_id": asset_id})
101
+ except ApiClientError as e:
102
+ click.echo(f"Error: {e.message}", err=True)
103
+ raise SystemExit(1) from None