devlift-cli 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 (56) hide show
  1. devlift_cli/MANUAL.md +1066 -0
  2. devlift_cli/__init__.py +3 -0
  3. devlift_cli/__main__.py +4 -0
  4. devlift_cli/api/__init__.py +0 -0
  5. devlift_cli/api/approvals.py +53 -0
  6. devlift_cli/api/catalog.py +96 -0
  7. devlift_cli/api/client.py +125 -0
  8. devlift_cli/api/context.py +21 -0
  9. devlift_cli/api/deployments.py +37 -0
  10. devlift_cli/api/infra.py +94 -0
  11. devlift_cli/api/infra_list.py +61 -0
  12. devlift_cli/api/kong.py +29 -0
  13. devlift_cli/api/services.py +106 -0
  14. devlift_cli/api/vpc.py +24 -0
  15. devlift_cli/app.py +163 -0
  16. devlift_cli/auth/__init__.py +0 -0
  17. devlift_cli/auth/oauth.py +270 -0
  18. devlift_cli/auth/session.py +64 -0
  19. devlift_cli/auth/storage.py +135 -0
  20. devlift_cli/commands/__init__.py +0 -0
  21. devlift_cli/commands/approval.py +51 -0
  22. devlift_cli/commands/auth.py +180 -0
  23. devlift_cli/commands/catalog.py +187 -0
  24. devlift_cli/commands/clusters.py +108 -0
  25. devlift_cli/commands/deployment.py +77 -0
  26. devlift_cli/commands/dynamodb.py +121 -0
  27. devlift_cli/commands/eks.py +326 -0
  28. devlift_cli/commands/kong.py +145 -0
  29. devlift_cli/commands/languages.py +40 -0
  30. devlift_cli/commands/manual.py +82 -0
  31. devlift_cli/commands/repositories.py +49 -0
  32. devlift_cli/commands/request.py +89 -0
  33. devlift_cli/commands/s3.py +198 -0
  34. devlift_cli/commands/sqs.py +229 -0
  35. devlift_cli/config.py +94 -0
  36. devlift_cli/context.py +97 -0
  37. devlift_cli/data/placement/vance.json +16 -0
  38. devlift_cli/errors.py +52 -0
  39. devlift_cli/ops/__init__.py +0 -0
  40. devlift_cli/ops/approvals.py +343 -0
  41. devlift_cli/ops/eks.py +877 -0
  42. devlift_cli/ops/kong.py +343 -0
  43. devlift_cli/ops/placement.py +128 -0
  44. devlift_cli/ops/resources.py +418 -0
  45. devlift_cli/ops/status.py +152 -0
  46. devlift_cli/ops/wait.py +82 -0
  47. devlift_cli/render/__init__.py +0 -0
  48. devlift_cli/render/output.py +75 -0
  49. devlift_cli/resolve/__init__.py +0 -0
  50. devlift_cli/resolve/allowlist.py +192 -0
  51. devlift_cli/resolve/names.py +179 -0
  52. devlift_cli-0.1.0.dist-info/METADATA +106 -0
  53. devlift_cli-0.1.0.dist-info/RECORD +56 -0
  54. devlift_cli-0.1.0.dist-info/WHEEL +5 -0
  55. devlift_cli-0.1.0.dist-info/entry_points.txt +3 -0
  56. devlift_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,75 @@
