Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ pip install 'grapharc[openai]' # OpenAI and OpenAI-compatible endpoints
pip install 'grapharc[ollama]' # a local server
pip install 'grapharc[server]' # the FastAPI + SSE HTTP API
pip install 'grapharc[otel]' # OpenTelemetry span export
pip install 'grapharc[slack]' # run the CLI from Slack — docs/cookbook/07-slack.md
pip install 'grapharc[all]' # every one of the above
```

Expand Down
119 changes: 119 additions & 0 deletions docs/cookbook/07-slack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Running the CLI from Slack

`grapharc` on a laptop, driven from the Slack app on a phone. The bot in
`grapharc.slack` holds one *outbound* Socket Mode connection to Slack, so it
needs no public URL, no open port and no reverse proxy — home Wi‑Fi behind NAT
is enough. A workspace member types `/grapharc metrics t.jsonl r1` (or
mentions the bot in a channel); the bot runs the command on the host and posts
the output back in the thread.

Nothing in this page is byte-compared by the test suite — Slack is on the
other end of every interesting command. What *is* tested, in
`tests/test_slack_gateway.py`, is everything short of Slack itself: the gate
that decides what text may become an argv, the runner, and the formatter.

## What the bot will and will not run

Anyone in the workspace can talk to the bot, so admission is the design, not
an afterthought. The defaults:

| Reachable from Slack | Refused from Slack |
|---|---|
| `demo`, `run`, `plan`, `models`, `replay`, `diff`, `trace`, `metrics`, `viz` | `agent` (arbitrary tool execution on the host), `serve` |
| Paths that resolve inside the bot's working directory | Any path that escapes it (`trace ../../.env` is refused before a process spawns) |
| The budget, policy and trace flags each command already has | `--registry` (imports an arbitrary module), `--config`, `--json`, `--no-color` |
| — | `--model` / `--reviewer-model`, unless the operator opts in |

With `--model` off, every reachable command runs the scripted, spend-free
path. The default answer to "can someone in Slack cost me money?" is **no**;
`GRAPHARC_SLACK_ALLOW_MODEL=1` changes that answer deliberately, in the shell
that starts the bot, not from Slack.

Output is the CLI's piped-mode bytes in a code fence. stdout in the bot is a
pipe, so by the CLI's own contract there is no colour to strip and the bytes
match what `grapharc … | cat` prints on the host.

## Slack app setup (once, ~5 minutes)

1. <https://api.slack.com/apps> → **Create New App** → *From a manifest*, pick
the workspace, and paste:

```yaml
display_information:
name: grapharc
features:
bot_user:
display_name: grapharc
slash_commands:
- command: /grapharc
description: run a grapharc command on the host
usage_hint: "metrics t.jsonl r1"
oauth_config:
scopes:
bot:
- commands
- app_mentions:read
- chat:write
settings:
event_subscriptions:
bot_events:
- app_mention
socket_mode_enabled: true
interactivity:
is_enabled: true
```

2. **Basic Information → App-Level Tokens** → generate one with the
`connections:write` scope. That is `SLACK_APP_TOKEN` (`xapp-…`).
3. **Install App** to the workspace. The bot token on the OAuth page is
`SLACK_BOT_TOKEN` (`xoxb-…`).
4. Invite the bot to a channel: `/invite @grapharc`.

## Running it

```bash
uv sync --extra slack # or: pip install 'grapharc[slack]'

export SLACK_BOT_TOKEN=xoxb-…
export SLACK_APP_TOKEN=xapp-…
mkdir -p ~/grapharc-slack && cd ~/grapharc-slack # the bot's whole world
python -m grapharc.slack
```

The startup line states the resolved working directory, the timeout and
whether model flags are on — the three decisions that matter — then blocks
until interrupted. From Slack:

```
/grapharc plan "investigate the checkout outage"
/grapharc trace t.jsonl
@grapharc metrics t.jsonl <run-id>
```

Configuration is environment-only, read once at startup:

| Variable | Default | Meaning |
|---|---|---|
| `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | — (required) | the two tokens from the app page |
| `GRAPHARC_SLACK_WORKDIR` | the bot's cwd | the directory every path must resolve inside |
| `GRAPHARC_SLACK_TIMEOUT` | `120` | seconds one command may run before it is killed |
| `GRAPHARC_SLACK_ALLOW_MODEL` | off | `1` admits `--model`/`--reviewer-model` |
| `GRAPHARC_SLACK_COMMAND` | `/grapharc` | the slash command to answer to |

The bot reads tokens from the process environment only. The `.env`
upward-directory search that the model gateway performs is deliberately not
used here: a bot that a whole workspace can drive must not discover
credentials in a file the operator did not point it at.

## The honest caveats

- **The bot is alive while the process is.** Laptop lid closed means commands
from a phone go unanswered — Slack shows the slash command timing out, and
nothing queues. The same script runs unchanged on any always-on box.
- **Slack's three-second ack.** The bot acks immediately ("running …") and
posts the result when the command finishes; the timeout bounds how long
that can be.
- **The workspace is the trust boundary.** The gate stops path escapes,
module imports and spend, but anyone in the workspace can run every allowed
command against every file in the working directory. Give the bot a
directory that contains nothing you would not show the whole channel.
48 changes: 48 additions & 0 deletions grapharc/slack/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""A Slack front door for the `grapharc` CLI, over Socket Mode.

