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.
- trello_axi-0.2.1/PKG-INFO +137 -0
- trello_axi-0.2.1/README.md +128 -0
- trello_axi-0.2.1/pyproject.toml +42 -0
- trello_axi-0.2.1/src/trello_axi/__init__.py +0 -0
- trello_axi-0.2.1/src/trello_axi/__main__.py +3 -0
- trello_axi-0.2.1/src/trello_axi/cli.py +371 -0
- trello_axi-0.2.1/src/trello_axi/client.py +262 -0
- trello_axi-0.2.1/src/trello_axi/config.py +71 -0
- trello_axi-0.2.1/src/trello_axi/errors.py +29 -0
- trello_axi-0.2.1/src/trello_axi/models.py +52 -0
- trello_axi-0.2.1/src/trello_axi/output.py +76 -0
- trello_axi-0.2.1/src/trello_axi/py.typed +0 -0
- trello_axi-0.2.1/src/trello_axi/resources/SKILL.md +35 -0
- trello_axi-0.2.1/src/trello_axi/resources/__init__.py +0 -0
- trello_axi-0.2.1/src/trello_axi/resources/skill.py +39 -0
- trello_axi-0.2.1/src/trello_axi/setup.py +76 -0
|
@@ -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,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)
|