trello-axi 0.2.1__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,137 @@
1
+ Metadata-Version: 2.3
2
+ Name: trello-axi
3
+ Version: 0.2.1
4
+ Summary: Agent-native, token-efficient CLI for Trello
5
+ Requires-Dist: httpx>=0.28.1
6
+ Requires-Dist: pyyaml>=6.0.3
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
10
+ # trello-axi
11
+
12
+ Agent-native, token-efficient CLI for Trello. It calls the official Trello REST API directly and implements the [AXI](https://axi.md/) design principles: compact truthful output, bounded reads, exact resolution, idempotent mutations, structured failures, batching, and non-interactive operation.
13
+
14
+ ## Install
15
+
16
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
17
+
18
+ ```bash
19
+ uv tool install trello-axi
20
+ ```
21
+
22
+ For development:
23
+
24
+ ```bash
25
+ uv sync --locked --all-groups
26
+ uv run trello-axi --version
27
+ ```
28
+
29
+ ## Authentication
30
+
31
+ Prefer environment variables in managed environments:
32
+
33
+ ```bash
34
+ export TRELLO_API_KEY='...'
35
+ export TRELLO_TOKEN='...'
36
+ trello-axi auth status
37
+ ```
38
+
39
+ Or store them locally (the file is created with mode `0600`):
40
+
41
+ ```bash
42
+ trello-axi auth set --api-key '...' --token '...'
43
+ ```
44
+
45
+ Install the bundled skill and optional Claude Code/Codex SessionStart hooks:
46
+
47
+ ```bash
48
+ trello-axi setup skill
49
+ trello-axi setup hooks
50
+ ```
51
+
52
+ The hook installer preserves unmanaged hooks and updates only entries marked for `trello-axi`.
53
+
54
+ Default path: `${XDG_CONFIG_HOME:-~/.config}/trello-axi/config.json`. Environment variables take precedence. Never commit the token or pass credentials through agent prompts.
55
+
56
+ ## Agent-oriented workflows
57
+
58
+ No arguments returns live board data rather than help:
59
+
60
+ ```bash
61
+ trello-axi
62
+ trello-axi boards
63
+ trello-axi board Dream
64
+ trello-axi lists --board Dream
65
+ trello-axi cards --board Dream --list Backlog --limit 50
66
+ trello-axi search videoaula --board Dream
67
+ trello-axi card view CARD_ID # descriptions are bounded to 2,000 chars
68
+ trello-axi card view CARD_ID --full # explicitly request the complete description
69
+ ```
70
+
71
+ Mutations:
72
+
73
+ ```bash
74
+ trello-axi card create --board Dream --list Backlog --title 'Task' --description-file task.md
75
+ trello-axi card ensure --board Dream --list Backlog --title 'Task' --description-file task.md
76
+ trello-axi card update CARD_ID --title 'New title' --due 2026-06-01
77
+ trello-axi card move CARD_ID --board Dream --list Doing
78
+ trello-axi card comment CARD_ID --text 'Implementation started'
79
+ trello-axi card add-label CARD_ID --label-id LABEL_ID
80
+ trello-axi card add-checklist CARD_ID --name Acceptance --item 'Tests pass' --item 'Docs updated'
81
+ trello-axi card archive CARD_ID
82
+ ```
83
+
84
+ `ensure` is idempotent by exact case-insensitive title: it creates a missing card, moves/updates one match, leaves the desired state unchanged, and refuses ambiguous matches.
85
+
86
+ Batch creation accepts JSON or YAML:
87
+
88
+ ```yaml
89
+ cards:
90
+ - title: First task
91
+ description: Detailed scope
92
+ - title: Second task
93
+ due: '2026-06-30'
94
+ ```
95
+
96
+ ```bash
97
+ trello-axi card create-batch --board Dream --list Backlog --file cards.yaml --ensure
98
+ ```
99
+
100
+ ## Output contract
101
+
102
+ TOON-style output is the default and includes contextual next-command suggestions. JSON is available globally and uses a stable `{<resource>: ..., "help": [...]}` envelope:
103
+
104
+ ```bash
105
+ trello-axi --format json cards --board Dream --limit 10
106
+ ```
107
+
108
+ Unknown flags fail. Reads default to 50 records and are capped at 1000. Failures are written to stderr with stable exit codes:
109
+
110
+ | Code | Meaning |
111
+ |---:|---|
112
+ | 0 | Success / desired state already satisfied |
113
+ | 1 | Unexpected local failure |
114
+ | 2 | Invalid input or configuration |
115
+ | 3 | Authentication/permission failure |
116
+ | 4 | Resource not found |
117
+ | 5 | Ambiguous name; use an ID |
118
+ | 6 | Trello API/network failure |
119
+
120
+ ## Scope
121
+
122
+ The MVP supports boards and lists, card CRUD, search, moves, archive, comments, labels, checklists, idempotent ensure, and batch creation. It intentionally excludes Power-Ups, webhooks, OAuth multi-user applications, and destructive permanent deletion.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ uv sync --locked --all-groups
128
+ uv run ruff format --check .
129
+ uv run ruff check .
130
+ uv run ty check .
131
+ uv run pytest
132
+ uv build
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,128 @@
1
+ # trello-axi
2
+
3
+ Agent-native, token-efficient CLI for Trello. It calls the official Trello REST API directly and implements the [AXI](https://axi.md/) design principles: compact truthful output, bounded reads, exact resolution, idempotent mutations, structured failures, batching, and non-interactive operation.
4
+
5
+ ## Install
6
+
7
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
8
+
9
+ ```bash
10
+ uv tool install trello-axi
11
+ ```
12
+
13
+ For development:
14
+
15
+ ```bash
16
+ uv sync --locked --all-groups
17
+ uv run trello-axi --version
18
+ ```
19
+
20
+ ## Authentication
21
+
22
+ Prefer environment variables in managed environments:
23
+
24
+ ```bash
25
+ export TRELLO_API_KEY='...'
26
+ export TRELLO_TOKEN='...'
27
+ trello-axi auth status
28
+ ```
29
+
30
+ Or store them locally (the file is created with mode `0600`):
31
+
32
+ ```bash
33
+ trello-axi auth set --api-key '...' --token '...'
34
+ ```
35
+
36
+ Install the bundled skill and optional Claude Code/Codex SessionStart hooks:
37
+
38
+ ```bash
39
+ trello-axi setup skill
40
+ trello-axi setup hooks
41
+ ```
42
+
43
+ The hook installer preserves unmanaged hooks and updates only entries marked for `trello-axi`.
44
+
45
+ Default path: `${XDG_CONFIG_HOME:-~/.config}/trello-axi/config.json`. Environment variables take precedence. Never commit the token or pass credentials through agent prompts.
46
+
47
+ ## Agent-oriented workflows
48
+
49
+ No arguments returns live board data rather than help:
50
+
51
+ ```bash
52
+ trello-axi
53
+ trello-axi boards
54
+ trello-axi board Dream
55
+ trello-axi lists --board Dream
56
+ trello-axi cards --board Dream --list Backlog --limit 50
57
+ trello-axi search videoaula --board Dream
58
+ trello-axi card view CARD_ID # descriptions are bounded to 2,000 chars
59
+ trello-axi card view CARD_ID --full # explicitly request the complete description
60
+ ```
61
+
62
+ Mutations:
63
+
64
+ ```bash
65
+ trello-axi card create --board Dream --list Backlog --title 'Task' --description-file task.md
66
+ trello-axi card ensure --board Dream --list Backlog --title 'Task' --description-file task.md
67
+ trello-axi card update CARD_ID --title 'New title' --due 2026-06-01
68
+ trello-axi card move CARD_ID --board Dream --list Doing
69
+ trello-axi card comment CARD_ID --text 'Implementation started'
70
+ trello-axi card add-label CARD_ID --label-id LABEL_ID
71
+ trello-axi card add-checklist CARD_ID --name Acceptance --item 'Tests pass' --item 'Docs updated'
72
+ trello-axi card archive CARD_ID
73
+ ```
74
+
75
+ `ensure` is idempotent by exact case-insensitive title: it creates a missing card, moves/updates one match, leaves the desired state unchanged, and refuses ambiguous matches.
76
+
77
+ Batch creation accepts JSON or YAML:
78
+
79
+ ```yaml
80
+ cards:
81
+ - title: First task
82
+ description: Detailed scope
83
+ - title: Second task
84
+ due: '2026-06-30'
85
+ ```
86
+
87
+ ```bash
88
+ trello-axi card create-batch --board Dream --list Backlog --file cards.yaml --ensure
89
+ ```
90
+
91
+ ## Output contract
92
+
93
+ TOON-style output is the default and includes contextual next-command suggestions. JSON is available globally and uses a stable `{<resource>: ..., "help": [...]}` envelope:
94
+
95
+ ```bash
96
+ trello-axi --format json cards --board Dream --limit 10
97
+ ```
98
+
99
+ Unknown flags fail. Reads default to 50 records and are capped at 1000. Failures are written to stderr with stable exit codes:
100
+
101
+ | Code | Meaning |
102
+ |---:|---|
103
+ | 0 | Success / desired state already satisfied |
104
+ | 1 | Unexpected local failure |
105
+ | 2 | Invalid input or configuration |
106
+ | 3 | Authentication/permission failure |
107
+ | 4 | Resource not found |
108
+ | 5 | Ambiguous name; use an ID |
109
+ | 6 | Trello API/network failure |
110
+
111
+ ## Scope
112
+
113
+ The MVP supports boards and lists, card CRUD, search, moves, archive, comments, labels, checklists, idempotent ensure, and batch creation. It intentionally excludes Power-Ups, webhooks, OAuth multi-user applications, and destructive permanent deletion.
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ uv sync --locked --all-groups
119
+ uv run ruff format --check .
120
+ uv run ruff check .
121
+ uv run ty check .
122
+ uv run pytest
123
+ uv build
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8,<1"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "trello-axi"
7
+ version = "0.2.1"
8
+ description = "Agent-native, token-efficient CLI for Trello"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "httpx>=0.28.1",
13
+ "pyyaml>=6.0.3",
14
+ ]
15
+
16
+ [project.scripts]
17
+ trello-axi = "trello_axi.cli:main"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8",
22
+ "pytest-httpx>=0.36.2",
23
+ "ruff>=0.12",
24
+ "ty>=0.0.1",
25
+ ]
26
+
27
+ [tool.pytest.ini_options]
28
+ addopts = ["-ra", "--strict-config", "--strict-markers"]
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff]
32
+ target-version = "py313"
33
+ line-length = 100
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B", "SIM"]
37
+
38
+ [tool.ty.environment]
39
+ python-version = "3.13"
40
+
41
+ [tool.ty.src]
42
+ include = ["src", "tests"]
File without changes
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,371 @@
1
+ """Argument parsing and orchestration for trello-axi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from .client import TrelloClient
14
+ from .config import load_credentials, save_credentials
15
+ from .errors import TrelloAxiError
16
+ from .models import board as normalize_board
17
+ from .models import card as normalize_card
18
+ from .models import trello_list as normalize_list
19
+ from .output import emit, emit_error
20
+ from .setup import install_hooks, install_skill
21
+
22
+
23
+ def _parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog="trello-axi", description="Agent-native Trello CLI")
25
+ parser.add_argument(
26
+ "--format", choices=("toon", "json"), default="toon", help="output format (default: toon)"
27
+ )
28
+ parser.add_argument("--version", action="version", version="trello-axi 0.2.1")
29
+ commands = parser.add_subparsers(dest="command")
30
+
31
+ auth = commands.add_parser("auth", help="manage and verify credentials")
32
+ auth_commands = auth.add_subparsers(dest="auth_command", required=True)
33
+ auth_set = auth_commands.add_parser("set", help="store credentials in a mode-0600 file")
34
+ auth_set.add_argument("--api-key", required=True)
35
+ auth_set.add_argument("--token", required=True)
36
+ auth_commands.add_parser("status", help="verify credentials against Trello")
37
+
38
+ setup = commands.add_parser("setup", help="install agent integration")
39
+ setup_commands = setup.add_subparsers(dest="setup_command", required=True)
40
+ setup_commands.add_parser("skill", help="install the bundled agent skill")
41
+ hooks = setup_commands.add_parser("hooks", help="install Claude/Codex SessionStart hooks")
42
+ hooks.add_argument(
43
+ "--command", dest="hook_command", help="absolute trello-axi executable used by hooks"
44
+ )
45
+
46
+ boards = commands.add_parser("boards", help="list visible boards")
47
+ boards.add_argument("--all", action="store_true", help="include closed boards")
48
+
49
+ board_cmd = commands.add_parser("board", help="show a board summary")
50
+ board_cmd.add_argument("board")
51
+
52
+ lists = commands.add_parser("lists", help="list board lists")
53
+ lists.add_argument("--board", required=True)
54
+ lists.add_argument("--all", action="store_true")
55
+
56
+ cards = commands.add_parser("cards", help="list cards")
57
+ cards.add_argument("--board", required=True)
58
+ cards.add_argument("--list")
59
+ cards.add_argument("--limit", type=_positive_int, default=50)
60
+ cards.add_argument("--full", action="store_true")
61
+
62
+ search = commands.add_parser("search", help="search cards")
63
+ search.add_argument("query")
64
+ search.add_argument("--board")
65
+ search.add_argument("--limit", type=_positive_int, default=50)
66
+ search.add_argument("--full", action="store_true")
67
+
68
+ card = commands.add_parser("card", help="inspect or mutate cards")
69
+ card_commands = card.add_subparsers(dest="card_command", required=True)
70
+ view = card_commands.add_parser("view")
71
+ view.add_argument("card")
72
+ view.add_argument("--board")
73
+ view.add_argument("--full", action="store_true", help="do not truncate the description")
74
+ create = card_commands.add_parser("create")
75
+ _create_args(create)
76
+ ensure = card_commands.add_parser("ensure", help="create, move, or update by unique title")
77
+ _create_args(ensure)
78
+ update = card_commands.add_parser("update")
79
+ update.add_argument("card")
80
+ update.add_argument("--title")
81
+ update.add_argument("--description")
82
+ update.add_argument("--description-file", type=Path)
83
+ update.add_argument("--due")
84
+ update.add_argument("--due-complete", choices=("true", "false"))
85
+ move = card_commands.add_parser("move")
86
+ move.add_argument("card")
87
+ move.add_argument("--board", required=True)
88
+ move.add_argument("--list", required=True)
89
+ archive = card_commands.add_parser("archive")
90
+ archive.add_argument("card")
91
+ comment = card_commands.add_parser("comment")
92
+ comment.add_argument("card")
93
+ comment.add_argument("--text", required=True)
94
+ label = card_commands.add_parser("add-label")
95
+ label.add_argument("card")
96
+ label.add_argument("--label-id", required=True)
97
+ checklist = card_commands.add_parser("add-checklist")
98
+ checklist.add_argument("card")
99
+ checklist.add_argument("--name", required=True)
100
+ checklist.add_argument("--item", action="append", default=[])
101
+ batch = card_commands.add_parser("create-batch", help="create cards from JSON/YAML")
102
+ batch.add_argument("--board", required=True)
103
+ batch.add_argument("--list", required=True)
104
+ batch.add_argument("--file", required=True, type=Path)
105
+ batch.add_argument(
106
+ "--ensure",
107
+ action="store_true",
108
+ help="idempotently ensure titles instead of always creating",
109
+ )
110
+ return parser
111
+
112
+
113
+ def _create_args(parser: argparse.ArgumentParser) -> None:
114
+ parser.add_argument("--board", required=True)
115
+ parser.add_argument("--list", required=True)
116
+ parser.add_argument("--title", required=True)
117
+ parser.add_argument("--description", default="")
118
+ parser.add_argument("--description-file", type=Path)
119
+ parser.add_argument("--due")
120
+ parser.add_argument("--label-id", action="append", default=[])
121
+
122
+
123
+ def _positive_int(value: str) -> int:
124
+ number = int(value)
125
+ if number < 1 or number > 1000:
126
+ raise argparse.ArgumentTypeError("must be between 1 and 1000")
127
+ return number
128
+
129
+
130
+ def _description(args: argparse.Namespace) -> str:
131
+ if getattr(args, "description_file", None):
132
+ if getattr(args, "description", ""):
133
+ raise ValueError("use only one of --description and --description-file")
134
+ return args.description_file.read_text()
135
+ return getattr(args, "description", "") or ""
136
+
137
+
138
+ def _load_batch(path: Path) -> list[dict[str, Any]]:
139
+ try:
140
+ raw = yaml.safe_load(path.read_text())
141
+ except (OSError, yaml.YAMLError) as exc:
142
+ raise ValueError(f"cannot load batch file {path}: {exc}") from exc
143
+ if isinstance(raw, dict):
144
+ raw = raw.get("cards")
145
+ if not isinstance(raw, list) or not all(isinstance(item, dict) for item in raw):
146
+ raise ValueError("batch file must be a list, or an object containing a `cards` list")
147
+ for index, item in enumerate(raw, 1):
148
+ if not str(item.get("title", "")).strip():
149
+ raise ValueError(f"batch card {index} is missing a title")
150
+ label_ids = item.get("label_ids", [])
151
+ if not isinstance(label_ids, list) or not all(
152
+ isinstance(label_id, str) and label_id.strip() for label_id in label_ids
153
+ ):
154
+ raise ValueError(f"batch card {index} label_ids must be a list of strings")
155
+ due = item.get("due")
156
+ if due is not None and not isinstance(due, str):
157
+ raise ValueError(f"batch card {index} due must be a string")
158
+ return raw
159
+
160
+
161
+ def run(args: argparse.Namespace) -> tuple[Any, str]:
162
+ if args.command == "auth" and args.auth_command == "set":
163
+ path = save_credentials(args.api_key, args.token)
164
+ return {"configured": True, "path": str(path), "permissions": "0600"}, "auth"
165
+ if args.command == "setup":
166
+ if args.setup_command == "skill":
167
+ path = install_skill()
168
+ return {"installed": True, "skill": str(path)}, "setup"
169
+ paths = install_hooks(command=args.hook_command)
170
+ return {"installed": True, "hooks": [str(path) for path in paths]}, "setup"
171
+ credentials = load_credentials()
172
+ with TrelloClient(credentials) as client:
173
+ if args.command == "auth":
174
+ member = client.member()
175
+ return {
176
+ "authenticated": True,
177
+ "username": member.get("username"),
178
+ "name": member.get("fullName"),
179
+ }, "auth"
180
+ if args.command in {None, "boards"}:
181
+ return [
182
+ normalize_board(item)
183
+ for item in client.boards(include_closed=getattr(args, "all", False))
184
+ ], "boards"
185
+ if args.command == "board":
186
+ selected = client.resolve_board(args.board)
187
+ lists = client.lists(args.board)
188
+ cards = client.all_cards(args.board)
189
+ counts = {item["id"]: 0 for item in lists}
190
+ for item in cards:
191
+ counts[item["idList"]] = counts.get(item["idList"], 0) + 1
192
+ return {
193
+ **normalize_board(selected),
194
+ "list_count": len(lists),
195
+ "open_card_count": len(cards),
196
+ "lists": [
197
+ {"id": item["id"], "name": item["name"], "cards": counts[item["id"]]}
198
+ for item in lists
199
+ ],
200
+ }, "board"
201
+ if args.command == "lists":
202
+ return [
203
+ normalize_list(item) for item in client.lists(args.board, include_closed=args.all)
204
+ ], "lists"
205
+ if args.command == "cards":
206
+ return [
207
+ normalize_card(item, full=args.full)
208
+ for item in client.cards(args.board, list_value=args.list, limit=args.limit)
209
+ ], "cards"
210
+ if args.command == "search":
211
+ return [
212
+ normalize_card(item, full=args.full)
213
+ for item in client.search(args.query, board=args.board, limit=args.limit)
214
+ ], "cards"
215
+ if args.command == "card":
216
+ return _run_card(client, args)
217
+ raise ValueError("unknown command")
218
+
219
+
220
+ def _run_card(client: TrelloClient, args: argparse.Namespace) -> tuple[Any, str]:
221
+ command = args.card_command
222
+ if command == "view":
223
+ limit = None if args.full else 2000
224
+ return normalize_card(
225
+ client.card(args.card, board=args.board), full=True, description_limit=limit
226
+ ), "card"
227
+ if command in {"create", "ensure"}:
228
+ description = _description(args)
229
+ if command == "create":
230
+ result = client.create_card(
231
+ board=args.board,
232
+ list_value=args.list,
233
+ title=args.title,
234
+ description=description,
235
+ due=args.due,
236
+ label_ids=args.label_id,
237
+ )
238
+ action = "created"
239
+ else:
240
+ result, action = client.ensure_card(
241
+ board=args.board,
242
+ list_value=args.list,
243
+ title=args.title,
244
+ description=description,
245
+ due=args.due,
246
+ label_ids=args.label_id,
247
+ )
248
+ return {"action": action, **normalize_card(result, full=True)}, "card"
249
+ if command == "update":
250
+ description = _description(args) if args.description or args.description_file else None
251
+ if all(value is None for value in (args.title, description, args.due, args.due_complete)):
252
+ raise ValueError("card update requires at least one mutation flag")
253
+ result = client.update_card(
254
+ args.card,
255
+ name=args.title,
256
+ desc=description,
257
+ due=args.due,
258
+ dueComplete=args.due_complete,
259
+ )
260
+ return {"action": "updated", **normalize_card(result, full=True)}, "card"
261
+ if command == "move":
262
+ result, action = client.move_card(args.card, board=args.board, list_value=args.list)
263
+ return {"action": action, **normalize_card(result)}, "card"
264
+ if command == "archive":
265
+ result, action = client.archive_card(args.card)
266
+ return {"action": action, **normalize_card(result)}, "card"
267
+ if command == "comment":
268
+ result = client.comment(args.card, args.text)
269
+ return {"action": "commented", "id": result.get("id"), "card_id": args.card}, "comment"
270
+ if command == "add-label":
271
+ result = client.add_label(args.card, args.label_id)
272
+ return {"action": "label_added", "id": result.get("id")}, "label"
273
+ if command == "add-checklist":
274
+ result = client.add_checklist(args.card, args.name, args.item)
275
+ return {
276
+ "action": "checklist_added",
277
+ "id": result.get("id"),
278
+ "name": result.get("name"),
279
+ "items": len(args.item),
280
+ }, "checklist"
281
+ if command == "create-batch":
282
+ results = []
283
+ target = client.resolve_list(args.board, args.list)
284
+ index = client.index_cards(client.all_cards(args.board)) if args.ensure else {}
285
+ for item in _load_batch(args.file):
286
+ title = str(item["title"])
287
+ description = str(item.get("description", ""))
288
+ due = item.get("due")
289
+ label_ids = item.get("label_ids", ())
290
+ if args.ensure:
291
+ result, action = client.ensure_card(
292
+ board=args.board,
293
+ list_value=args.list,
294
+ title=title,
295
+ description=description,
296
+ due=due,
297
+ label_ids=label_ids,
298
+ target_list=target,
299
+ card_index=index,
300
+ )
301
+ index[title.casefold()] = [result]
302
+ else:
303
+ result = client.create_card_in_list(
304
+ str(target["id"]),
305
+ title=title,
306
+ description=description,
307
+ due=due,
308
+ label_ids=label_ids,
309
+ )
310
+ action = "created"
311
+ results.append({"action": action, **normalize_card(result)})
312
+ return results, "cards"
313
+ raise ValueError(f"unsupported card command: {command}")
314
+
315
+
316
+ def _next_steps(args: argparse.Namespace, data: Any) -> tuple[str, ...]:
317
+ if args.command in {None, "boards"}:
318
+ return ("trello-axi board <board-id>", "trello-axi lists --board <board-id>")
319
+ if args.command == "board":
320
+ return (
321
+ f"trello-axi cards --board {args.board} --list <list-id>",
322
+ f"trello-axi search <query> --board {args.board}",
323
+ )
324
+ if args.command == "lists":
325
+ return (f"trello-axi cards --board {args.board} --list <list-id>",)
326
+ if args.command in {"cards", "search"}:
327
+ return (
328
+ "trello-axi card view <card-id>",
329
+ "trello-axi card move <card-id> --board <board-id> --list <list-id>",
330
+ )
331
+ if args.command == "card" and isinstance(data, dict) and data.get("id"):
332
+ return (
333
+ f"trello-axi card view {data['id']}",
334
+ f"trello-axi card comment {data['id']} --text <text>",
335
+ )
336
+ if args.command == "setup":
337
+ return ("trello-axi auth status", "trello-axi boards")
338
+ if args.command == "auth":
339
+ return ("trello-axi boards", "trello-axi setup skill")
340
+ return ()
341
+
342
+
343
+ def main(argv: list[str] | None = None) -> int:
344
+ parser = _parser()
345
+ args = parser.parse_args(argv)
346
+ try:
347
+ data, name = run(args)
348
+ emit(
349
+ data,
350
+ fmt=args.format,
351
+ name=name,
352
+ help_commands=_next_steps(args, data),
353
+ )
354
+ return 0
355
+ except TrelloAxiError as exc:
356
+ emit_error(
357
+ str(exc),
358
+ code=exc.__class__.__name__,
359
+ fmt=args.format,
360
+ help_commands=("trello-axi auth status", "trello-axi --help"),
361
+ )
362
+ return exc.exit_code
363
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
364
+ emit_error(
365
+ str(exc), code="InputError", fmt=args.format, help_commands=("trello-axi --help",)
366
+ )
367
+ return 2
368
+
369
+
370
+ if __name__ == "__main__":
371
+ sys.exit(main())
@@ -0,0 +1,262 @@
1
+ """Thin Trello REST adapter with exact-name resolution and safe mutations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .config import Credentials
11
+ from .errors import AmbiguousMatchError, ApiError, AuthenticationError, NotFoundError
12
+
13
+ CARD_FIELDS = "id,name,idList,desc,due,dueComplete,closed,url,labels,idMembers,idChecklists"
14
+
15
+
16
+ class TrelloClient:
17
+ def __init__(
18
+ self, credentials: Credentials, *, transport: httpx.BaseTransport | None = None
19
+ ) -> None:
20
+ self._client = httpx.Client(
21
+ base_url="https://api.trello.com/1",
22
+ params={"key": credentials.api_key, "token": credentials.token},
23
+ timeout=30,
24
+ transport=transport,
25
+ )
26
+
27
+ def __enter__(self) -> TrelloClient:
28
+ return self
29
+
30
+ def __exit__(self, *_args: object) -> None:
31
+ self._client.close()
32
+
33
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
34
+ try:
35
+ response = self._client.request(method, path, **kwargs)
36
+ except httpx.HTTPError as exc:
37
+ raise ApiError(f"Trello request failed: {exc}") from exc
38
+ if response.status_code in {401, 403}:
39
+ raise AuthenticationError("Trello rejected the credentials or required permission")
40
+ if response.status_code == 404:
41
+ raise NotFoundError("Trello resource was not found or is not visible to this token")
42
+ if response.is_error:
43
+ try:
44
+ detail = response.json().get("message", response.text)
45
+ except (ValueError, AttributeError):
46
+ detail = response.text
47
+ raise ApiError(
48
+ f"Trello API returned HTTP {response.status_code}: {detail}",
49
+ status_code=response.status_code,
50
+ )
51
+ if not response.content:
52
+ return {}
53
+ return response.json()
54
+
55
+ def member(self) -> dict[str, Any]:
56
+ return self.request("GET", "/members/me", params={"fields": "id,username,fullName"})
57
+
58
+ def boards(self, *, include_closed: bool = False) -> list[dict[str, Any]]:
59
+ return self.request(
60
+ "GET",
61
+ "/members/me/boards",
62
+ params={"filter": "all" if include_closed else "open", "fields": "id,name,closed,url"},
63
+ )
64
+
65
+ def resolve_board(self, value: str) -> dict[str, Any]:
66
+ return self._resolve(value, self.boards(include_closed=True), "board")
67
+
68
+ def lists(self, board: str, *, include_closed: bool = False) -> list[dict[str, Any]]:
69
+ board_id = self.resolve_board(board)["id"]
70
+ return self.request(
71
+ "GET",
72
+ f"/boards/{board_id}/lists",
73
+ params={"filter": "all" if include_closed else "open", "fields": "id,name,closed,pos"},
74
+ )
75
+
76
+ def resolve_list(self, board: str, value: str) -> dict[str, Any]:
77
+ return self._resolve(value, self.lists(board, include_closed=True), "list")
78
+
79
+ def cards(
80
+ self, board: str, *, list_value: str | None = None, limit: int = 50
81
+ ) -> list[dict[str, Any]]:
82
+ path = self._cards_path(board, list_value)
83
+ return self.request(
84
+ "GET",
85
+ path,
86
+ params={"filter": "open", "limit": limit, "fields": CARD_FIELDS},
87
+ )
88
+
89
+ def all_cards(self, board: str) -> list[dict[str, Any]]:
90
+ """Read every open card with deterministic Trello ID pagination."""
91
+ path = self._cards_path(board, None)
92
+ results: list[dict[str, Any]] = []
93
+ before: str | None = None
94
+ while True:
95
+ params = {"filter": "open", "limit": 1000, "fields": CARD_FIELDS}
96
+ if before:
97
+ params["before"] = before
98
+ page = self.request("GET", path, params=params)
99
+ results.extend(page)
100
+ if len(page) < 1000:
101
+ return results
102
+ next_before = str(page[-1]["id"])
103
+ if next_before == before:
104
+ raise ApiError("Trello card pagination did not advance")
105
+ before = next_before
106
+
107
+ def _cards_path(self, board: str, list_value: str | None) -> str:
108
+ if list_value:
109
+ list_id = self.resolve_list(board, list_value)["id"]
110
+ return f"/lists/{list_id}/cards"
111
+ board_id = self.resolve_board(board)["id"]
112
+ return f"/boards/{board_id}/cards"
113
+
114
+ def card(self, value: str, *, board: str | None = None) -> dict[str, Any]:
115
+ if board:
116
+ return self._resolve(value, self.all_cards(board), "card")
117
+ return self.request("GET", f"/cards/{value}", params={"fields": CARD_FIELDS})
118
+
119
+ def search(
120
+ self, query: str, *, board: str | None = None, limit: int = 50
121
+ ) -> list[dict[str, Any]]:
122
+ params: dict[str, Any] = {
123
+ "query": query,
124
+ "modelTypes": "cards",
125
+ "card_fields": CARD_FIELDS,
126
+ "cards_limit": limit,
127
+ }
128
+ if board:
129
+ params["idBoards"] = self.resolve_board(board)["id"]
130
+ result = self.request("GET", "/search", params=params)
131
+ return result.get("cards", [])
132
+
133
+ def create_card(
134
+ self,
135
+ *,
136
+ board: str,
137
+ list_value: str,
138
+ title: str,
139
+ description: str = "",
140
+ due: str | None = None,
141
+ label_ids: Sequence[str] = (),
142
+ ) -> dict[str, Any]:
143
+ list_id = self.resolve_list(board, list_value)["id"]
144
+ return self.create_card_in_list(
145
+ list_id, title=title, description=description, due=due, label_ids=label_ids
146
+ )
147
+
148
+ def create_card_in_list(
149
+ self,
150
+ list_id: str,
151
+ *,
152
+ title: str,
153
+ description: str = "",
154
+ due: str | None = None,
155
+ label_ids: Sequence[str] = (),
156
+ ) -> dict[str, Any]:
157
+ data: dict[str, Any] = {
158
+ "idList": list_id,
159
+ "name": title,
160
+ "desc": description,
161
+ "pos": "bottom",
162
+ }
163
+ if due is not None:
164
+ data["due"] = due
165
+ if label_ids:
166
+ data["idLabels"] = ",".join(label_ids)
167
+ return self.request("POST", "/cards", data=data)
168
+
169
+ def ensure_card(
170
+ self,
171
+ *,
172
+ board: str,
173
+ list_value: str,
174
+ title: str,
175
+ description: str = "",
176
+ due: str | None = None,
177
+ label_ids: Sequence[str] = (),
178
+ target_list: Mapping[str, Any] | None = None,
179
+ card_index: Mapping[str, Sequence[dict[str, Any]]] | None = None,
180
+ ) -> tuple[dict[str, Any], str]:
181
+ target = target_list or self.resolve_list(board, list_value)
182
+ index = card_index if card_index is not None else self.index_cards(self.all_cards(board))
183
+ matches = list(index.get(title.casefold(), ()))
184
+ if len(matches) > 1:
185
+ raise AmbiguousMatchError(f"multiple cards named {title!r}; use an ID")
186
+ if not matches:
187
+ return self.create_card_in_list(
188
+ str(target["id"]),
189
+ title=title,
190
+ description=description,
191
+ due=due,
192
+ label_ids=label_ids,
193
+ ), "created"
194
+ current = matches[0]
195
+ changes: dict[str, Any] = {}
196
+ if current.get("idList") != target["id"]:
197
+ changes["idList"] = target["id"]
198
+ if description and current.get("desc") != description:
199
+ changes["desc"] = description
200
+ if due is not None and current.get("due") != due:
201
+ changes["due"] = due
202
+ if label_ids:
203
+ current_labels = {str(label.get("id")) for label in current.get("labels", [])}
204
+ if current_labels != set(label_ids):
205
+ changes["idLabels"] = ",".join(label_ids)
206
+ if changes:
207
+ current = self.request("PUT", f"/cards/{current['id']}", data=changes)
208
+ return current, "updated"
209
+ return current, "unchanged"
210
+
211
+ @staticmethod
212
+ def index_cards(cards: Sequence[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
213
+ index: dict[str, list[dict[str, Any]]] = {}
214
+ for item in cards:
215
+ index.setdefault(str(item.get("name", "")).casefold(), []).append(item)
216
+ return index
217
+
218
+ def update_card(self, card_id: str, **changes: Any) -> dict[str, Any]:
219
+ data = {key: value for key, value in changes.items() if value is not None}
220
+ if not data:
221
+ return self.card(card_id)
222
+ return self.request("PUT", f"/cards/{card_id}", data=data)
223
+
224
+ def move_card(self, card_id: str, *, board: str, list_value: str) -> tuple[dict[str, Any], str]:
225
+ target = self.resolve_list(board, list_value)
226
+ current = self.card(card_id)
227
+ if current.get("idList") == target["id"]:
228
+ return current, "unchanged"
229
+ return self.update_card(card_id, idList=target["id"]), "moved"
230
+
231
+ def archive_card(self, card_id: str) -> tuple[dict[str, Any], str]:
232
+ current = self.card(card_id)
233
+ if current.get("closed"):
234
+ return current, "unchanged"
235
+ return self.update_card(card_id, closed="true"), "archived"
236
+
237
+ def comment(self, card_id: str, text: str) -> dict[str, Any]:
238
+ return self.request("POST", f"/cards/{card_id}/actions/comments", data={"text": text})
239
+
240
+ def add_label(self, card_id: str, label_id: str) -> dict[str, Any]:
241
+ return self.request("POST", f"/cards/{card_id}/idLabels", data={"value": label_id})
242
+
243
+ def add_checklist(self, card_id: str, name: str, items: Sequence[str]) -> dict[str, Any]:
244
+ checklist = self.request("POST", f"/cards/{card_id}/checklists", data={"name": name})
245
+ for item in items:
246
+ self.request("POST", f"/checklists/{checklist['id']}/checkItems", data={"name": item})
247
+ return checklist
248
+
249
+ @staticmethod
250
+ def _resolve(value: str, objects: Sequence[Mapping[str, Any]], kind: str) -> dict[str, Any]:
251
+ by_id = [item for item in objects if item.get("id") == value]
252
+ if by_id:
253
+ return dict(by_id[0])
254
+ exact = [
255
+ item for item in objects if str(item.get("name", "")).casefold() == value.casefold()
256
+ ]
257
+ if not exact:
258
+ raise NotFoundError(f"{kind} {value!r} was not found; use `{kind}s` to list valid IDs")
259
+ if len(exact) > 1:
260
+ ids = ", ".join(str(item.get("id")) for item in exact)
261
+ raise AmbiguousMatchError(f"{kind} {value!r} is ambiguous; matching IDs: {ids}")
262
+ return dict(exact[0])
@@ -0,0 +1,71 @@
1
+ """Credential loading without leaking secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Credentials:
15
+ api_key: str
16
+ token: str
17
+
18
+
19
+ def default_config_path() -> Path:
20
+ return (
21
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
22
+ / "trello-axi"
23
+ / "config.json"
24
+ )
25
+
26
+
27
+ def load_credentials(config_path: Path | None = None) -> Credentials:
28
+ """Load environment credentials first, then the local config file."""
29
+ api_key = os.environ.get("TRELLO_API_KEY", "").strip()
30
+ token = os.environ.get("TRELLO_TOKEN", "").strip()
31
+ path = config_path or default_config_path()
32
+ if (not api_key or not token) and path.exists():
33
+ try:
34
+ raw = json.loads(path.read_text())
35
+ except (OSError, json.JSONDecodeError) as exc:
36
+ raise ValueError(f"cannot read credential file {path}: {exc}") from exc
37
+ if not isinstance(raw, Mapping):
38
+ raise ValueError(f"credential file {path} must contain a JSON object")
39
+ api_key = api_key or str(raw.get("api_key", "")).strip()
40
+ token = token or str(raw.get("token", "")).strip()
41
+ if not api_key or not token:
42
+ raise ValueError(
43
+ "Trello credentials are missing. Set TRELLO_API_KEY and TRELLO_TOKEN, "
44
+ "or run `trello-axi auth set --api-key <key> --token <token>`."
45
+ )
46
+ return Credentials(api_key=api_key, token=token)
47
+
48
+
49
+ def save_credentials(api_key: str, token: str, config_path: Path | None = None) -> Path:
50
+ if not api_key.strip() or not token.strip():
51
+ raise ValueError("both --api-key and --token are required")
52
+ path = config_path or default_config_path()
53
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
54
+ path.parent.chmod(0o700)
55
+ payload = json.dumps({"api_key": api_key.strip(), "token": token.strip()}) + "\n"
56
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
57
+ temporary = Path(temporary_name)
58
+ try:
59
+ os.fchmod(descriptor, 0o600)
60
+ with os.fdopen(descriptor, "w") as stream:
61
+ descriptor = -1
62
+ stream.write(payload)
63
+ stream.flush()
64
+ os.fsync(stream.fileno())
65
+ temporary.replace(path)
66
+ except BaseException:
67
+ if descriptor >= 0:
68
+ os.close(descriptor)
69
+ temporary.unlink(missing_ok=True)
70
+ raise
71
+ return path
@@ -0,0 +1,29 @@
1
+ """Domain errors mapped to stable CLI exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class TrelloAxiError(Exception):
7
+ """Base actionable error."""
8
+
9
+ exit_code = 1
10
+
11
+
12
+ class AuthenticationError(TrelloAxiError):
13
+ exit_code = 3
14
+
15
+
16
+ class NotFoundError(TrelloAxiError):
17
+ exit_code = 4
18
+
19
+
20
+ class AmbiguousMatchError(TrelloAxiError):
21
+ exit_code = 5
22
+
23
+
24
+ class ApiError(TrelloAxiError):
25
+ exit_code = 6
26
+
27
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
28
+ super().__init__(message)
29
+ self.status_code = status_code
@@ -0,0 +1,52 @@
1
+ """Pure normalization functions for token-efficient API output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def board(raw: dict[str, Any]) -> dict[str, Any]:
9
+ return {
10
+ "id": raw.get("id"),
11
+ "name": raw.get("name"),
12
+ "closed": raw.get("closed", False),
13
+ "url": raw.get("url"),
14
+ }
15
+
16
+
17
+ def trello_list(raw: dict[str, Any]) -> dict[str, Any]:
18
+ return {
19
+ "id": raw.get("id"),
20
+ "name": raw.get("name"),
21
+ "closed": raw.get("closed", False),
22
+ "pos": raw.get("pos"),
23
+ }
24
+
25
+
26
+ def card(
27
+ raw: dict[str, Any], *, full: bool = False, description_limit: int | None = None
28
+ ) -> dict[str, Any]:
29
+ result: dict[str, Any] = {
30
+ "id": raw.get("id"),
31
+ "name": raw.get("name"),
32
+ "list_id": raw.get("idList"),
33
+ "due": raw.get("due"),
34
+ }
35
+ labels = raw.get("labels") or []
36
+ result["labels"] = ";".join(item.get("name") or item.get("color", "") for item in labels)
37
+ if full:
38
+ description = str(raw.get("desc", ""))
39
+ truncated = description_limit is not None and len(description) > description_limit
40
+ if truncated:
41
+ description = description[:description_limit] + "…"
42
+ result.update(
43
+ description=description,
44
+ description_chars=len(str(raw.get("desc", ""))),
45
+ description_truncated=truncated,
46
+ closed=raw.get("closed", False),
47
+ url=raw.get("url"),
48
+ due_complete=raw.get("dueComplete", False),
49
+ member_ids=raw.get("idMembers", []),
50
+ checklist_ids=raw.get("idChecklists", []),
51
+ )
52
+ return result
@@ -0,0 +1,76 @@
1
+ """Compact deterministic output for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any
9
+
10
+
11
+ def _scalar(value: Any) -> str:
12
+ if value is None:
13
+ return ""
14
+ if isinstance(value, bool):
15
+ return "true" if value else "false"
16
+ text = str(value).replace("\n", "\\n")
17
+ if any(char in text for char in ',"'):
18
+ return '"' + text.replace('"', '""') + '"'
19
+ return text
20
+
21
+
22
+ def toon(data: Any, *, name: str = "result") -> str:
23
+ """Render a small TOON-style subset: scalar maps and tabular arrays."""
24
+ if isinstance(data, list):
25
+ if not data:
26
+ return f"{name}[0]:"
27
+ rows = [item for item in data if isinstance(item, Mapping)]
28
+ if len(rows) == len(data):
29
+ fields = list(
30
+ dict.fromkeys(
31
+ key for row in rows for key in row if not isinstance(row[key], (dict, list))
32
+ )
33
+ )
34
+ lines = [f"{name}[{len(rows)}]{{{','.join(fields)}}}:"]
35
+ lines.extend(
36
+ " " + ",".join(_scalar(row.get(field)) for field in fields) for row in rows
37
+ )
38
+ return "\n".join(lines)
39
+ if isinstance(data, Mapping):
40
+ lines = [f"{name}:"]
41
+ for key, value in data.items():
42
+ if isinstance(value, list):
43
+ lines.append(toon(value, name=str(key)))
44
+ elif isinstance(value, Mapping):
45
+ lines.append(
46
+ f" {key}: {json.dumps(value, ensure_ascii=False, separators=(',', ':'))}"
47
+ )
48
+ else:
49
+ lines.append(f" {key}: {_scalar(value)}")
50
+ return "\n".join(lines)
51
+ return f"{name}: {_scalar(data)}"
52
+
53
+
54
+ def emit(
55
+ data: Any,
56
+ *,
57
+ fmt: str = "toon",
58
+ name: str = "result",
59
+ help_commands: Sequence[str] = (),
60
+ ) -> None:
61
+ if fmt == "json":
62
+ print(json.dumps({name: data, "help": list(help_commands)}, ensure_ascii=False, indent=2))
63
+ else:
64
+ print(toon(data, name=name))
65
+ if help_commands:
66
+ print(toon([{"command": command} for command in help_commands], name="help"))
67
+
68
+
69
+ def emit_error(
70
+ message: str, *, code: str, fmt: str = "toon", help_commands: Sequence[str] = ()
71
+ ) -> None:
72
+ payload = {"error": code, "message": message, "help": list(help_commands)}
73
+ if fmt == "json":
74
+ print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
75
+ else:
76
+ print(toon(payload, name="failure"), file=sys.stderr)
File without changes
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: trello-axi
3
+ description: Use Trello through an agent-native CLI for compact board context, card CRUD, search, comments, labels, checklists, and idempotent batch workflows.
4
+ ---
5
+
6
+ # trello-axi
7
+
8
+ Use IDs after initial discovery. Keep reads bounded and request JSON only when another program must parse output.
9
+
10
+ ## Discover
11
+
12
+ ```bash
13
+ trello-axi auth status
14
+ trello-axi boards
15
+ trello-axi board "<board>"
16
+ trello-axi cards --board "<board>" --list "<list>" --limit 50
17
+ ```
18
+
19
+ ## Mutate safely
20
+
21
+ Prefer idempotent `ensure` when retrying is possible:
22
+
23
+ ```bash
24
+ trello-axi card ensure --board "<board>" --list "<list>" --title "<title>" --description-file <path>
25
+ ```
26
+
27
+ Use card IDs for subsequent operations:
28
+
29
+ ```bash
30
+ trello-axi card move <card-id> --board "<board>" --list "<list>"
31
+ trello-axi card comment <card-id> --text "<comment>"
32
+ trello-axi card archive <card-id>
33
+ ```
34
+
35
+ Execute explicit archive and batch requests noninteractively; do not introduce confirmation prompts inside the command workflow. Never print, log, or request API credentials in chat. Do not use permanent deletion; this CLI intentionally archives cards instead.
File without changes
@@ -0,0 +1,39 @@
1
+ """Bundled trello-axi agent skill."""
2
+ # ruff: noqa: E501
3
+
4
+ SKILL_TEXT = """---
5
+ name: trello-axi
6
+ description: Use Trello through an agent-native CLI for compact board context, card CRUD, search, comments, labels, checklists, and idempotent batch workflows.
7
+ ---
8
+
9
+ # trello-axi
10
+
11
+ Use IDs after initial discovery. Keep reads bounded and request JSON only when another program must parse output.
12
+
13
+ ## Discover
14
+
15
+ ```bash
16
+ trello-axi auth status
17
+ trello-axi boards
18
+ trello-axi board "<board>"
19
+ trello-axi cards --board "<board>" --list "<list>" --limit 50
20
+ ```
21
+
22
+ ## Mutate safely
23
+
24
+ Prefer idempotent `ensure` when retrying is possible:
25
+
26
+ ```bash
27
+ trello-axi card ensure --board "<board>" --list "<list>" --title "<title>" --description-file <path>
28
+ ```
29
+
30
+ Use card IDs for subsequent operations:
31
+
32
+ ```bash
33
+ trello-axi card move <card-id> --board "<board>" --list "<list>"
34
+ trello-axi card comment <card-id> --text "<comment>"
35
+ trello-axi card archive <card-id>
36
+ ```
37
+
38
+ Execute explicit archive and batch requests noninteractively; do not introduce confirmation prompts inside the command workflow. Never print, log, or request API credentials in chat. Do not use permanent deletion; this CLI intentionally archives cards instead.
39
+ """
@@ -0,0 +1,76 @@
1
+ """Idempotent agent skill and SessionStart hook installers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .resources.skill import SKILL_TEXT
11
+
12
+ MARKER = "trello-axi"
13
+
14
+
15
+ def install_skill(destination: Path | None = None) -> Path:
16
+ target = destination or Path.home() / ".agents" / "skills" / MARKER / "SKILL.md"
17
+ target.parent.mkdir(parents=True, exist_ok=True)
18
+ if not target.exists() or target.read_text() != SKILL_TEXT:
19
+ target.write_text(SKILL_TEXT)
20
+ return target
21
+
22
+
23
+ def install_hooks(*, command: str | None = None) -> list[Path]:
24
+ """Install managed Claude Code and Codex SessionStart hooks."""
25
+ executable = command or shutil.which("trello-axi")
26
+ if not executable:
27
+ raise ValueError("trello-axi executable was not found on PATH")
28
+ hook = {"type": "command", "command": executable, "timeout": 10}
29
+ installed = []
30
+ for path in (Path.home() / ".claude" / "settings.json", Path.home() / ".codex" / "hooks.json"):
31
+ settings = _load_object(path)
32
+ hooks = settings.setdefault("hooks", {})
33
+ if not isinstance(hooks, dict):
34
+ raise ValueError(f"{path} contains a non-object hooks setting")
35
+ groups = hooks.setdefault("SessionStart", [])
36
+ if not isinstance(groups, list):
37
+ raise ValueError(f"{path} contains a non-list SessionStart setting")
38
+ managed = None
39
+ for group in groups:
40
+ if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
41
+ continue
42
+ if any(
43
+ MARKER in str(item.get("command", ""))
44
+ for item in group["hooks"]
45
+ if isinstance(item, dict)
46
+ ):
47
+ managed = group
48
+ break
49
+ desired = {"matcher": "", "hooks": [hook]}
50
+ if managed is None:
51
+ groups.append(desired)
52
+ else:
53
+ managed.clear()
54
+ managed.update(desired)
55
+ _atomic_json(path, settings)
56
+ installed.append(path)
57
+ return installed
58
+
59
+
60
+ def _load_object(path: Path) -> dict[str, Any]:
61
+ if not path.exists():
62
+ return {}
63
+ try:
64
+ raw = json.loads(path.read_text())
65
+ except (OSError, json.JSONDecodeError) as exc:
66
+ raise ValueError(f"cannot read agent settings {path}: {exc}") from exc
67
+ if not isinstance(raw, dict):
68
+ raise ValueError(f"agent settings {path} must contain a JSON object")
69
+ return raw
70
+
71
+
72
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ temporary = path.with_name(f".{path.name}.trello-axi.tmp")
75
+ temporary.write_text(json.dumps(value, indent=2) + "\n")
76
+ temporary.replace(path)