The bot holds one outbound WebSocket to Slack, so it runs anywhere with
internet — a laptop behind NAT included. No public URL, no open port. A
workspace member types `/grapharc metrics t.jsonl r1` (or mentions the bot)
from any Slack client, the bot runs the command on the host, and posts the
piped-mode output back in the thread. The piped bytes are already the CLI's
machine interface — colourless, byte-compared against the docs — which is why
the bot posts them verbatim inside a code fence instead of inventing a third
output format.

The module is layered so everything with behaviour is testable without Slack:

command.py what Slack text is allowed to become an argv (the gate)
runner.py run an argv against this interpreter's grapharc, with a timeout
format.py turn an exit code and captured output into one Slack message
bot.py slack-bolt wiring; the only file that imports slack_bolt
config.py tokens and limits from the environment, nothing else

Only `bot.py` needs the `slack` extra, and it imports it lazily — every other
module (and this package) is stdlib-only, so a wheel without the extra still
imports.

The gate's default is deliberately spend-free: `agent` and `serve` are refused,
`--model` is refused unless the operator opts in, and every path argument must
resolve inside the bot's working directory. Anyone in the workspace can talk
to the bot; the gate is what makes that safe to allow.
"""

from grapharc.slack.command import (
ALLOWED_COMMANDS,
SlackCommandError,
parse_command,
)
from grapharc.slack.config import SlackBotConfig, SlackConfigError
from grapharc.slack.format import format_result
from grapharc.slack.runner import CommandResult, run_command

__all__ = [
"ALLOWED_COMMANDS",
"CommandResult",
"SlackBotConfig",
"SlackCommandError",
"SlackConfigError",
"format_result",
"parse_command",
"run_command",
]
43 changes: 43 additions & 0 deletions grapharc/slack/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""`python -m grapharc.slack` — start the bot, or say exactly why it cannot.

A deliberate module entry rather than a `grapharc slack` subcommand: the CLI's
help text is printed verbatim in README.md and byte-compared by the test
suite, and a long-running daemon does not belong in a parser whose every other
command terminates. Exit codes keep the CLI's meaning: 0 on a clean shutdown,
2 when the environment does not describe a runnable bot.
"""

from __future__ import annotations

import sys

from grapharc.slack.command import SlackCommandError
from grapharc.slack.config import SlackBotConfig, SlackConfigError


def main() -> int:
try:
config = SlackBotConfig.from_env()
except SlackConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
try:
from grapharc.slack.bot import serve

print(
f"grapharc slack bot: workdir {config.workdir}, "
f"timeout {config.timeout_seconds:.0f}s, "
f"model flags {'on' if config.allow_model else 'off'}",
file=sys.stderr,
)
serve(config)
except SlackCommandError as exc: # the missing-extra message from build_app
print(f"error: {exc}", file=sys.stderr)
return 2
except KeyboardInterrupt:
pass
return 0


if __name__ == "__main__":
sys.exit(main())
79 changes: 79 additions & 0 deletions grapharc/slack/bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""slack-bolt wiring: the only module that touches Slack itself.

Everything with behaviour lives in `command`/`runner`/`format`; what remains
here is `handle_text` (their composition, still import-safe without slack-bolt
and tested that way) and the listener glue. Slack requires an ack within three
seconds, so each listener acks with "running…" first and posts the result when
the command finishes — bolt runs listeners on worker threads, so a slow
command blocks neither the socket nor other requests.

`slack_bolt` is imported inside `build_app`, not at module top: the wheel-check
imports every module in an environment without the extra, and a user who never
runs the bot should never need it installed.
"""

from __future__ import annotations

import re
from typing import Any

from grapharc.slack.command import SlackCommandError, parse_command, usage_text
from grapharc.slack.config import SlackBotConfig
from grapharc.slack.format import format_result
from grapharc.slack.runner import run_command

# An app_mention's text arrives as "<@U0BOTID> metrics t.jsonl r1".
_MENTION = re.compile(r"<@[A-Z0-9]+>\s*")


def handle_text(text: str, config: SlackBotConfig) -> str:
"""Gate, run, format: the whole request path, with Slack stripped away."""
stripped = _MENTION.sub("", text).strip()
try:
argv = parse_command(
stripped, workdir=config.workdir, allow_model=config.allow_model
)
except SlackCommandError as exc:
return str(exc)
result = run_command(
argv, workdir=config.workdir, timeout_seconds=config.timeout_seconds
)
return format_result(result)


def build_app(config: SlackBotConfig) -> Any:
"""A configured `slack_bolt.App`; raises with the install hint if the extra is absent."""
try:
from slack_bolt import App
except ImportError:
raise SlackCommandError(
"the Slack bot needs the `slack` extra: uv sync --extra slack "
"(or: pip install 'grapharc[slack]')"
) from None

app = App(token=config.bot_token)

@app.command(config.slash_command)
def _slash(ack: Any, respond: Any, command: dict[str, Any]) -> None:
text = command.get("text", "").strip()
if not text:
ack(usage_text(allow_model=config.allow_model))
return
ack(f"running `grapharc {text}`…")
respond(handle_text(text, config))

@app.event("app_mention")
def _mention(event: dict[str, Any], say: Any) -> None:
say(
handle_text(event.get("text", ""), config),
thread_ts=event.get("thread_ts") or event.get("ts"),
)

return app


def serve(config: SlackBotConfig) -> None:
"""Open the Socket Mode connection and block until interrupted."""
from slack_bolt.adapter.socket_mode import SocketModeHandler

SocketModeHandler(build_app(config), config.app_token).start()
Loading
Loading