trelloctl 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.
trelloctl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """trelloctl - A command-line interface for Trello."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("trelloctl")
trelloctl/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """Main CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from trelloctl import __version__
11
+ from trelloctl.client import TrelloClient
12
+ from trelloctl.config import Config
13
+ from trelloctl.output import OutputFormat, print_error
14
+ from trelloctl.resolver import Resolver
15
+
16
+
17
+ class Context:
18
+ """CLI context object holding shared state."""
19
+
20
+ def __init__(self) -> None:
21
+ self.profile = os.environ.get("TRELLOCTL_PROFILE", "default")
22
+ self.config = Config(self.profile)
23
+ self.client: TrelloClient | None = None
24
+ self._resolver: Resolver | None = None
25
+ self.format = OutputFormat.TABLE
26
+
27
+ def ensure_client(self) -> TrelloClient:
28
+ """Ensure we have an authenticated client."""
29
+ if self.client is None:
30
+ api_key = self.config.get_api_key()
31
+ token = self.config.get_token()
32
+
33
+ if not api_key or not token:
34
+ print_error("Not authenticated. Run 'trelloctl auth login' first.")
35
+ sys.exit(1)
36
+
37
+ self.client = TrelloClient(api_key, token)
38
+
39
+ return self.client
40
+
41
+ @property
42
+ def resolver(self) -> Resolver:
43
+ """Get the name resolver."""
44
+ if self._resolver is None:
45
+ self._resolver = Resolver(self.ensure_client())
46
+ return self._resolver
47
+
48
+
49
+ pass_context = click.make_pass_decorator(Context, ensure=True)
50
+
51
+
52
+ @click.group()
53
+ @click.version_option(version=__version__, prog_name="trelloctl")
54
+ @click.option(
55
+ "--format",
56
+ "-f",
57
+ type=click.Choice(["table", "json", "csv", "plain"]),
58
+ default="table",
59
+ help="Output format",
60
+ )
61
+ @click.option(
62
+ "--profile",
63
+ "-p",
64
+ envvar="TRELLOCTL_PROFILE",
65
+ default="default",
66
+ help="Configuration profile to use",
67
+ )
68
+ @pass_context
69
+ def main(ctx: Context, format: str, profile: str) -> None:
70
+ """trelloctl - Manage your Trello boards from the command line."""
71
+ ctx.format = OutputFormat(format)
72
+ ctx.profile = profile
73
+ ctx.config = Config(profile)
74
+
75
+
76
+ def _register_commands() -> None:
77
+ """Register command groups with the main CLI."""
78
+ from trelloctl.commands import auth, board, card
79
+ from trelloctl.commands import list as list_cmd
80
+
81
+ main.add_command(auth.auth)
82
+ main.add_command(board.board)
83
+ main.add_command(card.card)
84
+ main.add_command(list_cmd.list_)
85
+
86
+
87
+ _register_commands()
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
trelloctl/client.py ADDED
@@ -0,0 +1,179 @@
1
+ """Trello API client."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ BASE_URL = "https://api.trello.com/1"
8
+
9
+
10
+ class TrelloClient:
11
+ """HTTP client for Trello API."""
12
+
13
+ def __init__(self, api_key: str, token: str) -> None:
14
+ self.api_key = api_key
15
+ self.token = token
16
+ self._client = httpx.Client(timeout=30.0)
17
+
18
+ def _auth_params(self) -> dict[str, str]:
19
+ """Return authentication parameters."""
20
+ return {"key": self.api_key, "token": self.token}
21
+
22
+ def _request(
23
+ self,
24
+ method: str,
25
+ path: str,
26
+ params: dict | None = None,
27
+ json: dict | None = None,
28
+ ) -> Any:
29
+ """Make an authenticated request to Trello API."""
30
+ url = f"{BASE_URL}{path}"
31
+ all_params = self._auth_params()
32
+ if params:
33
+ all_params.update(params)
34
+
35
+ response = self._client.request(method, url, params=all_params, json=json)
36
+ response.raise_for_status()
37
+
38
+ if response.status_code == 204:
39
+ return None
40
+ return response.json()
41
+
42
+ def get(self, path: str, params: dict | None = None) -> Any:
43
+ """Make a GET request."""
44
+ return self._request("GET", path, params=params)
45
+
46
+ def post(
47
+ self, path: str, params: dict | None = None, json: dict | None = None
48
+ ) -> Any:
49
+ """Make a POST request."""
50
+ return self._request("POST", path, params=params, json=json)
51
+
52
+ def put(
53
+ self, path: str, params: dict | None = None, json: dict | None = None
54
+ ) -> Any:
55
+ """Make a PUT request."""
56
+ return self._request("PUT", path, params=params, json=json)
57
+
58
+ def delete(self, path: str, params: dict | None = None) -> Any:
59
+ """Make a DELETE request."""
60
+ return self._request("DELETE", path, params=params)
61
+
62
+ # Board methods
63
+ def get_boards(self, filter: str = "open") -> list[dict]:
64
+ """Get all boards for the authenticated user."""
65
+ return self.get("/members/me/boards", params={"filter": filter})
66
+
67
+ def get_board(self, board_id: str) -> dict:
68
+ """Get a board by ID."""
69
+ return self.get(f"/boards/{board_id}")
70
+
71
+ def create_board(self, name: str, desc: str = "") -> dict:
72
+ """Create a new board."""
73
+ return self.post("/boards", params={"name": name, "desc": desc})
74
+
75
+ def close_board(self, board_id: str, closed: bool = True) -> dict:
76
+ """Close or reopen a board."""
77
+ return self.put(f"/boards/{board_id}", params={"closed": str(closed).lower()})
78
+
79
+ def delete_board(self, board_id: str) -> None:
80
+ """Delete a board."""
81
+ self.delete(f"/boards/{board_id}")
82
+
83
+ def get_board_lists(self, board_id: str, filter: str = "open") -> list[dict]:
84
+ """Get all lists on a board."""
85
+ return self.get(f"/boards/{board_id}/lists", params={"filter": filter})
86
+
87
+ def get_board_labels(self, board_id: str) -> list[dict]:
88
+ """Get all labels on a board."""
89
+ return self.get(f"/boards/{board_id}/labels")
90
+
91
+ def get_board_members(self, board_id: str) -> list[dict]:
92
+ """Get all members of a board."""
93
+ return self.get(f"/boards/{board_id}/members")
94
+
95
+ # List methods
96
+ def get_list(self, list_id: str) -> dict:
97
+ """Get a list by ID."""
98
+ return self.get(f"/lists/{list_id}")
99
+
100
+ def create_list(self, board_id: str, name: str, pos: str = "bottom") -> dict:
101
+ """Create a new list on a board."""
102
+ return self.post(
103
+ "/lists", params={"idBoard": board_id, "name": name, "pos": pos}
104
+ )
105
+
106
+ def archive_list(self, list_id: str, closed: bool = True) -> dict:
107
+ """Archive or unarchive a list."""
108
+ return self.put(f"/lists/{list_id}", params={"closed": str(closed).lower()})
109
+
110
+ def get_list_cards(self, list_id: str) -> list[dict]:
111
+ """Get all cards in a list."""
112
+ return self.get(f"/lists/{list_id}/cards")
113
+
114
+ # Card methods
115
+ def get_card(self, card_id: str) -> dict:
116
+ """Get a card by ID."""
117
+ return self.get(f"/cards/{card_id}")
118
+
119
+ def create_card(
120
+ self,
121
+ list_id: str,
122
+ name: str,
123
+ desc: str = "",
124
+ pos: str = "bottom",
125
+ due: str | None = None,
126
+ labels: list[str] | None = None,
127
+ members: list[str] | None = None,
128
+ ) -> dict:
129
+ """Create a new card."""
130
+ params: dict[str, Any] = {
131
+ "idList": list_id,
132
+ "name": name,
133
+ "desc": desc,
134
+ "pos": pos,
135
+ }
136
+ if due:
137
+ params["due"] = due
138
+ if labels:
139
+ params["idLabels"] = ",".join(labels)
140
+ if members:
141
+ params["idMembers"] = ",".join(members)
142
+ return self.post("/cards", params=params)
143
+
144
+ def update_card(self, card_id: str, **kwargs: Any) -> dict:
145
+ """Update a card."""
146
+ return self.put(f"/cards/{card_id}", params=kwargs)
147
+
148
+ def move_card(self, card_id: str, list_id: str, pos: str = "bottom") -> dict:
149
+ """Move a card to a different list."""
150
+ return self.put(f"/cards/{card_id}", params={"idList": list_id, "pos": pos})
151
+
152
+ def archive_card(self, card_id: str, closed: bool = True) -> dict:
153
+ """Archive or unarchive a card."""
154
+ return self.put(f"/cards/{card_id}", params={"closed": str(closed).lower()})
155
+
156
+ def delete_card(self, card_id: str) -> None:
157
+ """Delete a card."""
158
+ self.delete(f"/cards/{card_id}")
159
+
160
+ def add_card_member(self, card_id: str, member_id: str) -> list[dict]:
161
+ """Add a member to a card."""
162
+ return self.post(f"/cards/{card_id}/idMembers", params={"value": member_id})
163
+
164
+ def remove_card_member(self, card_id: str, member_id: str) -> list[dict]:
165
+ """Remove a member from a card."""
166
+ return self.delete(f"/cards/{card_id}/idMembers/{member_id}")
167
+
168
+ def add_card_comment(self, card_id: str, text: str) -> dict:
169
+ """Add a comment to a card."""
170
+ return self.post(f"/cards/{card_id}/actions/comments", params={"text": text})
171
+
172
+ def get_card_comments(self, card_id: str) -> list[dict]:
173
+ """Get comments on a card."""
174
+ return self.get(f"/cards/{card_id}/actions", params={"filter": "commentCard"})
175
+
176
+ # Member methods
177
+ def get_me(self) -> dict:
178
+ """Get the authenticated user."""
179
+ return self.get("/members/me")
@@ -0,0 +1 @@
1
+ """Command modules for Trello CLI."""
@@ -0,0 +1,91 @@
1
+ """Authentication commands."""
2
+
3
+ import webbrowser
4
+
5
+ import click
6
+
7
+ from trelloctl.cli import Context, pass_context
8
+ from trelloctl.output import print_error, print_info, print_success
9
+
10
+
11
+ @click.group()
12
+ def auth() -> None:
13
+ """Authentication commands."""
14
+ pass
15
+
16
+
17
+ @auth.command("login")
18
+ @click.option("--api-key", prompt=False, help="Trello API key")
19
+ @click.option("--token", prompt=False, help="Trello token")
20
+ @pass_context
21
+ def login(ctx: Context, api_key: str | None, token: str | None) -> None:
22
+ """Set up authentication with Trello.
23
+
24
+ You can get your API key at: https://trello.com/app-key
25
+ """
26
+ if not api_key:
27
+ print_info("Get your API key at: https://trello.com/app-key")
28
+ if click.confirm("Open browser to get API key?", default=True):
29
+ webbrowser.open("https://trello.com/app-key")
30
+ api_key = click.prompt("Enter your API key")
31
+
32
+ ctx.config.set_api_key(api_key)
33
+ print_success("API key saved")
34
+
35
+ if not token:
36
+ token_url = (
37
+ f"https://trello.com/1/authorize?"
38
+ f"expiration=never&scope=read,write,account&"
39
+ f"response_type=token&name=trelloctl&key={api_key}"
40
+ )
41
+ print_info(f"Generate a token at: {token_url}")
42
+ if click.confirm("Open browser to generate token?", default=True):
43
+ webbrowser.open(token_url)
44
+ token = click.prompt("Enter your token")
45
+
46
+ ctx.config.set_token(token)
47
+ print_success("Token saved")
48
+
49
+ # Verify credentials
50
+ try:
51
+ client = ctx.ensure_client()
52
+ me = client.get_me()
53
+ print_success(f"Authenticated as: {me['fullName']} (@{me['username']})")
54
+ except Exception as e:
55
+ print_error(f"Authentication failed: {e}")
56
+
57
+
58
+ @auth.command("status")
59
+ @pass_context
60
+ def status(ctx: Context) -> None:
61
+ """Check authentication status."""
62
+ if not ctx.config.is_configured():
63
+ print_error("Not authenticated. Run 'trelloctl auth login' first.")
64
+ return
65
+
66
+ try:
67
+ client = ctx.ensure_client()
68
+ me = client.get_me()
69
+ print_success(f"Authenticated as: {me['fullName']} (@{me['username']})")
70
+ except Exception as e:
71
+ print_error(f"Authentication error: {e}")
72
+
73
+
74
+ @auth.command("logout")
75
+ @pass_context
76
+ def logout(ctx: Context) -> None:
77
+ """Remove stored credentials."""
78
+ import keyring
79
+ from trelloctl.config import SERVICE_NAME
80
+
81
+ try:
82
+ keyring.delete_password(SERVICE_NAME, f"{ctx.profile}:api_key")
83
+ except keyring.errors.PasswordDeleteError:
84
+ pass
85
+
86
+ try:
87
+ keyring.delete_password(SERVICE_NAME, f"{ctx.profile}:token")
88
+ except keyring.errors.PasswordDeleteError:
89
+ pass
90
+
91
+ print_success("Credentials removed")
@@ -0,0 +1,209 @@
1
+ """Board commands."""
2
+
3
+ import click
4
+
5
+ from trelloctl.cli import Context, pass_context
6
+ from trelloctl.output import format_output, print_error, print_success
7
+
8
+
9
+ @click.group()
10
+ def board() -> None:
11
+ """Board management commands."""
12
+ pass
13
+
14
+
15
+ @board.command("list")
16
+ @click.option(
17
+ "--filter",
18
+ "-f",
19
+ type=click.Choice(
20
+ ["all", "closed", "members", "open", "organization", "public", "starred"]
21
+ ),
22
+ default="open",
23
+ help="Filter boards",
24
+ )
25
+ @pass_context
26
+ def list_boards(ctx: Context, filter: str) -> None:
27
+ """List all accessible boards."""
28
+ client = ctx.ensure_client()
29
+ boards = client.get_boards(filter=filter)
30
+
31
+ data = [
32
+ {
33
+ "id": b["id"],
34
+ "name": b["name"],
35
+ "url": b["url"],
36
+ "closed": "Yes" if b.get("closed") else "No",
37
+ }
38
+ for b in boards
39
+ ]
40
+
41
+ format_output(
42
+ data,
43
+ ctx.format,
44
+ columns=[("name", "Name"), ("id", "ID"), ("closed", "Closed")],
45
+ title="Boards",
46
+ template="{name} ({id})",
47
+ )
48
+
49
+
50
+ @board.command("show")
51
+ @click.argument("board")
52
+ @pass_context
53
+ def show_board(ctx: Context, board: str) -> None:
54
+ """Show details of a board.
55
+
56
+ BOARD can be an ID or name (partial match supported).
57
+ """
58
+ client = ctx.ensure_client()
59
+
60
+ try:
61
+ board_id = ctx.resolver.resolve_board(board)
62
+ board_data = client.get_board(board_id)
63
+ except Exception as e:
64
+ print_error(str(e))
65
+ return
66
+
67
+ data = {
68
+ "id": board_data["id"],
69
+ "name": board_data["name"],
70
+ "description": board_data.get("desc", ""),
71
+ "url": board_data["url"],
72
+ "closed": "Yes" if board_data.get("closed") else "No",
73
+ }
74
+
75
+ format_output(
76
+ data,
77
+ ctx.format,
78
+ columns=[
79
+ ("name", "Name"),
80
+ ("id", "ID"),
81
+ ("description", "Description"),
82
+ ("url", "URL"),
83
+ ("closed", "Closed"),
84
+ ],
85
+ )
86
+
87
+
88
+ @board.command("create")
89
+ @click.option("--name", "-n", required=True, help="Board name")
90
+ @click.option("--description", "-d", default="", help="Board description")
91
+ @pass_context
92
+ def create_board(ctx: Context, name: str, description: str) -> None:
93
+ """Create a new board."""
94
+ client = ctx.ensure_client()
95
+
96
+ try:
97
+ board_data = client.create_board(name, description)
98
+ print_success(f"Created board: {board_data['name']} ({board_data['id']})")
99
+ except Exception as e:
100
+ print_error(f"Failed to create board: {e}")
101
+
102
+
103
+ @board.command("close")
104
+ @click.argument("board")
105
+ @click.option("--reopen", is_flag=True, help="Reopen instead of close")
106
+ @pass_context
107
+ def close_board(ctx: Context, board: str, reopen: bool) -> None:
108
+ """Close (archive) a board.
109
+
110
+ BOARD can be an ID or name (partial match supported).
111
+ """
112
+ client = ctx.ensure_client()
113
+
114
+ try:
115
+ board_id = ctx.resolver.resolve_board(board)
116
+ client.close_board(board_id, closed=not reopen)
117
+ action = "Reopened" if reopen else "Closed"
118
+ print_success(f"{action} board: {board}")
119
+ except Exception as e:
120
+ print_error(str(e))
121
+
122
+
123
+ @board.command("delete")
124
+ @click.argument("board")
125
+ @click.confirmation_option(prompt="Are you sure you want to delete this board?")
126
+ @pass_context
127
+ def delete_board(ctx: Context, board: str) -> None:
128
+ """Delete a board (permanently).
129
+
130
+ BOARD can be an ID or name (partial match supported).
131
+ """
132
+ client = ctx.ensure_client()
133
+
134
+ try:
135
+ board_id = ctx.resolver.resolve_board(board)
136
+ client.delete_board(board_id)
137
+ print_success(f"Deleted board: {board}")
138
+ except Exception as e:
139
+ print_error(str(e))
140
+
141
+
142
+ @board.command("labels")
143
+ @click.argument("board")
144
+ @pass_context
145
+ def board_labels(ctx: Context, board: str) -> None:
146
+ """List labels on a board.
147
+
148
+ BOARD can be an ID or name (partial match supported).
149
+ """
150
+ client = ctx.ensure_client()
151
+
152
+ try:
153
+ board_id = ctx.resolver.resolve_board(board)
154
+ labels = client.get_board_labels(board_id)
155
+ except Exception as e:
156
+ print_error(str(e))
157
+ return
158
+
159
+ data = [
160
+ {
161
+ "id": label["id"],
162
+ "name": label.get("name", ""),
163
+ "color": label.get("color", ""),
164
+ }
165
+ for label in labels
166
+ ]
167
+
168
+ format_output(
169
+ data,
170
+ ctx.format,
171
+ columns=[("name", "Name"), ("color", "Color"), ("id", "ID")],
172
+ title="Labels",
173
+ template="{name} ({color})",
174
+ )
175
+
176
+
177
+ @board.command("members")
178
+ @click.argument("board")
179
+ @pass_context
180
+ def board_members(ctx: Context, board: str) -> None:
181
+ """List members of a board.
182
+
183
+ BOARD can be an ID or name (partial match supported).
184
+ """
185
+ client = ctx.ensure_client()
186
+
187
+ try:
188
+ board_id = ctx.resolver.resolve_board(board)
189
+ members = client.get_board_members(board_id)
190
+ except Exception as e:
191
+ print_error(str(e))
192
+ return
193
+
194
+ data = [
195
+ {
196
+ "id": m["id"],
197
+ "username": m.get("username", ""),
198
+ "fullName": m.get("fullName", ""),
199
+ }
200
+ for m in members
201
+ ]
202
+
203
+ format_output(
204
+ data,
205
+ ctx.format,
206
+ columns=[("fullName", "Name"), ("username", "Username"), ("id", "ID")],
207
+ title="Members",
208
+ template="{fullName} (@{username})",
209
+ )