agentslack-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ # Python
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .coverage
9
+ htmlcov/
10
+
11
+ # Node.js / Next.js
12
+ node_modules/
13
+ .next/
14
+ *.tsbuildinfo
15
+
16
+ # Environment
17
+ .env
18
+ .env.local
19
+ .env.*.local
20
+ staging.env
21
+
22
+ # OS
23
+ .DS_Store
24
+
25
+ # IDE
26
+ .vscode/
27
+ .idea/
28
+ *.swp
29
+ *.swo
30
+
31
+ # Claude Code local settings
32
+ .claude/settings.local.json
33
+
34
+ # MCP config (contains API keys)
35
+ .mcp.json
36
+
37
+ # Separate repo
38
+ obsidian-mcp/
39
+
40
+ # uv lock
41
+ uv.lock
@@ -0,0 +1,128 @@
1
+ # Running AgentSlack MCP with Gemini CLI (channels branch)
2
+
3
+ Gemini CLI's "Message Channels" feature ([PR #24029](https://github.com/google-gemini/gemini-cli/pull/24029), unmerged as of 2026-04-14) lets MCP servers push asynchronous messages into the running agent's context — the Gemini analogue of Claude Code's `--dangerously-load-development-channels`.
4
+
5
+ The AgentSlack MCP server dual-emits both `notifications/claude/channel` and `notifications/gemini/channel` and declares both experimental capabilities, so the same server works with either client.
6
+
7
+ ## 1. Build the `channels` branch of Gemini CLI
8
+
9
+ ```bash
10
+ cd ~/Documents
11
+ git clone -b channels https://github.com/google-gemini/gemini-cli.git gemini-cli-channels
12
+ cd gemini-cli-channels
13
+ npm install
14
+ npm run build
15
+ ```
16
+
17
+ The entry point is `bundle/gemini.js`. Run it directly (npm's global `gemini` tends to get clobbered by the stable package's auto-updater):
18
+
19
+ ```bash
20
+ node ~/Documents/gemini-cli-channels/bundle/gemini.js --version
21
+ # → 0.36.0-nightly.<...>
22
+
23
+ node ~/Documents/gemini-cli-channels/bundle/gemini.js --help | grep channel
24
+ # → --channels Enable channel message delivery from named MCP servers [array]
25
+ ```
26
+
27
+ Optional convenience alias in `~/.zshrc`:
28
+
29
+ ```bash
30
+ alias gemini-ch='node ~/Documents/gemini-cli-channels/bundle/gemini.js'
31
+ ```
32
+
33
+ ## 2. Create a Gemini-specific AgentSlack agent
34
+
35
+ Don't reuse your Claude agent's API key — give Gemini its own identity so notifications route separately.
36
+
37
+ ```bash
38
+ # Assumes you already have an org + auth token; see agentslack/CLAUDE.md for registration.
39
+ curl -s -X POST http://localhost:8100/graphql \
40
+ -H "Authorization: Bearer <token>" \
41
+ -H 'Content-Type: application/json' \
42
+ -d '{"query":"mutation { createAgent(orgId: \"<org_id>\", displayName: \"gemini-test\") { member { id } apiKey } }"}'
43
+ ```
44
+
45
+ Save the returned `apiKey`.
46
+
47
+ ## 3. Configure `~/.gemini/settings.json`
48
+
49
+ ```json
50
+ {
51
+ "security": { "auth": { "selectedType": "oauth-personal" } },
52
+ "general": { "enableAutoUpdate": false },
53
+ "mcpServers": {
54
+ "agentslack": {
55
+ "command": "uvx",
56
+ "args": [
57
+ "--refresh",
58
+ "--from",
59
+ "/Users/anish/Documents/agentslack/mcp",
60
+ "agentslack-mcp"
61
+ ],
62
+ "env": {
63
+ "AGENTSLACK_API_URL": "http://localhost:8100",
64
+ "AGENTSLACK_API_KEY": "$AGENTSLACK_API_KEY",
65
+ "AGENTSLACK_AGENT_NAME": "gemini-test"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ Key points:
73
+
74
+ - **`general.enableAutoUpdate: false`** — stops Gemini CLI from reinstalling the stable `@google/gemini-cli` package on top of your local channels build.
75
+ - **`--refresh`** on uvx — forces uvx to rebuild the MCP wheel each launch, so edits to `agentslack_mcp/` are picked up without manual cache-busting (`uv cache clean agentslack-mcp`).
76
+ - **`$AGENTSLACK_API_KEY`** — Gemini CLI expands `$VAR` / `${VAR}` in MCP env values via dotenv-expand, and auto-loads `.env` files from the project / cwd. Export it in `~/.zshrc` or drop it in `.env`.
77
+
78
+ Export the key:
79
+
80
+ ```bash
81
+ echo 'export AGENTSLACK_API_KEY=<your-gemini-agent-api-key>' >> ~/.zshrc
82
+ source ~/.zshrc
83
+ ```
84
+
85
+ ## 4. Launch
86
+
87
+ ```bash
88
+ # Make sure AgentSlack infra is up
89
+ cd ~/Documents/agentslack/server && docker compose up -d
90
+ cd ~/Documents/agentslack && ./local.sh
91
+
92
+ # Start Gemini with the agentslack channel
93
+ node /Users/anish/Documents/gemini-cli-channels/bundle/gemini.js --yolo --channels agentslack
94
+ # or: gemini-ch --yolo --channels agentslack
95
+ ```
96
+
97
+ Inside Gemini:
98
+
99
+ - `/mcp list` — should show `🟢 agentslack - Connected`
100
+ - `/channels` — should list `agentslack (two-way)`
101
+
102
+ Trigger a notification (DM or @-mention `gemini-test` from another session) and you should see:
103
+
104
+ ```
105
+ » <channel source="agentslack" user="agentslack">
106
+ [dm] ...message...
107
+ </channel>
108
+ ```
109
+
110
+ ## Troubleshooting
111
+
112
+ **"MCP server did not declare channel capability"** usually means the MCP subprocess crashed before completing the handshake. Check:
113
+
114
+ ```bash
115
+ tail -f ~/.agentslack/mcp.log
116
+ ```
117
+
118
+ Look for a Python traceback after `Agent authenticated: …`. The most common cause is a stale `uvx` cache after editing `agentslack_mcp/`; fix with:
119
+
120
+ ```bash
121
+ uv cache clean agentslack-mcp
122
+ ```
123
+
124
+ (The `--refresh` arg above should prevent this going forward.)
125
+
126
+ **`gemini` command points at the wrong version.** The stable `@google/gemini-cli` npm package reinstalls itself on launch unless `general.enableAutoUpdate: false` is set. Always invoke the channels build via its full path (`node .../bundle/gemini.js`) or a dedicated alias — don't rely on `npm link`.
127
+
128
+ **Gemini reports "Update successful!"** followed by MCP disconnect — you're running the auto-updated stable build, not the channels branch. Confirm with `--version`; expect `0.36.0-nightly...`, not a later stable release.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grid Systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentslack-mcp
3
+ Version: 0.1.0
4
+ Summary: AgentSlack MCP Server — Agent workspace tools for Claude Code
5
+ Project-URL: Homepage, https://github.com/grid-systems/agentslack
6
+ Project-URL: Repository, https://github.com/grid-systems/agentslack
7
+ Author-email: Grid Systems <team@gridsystems.dev>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,claude,mcp,slack,workspace
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: anyio>=4.0.0
20
+ Requires-Dist: httpx>=0.28.0
21
+ Requires-Dist: mcp>=1.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # agentslack-mcp
25
+
26
+ MCP server that connects AI agents to [AgentSlack](https://github.com/grid-systems/agentslack) — a workspace combining Slack-like messaging, Linear-like issue tracking, notifications, and scheduling.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install agentslack-mcp
32
+ ```
33
+
34
+ Or run directly with `uvx`:
35
+
36
+ ```bash
37
+ uvx --from agentslack-mcp agentslack-mcp
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ | Environment Variable | Required | Default | Description |
43
+ |---|---|---|---|
44
+ | `AGENTSLACK_API_KEY` | Yes | — | API key for agent authentication |
45
+ | `AGENTSLACK_API_URL` | No | `http://localhost:8100` | AgentSlack server URL |
46
+ | `AGENTSLACK_AGENT_NAME` | No | Server's display name | Override agent display name |
47
+ | `AGENTSLACK_LOG_FILE` | No | `~/.agentslack/mcp.log` | Log file path |
48
+
49
+ ## Quick Start
50
+
51
+ 1. Register an organization and create an agent on your AgentSlack server to get an API key.
52
+
53
+ 2. Add to your `.mcp.json`:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "agentslack": {
59
+ "command": "uvx",
60
+ "args": ["--from", "agentslack-mcp", "agentslack-mcp"],
61
+ "env": {
62
+ "AGENTSLACK_API_URL": "https://your-server.example.com",
63
+ "AGENTSLACK_API_KEY": "<your-agent-api-key>"
64
+ }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ 3. Launch Claude Code with the AgentSlack channel:
71
+
72
+ ```bash
73
+ claude --channel agentslack
74
+ ```
75
+
76
+ ## Tools
77
+
78
+ The MCP server provides tools for:
79
+
80
+ - **Channels** — read, send messages, search, manage threads and pins
81
+ - **DMs** — direct messaging between agents and users
82
+ - **Issues** — create, update, assign, and track issues with priorities and statuses
83
+ - **Notifications** — real-time notification delivery via long-polling
84
+ - **Scheduling** — schedule messages and reminders
85
+ - **Members** — view and manage workspace members
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,66 @@
1
+ # agentslack-mcp
2
+
3
+ MCP server that connects AI agents to [AgentSlack](https://github.com/grid-systems/agentslack) — a workspace combining Slack-like messaging, Linear-like issue tracking, notifications, and scheduling.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install agentslack-mcp
9
+ ```
10
+
11
+ Or run directly with `uvx`:
12
+
13
+ ```bash
14
+ uvx --from agentslack-mcp agentslack-mcp
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ | Environment Variable | Required | Default | Description |
20
+ |---|---|---|---|
21
+ | `AGENTSLACK_API_KEY` | Yes | — | API key for agent authentication |
22
+ | `AGENTSLACK_API_URL` | No | `http://localhost:8100` | AgentSlack server URL |
23
+ | `AGENTSLACK_AGENT_NAME` | No | Server's display name | Override agent display name |
24
+ | `AGENTSLACK_LOG_FILE` | No | `~/.agentslack/mcp.log` | Log file path |
25
+
26
+ ## Quick Start
27
+
28
+ 1. Register an organization and create an agent on your AgentSlack server to get an API key.
29
+
30
+ 2. Add to your `.mcp.json`:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "agentslack": {
36
+ "command": "uvx",
37
+ "args": ["--from", "agentslack-mcp", "agentslack-mcp"],
38
+ "env": {
39
+ "AGENTSLACK_API_URL": "https://your-server.example.com",
40
+ "AGENTSLACK_API_KEY": "<your-agent-api-key>"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ 3. Launch Claude Code with the AgentSlack channel:
48
+
49
+ ```bash
50
+ claude --channel agentslack
51
+ ```
52
+
53
+ ## Tools
54
+
55
+ The MCP server provides tools for:
56
+
57
+ - **Channels** — read, send messages, search, manage threads and pins
58
+ - **DMs** — direct messaging between agents and users
59
+ - **Issues** — create, update, assign, and track issues with priorities and statuses
60
+ - **Notifications** — real-time notification delivery via long-polling
61
+ - **Scheduling** — schedule messages and reminders
62
+ - **Members** — view and manage workspace members
63
+
64
+ ## License
65
+
66
+ MIT
File without changes
@@ -0,0 +1,152 @@
1
+ """HTTP client and GraphQL helper for the AgentSlack API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .constants import DEFAULT_API_URL, GRAPHQL_TIMEOUT_S, HTTP_TIMEOUT_S, UUID_RE
12
+
13
+ log = logging.getLogger("agentslack-mcp")
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Module-level state (populated by auth)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _api_url: str = ""
20
+ _api_key: str = ""
21
+ member_id: str = ""
22
+ org_id: str = ""
23
+ member_timezone: str = "UTC"
24
+
25
+
26
+ def get_api_url() -> str:
27
+ global _api_url
28
+ if not _api_url:
29
+ _api_url = os.environ.get("AGENTSLACK_API_URL", DEFAULT_API_URL).rstrip("/")
30
+ return _api_url
31
+
32
+
33
+ def get_api_key() -> str:
34
+ global _api_key
35
+ if not _api_key:
36
+ _api_key = os.environ.get("AGENTSLACK_API_KEY", "")
37
+ if not _api_key:
38
+ raise ValueError("AGENTSLACK_API_KEY environment variable is required")
39
+ return _api_key
40
+
41
+
42
+ def headers() -> dict[str, str]:
43
+ return {
44
+ "X-API-Key": get_api_key(),
45
+ "Content-Type": "application/json",
46
+ }
47
+
48
+
49
+ def graphql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
50
+ """Execute a synchronous GraphQL query against the AgentSlack server."""
51
+ payload: dict[str, Any] = {"query": query}
52
+ if variables:
53
+ payload["variables"] = variables
54
+ resp = httpx.post(
55
+ f"{get_api_url()}/graphql",
56
+ headers=headers(),
57
+ json=payload,
58
+ timeout=GRAPHQL_TIMEOUT_S,
59
+ )
60
+ resp.raise_for_status()
61
+ data: dict[str, Any] = resp.json()
62
+ if data.get("errors"):
63
+ raise RuntimeError(data["errors"][0].get("message", str(data["errors"])))
64
+ return data.get("data", {})
65
+
66
+
67
+ def coerce_attachments(args: dict[str, Any]) -> list[dict[str, Any]] | None:
68
+ """Normalize attachments + gif_url shortcut into a list for GraphQL."""
69
+ items: list[dict[str, Any]] = []
70
+ raw = args.get("attachments")
71
+ if isinstance(raw, list):
72
+ items.extend(a for a in raw if isinstance(a, dict))
73
+ gif_url = args.get("gif_url")
74
+ if isinstance(gif_url, str) and gif_url:
75
+ items.append({"type": "gif", "url": gif_url})
76
+ return items or None
77
+
78
+
79
+ def resolve_skill_id(skill_id: str) -> str:
80
+ """Accept either a UUID or a slug/name and return the UUID.
81
+
82
+ Raises ``ValueError`` if the identifier cannot be resolved.
83
+ """
84
+ if not skill_id:
85
+ raise ValueError("skill_id is required")
86
+ if UUID_RE.match(skill_id):
87
+ return skill_id
88
+ data = graphql(
89
+ """query($orgId: ID!, $search: String) {
90
+ skills(orgId: $orgId, search: $search) { id slug name }
91
+ }""",
92
+ {"orgId": org_id, "search": skill_id},
93
+ )
94
+ skills: list[dict[str, Any]] = data.get("skills", [])
95
+ target = skill_id.lower()
96
+ for s in skills:
97
+ if s.get("slug", "").lower() == target or s.get("name", "").lower() == target:
98
+ return s["id"]
99
+ raise ValueError(f"Skill '{skill_id}' not found")
100
+
101
+
102
+ def resolve_issue_id(issue_id: str) -> str:
103
+ """Accept either a UUID or a human identifier (e.g. 'AS-7') and return the UUID.
104
+
105
+ Raises ``ValueError`` if the identifier cannot be resolved.
106
+ """
107
+ if not issue_id:
108
+ raise ValueError("issue_id is required")
109
+ if UUID_RE.match(issue_id):
110
+ return issue_id
111
+ # Treat as a human identifier -- search for it
112
+ data = graphql(
113
+ """query($orgId: ID!, $search: String) {
114
+ issues(orgId: $orgId, search: $search) { id identifier }
115
+ }""",
116
+ {"orgId": org_id, "search": issue_id},
117
+ )
118
+ issues: list[dict[str, Any]] = data.get("issues", [])
119
+ target = issue_id.lower()
120
+ for i in issues:
121
+ if i.get("identifier", "").lower() == target:
122
+ return i["id"]
123
+ raise ValueError(f"Issue '{issue_id}' not found")
124
+
125
+
126
+ def auth() -> dict[str, str]:
127
+ """Fetch identity via GraphQL ``{ me { ... } }``.
128
+
129
+ Session-start is detected server-side by the heartbeat in the
130
+ notification poll/stream endpoints, so no dedicated auth call is needed.
131
+ """
132
+ global member_id, org_id, member_timezone
133
+ resp = httpx.post(
134
+ f"{get_api_url()}/graphql",
135
+ headers=headers(),
136
+ json={"query": "{ me { id orgId displayName timezone } }"},
137
+ timeout=HTTP_TIMEOUT_S,
138
+ )
139
+ resp.raise_for_status()
140
+ gql: dict[str, Any] = resp.json()
141
+ me: dict[str, Any] | None = (gql.get("data") or {}).get("me")
142
+ if not me:
143
+ errors = gql.get("errors", [])
144
+ detail = errors[0]["message"] if errors else "unauthorized"
145
+ raise ValueError(f"GraphQL me query failed: {detail}")
146
+
147
+ member_id = me.get("id", "")
148
+ org_id = me.get("orgId", "")
149
+ member_timezone = me.get("timezone", "UTC")
150
+ display_name = me.get("displayName", "unknown")
151
+
152
+ return {"member_id": member_id, "org_id": org_id, "display_name": display_name}
@@ -0,0 +1,90 @@
1
+ """Shared constants for the AgentSlack MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Timeouts (seconds)
9
+ # ---------------------------------------------------------------------------
10
+
11
+ GRAPHQL_TIMEOUT_S: int = 15
12
+ HTTP_TIMEOUT_S: int = 10
13
+ NOTIFICATION_STREAM_TIMEOUT_S: int = 30
14
+ NOTIFICATION_CLIENT_TIMEOUT_S: int = 35 # slightly > stream timeout
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Polling
18
+ # ---------------------------------------------------------------------------
19
+
20
+ POLL_INTERVAL_S: int = 3
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Defaults
24
+ # ---------------------------------------------------------------------------
25
+
26
+ DEFAULT_API_URL: str = "http://localhost:8100"
27
+ DEFAULT_MESSAGE_LIMIT: int = 30
28
+ DEFAULT_NOTIFICATION_LIMIT: int = 20
29
+ DEFAULT_RECAP_LIMIT: int = 5
30
+ DEFAULT_GIF_LIMIT: int = 20
31
+ MAX_GIF_LIMIT: int = 50
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # GraphQL field fragments
35
+ # ---------------------------------------------------------------------------
36
+
37
+ MSG_FIELDS: str = (
38
+ "id content authorId createdAt "
39
+ "author { id displayName type } "
40
+ "replyCount latestReplyAt "
41
+ "threadParticipants { id displayName }"
42
+ )
43
+
44
+ DM_MSG_FIELDS: str = (
45
+ "id dmChannelId content authorId createdAt "
46
+ "author { id displayName type } "
47
+ "replyCount latestReplyAt"
48
+ )
49
+
50
+ ISSUE_FIELDS: str = (
51
+ "id identifier title description status priority "
52
+ "createdBy { id displayName } createdAt updatedAt completedAt "
53
+ "assignees { id displayName } "
54
+ "labels { id name color }"
55
+ )
56
+
57
+ CYCLE_FIELDS: str = (
58
+ "id orgId name description status startAt endAt "
59
+ "createdBy { id displayName } createdAt updatedAt"
60
+ )
61
+
62
+ PROJECT_FIELDS: str = (
63
+ "id orgId name description status startAt endAt "
64
+ "createdBy { id displayName } createdAt updatedAt"
65
+ )
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Patterns
69
+ # ---------------------------------------------------------------------------
70
+
71
+ UUID_RE: re.Pattern[str] = re.compile(
72
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
73
+ re.IGNORECASE,
74
+ )
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Feedback
78
+ # ---------------------------------------------------------------------------
79
+
80
+ FEEDBACK_CATEGORIES: frozenset[str] = frozenset(
81
+ {"missing_tool", "missing_feature", "bug", "ux", "other"}
82
+ )
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Work status
86
+ # ---------------------------------------------------------------------------
87
+
88
+ WORK_STATUSES: frozenset[str] = frozenset(
89
+ {"available", "busy", "focus", "offline"}
90
+ )
@@ -0,0 +1,88 @@
1
+ """Shared formatting helpers for converting GraphQL results to human-readable text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def fmt_msg(msg: dict[str, Any]) -> str:
9
+ """Format a single message dict into a compact one-line string."""
10
+ author_obj = msg.get("author") or {}
11
+ author = author_obj.get("displayName", "unknown")
12
+ author_type = author_obj.get("type")
13
+ type_tag = f" [{author_type}]" if author_type else ""
14
+ ts = msg.get("createdAt", "?")
15
+ content = msg.get("content", "")
16
+ msg_id = msg.get("id", "")
17
+ id_tag = f" (id: {msg_id})" if msg_id else ""
18
+ reply_count: int = msg.get("replyCount") or 0
19
+ thread_info = ""
20
+ if reply_count:
21
+ participants: list[dict[str, Any]] = msg.get("threadParticipants", [])
22
+ names = ", ".join(p.get("displayName", "?") for p in participants)
23
+ thread_info = (
24
+ f" ({reply_count} replies — {names})" if names else f" ({reply_count} replies)"
25
+ )
26
+ return f"[{author}{type_tag} @ {ts}]{id_tag}{thread_info}: {content}"
27
+
28
+
29
+ def fmt_issue(issue: dict[str, Any]) -> str:
30
+ """Format a single issue dict into a compact one-line string."""
31
+ assignees = ", ".join(
32
+ a.get("displayName", a.get("id", "?")) for a in issue.get("assignees", [])
33
+ ) or "unassigned"
34
+ labels = ", ".join(lbl.get("name", "?") for lbl in issue.get("labels", []))
35
+ label_str = f" [{labels}]" if labels else ""
36
+ issue_id = issue.get("id", "")
37
+ id_tag = f" (id: {issue_id})" if issue_id else ""
38
+ return (
39
+ f"- {issue.get('identifier', '?')}{id_tag}: {issue.get('title', '?')} "
40
+ f"({issue.get('status', '?')}/{issue.get('priority', '?')}) "
41
+ f"→ {assignees}{label_str}"
42
+ )
43
+
44
+
45
+ def fmt_cycle(cycle: dict[str, Any]) -> str:
46
+ """Format a single cycle dict into a compact one-line string."""
47
+ status = cycle.get("status", "?")
48
+ start = cycle.get("startAt") or "unset"
49
+ end = cycle.get("endAt") or "unset"
50
+ creator = (cycle.get("createdBy") or {}).get("displayName", "?")
51
+ return (
52
+ f"- {cycle.get('name', '?')} (id: {cycle.get('id', '?')}) "
53
+ f"[{status}] {start} → {end} (by {creator})"
54
+ )
55
+
56
+
57
+ def fmt_project(project: dict[str, Any]) -> str:
58
+ """Format a single project dict into a compact one-line string."""
59
+ status = project.get("status", "?")
60
+ start = project.get("startAt") or "unset"
61
+ end = project.get("endAt") or "unset"
62
+ creator = (project.get("createdBy") or {}).get("displayName", "?")
63
+ return (
64
+ f"- {project.get('name', '?')} (id: {project.get('id', '?')}) "
65
+ f"[{status}] {start} → {end} (by {creator})"
66
+ )
67
+
68
+
69
+ def fmt_paginated_messages(
70
+ edges: list[dict[str, Any]],
71
+ page_info: dict[str, Any],
72
+ empty_label: str,
73
+ ) -> str:
74
+ """Format a paginated list of message edges with cursor info."""
75
+ if not edges:
76
+ return empty_label
77
+ lines = [f"{len(edges)} message(s):\n"]
78
+ for edge in edges:
79
+ lines.append(fmt_msg(edge["node"]))
80
+ if page_info.get("hasNextPage"):
81
+ lines.append(
82
+ f"\n(older messages available — use before_cursor: {page_info.get('endCursor')})"
83
+ )
84
+ if page_info.get("hasPreviousPage"):
85
+ lines.append(
86
+ f"\n(newer messages available — use after_cursor: {page_info.get('startCursor')})"
87
+ )
88
+ return "\n".join(lines)