pmctl 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python:*)",
5
+ "Bash(pmctl completion:*)"
6
+ ]
7
+ }
8
+ }
pmctl-0.1.0/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .env
12
+ .ruff_cache/
13
+ .mypy_cache/
pmctl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: pmctl
3
+ Version: 0.1.0
4
+ Summary: A CLI tool for managing Postman collections, environments, and workspaces
5
+ Project-URL: Homepage, https://github.com/wbinglee/pmctl
6
+ Project-URL: Repository, https://github.com/wbinglee/pmctl
7
+ Project-URL: Issues, https://github.com/wbinglee/pmctl/issues
8
+ Author: Wenbing Li
9
+ License-Expression: MIT
10
+ Keywords: api,cli,collections,postman
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx>=0.25.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: typer>=0.9.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pmctl
28
+
29
+ A CLI tool for managing Postman collections, environments, and workspaces.
30
+
31
+ The official Postman CLI only supports running collections. `pmctl` fills the gap by wrapping the [Postman API](https://www.postman.com/postman/postman-public-workspace/documentation/12946884/postman-api) to let you browse and manage your Postman resources from the terminal.
32
+
33
+ ## Features
34
+
35
+ - 🔑 **Multi-profile support** — manage multiple Postman accounts (personal, work, etc.)
36
+ - 📦 **Browse collections** — list and inspect collections with a beautiful tree view
37
+ - 🌍 **Environments** — list environments and view variables
38
+ - 🏢 **Workspaces** — list all accessible workspaces
39
+ - 🎨 **Rich output** — colored tables and trees powered by [Rich](https://github.com/Textualize/rich)
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install pmctl
45
+ ```
46
+
47
+ Or install from source:
48
+
49
+ ```bash
50
+ git clone https://github.com/wbinglee/pmctl.git
51
+ cd pmctl
52
+ pip install -e .
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ### 1. Add a profile
58
+
59
+ ```bash
60
+ # Add your Postman API key (get one at https://go.postman.co/settings/me/api-keys)
61
+ pmctl profile add personal --api-key "PMAK-..." --label "Personal Account" --default
62
+
63
+ # Add another profile (e.g., work account)
64
+ pmctl profile add work --api-key "PMAK-..." --label "Work Account"
65
+ ```
66
+
67
+ ### 2. Browse your resources
68
+
69
+ ```bash
70
+ # List collections
71
+ pmctl collections list
72
+
73
+ # List collections in a specific workspace
74
+ pmctl collections list --workspace <workspace-id>
75
+
76
+ # Show all requests in a collection (tree view)
77
+ pmctl collections show <collection-uid>
78
+
79
+ # List environments
80
+ pmctl environments list
81
+
82
+ # Show environment variables
83
+ pmctl environments show <env-id> --values
84
+
85
+ # List workspaces
86
+ pmctl workspaces list
87
+ ```
88
+
89
+ ### 3. Switch between profiles
90
+
91
+ ```bash
92
+ # Switch default profile
93
+ pmctl profile switch work
94
+
95
+ # Or use --profile flag on any command
96
+ pmctl collections list --profile personal
97
+
98
+ # Check who you're logged in as
99
+ pmctl profile whoami
100
+ ```
101
+
102
+ ## Profile Management
103
+
104
+ ```bash
105
+ pmctl profile list # List all profiles
106
+ pmctl profile add <name> # Add a new profile
107
+ pmctl profile remove <name> # Remove a profile
108
+ pmctl profile switch <name> # Set default profile
109
+ pmctl profile whoami # Show current user info
110
+ ```
111
+
112
+ ## Configuration
113
+
114
+ Profiles are stored in `~/.config/pmctl/config.toml`:
115
+
116
+ ```toml
117
+ [profiles.personal]
118
+ api_key = "PMAK-..."
119
+ label = "personal@example.com"
120
+
121
+ [profiles.work]
122
+ api_key = "PMAK-..."
123
+ label = "work@company.com"
124
+
125
+ default_profile = "work"
126
+ ```
127
+
128
+ ## Getting a Postman API Key
129
+
130
+ 1. Go to [Postman API Keys](https://go.postman.co/settings/me/api-keys)
131
+ 2. Click **Generate API Key**
132
+ 3. Copy the key and add it with `pmctl profile add`
133
+
134
+ > **Note:** If you have multiple Postman accounts (e.g., personal + company SSO), each account has its own API keys page. Log into the correct account first.
135
+
136
+ ## License
137
+
138
+ MIT
pmctl-0.1.0/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # pmctl
2
+
3
+ A CLI tool for managing Postman collections, environments, and workspaces.
4
+
5
+ The official Postman CLI only supports running collections. `pmctl` fills the gap by wrapping the [Postman API](https://www.postman.com/postman/postman-public-workspace/documentation/12946884/postman-api) to let you browse and manage your Postman resources from the terminal.
6
+
7
+ ## Features
8
+
9
+ - 🔑 **Multi-profile support** — manage multiple Postman accounts (personal, work, etc.)
10
+ - 📦 **Browse collections** — list and inspect collections with a beautiful tree view
11
+ - 🌍 **Environments** — list environments and view variables
12
+ - 🏢 **Workspaces** — list all accessible workspaces
13
+ - 🎨 **Rich output** — colored tables and trees powered by [Rich](https://github.com/Textualize/rich)
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install pmctl
19
+ ```
20
+
21
+ Or install from source:
22
+
23
+ ```bash
24
+ git clone https://github.com/wbinglee/pmctl.git
25
+ cd pmctl
26
+ pip install -e .
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ### 1. Add a profile
32
+
33
+ ```bash
34
+ # Add your Postman API key (get one at https://go.postman.co/settings/me/api-keys)
35
+ pmctl profile add personal --api-key "PMAK-..." --label "Personal Account" --default
36
+
37
+ # Add another profile (e.g., work account)
38
+ pmctl profile add work --api-key "PMAK-..." --label "Work Account"
39
+ ```
40
+
41
+ ### 2. Browse your resources
42
+
43
+ ```bash
44
+ # List collections
45
+ pmctl collections list
46
+
47
+ # List collections in a specific workspace
48
+ pmctl collections list --workspace <workspace-id>
49
+
50
+ # Show all requests in a collection (tree view)
51
+ pmctl collections show <collection-uid>
52
+
53
+ # List environments
54
+ pmctl environments list
55
+
56
+ # Show environment variables
57
+ pmctl environments show <env-id> --values
58
+
59
+ # List workspaces
60
+ pmctl workspaces list
61
+ ```
62
+
63
+ ### 3. Switch between profiles
64
+
65
+ ```bash
66
+ # Switch default profile
67
+ pmctl profile switch work
68
+
69
+ # Or use --profile flag on any command
70
+ pmctl collections list --profile personal
71
+
72
+ # Check who you're logged in as
73
+ pmctl profile whoami
74
+ ```
75
+
76
+ ## Profile Management
77
+
78
+ ```bash
79
+ pmctl profile list # List all profiles
80
+ pmctl profile add <name> # Add a new profile
81
+ pmctl profile remove <name> # Remove a profile
82
+ pmctl profile switch <name> # Set default profile
83
+ pmctl profile whoami # Show current user info
84
+ ```
85
+
86
+ ## Configuration
87
+
88
+ Profiles are stored in `~/.config/pmctl/config.toml`:
89
+
90
+ ```toml
91
+ [profiles.personal]
92
+ api_key = "PMAK-..."
93
+ label = "personal@example.com"
94
+
95
+ [profiles.work]
96
+ api_key = "PMAK-..."
97
+ label = "work@company.com"
98
+
99
+ default_profile = "work"
100
+ ```
101
+
102
+ ## Getting a Postman API Key
103
+
104
+ 1. Go to [Postman API Keys](https://go.postman.co/settings/me/api-keys)
105
+ 2. Click **Generate API Key**
106
+ 3. Copy the key and add it with `pmctl profile add`
107
+
108
+ > **Note:** If you have multiple Postman accounts (e.g., personal + company SSO), each account has its own API keys page. Log into the correct account first.
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pmctl"
7
+ version = "0.1.0"
8
+ description = "A CLI tool for managing Postman collections, environments, and workspaces"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Wenbing Li" },
14
+ ]
15
+ keywords = ["postman", "cli", "api", "collections"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Testing",
26
+ "Topic :: Utilities",
27
+ ]
28
+ dependencies = [
29
+ "typer>=0.9.0",
30
+ "httpx>=0.25.0",
31
+ "rich>=13.0.0",
32
+ ]
33
+
34
+ [project.scripts]
35
+ pmctl = "pmctl.cli:app"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/wbinglee/pmctl"
39
+ Repository = "https://github.com/wbinglee/pmctl"
40
+ Issues = "https://github.com/wbinglee/pmctl/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/pmctl"]
44
+
45
+ [tool.ruff]
46
+ target-version = "py311"
47
+ line-length = 100
@@ -0,0 +1,3 @@
1
+ """pmctl - A CLI tool for managing Postman resources."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,74 @@
1
+ """Postman API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ BASE_URL = "https://api.getpostman.com"
10
+
11
+
12
+ class PostmanClient:
13
+ """HTTP client for the Postman API."""
14
+
15
+ def __init__(self, api_key: str):
16
+ self.api_key = api_key
17
+ self._client = httpx.Client(
18
+ base_url=BASE_URL,
19
+ headers={"X-Api-Key": api_key},
20
+ timeout=30.0,
21
+ )
22
+
23
+ def _get(self, path: str, params: Optional[dict] = None) -> dict[str, Any]:
24
+ """Make a GET request to the Postman API."""
25
+ response = self._client.get(path, params=params)
26
+ response.raise_for_status()
27
+ return response.json()
28
+
29
+ def close(self) -> None:
30
+ self._client.close()
31
+
32
+ def __enter__(self) -> "PostmanClient":
33
+ return self
34
+
35
+ def __exit__(self, *args: Any) -> None:
36
+ self.close()
37
+
38
+ # --- User ---
39
+
40
+ def get_me(self) -> dict[str, Any]:
41
+ """Get current user info."""
42
+ return self._get("/me")
43
+
44
+ # --- Workspaces ---
45
+
46
+ def list_workspaces(self) -> list[dict[str, Any]]:
47
+ """List all accessible workspaces."""
48
+ return self._get("/workspaces").get("workspaces", [])
49
+
50
+ def get_workspace(self, workspace_id: str) -> dict[str, Any]:
51
+ """Get workspace details."""
52
+ return self._get(f"/workspaces/{workspace_id}").get("workspace", {})
53
+
54
+ # --- Collections ---
55
+
56
+ def list_collections(self, workspace_id: Optional[str] = None) -> list[dict[str, Any]]:
57
+ """List collections, optionally filtered by workspace."""
58
+ params = {"workspace": workspace_id} if workspace_id else None
59
+ return self._get("/collections", params=params).get("collections", [])
60
+
61
+ def get_collection(self, collection_uid: str) -> dict[str, Any]:
62
+ """Get full collection details including all requests."""
63
+ return self._get(f"/collections/{collection_uid}").get("collection", {})
64
+
65
+ # --- Environments ---
66
+
67
+ def list_environments(self, workspace_id: Optional[str] = None) -> list[dict[str, Any]]:
68
+ """List environments, optionally filtered by workspace."""
69
+ params = {"workspace": workspace_id} if workspace_id else None
70
+ return self._get("/environments", params=params).get("environments", [])
71
+
72
+ def get_environment(self, environment_id: str) -> dict[str, Any]:
73
+ """Get environment details including variables."""
74
+ return self._get(f"/environments/{environment_id}").get("environment", {})
@@ -0,0 +1,441 @@
1
+ """pmctl - CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.tree import Tree
11
+
12
+ from pmctl.api import PostmanClient
13
+ from pmctl.config import (
14
+ add_profile,
15
+ load_config,
16
+ remove_profile,
17
+ set_default_profile,
18
+ set_profile_workspace,
19
+ )
20
+
21
+ app = typer.Typer(
22
+ name="pmctl",
23
+ help="A CLI tool for managing Postman collections, environments, and workspaces.",
24
+ no_args_is_help=True,
25
+ )
26
+ console = Console()
27
+
28
+ # --- Profile subcommand ---
29
+
30
+ profile_app = typer.Typer(help="Manage Postman API key profiles.", no_args_is_help=True)
31
+ app.add_typer(profile_app, name="profile")
32
+
33
+
34
+ @profile_app.command("list")
35
+ def profile_list():
36
+ """List all configured profiles."""
37
+ config = load_config()
38
+ table = Table(title="Profiles")
39
+ table.add_column("Name", style="cyan")
40
+ table.add_column("Label", style="dim")
41
+ table.add_column("Default", style="green")
42
+ table.add_column("Workspace", style="dim")
43
+ table.add_column("API Key", style="dim")
44
+
45
+ for prof_name, profile in config.profiles.items():
46
+ is_default = "✓" if prof_name == config.default_profile else ""
47
+ masked_key = profile.api_key[:12] + "..." + profile.api_key[-4:]
48
+ ws = profile.workspace[:12] + "..." if len(profile.workspace) > 12 else profile.workspace
49
+ table.add_row(prof_name, profile.label, is_default, ws, masked_key)
50
+
51
+ console.print(table)
52
+
53
+
54
+ @profile_app.command("add")
55
+ def profile_add(
56
+ name: str = typer.Argument(help="Profile name"),
57
+ api_key: str = typer.Option(..., "--api-key", "-k", help="Postman API key"),
58
+ label: str = typer.Option("", "--label", "-l", help="Description label"),
59
+ default: bool = typer.Option(False, "--default", "-d", help="Set as default profile"),
60
+ ):
61
+ """Add a new profile."""
62
+ config = add_profile(name, api_key, label, set_default=default)
63
+ console.print(f"[green]✓[/] Profile '{name}' added.")
64
+ if name == config.default_profile:
65
+ console.print(f"[green]✓[/] Set as default profile.")
66
+
67
+
68
+ @profile_app.command("remove")
69
+ def profile_remove(name: str = typer.Argument(help="Profile name to remove")):
70
+ """Remove a profile."""
71
+ remove_profile(name)
72
+ console.print(f"[green]✓[/] Profile '{name}' removed.")
73
+
74
+
75
+ @profile_app.command("switch")
76
+ def profile_switch(name: str = typer.Argument(help="Profile name to set as default")):
77
+ """Switch the default profile."""
78
+ set_default_profile(name)
79
+ console.print(f"[green]✓[/] Default profile switched to '{name}'.")
80
+
81
+
82
+ @profile_app.command("set-workspace")
83
+ def profile_set_workspace(
84
+ workspace_id: str = typer.Argument(help="Default workspace ID for this profile"),
85
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to update (default: current default)"),
86
+ ):
87
+ """Set the default workspace for a profile."""
88
+ config = set_profile_workspace(profile or "", workspace_id)
89
+ name = profile or config.default_profile
90
+ console.print(f"[green]✓[/] Default workspace for '{name}' set to '{workspace_id}'.")
91
+
92
+
93
+ @profile_app.command("whoami")
94
+ def profile_whoami(
95
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
96
+ ):
97
+ """Show current user info for the active profile."""
98
+ config = load_config()
99
+ p = config.get_profile(profile)
100
+ with PostmanClient(p.api_key) as client:
101
+ user = client.get_me().get("user", {})
102
+ console.print(f"[cyan]Email:[/] {user.get('email', 'N/A')}")
103
+ console.print(f"[cyan]Name:[/] {user.get('fullName', 'N/A')}")
104
+ console.print(f"[cyan]Team:[/] {user.get('teamName', 'N/A')}")
105
+ console.print(f"[cyan]Domain:[/] {user.get('teamDomain', 'N/A')}")
106
+
107
+
108
+ # --- Workspaces subcommand ---
109
+
110
+ workspace_app = typer.Typer(help="Manage Postman workspaces.", no_args_is_help=True)
111
+ app.add_typer(workspace_app, name="workspaces")
112
+
113
+
114
+ @workspace_app.command("list")
115
+ def workspaces_list(
116
+ search: Optional[str] = typer.Option(None, "--search", "-s", help="Filter workspaces by name (case-insensitive)"),
117
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
118
+ ):
119
+ """List all accessible workspaces."""
120
+ config = load_config()
121
+ p = config.get_profile(profile)
122
+ with PostmanClient(p.api_key) as client:
123
+ workspaces = client.list_workspaces()
124
+
125
+ if search:
126
+ keyword = search.lower()
127
+ workspaces = [ws for ws in workspaces if keyword in ws["name"].lower()]
128
+
129
+ table = Table(title=f"Workspaces ({p.label or p.name})")
130
+ table.add_column("Name", style="cyan")
131
+ table.add_column("ID", style="dim")
132
+ table.add_column("Type", style="green")
133
+
134
+ for ws in sorted(workspaces, key=lambda w: w["name"].lower()):
135
+ table.add_row(ws["name"], ws["id"], ws.get("type", ""))
136
+
137
+ console.print(table)
138
+ console.print(f"\n[dim]Total: {len(workspaces)} workspaces[/]")
139
+
140
+
141
+ # --- Collections subcommand ---
142
+
143
+ collection_app = typer.Typer(help="Manage Postman collections.", no_args_is_help=True)
144
+ app.add_typer(collection_app, name="collections")
145
+
146
+
147
+ @collection_app.command("list")
148
+ def collections_list(
149
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Filter by workspace ID"),
150
+ all_workspaces: bool = typer.Option(False, "--all", "-a", help="Show collections from all workspaces"),
151
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
152
+ ):
153
+ """List collections."""
154
+ config = load_config()
155
+ p = config.get_profile(profile)
156
+ effective_workspace = workspace or (None if all_workspaces else p.workspace or None)
157
+ with PostmanClient(p.api_key) as client:
158
+ collections = client.list_collections(workspace_id=effective_workspace)
159
+
160
+ table = Table(title=f"Collections ({p.label or p.name})")
161
+ table.add_column("Name", style="cyan")
162
+ table.add_column("UID", style="dim")
163
+ table.add_column("Updated", style="dim")
164
+
165
+ for col in sorted(collections, key=lambda c: c["name"].lower()):
166
+ updated = col.get("updatedAt", "")[:10]
167
+ table.add_row(col["name"], col["uid"], updated)
168
+
169
+ console.print(table)
170
+ console.print(f"\n[dim]Total: {len(collections)} collections[/]")
171
+
172
+
173
+ @collection_app.command("show")
174
+ def collections_show(
175
+ uid: str = typer.Argument(help="Collection UID"),
176
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
177
+ ):
178
+ """Show all requests in a collection as a tree."""
179
+ config = load_config()
180
+ p = config.get_profile(profile)
181
+ with PostmanClient(p.api_key) as client:
182
+ collection = client.get_collection(uid)
183
+
184
+ tree = Tree(f"[bold cyan]{collection.get('info', {}).get('name', 'Collection')}[/]")
185
+ _build_tree(tree, collection.get("item", []))
186
+ console.print(tree)
187
+
188
+
189
+ def _build_tree(tree: Tree, items: list) -> None:
190
+ """Recursively build a Rich tree from collection items."""
191
+ for item in items:
192
+ if "item" in item:
193
+ # It's a folder
194
+ branch = tree.add(f"📁 [bold]{item['name']}[/]")
195
+ _build_tree(branch, item["item"])
196
+ else:
197
+ # It's a request
198
+ req = item.get("request", {})
199
+ method = req.get("method", "?")
200
+ url = req.get("url", {})
201
+ raw_url = url.get("raw", url) if isinstance(url, dict) else url
202
+
203
+ method_colors = {
204
+ "GET": "green",
205
+ "POST": "yellow",
206
+ "PUT": "blue",
207
+ "PATCH": "magenta",
208
+ "DELETE": "red",
209
+ }
210
+ color = method_colors.get(method, "white")
211
+ tree.add(f"[bold {color}]{method:7s}[/] {item['name']} [dim]{raw_url}[/]")
212
+
213
+
214
+ @collection_app.command("request")
215
+ def collections_request(
216
+ uid: str = typer.Argument(help="Collection UID"),
217
+ name: str = typer.Argument(help="Request name (case-insensitive substring match)"),
218
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
219
+ ):
220
+ """Show details of a specific request in a collection."""
221
+ config = load_config()
222
+ p = config.get_profile(profile)
223
+ with PostmanClient(p.api_key) as client:
224
+ collection = client.get_collection(uid)
225
+
226
+ matches = _find_requests(collection.get("item", []), name)
227
+ if not matches:
228
+ console.print(f"[red]No request matching '{name}' found.[/]")
229
+ raise typer.Exit(1)
230
+ if len(matches) > 1:
231
+ console.print(f"[yellow]Multiple matches found ({len(matches)}). Showing first match.[/]")
232
+ for i, (path, _) in enumerate(matches):
233
+ console.print(f" [dim]{i + 1}. {path}[/]")
234
+ console.print()
235
+
236
+ path, item = matches[0]
237
+ req = item.get("request", {})
238
+ method = req.get("method", "?")
239
+ url = req.get("url", {})
240
+ raw_url = url.get("raw", url) if isinstance(url, dict) else url
241
+
242
+ method_colors = {
243
+ "GET": "green", "POST": "yellow", "PUT": "blue",
244
+ "PATCH": "magenta", "DELETE": "red",
245
+ }
246
+ color = method_colors.get(method, "white")
247
+
248
+ console.print(f"[bold]{path}[/]\n")
249
+ console.print(f"[bold {color}]{method}[/] {raw_url}\n")
250
+
251
+ # Auth
252
+ auth = req.get("auth")
253
+ if auth:
254
+ auth_type = auth.get("type", "unknown")
255
+ console.print(f"[cyan]Auth:[/] {auth_type}")
256
+
257
+ # Headers
258
+ headers = req.get("header", [])
259
+ if headers:
260
+ table = Table(title="Headers", show_edge=False)
261
+ table.add_column("Key", style="cyan")
262
+ table.add_column("Value")
263
+ table.add_column("Enabled", style="dim")
264
+ for h in headers:
265
+ enabled = "✗" if h.get("disabled") else "✓"
266
+ table.add_row(h.get("key", ""), h.get("value", ""), enabled)
267
+ console.print(table)
268
+
269
+ # Query params
270
+ if isinstance(url, dict):
271
+ query = url.get("query", [])
272
+ if query:
273
+ table = Table(title="Query Params", show_edge=False)
274
+ table.add_column("Key", style="cyan")
275
+ table.add_column("Value")
276
+ table.add_column("Enabled", style="dim")
277
+ for q in query:
278
+ enabled = "✗" if q.get("disabled") else "✓"
279
+ table.add_row(q.get("key", ""), q.get("value", ""), enabled)
280
+ console.print(table)
281
+
282
+ # Path variables
283
+ variables = url.get("variable", [])
284
+ if variables:
285
+ table = Table(title="Path Variables", show_edge=False)
286
+ table.add_column("Key", style="cyan")
287
+ table.add_column("Value")
288
+ for v in variables:
289
+ table.add_row(v.get("key", ""), v.get("value", ""))
290
+ console.print(table)
291
+
292
+ # Body
293
+ body = req.get("body")
294
+ if body:
295
+ mode = body.get("mode", "")
296
+ console.print(f"\n[cyan]Body[/] [dim]({mode})[/]")
297
+ if mode == "raw":
298
+ raw = body.get("raw", "")
299
+ if raw:
300
+ console.print(raw)
301
+ elif mode == "formdata":
302
+ table = Table(show_edge=False)
303
+ table.add_column("Key", style="cyan")
304
+ table.add_column("Value")
305
+ table.add_column("Type", style="dim")
306
+ for fd in body.get("formdata", []):
307
+ table.add_row(fd.get("key", ""), fd.get("value", ""), fd.get("type", "text"))
308
+ console.print(table)
309
+ elif mode == "urlencoded":
310
+ table = Table(show_edge=False)
311
+ table.add_column("Key", style="cyan")
312
+ table.add_column("Value")
313
+ for ue in body.get("urlencoded", []):
314
+ table.add_row(ue.get("key", ""), ue.get("value", ""))
315
+ console.print(table)
316
+
317
+
318
+ def _find_requests(items: list, name: str, prefix: str = "") -> list[tuple[str, dict]]:
319
+ """Recursively find requests matching a name (case-insensitive substring)."""
320
+ matches = []
321
+ keyword = name.lower()
322
+ for item in items:
323
+ path = f"{prefix}/{item['name']}" if prefix else item["name"]
324
+ if "item" in item:
325
+ matches.extend(_find_requests(item["item"], name, path))
326
+ elif keyword in item["name"].lower():
327
+ matches.append((path, item))
328
+ return matches
329
+
330
+
331
+ # --- Environments subcommand ---
332
+
333
+ env_app = typer.Typer(help="Manage Postman environments.", no_args_is_help=True)
334
+ app.add_typer(env_app, name="environments")
335
+
336
+
337
+ @env_app.command("list")
338
+ def environments_list(
339
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Filter by workspace ID"),
340
+ all_workspaces: bool = typer.Option(False, "--all", "-a", help="Show environments from all workspaces"),
341
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
342
+ ):
343
+ """List environments."""
344
+ config = load_config()
345
+ p = config.get_profile(profile)
346
+ effective_workspace = workspace or (None if all_workspaces else p.workspace or None)
347
+ with PostmanClient(p.api_key) as client:
348
+ environments = client.list_environments(workspace_id=effective_workspace)
349
+
350
+ table = Table(title=f"Environments ({p.label or p.name})")
351
+ table.add_column("Name", style="cyan")
352
+ table.add_column("ID", style="dim")
353
+
354
+ for env in sorted(environments, key=lambda e: e["name"].lower()):
355
+ table.add_row(env["name"], env["id"])
356
+
357
+ console.print(table)
358
+ console.print(f"\n[dim]Total: {len(environments)} environments[/]")
359
+
360
+
361
+ @env_app.command("show")
362
+ def environments_show(
363
+ env_id: str = typer.Argument(help="Environment ID"),
364
+ show_values: bool = typer.Option(False, "--values", "-v", help="Show variable values"),
365
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to use"),
366
+ ):
367
+ """Show environment variables."""
368
+ config = load_config()
369
+ p = config.get_profile(profile)
370
+ with PostmanClient(p.api_key) as client:
371
+ env = client.get_environment(env_id)
372
+
373
+ console.print(f"[bold cyan]{env.get('name', 'Environment')}[/]\n")
374
+
375
+ table = Table()
376
+ table.add_column("Variable", style="cyan")
377
+ table.add_column("Type", style="dim")
378
+ if show_values:
379
+ table.add_column("Value")
380
+
381
+ for var in env.get("values", []):
382
+ row = [var["key"], var.get("type", "default")]
383
+ if show_values:
384
+ value = var.get("value", "")
385
+ # Mask sensitive-looking values
386
+ if any(k in var["key"].lower() for k in ("password", "secret", "token", "key")):
387
+ value = value[:4] + "****" if len(value) > 4 else "****"
388
+ row.append(value)
389
+ table.add_row(*row)
390
+
391
+ console.print(table)
392
+
393
+
394
+ # --- Completion subcommand ---
395
+
396
+ completion_app = typer.Typer(help="Generate shell completion scripts.", no_args_is_help=True)
397
+ app.add_typer(completion_app, name="completion")
398
+
399
+
400
+ def _print_completion_script(shell: str) -> None:
401
+ """Generate and print a shell completion script."""
402
+ from click.shell_completion import get_completion_class
403
+
404
+ click_app = typer.main.get_command(app)
405
+ comp_cls = get_completion_class(shell)
406
+ if comp_cls is None:
407
+ console.print(f"[red]Error:[/] Unsupported shell '{shell}'.")
408
+ raise typer.Exit(1)
409
+ comp = comp_cls(click_app, {}, "pmctl", "_PMCTL_COMPLETE")
410
+ typer.echo(comp.source())
411
+
412
+
413
+ @completion_app.command("bash")
414
+ def completion_bash():
415
+ """Generate bash completion script.
416
+
417
+ Usage: eval "$(pmctl completion bash)"
418
+ """
419
+ _print_completion_script("bash")
420
+
421
+
422
+ @completion_app.command("zsh")
423
+ def completion_zsh():
424
+ """Generate zsh completion script.
425
+
426
+ Usage: eval "$(pmctl completion zsh)"
427
+ """
428
+ _print_completion_script("zsh")
429
+
430
+
431
+ @completion_app.command("fish")
432
+ def completion_fish():
433
+ """Generate fish completion script.
434
+
435
+ Usage: pmctl completion fish > ~/.config/fish/completions/pmctl.fish
436
+ """
437
+ _print_completion_script("fish")
438
+
439
+
440
+ if __name__ == "__main__":
441
+ app()
@@ -0,0 +1,154 @@
1
+ """Configuration management for pmctl.
2
+
3
+ Manages multiple Postman API key profiles stored in ~/.config/pmctl/config.toml.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import tomllib
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ CONFIG_DIR = Path.home() / ".config" / "pmctl"
14
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
15
+
16
+
17
+ @dataclass
18
+ class Profile:
19
+ """A Postman API key profile."""
20
+
21
+ name: str
22
+ api_key: str
23
+ label: str = ""
24
+ workspace: str = ""
25
+
26
+
27
+ @dataclass
28
+ class Config:
29
+ """Application configuration."""
30
+
31
+ profiles: dict[str, Profile]
32
+ default_profile: str
33
+
34
+ def get_profile(self, name: Optional[str] = None) -> Profile:
35
+ """Get a profile by name, or the default profile."""
36
+ profile_name = name or self.default_profile
37
+ if profile_name not in self.profiles:
38
+ available = ", ".join(self.profiles.keys())
39
+ raise ValueError(
40
+ f"Profile '{profile_name}' not found. Available profiles: {available}"
41
+ )
42
+ return self.profiles[profile_name]
43
+
44
+
45
+ def _ensure_config_dir() -> None:
46
+ """Create config directory if it doesn't exist."""
47
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
48
+
49
+
50
+ def load_config() -> Config:
51
+ """Load configuration from TOML file."""
52
+ if not CONFIG_FILE.exists():
53
+ raise FileNotFoundError(
54
+ f"Config file not found at {CONFIG_FILE}\n"
55
+ f"Run 'pmctl profile add <name> --api-key <key>' to create one."
56
+ )
57
+
58
+ with open(CONFIG_FILE, "rb") as f:
59
+ data = tomllib.load(f)
60
+
61
+ profiles = {}
62
+ for name, profile_data in data.get("profiles", {}).items():
63
+ profiles[name] = Profile(
64
+ name=name,
65
+ api_key=profile_data["api_key"],
66
+ label=profile_data.get("label", ""),
67
+ workspace=profile_data.get("workspace", ""),
68
+ )
69
+
70
+ if not profiles:
71
+ raise ValueError("No profiles found in config file.")
72
+
73
+ default = data.get("default_profile", next(iter(profiles)))
74
+ return Config(profiles=profiles, default_profile=default)
75
+
76
+
77
+ def save_config(config: Config) -> None:
78
+ """Save configuration to TOML file."""
79
+ _ensure_config_dir()
80
+
81
+ lines = []
82
+ # Write top-level keys first (before any table sections)
83
+ lines.append(f'default_profile = "{config.default_profile}"')
84
+ lines.append("")
85
+
86
+ for name, profile in config.profiles.items():
87
+ lines.append(f"[profiles.{name}]")
88
+ lines.append(f'api_key = "{profile.api_key}"')
89
+ if profile.label:
90
+ lines.append(f'label = "{profile.label}"')
91
+ if profile.workspace:
92
+ lines.append(f'workspace = "{profile.workspace}"')
93
+ lines.append("")
94
+
95
+ CONFIG_FILE.write_text("\n".join(lines))
96
+
97
+
98
+ def add_profile(name: str, api_key: str, label: str = "", workspace: str = "", set_default: bool = False) -> Config:
99
+ """Add a new profile to the config."""
100
+ try:
101
+ config = load_config()
102
+ except FileNotFoundError:
103
+ config = Config(profiles={}, default_profile=name)
104
+
105
+ config.profiles[name] = Profile(name=name, api_key=api_key, label=label, workspace=workspace)
106
+
107
+ if set_default or len(config.profiles) == 1:
108
+ config.default_profile = name
109
+
110
+ save_config(config)
111
+ return config
112
+
113
+
114
+ def remove_profile(name: str) -> Config:
115
+ """Remove a profile from the config."""
116
+ config = load_config()
117
+
118
+ if name not in config.profiles:
119
+ raise ValueError(f"Profile '{name}' not found.")
120
+
121
+ del config.profiles[name]
122
+
123
+ if config.default_profile == name:
124
+ config.default_profile = next(iter(config.profiles), "")
125
+
126
+ save_config(config)
127
+ return config
128
+
129
+
130
+ def set_default_profile(name: str) -> Config:
131
+ """Set the default profile."""
132
+ config = load_config()
133
+
134
+ if name not in config.profiles:
135
+ available = ", ".join(config.profiles.keys())
136
+ raise ValueError(f"Profile '{name}' not found. Available: {available}")
137
+
138
+ config.default_profile = name
139
+ save_config(config)
140
+ return config
141
+
142
+
143
+ def set_profile_workspace(profile_name: str, workspace_id: str) -> Config:
144
+ """Set the default workspace for a profile."""
145
+ config = load_config()
146
+
147
+ name = profile_name or config.default_profile
148
+ if name not in config.profiles:
149
+ available = ", ".join(config.profiles.keys())
150
+ raise ValueError(f"Profile '{name}' not found. Available: {available}")
151
+
152
+ config.profiles[name].workspace = workspace_id
153
+ save_config(config)
154
+ return config
pmctl-0.1.0/uv.lock ADDED
@@ -0,0 +1,194 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "annotated-doc"
7
+ version = "0.0.4"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "anyio"
16
+ version = "4.12.1"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "idna" },
20
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
21
+ ]
22
+ sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
23
+ wheels = [
24
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
25
+ ]
26
+
27
+ [[package]]
28
+ name = "certifi"
29
+ version = "2026.1.4"
30
+ source = { registry = "https://pypi.org/simple" }
31
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
32
+ wheels = [
33
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
34
+ ]
35
+
36
+ [[package]]
37
+ name = "click"
38
+ version = "8.3.1"
39
+ source = { registry = "https://pypi.org/simple" }
40
+ dependencies = [
41
+ { name = "colorama", marker = "sys_platform == 'win32'" },
42
+ ]
43
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
44
+ wheels = [
45
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
46
+ ]
47
+
48
+ [[package]]
49
+ name = "colorama"
50
+ version = "0.4.6"
51
+ source = { registry = "https://pypi.org/simple" }
52
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "h11"
59
+ version = "0.16.0"
60
+ source = { registry = "https://pypi.org/simple" }
61
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
62
+ wheels = [
63
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
64
+ ]
65
+
66
+ [[package]]
67
+ name = "httpcore"
68
+ version = "1.0.9"
69
+ source = { registry = "https://pypi.org/simple" }
70
+ dependencies = [
71
+ { name = "certifi" },
72
+ { name = "h11" },
73
+ ]
74
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
75
+ wheels = [
76
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
77
+ ]
78
+
79
+ [[package]]
80
+ name = "httpx"
81
+ version = "0.28.1"
82
+ source = { registry = "https://pypi.org/simple" }
83
+ dependencies = [
84
+ { name = "anyio" },
85
+ { name = "certifi" },
86
+ { name = "httpcore" },
87
+ { name = "idna" },
88
+ ]
89
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
90
+ wheels = [
91
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
92
+ ]
93
+
94
+ [[package]]
95
+ name = "idna"
96
+ version = "3.11"
97
+ source = { registry = "https://pypi.org/simple" }
98
+ sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
99
+ wheels = [
100
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
101
+ ]
102
+
103
+ [[package]]
104
+ name = "markdown-it-py"
105
+ version = "4.0.0"
106
+ source = { registry = "https://pypi.org/simple" }
107
+ dependencies = [
108
+ { name = "mdurl" },
109
+ ]
110
+ sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
111
+ wheels = [
112
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
113
+ ]
114
+
115
+ [[package]]
116
+ name = "mdurl"
117
+ version = "0.1.2"
118
+ source = { registry = "https://pypi.org/simple" }
119
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
120
+ wheels = [
121
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
122
+ ]
123
+
124
+ [[package]]
125
+ name = "pmctl"
126
+ version = "0.1.0"
127
+ source = { editable = "." }
128
+ dependencies = [
129
+ { name = "httpx" },
130
+ { name = "rich" },
131
+ { name = "typer" },
132
+ ]
133
+
134
+ [package.metadata]
135
+ requires-dist = [
136
+ { name = "httpx", specifier = ">=0.25.0" },
137
+ { name = "rich", specifier = ">=13.0.0" },
138
+ { name = "typer", specifier = ">=0.9.0" },
139
+ ]
140
+
141
+ [[package]]
142
+ name = "pygments"
143
+ version = "2.19.2"
144
+ source = { registry = "https://pypi.org/simple" }
145
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
146
+ wheels = [
147
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
148
+ ]
149
+
150
+ [[package]]
151
+ name = "rich"
152
+ version = "14.3.2"
153
+ source = { registry = "https://pypi.org/simple" }
154
+ dependencies = [
155
+ { name = "markdown-it-py" },
156
+ { name = "pygments" },
157
+ ]
158
+ sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
159
+ wheels = [
160
+ { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
161
+ ]
162
+
163
+ [[package]]
164
+ name = "shellingham"
165
+ version = "1.5.4"
166
+ source = { registry = "https://pypi.org/simple" }
167
+ sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
168
+ wheels = [
169
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
170
+ ]
171
+
172
+ [[package]]
173
+ name = "typer"
174
+ version = "0.23.1"
175
+ source = { registry = "https://pypi.org/simple" }
176
+ dependencies = [
177
+ { name = "annotated-doc" },
178
+ { name = "click" },
179
+ { name = "rich" },
180
+ { name = "shellingham" },
181
+ ]
182
+ sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" }
183
+ wheels = [
184
+ { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" },
185
+ ]
186
+
187
+ [[package]]
188
+ name = "typing-extensions"
189
+ version = "4.15.0"
190
+ source = { registry = "https://pypi.org/simple" }
191
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
192
+ wheels = [
193
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
194
+ ]