1
+ """Rendering: table on a terminal, json when piped, yaml on request.
2
+
3
+ `emit(data, fmt, table=…)`: `data` is what -o json prints, byte for byte the
4
+ server's shape plus whatever the command adds. `table` is a callable that
5
+ turns the same data into a Rich renderable; it is only called for table
6
+ output, so json never depends on presentation code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Any, Callable, Iterable
14
+
15
+ import yaml
16
+ from rich.console import Console
17
+ from rich.markup import escape
18
+ from rich.table import Table
19
+
20
+ FORMATS = ("table", "json", "yaml")
21
+
22
+ console = Console()
23
+ err_console = Console(stderr=True)
24
+
25
+
26
+ def resolve_format(requested: str | None, profile_default: str) -> str:
27
+ if requested:
28
+ return requested
29
+ if not sys.stdout.isatty():
30
+ return "json"
31
+ return profile_default if profile_default in FORMATS else "table"
32
+
33
+
34
+ def emit(data: Any, fmt: str, table: Callable[[Any], Any] | None = None) -> None:
35
+ if fmt == "json":
36
+ print(json.dumps(data, indent=2, default=str))
37
+ elif fmt == "yaml":
38
+ print(yaml.safe_dump(data, sort_keys=False, default_flow_style=False), end="")
39
+ else:
40
+ if table is None:
41
+ print(json.dumps(data, indent=2, default=str))
42
+ else:
43
+ console.print(table(data))
44
+
45
+
46
+ def kv_table(rows: Iterable[tuple[str, Any]], title: str | None = None) -> Table:
47
+ t = Table(title=title, show_header=False, box=None, pad_edge=False)
48
+ t.add_column("key", style="bold")
49
+ t.add_column("value")
50
+ for key, value in rows:
51
+ # Values are data, never markup: a branch list rendered as "[main]"
52
+ # would otherwise be swallowed as a Rich style tag.
53
+ t.add_row(escape(str(key)), "" if value is None else escape(str(value)))
54
+ return t
55
+
56
+
57
+ def rows_table(columns: list[str], rows: Iterable[Iterable[Any]], title: str | None = None) -> Table:
58
+ t = Table(title=title, header_style="bold")
59
+ for col in columns:
60
+ t.add_column(col)
61
+ for row in rows:
62
+ t.add_row(*("" if v is None else escape(str(v)) for v in row))
63
+ return t
64
+
65
+
66
+ def info(message: str) -> None:
67
+ err_console.print(message)
68
+
69
+
70
+ def warn(message: str) -> None:
71
+ err_console.print(f"[yellow]{message}[/yellow]")
72
+
73
+
74
+ def success(message: str) -> None:
75
+ err_console.print(f"[green]{message}[/green]")
File without changes
@@ -0,0 +1,192 @@
1
+ """Placement allowlist: what each product, environment and region really offers.
2
+
3
+ The backend returns every region for every application, which is wider than
4
+ what exists. `data/placement/{tenant}.json` records the truth, in the shape
5
+
6
+ {"<workspace>": {"<product>": {"<env>": {"<aws region>": ["s3", "sqs", ...]}}}}
7
+
8
+ and is the same file the chatbot carries under its own data directory;
9
+ keep the two in step. The dashboard carries its own, coarser copy.
10
+
11
+ A file is found by its name, `{tenant}.json`, or by listing the tenant in its
12
+ own `_tenants` key, which is how one customer known by several tenant codes
13
+ gets one file instead of a copy per code. Keys beginning with `_` are metadata
14
+ and are never read as workspaces.
15
+
16
+ Matching is on LABELS, never on codes: the config is written in human terms
17
+ (product `core`, environment `stage`, region `ap-south-1`) while the API
18
+ speaks opaque codes, so the config only ever decides which API rows survive.
19
+
20
+ Safety stance: this filter fails CLOSED. If matching goes wrong the user is
21
+ told there is no placement rather than handed one that does not exist — a
22
+ refusal is recoverable, a bucket in the wrong region is not. Filtering is
23
+ opt-in per tenant: a tenant with no config file is left unfiltered.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from pathlib import Path
30
+
31
+ _DIR = Path(__file__).resolve().parent.parent / "data" / "placement"
32
+
33
+ # The service key each command's resources use in the config's leaf lists.
34
+ SERVICE_S3 = "s3"
35
+ SERVICE_SQS = "sqs"
36
+ SERVICE_DYNAMODB = "dynamo"
37
+ SERVICE_EKS = "eks"
38
+ SERVICE_GATEWAY = "gateway"
39
+
40
+ # AWS region code -> the city name the API uses as a region label.
41
+ REGION_CITY = {
42
+ "ap-south-1": "mumbai", "ap-southeast-1": "singapore", "ap-northeast-1": "tokyo",
43
+ "eu-west-1": "ireland", "eu-west-2": "london", "eu-central-1": "frankfurt",
44
+ "us-east-1": "virginia", "us-east-2": "ohio", "us-west-2": "oregon",
45
+ "ca-central-1": "canada",
46
+ }
47
+
48
+ _cache: dict[str, dict | None] = {}
49
+
50
+
51
+ def _norm(text) -> str:
52
+ return str(text or "").strip().casefold()
53
+
54
+
55
+ def _read(path: Path) -> dict | None:
56
+ """The raw JSON at `path`, or None if it is missing or unreadable."""
57
+ try:
58
+ raw = json.loads(path.read_text())
59
+ except (OSError, ValueError):
60
+ return None
61
+ return raw if isinstance(raw, dict) else None
62
+
63
+
64
+ def _serves(raw: dict, tenant: str) -> bool:
65
+ """Does this file's `_tenants` list name the tenant?
66
+
67
+ One customer can appear under more than one tenant code. The codes are
68
+ listed in the file itself rather than mapped in here, so every name
69
+ belonging to a customer stays in that customer's own data file and none
70
+ is compiled into the CLI.
71
+ """
72
+ declared = raw.get("_tenants")
73
+ return isinstance(declared, list) and any(_norm(t) == tenant for t in declared)
74
+
75
+
76
+ def _locate(tenant: str) -> dict | None:
77
+ """The raw config for this tenant: its own file, else one that claims it."""
78
+ raw = _read(_DIR / f"{tenant}.json")
79
+ if raw is not None:
80
+ return raw
81
+ try:
82
+ candidates = sorted(_DIR.glob("*.json"))
83
+ except OSError:
84
+ return None
85
+ for path in candidates:
86
+ other = _read(path)
87
+ if other is not None and _serves(other, tenant):
88
+ return other
89
+ return None
90
+
91
+
92
+ def load(tenant_code: str) -> dict | None:
93
+ """The tenant's config, or None when it has none (then nothing is filtered)."""
94
+ tenant = _norm(tenant_code)
95
+ if tenant not in _cache:
96
+ raw = _locate(tenant)
97
+ # `_`-prefixed keys are metadata (`_tenants`, notes), never workspaces.
98
+ _cache[tenant] = (
99
+ {k: v for k, v in raw.items() if not str(k).startswith("_")} if raw else None
100
+ ) or None
101
+ return _cache[tenant]
102
+
103
+
104
+ def _products(config: dict) -> dict:
105
+ """Collapse the workspace envelope: the CLI has no workspace selector."""
106
+ products: dict = {}
107
+ for workspace in config.values():
108
+ if isinstance(workspace, dict):
109
+ products.update(workspace)
110
+ return products
111
+
112
+
113
+ def _matches_region(config_region: str, row: dict) -> bool:
114
+ """Does an API placement row correspond to a config region code?"""
115
+ label, value = _norm(row.get("region")), _norm(row.get("geo_loc_mst_code"))
116
+ code = _norm(config_region)
117
+ city = REGION_CITY.get(code, "")
118
+ if code and (code == label or code == value):
119
+ return True
120
+ return bool(city) and (city == label or city in value)
121
+
122
+
123
+ def allowed_regions(config: dict, product: str, environment: str, service: str) -> list[str]:
124
+ """The config's region codes offering `service` for this product and environment."""
125
+ products = _products(config)
126
+ key = next((k for k in products if _norm(k) == _norm(product)), None)
127
+ if key is None:
128
+ return []
129
+ envs = products[key] or {}
130
+ env_key = next((k for k in envs if _norm(k) == _norm(environment)), None)
131
+ if env_key is None:
132
+ return []
133
+ return [r for r, services in (envs[env_key] or {}).items()
134
+ if isinstance(services, list) and service in services]
135
+
136
+
137
+ def any_regions(config: dict, product: str, environment: str) -> list[str]:
138
+ """Every region the config lists for this product and environment, whatever
139
+ the service. This is what "does this placement exist at all" means."""
140
+ products = _products(config)
141
+ key = next((k for k in products if _norm(k) == _norm(product)), None)
142
+ if key is None:
143
+ return []
144
+ envs = products[key] or {}
145
+ env_key = next((k for k in envs if _norm(k) == _norm(environment)), None)
146
+ if env_key is None:
147
+ return []
148
+ return [r for r, services in (envs[env_key] or {}).items() if services]
149
+
150
+
151
+ def filter_placements(rows: list[dict], tenant_code: str, service: str | None = None) -> list[dict]:
152
+ """Keep only the placement rows this tenant really has.
153
+
154
+ `rows` are the flattened placement options (product, environment, region,
155
+ geo_loc_mst_code). With `service`, only placements offering that service
156
+ survive; without one, any placement the config lists at all. Returns the
157
+ rows unchanged when the tenant has no config.
158
+ """
159
+ config = load(tenant_code)
160
+ if not config:
161
+ return rows
162
+ kept = []
163
+ for row in rows:
164
+ product, environment = row.get("product", ""), row.get("environment", "")
165
+ allowed = (allowed_regions(config, product, environment, service) if service
166
+ else any_regions(config, product, environment))
167
+ if any(_matches_region(r, row) for r in allowed):
168
+ kept.append(row)
169
+ return kept
170
+
171
+
172
+ def filter_applications(apps: list[dict], rows: list[dict], tenant_code: str) -> list[dict]:
173
+ """Keep only the applications that have at least one real placement."""
174
+ if not load(tenant_code):
175
+ return apps
176
+ codes = {r.get("application_code") for r in filter_placements(rows, tenant_code)}
177
+ return [a for a in apps if a.get("application_code") in codes]
178
+
179
+
180
+ def describe(tenant_code: str, service: str | None) -> str:
181
+ """One line naming what the config does offer, for an error message."""
182
+ config = load(tenant_code) if service else None
183
+ if not config:
184
+ return ""
185
+ bits = []
186
+ for product, envs in _products(config).items():
187
+ for env, regions in (envs or {}).items():
188
+ names = [REGION_CITY.get(_norm(r), r).title() for r, s in (regions or {}).items()
189
+ if isinstance(s, list) and service in s]
190
+ if names:
191
+ bits.append(f"{product}/{env}: {', '.join(sorted(names))}")
192
+ return "; ".join(sorted(bits))
@@ -0,0 +1,179 @@
1
+ """Names in, codes out.
2
+
3
+ Users say `--app core --env stage --region mumbai --service demo-api`; the
4
+ API wants `application_code`, `EnvironmentEnum`, `geo_loc_mst_code`,
5
+ `service_code`. Matching is case-insensitive against name AND code, exact
6
+ first, then unique prefix. Ambiguity is an InputError that lists the
7
+ candidates; nothing is ever guessed.
8
+
9
+ Catalog responses are cached per profile+backend for a few minutes so a
10
+ command that resolves three names does not pay three round trips every time.
11
+ `--no-cache` (or DEVLIFT_NO_CACHE=1) bypasses it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import time
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Callable
23
+
24
+ from devlift_cli.api import catalog
25
+ from devlift_cli.api.client import ApiClient
26
+ from devlift_cli.config import CONFIG_DIR
27
+ from devlift_cli.errors import EXIT_NOT_FOUND, CliError, InputError
28
+
29
+ CACHE_TTL_SECONDS = 600
30
+ ENVIRONMENTS = ("dev", "stage", "qa", "prod")
31
+ _ENV_ALIASES = {"staging": "stage", "production": "prod", "development": "dev"}
32
+
33
+
34
+ @dataclass
35
+ class Placement:
36
+ application_code: str
37
+ application_name: str
38
+ environment: str
39
+ geo_loc_mst_code: str
40
+ region_name: str
41
+
42
+
43
+ class Cache:
44
+ def __init__(self, profile_name: str, base_url: str, enabled: bool = True):
45
+ key = hashlib.sha1(f"{profile_name}|{base_url}".encode()).hexdigest()[:12]
46
+ self.path = CONFIG_DIR / "cache" / f"{key}.json"
47
+ self.enabled = enabled and not os.environ.get("DEVLIFT_NO_CACHE")
48
+ self._data: dict | None = None
49
+
50
+ def _load(self) -> dict:
51
+ if self._data is None:
52
+ try:
53
+ self._data = json.loads(self.path.read_text())
54
+ except (FileNotFoundError, ValueError):
55
+ self._data = {}
56
+ return self._data
57
+
58
+ def get(self, name: str, fetch: Callable[[], list]) -> list:
59
+ data = self._load()
60
+ entry = data.get(name)
61
+ if self.enabled and entry and time.time() - entry.get("at", 0) < CACHE_TTL_SECONDS:
62
+ return entry["value"]
63
+ value = fetch()
64
+ if self.enabled:
65
+ data[name] = {"at": time.time(), "value": value}
66
+ try:
67
+ self.path.parent.mkdir(parents=True, exist_ok=True)
68
+ tmp = self.path.with_suffix(".tmp")
69
+ tmp.write_text(json.dumps(data))
70
+ tmp.chmod(0o600)
71
+ tmp.replace(self.path)
72
+ except OSError:
73
+ pass
74
+ return value
75
+
76
+ def clear(self) -> None:
77
+ try:
78
+ self.path.unlink()
79
+ except FileNotFoundError:
80
+ pass
81
+ self._data = {}
82
+
83
+
84
+ def _match(items: list[dict], wanted: str, *keys: str, what: str) -> dict:
85
+ needle = wanted.strip().lower()
86
+ exact = [i for i in items if any(str(i.get(k) or "").lower() == needle for k in keys)]
87
+ if len(exact) == 1:
88
+ return exact[0]
89
+ if len(exact) > 1:
90
+ raise InputError(f"'{wanted}' matches more than one {what}: " + ", ".join(_label(i, keys) for i in exact))
91
+ prefix = [i for i in items if any(str(i.get(k) or "").lower().startswith(needle) for k in keys)]
92
+ if len(prefix) == 1:
93
+ return prefix[0]
94
+ if len(prefix) > 1:
95
+ raise InputError(f"'{wanted}' is ambiguous for {what}: " + ", ".join(_label(i, keys) for i in prefix))
96
+ known = ", ".join(sorted({str(i.get(keys[0]) or "") for i in items})) or "none"
97
+ raise CliError(f"No {what} named '{wanted}'. Known: {known}", EXIT_NOT_FOUND)
98
+
99
+
100
+ def _label(item: dict, keys: tuple[str, ...]) -> str:
101
+ return str(item.get(keys[0]) or item.get(keys[-1]) or "?")
102
+
103
+
104
+ class Resolver:
105
+ def __init__(self, api: ApiClient, profile_name: str, use_cache: bool = True):
106
+ self.api = api
107
+ self.cache = Cache(profile_name, api.profile.base_url, enabled=use_cache)
108
+
109
+ # ── raw lists (cached) ───────────────────────────────────────────────
110
+ def applications(self) -> list[dict]:
111
+ return self.cache.get("applications", lambda: catalog.list_applications(self.api))
112
+
113
+ def services(self) -> list[dict]:
114
+ return self.cache.get("services", lambda: catalog.list_services(self.api))
115
+
116
+ def tenant_code(self) -> str:
117
+ """The signed-in tenant, which keys the placement allowlist."""
118
+ from devlift_cli.api.context import get_context
119
+
120
+ return self.cache.get("tenant", lambda: [get_context(self.api).tenant_code])[0]
121
+
122
+ def placements(self) -> list[dict]:
123
+ return self.cache.get("placements", lambda: catalog.placement_rows(catalog.placement_options(self.api)))
124
+
125
+ def resource_groups(self) -> list[dict]:
126
+ return self.cache.get("resource_groups", lambda: catalog.list_resource_groups(self.api))
127
+
128
+ def infrastructure_types(self) -> list[dict]:
129
+ return self.cache.get("infrastructure_types", lambda: catalog.list_infrastructure_types(self.api))
130
+
131
+ # ── resolutions ──────────────────────────────────────────────────────
132
+ def application(self, name_or_code: str) -> dict:
133
+ return _match(self.applications(), name_or_code, "application_name", "application_code", what="application")
134
+
135
+ def environment(self, name: str) -> str:
136
+ value = _ENV_ALIASES.get(name.strip().lower(), name.strip().lower())
137
+ if value not in ENVIRONMENTS:
138
+ raise InputError(f"Unknown environment '{name}'. One of: {', '.join(ENVIRONMENTS)}")
139
+ return value
140
+
141
+ def service(self, name_or_code: str) -> dict:
142
+ return _match(self.services(), name_or_code, "service_name", "service_code", what="service")
143
+
144
+ def resource_group(self, name_or_code: str, application_code: str | None = None) -> dict:
145
+ groups = self.resource_groups()
146
+ if application_code:
147
+ groups = [g for g in groups if g.get("applications_mst_code") == application_code] or groups
148
+ return _match(groups, name_or_code, "name", "code", what="resource group")
149
+
150
+ def infrastructure_type(self, name_or_code: str) -> dict:
151
+ return _match(self.infrastructure_types(), name_or_code, "name", "code", what="resource type")
152
+
153
+ def placement(self, app: str, env: str, region: str) -> Placement:
154
+ """The one (product, environment, region) row the user means, or why not."""
155
+ application = self.application(app)
156
+ environment = self.environment(env)
157
+ rows = [r for r in self.placements() if r["application_code"] == application["application_code"]]
158
+ if not rows:
159
+ raise CliError(f"Application '{application['application_name']}' has no placements configured.", EXIT_NOT_FOUND)
160
+ in_env = [r for r in rows if r["environment"] == environment]
161
+ if not in_env:
162
+ envs = ", ".join(sorted({r["environment"] for r in rows}))
163
+ raise InputError(f"'{application['application_name']}' is not set up for {environment}. Environments: {envs}")
164
+ row = _match(in_env, region, "region", "geo_loc_mst_code", what=f"region for {application['application_name']}/{environment}")
165
+ return Placement(
166
+ application_code=application["application_code"],
167
+ application_name=application["application_name"],
168
+ environment=environment,
169
+ geo_loc_mst_code=row["geo_loc_mst_code"],
170
+ region_name=row["region"],
171
+ )
172
+
173
+ def regions_for(self, application_code: str | None = None, environment: str | None = None) -> list[dict]:
174
+ rows = self.placements()
175
+ if application_code:
176
+ rows = [r for r in rows if r["application_code"] == application_code]
177
+ if environment:
178
+ rows = [r for r in rows if r["environment"] == environment]
179
+ return rows
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: devlift-cli
3
+ Version: 0.1.0
4
+ Summary: DevLift command-line tool: create, change, review and deploy DevLift resources from the terminal.
5
+ License: Proprietary
6
+ Keywords: devlift,devops,deployment,infrastructure,cli
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: System Administrators
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Classifier: Topic :: System :: Systems Administration
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: typer>=0.15
23
+ Requires-Dist: rich>=13
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: keyring>=25
26
+ Requires-Dist: pydantic>=2
27
+ Requires-Dist: pyyaml>=6
28
+
29
+ # devlift-cli
30
+
31
+ DevLift from the terminal, in the style of the AWS CLI:
32
+
33
+ ```
34
+ devlift <group> <operation> [flags]
35
+ ```
36
+
37
+ Every command is a fixed sequence of calls to the DevLift backend's REST API,
38
+ the same endpoints the web dashboard uses. Nothing here talks to an LLM.
39
+
40
+ ## Install
41
+
42
+ ```
43
+ pip install devlift-cli
44
+ ```
45
+
46
+ Or, to keep it in its own environment and off your system Python:
47
+
48
+ ```
49
+ pipx install devlift-cli
50
+ ```
51
+
52
+ Upgrade with `pip install -U devlift-cli` (or `pipx upgrade devlift-cli`).
53
+ Your profile and sign-in live in `~/.config/devlift` and survive upgrades.
54
+
55
+ Then:
56
+
57
+ ```
58
+ devlift configure --base-url <your DevLift backend> --default
59
+ devlift login
60
+ devlift whoami
61
+ devlift man
62
+ ```
63
+
64
+ Requires Python 3.11 or newer. To remove it: `pip uninstall devlift-cli`
65
+ (or `pipx uninstall devlift-cli`). That leaves `~/.config/devlift`, so delete
66
+ that too if you want your profiles and sign-in gone as well.
67
+
68
+ Several backends? `devlift profile list`, `devlift profile use <name>` and
69
+ `devlift profile show` switch between them and say which one is active.
70
+
71
+ ## Manual
72
+
73
+ The full command reference ships inside the package: `devlift man` pages
74
+ through it like `man`, and `devlift manual s3` prints one section.
75
+
76
+ ## What it covers
77
+
78
+ - Resources: `s3`, `sqs`, `dynamodb` create / list / describe, `s3` and `sqs` update.
79
+ - Services: `eks create / edit / show / diff / deploy / status`, `kong route add / remove / rename`, `kong plugin add`.
80
+ - Review lane: `request submit / withdraw / discard / list / show`, `approval approve / request-changes / reject / revoke / list`.
81
+ - Deployments: `deployment list / status`, `queue list`.
82
+ - Discovery: applications, environments, regions, resource groups, services, repositories, branches, languages.
83
+
84
+ Variables and secrets stay in the dashboard by design.
85
+
86
+ ## Scripting
87
+
88
+ - `-o json` prints only JSON on stdout (the default when stdout is not a terminal).
89
+ - `--yes` answers confirmations, `--no-input` fails instead of prompting.
90
+ - `DEVLIFT_TOKEN` skips the stored login. `DEVLIFT_PROFILE`, `DEVLIFT_BASE_URL` override the profile.
91
+ - Exit codes: 0 ok · 1 error · 2 not signed in · 3 bad input · 4 permission · 5 confirmation needed · 6 not found · 7 conflict.
92
+
93
+ ## Claude skill
94
+
95
+ `skills/devlift/` teaches Claude (Claude Code or any Agent Skills host) to
96
+ drive this CLI non-interactively. Install with:
97
+
98
+ ```
99
+ ln -s "$(pwd)/skills/devlift" ~/.claude/skills/devlift # personal
100
+ # or copy it into a project's .claude/skills/devlift
101
+ ```
102
+
103
+ ## Development
104
+
105
+ Building, testing and cutting a release: see `CONTRIBUTING.md` in the source
106
+ repository. It is deliberately not part of the published package.
@@ -0,0 +1,56 @@
1
+ devlift_cli/MANUAL.md,sha256=D3GEU-Kqll9ePcLnkUVdcr7bMvc39YPLov32VnSQthw,47823
2
+ devlift_cli/__init__.py,sha256=ZGNQ5xyfg1UJc1q5_m9CSn-S5WuOV2Bezi-hUmuvTgA,56
3
+ devlift_cli/__main__.py,sha256=Tmx64_6dPMuxUtDmEC3FOa3KQbW-agv1fwAUkUtHhaY,72
4
+ devlift_cli/app.py,sha256=KBvRt-_05-Q-r3HnZA9IH9KKepsWLXXqVZpQSadx-Ik,6962
5
+ devlift_cli/config.py,sha256=sfcJI24GvT-oq-WcKnaFhAbQcwoYreXUilIzOvx2rRA,2958
6
+ devlift_cli/context.py,sha256=xXICnB0sVOaT5W_hWs2DgW4SrrL0wvQk6P0aUTz92Js,3857
7
+ devlift_cli/errors.py,sha256=_wo50KhVsN7smvVc4n7MNNwnXtlxpKClWT4ld9k1NY0,1620
8
+ devlift_cli/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ devlift_cli/api/approvals.py,sha256=yuYBDCf62wrHq1o7cMWl5QcT90-PLJqYNhK8oX-ixvw,2413
10
+ devlift_cli/api/catalog.py,sha256=jcAKUFMPtZjqvaHMzXn_aFFOGwI9Tx-0iUBBL_5N9uk,3791
11
+ devlift_cli/api/client.py,sha256=FTVVzsU-BA3xXiVweil4FAFl1hxcGJzBIEnINqFFvkU,4777
12
+ devlift_cli/api/context.py,sha256=3m9DOz08H4DtUwHHqALfrnL5vtVLmT0XY6ox7tTtivE,542
13
+ devlift_cli/api/deployments.py,sha256=F7Lqh0767wv-oiMHpBBEPhSYjKjJ5MfC7dz1cA9nL_8,1442
14
+ devlift_cli/api/infra.py,sha256=FxPYWlH17NVHT868oKae2opb7gG4Xlub4gitehJAl0A,3553
15
+ devlift_cli/api/infra_list.py,sha256=-XAQKvHeh_Ux57MrAyd_eFHc9FJIJWFs23Jt153EA2A,1955
16
+ devlift_cli/api/kong.py,sha256=1m8XOiWdsASecP7ubrmzaBH4VAAhdwD3ZpqLjzVriYU,1358
17
+ devlift_cli/api/services.py,sha256=yiZaRHwKNkh1Axm-UQQGZFM1Mxt703vy2VXceG1bDo0,3593
18
+ devlift_cli/api/vpc.py,sha256=Cj4JLa1Cmcibckr9RP7qN6gLMLKgc67dG2vTGY_wIjs,1050
19
+ devlift_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ devlift_cli/auth/oauth.py,sha256=xbyqpE0Yq3YS9Z1UTJXMqLJ3Dnhm-5pPqPX1AMF75Mc,10475
21
+ devlift_cli/auth/session.py,sha256=g25N6nVUOBBsLUmETIQPcfo4kmqD9dhXGH22__EDWHI,2342
22
+ devlift_cli/auth/storage.py,sha256=fKnWEdZ80dUQwmuQdYlgcyIH8xttYQilfvR9P_sVvdg,3594
23
+ devlift_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ devlift_cli/commands/approval.py,sha256=ZbwJ1XnYmZ4ZqSASlqCHDUTR4G0H-vm4Q5EkUAyarq4,2889
25
+ devlift_cli/commands/auth.py,sha256=RZfzC0rku3Gnun-j34eUzlUPVCjCxQeIaG6SG756VVI,7684
26
+ devlift_cli/commands/catalog.py,sha256=JaUDqER81fMhhWmOrKIVdLEevsrr4KdDgAS825W2bFY,10075
27
+ devlift_cli/commands/clusters.py,sha256=Z9NMeeR-M5LVz9qGZau_QsWUqHX5WRnmLaacN99FnW0,4944
28
+ devlift_cli/commands/deployment.py,sha256=-H-2NN_kRZZCYsTLNKKOaAUvL0y11dDJ3fNUkUMhOy4,3299
29
+ devlift_cli/commands/dynamodb.py,sha256=p2WJGCPR9Gwg52o3C1kmYWVcCCfauTHzsdpzVOckRao,5517
30
+ devlift_cli/commands/eks.py,sha256=hZkyv72iKDzdbTqzAHCwS4rmDFfO2o-yMG5PI2X-Fiw,18129
31
+ devlift_cli/commands/kong.py,sha256=aP1NbJu_Z5sO3-rdRdVM9G45sYlltR1li9jvbDAomD4,6671
32
+ devlift_cli/commands/languages.py,sha256=H5fBgeFOBJBDxmnj31K2bJ5q9KQM-cPPLcpb4_lpY3U,1507
33
+ devlift_cli/commands/manual.py,sha256=iy6vld-xMq86GCnr-Wmi__WWHuXFuxZg65zeltZgj1Q,3220
34
+ devlift_cli/commands/repositories.py,sha256=LuEDc8093y1A6FybexJi_9GaTL_iAjYOM_Wg25zYSNc,1862
35
+ devlift_cli/commands/request.py,sha256=ZSqv4oobqyPADFc62AgzYkuTSzZHm7-xVpWC5zv0JVA,4533
36
+ devlift_cli/commands/s3.py,sha256=h_i-jitna6bXd06XgoviSeMb3p5oPGUcKgqiTjxr_WM,9163
37
+ devlift_cli/commands/sqs.py,sha256=wAPxNM9mBYANywahHtMAE7CeLJxWOBYPMjYUTEjZt2Y,10944
38
+ devlift_cli/data/placement/vance.json,sha256=e9yIZkcTiJwa-oH_KZ9lfMD2Xum9cM6oKhighHEqsUg,330
39
+ devlift_cli/ops/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
+ devlift_cli/ops/approvals.py,sha256=XjjbjSor3MmSp8sUO4OsLvkOsYk2WQ2WdVIYMk9fzAc,17406
41
+ devlift_cli/ops/eks.py,sha256=tW8gq2f8c6WOnhvK6dDZyam05UnVbxWvGZNe1p9eIsI,43808
42
+ devlift_cli/ops/kong.py,sha256=DIcNB8-3BCkO5vog4aYOsTulqpKHMgtltW-9LD_ydHg,16652
43
+ devlift_cli/ops/placement.py,sha256=CWkinGOSNJ3opgwYqeBbvX-FYVKyqBX08VL3fn_5QlQ,5900
44
+ devlift_cli/ops/resources.py,sha256=Te_MGia6a9qEHxUTZbwoSjFe06bxclWhaYWhfvT6Ry8,18171
45
+ devlift_cli/ops/status.py,sha256=l1AahuXHw1k7TZUI9SI8ilHibAe0wMqwJIFnkqSfLb8,7211
46
+ devlift_cli/ops/wait.py,sha256=Q0c1XITipLUAWxxG306kIAuasNiAPr_QGNElMnVQmdo,3493
47
+ devlift_cli/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
+ devlift_cli/render/output.py,sha256=NlRFHfEvvV1gdktl6JbP-jDKFNqSxnpo1V4ze56jahc,2339
49
+ devlift_cli/resolve/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
+ devlift_cli/resolve/allowlist.py,sha256=wlnLOoY6g7G5UDC3ZRJVkpSzyXBAQ-G1XL-_7BV88uI,7534
51
+ devlift_cli/resolve/names.py,sha256=xHOiYHnDcLS22SLw0CtmRihqKc97IdsZFqwzta4ywpI,7849
52
+ devlift_cli-0.1.0.dist-info/METADATA,sha256=RbQDUkTXTii8Znqt--4EFNtWyEAgw2zHxCzPkQy-nLk,3696
53
+ devlift_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
54
+ devlift_cli-0.1.0.dist-info/entry_points.txt,sha256=QRD_aXcdHOAbbsh7j5Z6WYo0dnnw0XQyv68hJcq70IM,75
55
+ devlift_cli-0.1.0.dist-info/top_level.txt,sha256=uabaGJ5Bj1mSyc99HXldIoegI_rzU2S3-pNtwN7_iSk,12
56
+ devlift_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ devlift = devlift_cli.app:main
3
+ dl = devlift_cli.app:main
@@ -0,0 +1 @@
1
+ devlift_cli