diff --git a/plugins/slack-publish/.claude-plugin/plugin.json b/plugins/slack-publish/.claude-plugin/plugin.json index 86bf2ab..7bbcba1 100644 --- a/plugins/slack-publish/.claude-plugin/plugin.json +++ b/plugins/slack-publish/.claude-plugin/plugin.json @@ -5,5 +5,5 @@ "name": "1shooperman", "email": "contact@aglflorida.com" }, - "version": "1.0.0" + "version": "1.0.1" } diff --git a/plugins/slack-publish/agents/agent-token-checker.md b/plugins/slack-publish/agents/agent-token-checker.md new file mode 100644 index 0000000..b730cbf --- /dev/null +++ b/plugins/slack-publish/agents/agent-token-checker.md @@ -0,0 +1,17 @@ +--- +name: agent-token-checker +description: Checks whether SLACK_BOT_TOKEN is available (env var or .env file). Returns exit code 0 if found, 1 if missing. Used by the publish skill before attempting to post to Slack. +allowed-tools: [Bash] +model: haiku +--- + +## Instruction + +Run the token checker script and report the result: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/verify_slackbot_token.py" +``` + +- Exit code **0** → token is available. Report: "Token found." +- Exit code **1** → token is missing. Report: "Token missing." diff --git a/plugins/slack-publish/scripts/verify_slackbot_token.py b/plugins/slack-publish/scripts/verify_slackbot_token.py new file mode 100644 index 0000000..c777d8c --- /dev/null +++ b/plugins/slack-publish/scripts/verify_slackbot_token.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Exit 0 if SLACK_BOT_TOKEN is available, 1 otherwise.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def find_token() -> str | None: + token = os.environ.get("SLACK_BOT_TOKEN") + if token: + return token + + cwd = Path.cwd() + for candidate in [cwd / ".env", cwd / ".env.local"]: + if not candidate.is_file(): + continue + for raw in candidate.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if line.startswith("SLACK_BOT_TOKEN="): + value = line.split("=", 1)[1].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + if value: + return value + return None + + +def main() -> int: + return 0 if find_token() else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/slack-publish/skills/publish/SKILL.md b/plugins/slack-publish/skills/publish/SKILL.md index 7f27c5b..8a3ac6b 100644 --- a/plugins/slack-publish/skills/publish/SKILL.md +++ b/plugins/slack-publish/skills/publish/SKILL.md @@ -16,29 +16,45 @@ Parse `` and `` from `$ARGUMENTS`. Both are required — ## Workflow -1. Verify the markdown file exists at the given path. +1. **Check for `SLACK_BOT_TOKEN`** before anything else — use the `slack-publish:agent-token-checker` agent. If it reports "Token missing", **stop here** and show the user this guidance: + If this exits non-zero, **stop here** and show the user this guidance: -2. Run the publisher script, quoting each argument as a discrete shell word to prevent shell injection: + > **`SLACK_BOT_TOKEN` is not set.** To publish to Slack you need a bot token. Here's how to get one: + > + > 1. Go to **https://api.slack.com/apps** and click **Create New App → From a manifest** + > 2. Select your workspace and paste the contents of `slack-app-manifest.yaml` (in the plugin directory) + > 3. Click **Install to Workspace** and copy the **Bot User OAuth Token** (starts with `xoxb-`) + > 4. Invite the bot to your target channel: `/invite @Markdown Publisher` + > 5. Set the token in one of these ways: + > - **Shell environment**: `export SLACK_BOT_TOKEN='xoxb-...'` + > - **`.env` file** in your working directory: `SLACK_BOT_TOKEN=xoxb-...` + > - **Pass at runtime**: re-run with `--env-file /path/to/file.env` + > + > Once the token is set, try again. + +2. Verify the markdown file exists at the given path. + +3. Run the publisher script, quoting each argument as a discrete shell word to prevent shell injection: ```bash python3 "${CLAUDE_PLUGIN_ROOT}/skills/publish/scripts/publish_markdown_to_slack.py" \ -- "" "" ``` -3. If `SLACK_BOT_TOKEN` is not set in the environment, the script will also check `.env` and `.env.local` in the current directory automatically. To use a different token file: +4. To use a different token file: ```bash python3 "${CLAUDE_PLUGIN_ROOT}/skills/publish/scripts/publish_markdown_to_slack.py" \ -- "" "" --env-file "" ``` -4. To preview the converted Slack text without posting: +5. To preview the converted Slack text without posting: ```bash python3 "${CLAUDE_PLUGIN_ROOT}/skills/publish/scripts/publish_markdown_to_slack.py" \ -- "" "" --dry-run ``` -5. Report success including the resolved channel ID and message timestamp (`ts`). +6. Report success including the resolved channel ID and message timestamp (`ts`). -6. If posting fails, report the exact Slack API error. Common causes: +7. If posting fails, report the exact Slack API error. Common causes: - Missing token scopes (`chat:write`, `channels:read`, `groups:read`) - Bot not invited to the destination channel diff --git a/plugins/slack-publish/tests/test_publish_markdown_to_slack.py b/plugins/slack-publish/tests/test_publish_markdown_to_slack.py index 50b5dba..eb80018 100644 --- a/plugins/slack-publish/tests/test_publish_markdown_to_slack.py +++ b/plugins/slack-publish/tests/test_publish_markdown_to_slack.py @@ -10,7 +10,9 @@ _parse_dotenv, convert_inline, load_slack_token, + main, markdown_to_slack, + resolve_channel_id, ) @@ -174,3 +176,320 @@ def test_load_token_missing(monkeypatch, tmp_path): monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) monkeypatch.chdir(tmp_path) assert load_slack_token(tmp_path / "file.md", None) is None + + +# --- main --- + +def test_main_missing_token_exits_1(monkeypatch, tmp_path, capsys): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + monkeypatch.chdir(tmp_path) + md = tmp_path / "msg.md" + md.write_text("# Hello\n") + monkeypatch.setattr(sys, "argv", ["prog", str(md), "#general"]) + assert main() == 1 + err = capsys.readouterr().err + assert "SLACK_BOT_TOKEN" in err + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + monkeypatch.setattr(sys, "argv", ["prog", str(tmp_path / "missing.md"), "#general"]) + assert main() == 1 + err = capsys.readouterr().err + assert "not found" in err + + +def test_main_dry_run_prints_and_exits_0(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + md = tmp_path / "msg.md" + md.write_text("# Hello\n") + monkeypatch.setattr(sys, "argv", ["prog", str(md), "#general", "--dry-run"]) + assert main() == 0 + out = capsys.readouterr().out + assert "*Hello*" in out + + +def test_main_empty_rendered_exits_1(monkeypatch, tmp_path, capsys): + """A file whose content renders to empty (only blank lines) exits 1.""" + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + md = tmp_path / "empty.md" + md.write_text("\n\n\n") + monkeypatch.setattr(sys, "argv", ["prog", str(md), "#general"]) + assert main() == 1 + err = capsys.readouterr().err + assert "empty" in err.lower() + + +def test_main_api_error_exits_1(monkeypatch, tmp_path, capsys): + """A RuntimeError from resolve_channel_id is caught, printed to stderr, exits 1.""" + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + md = tmp_path / "msg.md" + md.write_text("Hello\n") + monkeypatch.setattr(sys, "argv", ["prog", str(md), "#general"]) + + import publish_markdown_to_slack as _mod + monkeypatch.setattr(_mod, "resolve_channel_id", lambda *_: (_ for _ in ()).throw(RuntimeError("channel_not_found"))) + assert main() == 1 + err = capsys.readouterr().err + assert "channel_not_found" in err + + +def test_main_post_success_exits_0(monkeypatch, tmp_path, capsys): + """A successful post prints channel and ts, exits 0.""" + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + md = tmp_path / "msg.md" + md.write_text("Hello world\n") + monkeypatch.setattr(sys, "argv", ["prog", str(md), "CABCDEF123"]) + + import publish_markdown_to_slack as _mod + from publish_markdown_to_slack import SlackResult + monkeypatch.setattr(_mod, "resolve_channel_id", lambda token, ch: "CABCDEF123") + monkeypatch.setattr(_mod, "post_message", lambda token, ch, text: SlackResult(channel="CABCDEF123", ts="1234567890.000100")) + assert main() == 0 + out = capsys.readouterr().out + assert "CABCDEF123" in out + assert "1234567890.000100" in out + + +# --- resolve_channel_id --- + +def test_resolve_channel_id_already_an_id(): + """A bare channel ID matching CHANNEL_ID_RE is returned unchanged without an API call.""" + # CHANNEL_ID_RE = r"^[CGD][A-Z0-9]{8,}$" + result = resolve_channel_id("xoxb-fake", "CABCDEF123") + assert result == "CABCDEF123" + + +def test_resolve_channel_id_strips_hash_prefix_when_already_id(): + """A '#'-prefixed raw channel ID still gets resolved (hash stripped, lookup attempted).""" + # '#CABCDEF123' -> normalized 'CABCDEF123' which matches CHANNEL_ID_RE -> returned directly + result = resolve_channel_id("xoxb-fake", "#CABCDEF123") + assert result == "CABCDEF123" + + +def test_resolve_channel_id_not_found_raises(monkeypatch): + """A channel name that does not appear in the API response raises RuntimeError.""" + import publish_markdown_to_slack as _mod + monkeypatch.setattr(_mod, "_iter_channels", lambda token: iter([{"name": "other-channel", "id": "COTHER0001"}])) + with pytest.raises(RuntimeError, match="Could not resolve channel name"): + resolve_channel_id("xoxb-fake", "#nonexistent") + + +def test_resolve_channel_id_found_by_name(monkeypatch): + """A channel name matching an API result returns the correct ID.""" + import publish_markdown_to_slack as _mod + monkeypatch.setattr(_mod, "_iter_channels", lambda token: iter([{"name": "general", "id": "CGENERAL001"}])) + result = resolve_channel_id("xoxb-fake", "#general") + assert result == "CGENERAL001" + + +# --- load_slack_token (additional cases) --- + +def test_load_token_from_env_local_in_cwd(monkeypatch, tmp_path): + """Token is found in .env.local in the cwd when no env var or .env exists.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env.local").write_text("SLACK_BOT_TOKEN=xoxb-local\n") + monkeypatch.chdir(tmp_path) + assert load_slack_token(tmp_path / "file.md", None) == "xoxb-local" + + +def test_load_token_from_dotenv_in_markdown_dir(monkeypatch, tmp_path): + """Token is found in .env next to the markdown file when cwd differs.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + subdir = tmp_path / "docs" + subdir.mkdir() + (subdir / ".env").write_text("SLACK_BOT_TOKEN=xoxb-subdir\n") + cwd_dir = tmp_path / "cwd" + cwd_dir.mkdir() + monkeypatch.chdir(cwd_dir) + assert load_slack_token(subdir / "file.md", None) == "xoxb-subdir" + + +def test_load_token_explicit_env_file_missing(monkeypatch, tmp_path): + """An explicit --env-file that does not exist returns None.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + result = load_slack_token(tmp_path / "file.md", str(tmp_path / "nonexistent.env")) + assert result is None + + +def test_load_token_explicit_env_file_no_token(monkeypatch, tmp_path): + """An explicit --env-file that exists but lacks SLACK_BOT_TOKEN returns None.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + env_file = tmp_path / "other.env" + env_file.write_text("OTHER_VAR=foo\n") + result = load_slack_token(tmp_path / "file.md", str(env_file)) + assert result is None + + +# --- convert_inline (additional cases) --- + +def test_convert_inline_backtick_unchanged(): + """Inline code spans are not modified by convert_inline.""" + assert convert_inline("`some code`") == "`some code`" + + +def test_convert_inline_bold_and_italic_together(): + """Bold and italic in the same string are each converted independently.""" + result = convert_inline("**bold** and *italic*") + assert "*bold*" in result + assert "_italic_" in result + + +# --- markdown_to_slack (additional cases) --- + +def test_bullet_plus(): + """'+' list marker is converted to a bullet.""" + assert markdown_to_slack("+ item") == "• item" + + +def test_task_list_uppercase_X(): + """'- [X] done' (uppercase X) is treated as checked.""" + assert markdown_to_slack("- [X] done") == "• [x] done" + + +def test_code_fence_with_language_tag(): + """A fenced code block with a language tag still outputs bare triple-backticks.""" + md = "```python\nprint('hi')\n```" + result = markdown_to_slack(md) + assert "```" in result + assert "print('hi')" in result + + +def test_horizontal_rule_passthrough(): + """A line that is not a heading, list, or quote passes through convert_inline unchanged.""" + assert markdown_to_slack("---") == "---" + + +# --- resolve_channel_id (additional cases) --- + +def test_resolve_channel_id_name_without_hash(monkeypatch): + """A bare channel name (no '#' prefix) is looked up via _iter_channels.""" + import publish_markdown_to_slack as _mod + monkeypatch.setattr(_mod, "_iter_channels", lambda token: iter([{"name": "general", "id": "CGENERAL001"}])) + result = resolve_channel_id("xoxb-fake", "general") + assert result == "CGENERAL001" + + +def test_resolve_channel_id_empty_channel_list_raises(monkeypatch): + """An empty channel list raises RuntimeError with an informative message.""" + import publish_markdown_to_slack as _mod + monkeypatch.setattr(_mod, "_iter_channels", lambda token: iter([])) + with pytest.raises(RuntimeError, match="Could not resolve channel name"): + resolve_channel_id("xoxb-fake", "emptychannel") + + +# --- _api_post error paths --- + +def test_api_post_http_error_raises_runtime_error(monkeypatch): + """An HTTP error from Slack raises RuntimeError with the status code.""" + import urllib.error + import publish_markdown_to_slack as _mod + + def _raise(*args, **kwargs): + raise urllib.error.HTTPError(url="https://slack.com/api/test", code=400, msg="Bad Request", hdrs=None, fp=None) + + monkeypatch.setattr(_mod.urllib.request, "urlopen", _raise) + with pytest.raises(RuntimeError, match="Slack HTTP error 400"): + _mod._api_post("xoxb-fake", "chat.postMessage", {"channel": "C1", "text": "hi"}) + + +def test_api_post_url_error_raises_runtime_error(monkeypatch): + """A URLError (network failure) raises RuntimeError with connection detail.""" + import urllib.error + import publish_markdown_to_slack as _mod + + def _raise(*args, **kwargs): + raise urllib.error.URLError(reason="Name or service not known") + + monkeypatch.setattr(_mod.urllib.request, "urlopen", _raise) + with pytest.raises(RuntimeError, match="Slack connection error"): + _mod._api_post("xoxb-fake", "chat.postMessage", {"channel": "C1", "text": "hi"}) + + +def test_api_post_ok_false_raises_runtime_error(monkeypatch): + """A 200 response with ok=false raises RuntimeError with the Slack error string.""" + import io + import publish_markdown_to_slack as _mod + + class _FakeResp: + def __enter__(self): + return self + def __exit__(self, *_): + pass + def read(self): + return b'{"ok": false, "error": "not_authed"}' + + monkeypatch.setattr(_mod.urllib.request, "urlopen", lambda *a, **kw: _FakeResp()) + with pytest.raises(RuntimeError, match="not_authed"): + _mod._api_post("xoxb-fake", "chat.postMessage", {"channel": "C1", "text": "hi"}) + + +# --- _iter_channels pagination --- + +def test_iter_channels_pagination(monkeypatch): + """_iter_channels follows next_cursor and yields channels from both pages.""" + import publish_markdown_to_slack as _mod + + call_count = 0 + pages = [ + {"ok": True, "channels": [{"name": "alpha", "id": "C0000000001"}], + "response_metadata": {"next_cursor": "cursor-abc"}}, + {"ok": True, "channels": [{"name": "beta", "id": "C0000000002"}], + "response_metadata": {"next_cursor": ""}}, + ] + + def _fake_api_post(token, endpoint, payload): + nonlocal call_count + result = pages[call_count] + call_count += 1 + return result + + monkeypatch.setattr(_mod, "_api_post", _fake_api_post) + channels = list(_mod._iter_channels("xoxb-fake")) + assert len(channels) == 2 + assert channels[0]["name"] == "alpha" + assert channels[1]["name"] == "beta" + assert call_count == 2 + + +# --- load_slack_token (additional cases) --- + +def test_load_token_export_prefix_in_dotenv(monkeypatch, tmp_path): + """A dotenv line with 'export ' prefix is parsed and the token is returned.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("export SLACK_BOT_TOKEN=xoxb-export\n") + monkeypatch.chdir(tmp_path) + assert load_slack_token(tmp_path / "file.md", None) == "xoxb-export" + + +# --- main (additional cases) --- + +def test_main_with_env_file_arg(monkeypatch, tmp_path, capsys): + """--env-file argument is forwarded to load_slack_token and used for the token.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + md = tmp_path / "msg.md" + md.write_text("Hello\n") + env_file = tmp_path / "custom.env" + env_file.write_text("SLACK_BOT_TOKEN=xoxb-custom\n") + + import publish_markdown_to_slack as _mod + from publish_markdown_to_slack import SlackResult + monkeypatch.setattr(_mod, "resolve_channel_id", lambda token, ch: "CABCDEF123") + monkeypatch.setattr(_mod, "post_message", lambda token, ch, text: SlackResult(channel="CABCDEF123", ts="111.222")) + monkeypatch.setattr(sys, "argv", ["prog", str(md), "#general", "--env-file", str(env_file)]) + assert main() == 0 + + +def test_main_post_message_runtime_error_exits_1(monkeypatch, tmp_path, capsys): + """A RuntimeError from post_message is caught, printed to stderr, exits 1.""" + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + md = tmp_path / "msg.md" + md.write_text("Hello\n") + monkeypatch.setattr(sys, "argv", ["prog", str(md), "CABCDEF123"]) + + import publish_markdown_to_slack as _mod + monkeypatch.setattr(_mod, "resolve_channel_id", lambda token, ch: "CABCDEF123") + monkeypatch.setattr(_mod, "post_message", lambda *_: (_ for _ in ()).throw(RuntimeError("message_too_long"))) + assert main() == 1 + err = capsys.readouterr().err + assert "message_too_long" in err diff --git a/plugins/slack-publish/tests/test_verify_slackbot_token.py b/plugins/slack-publish/tests/test_verify_slackbot_token.py new file mode 100644 index 0000000..192f25a --- /dev/null +++ b/plugins/slack-publish/tests/test_verify_slackbot_token.py @@ -0,0 +1,105 @@ +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +from verify_slackbot_token import find_token, main + + +def test_find_token_from_env(monkeypatch): + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-from-env") + assert find_token() == "xoxb-from-env" + + +def test_find_token_missing(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + monkeypatch.chdir(tmp_path) + assert find_token() is None + + +def test_find_token_from_dotenv(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("SLACK_BOT_TOKEN=xoxb-dotenv\n") + monkeypatch.chdir(tmp_path) + assert find_token() == "xoxb-dotenv" + + +def test_find_token_from_dotenv_local(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env.local").write_text("SLACK_BOT_TOKEN=xoxb-local\n") + monkeypatch.chdir(tmp_path) + assert find_token() == "xoxb-local" + + +def test_find_token_double_quoted(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text('SLACK_BOT_TOKEN="xoxb-quoted"\n') + monkeypatch.chdir(tmp_path) + assert find_token() == "xoxb-quoted" + + +def test_find_token_single_quoted(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("SLACK_BOT_TOKEN='xoxb-single'\n") + monkeypatch.chdir(tmp_path) + assert find_token() == "xoxb-single" + + +def test_find_token_env_takes_precedence_over_dotenv(monkeypatch, tmp_path): + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-env") + (tmp_path / ".env").write_text("SLACK_BOT_TOKEN=xoxb-file\n") + monkeypatch.chdir(tmp_path) + assert find_token() == "xoxb-env" + + +def test_find_token_dotenv_prefers_env_over_env_local(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("SLACK_BOT_TOKEN=xoxb-dotenv\n") + (tmp_path / ".env.local").write_text("SLACK_BOT_TOKEN=xoxb-local\n") + monkeypatch.chdir(tmp_path) + assert find_token() == "xoxb-dotenv" + + +def test_main_returns_0_when_token_set(monkeypatch, tmp_path): + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test") + assert main() == 0 + + +def test_main_returns_1_when_token_missing(monkeypatch, tmp_path): + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + monkeypatch.chdir(tmp_path) + assert main() == 1 + + +def test_find_token_empty_value_in_dotenv_returns_none(monkeypatch, tmp_path): + """An empty value after '=' in the dotenv file is treated as absent.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("SLACK_BOT_TOKEN=\n") + monkeypatch.chdir(tmp_path) + assert find_token() is None + + +def test_find_token_unrelated_keys_ignored(monkeypatch, tmp_path): + """Lines that do not start with SLACK_BOT_TOKEN= do not produce a token.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("OTHER_KEY=xoxb-other\nFOO=bar\n") + monkeypatch.chdir(tmp_path) + assert find_token() is None + + +def test_find_token_mismatched_quotes_returned_as_is(monkeypatch, tmp_path): + """A value with mismatched quote delimiters is returned verbatim (no stripping).""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").write_text("SLACK_BOT_TOKEN=\"xoxb-bad'\n") + monkeypatch.chdir(tmp_path) + # Mismatched quotes: first char '"' != last char "'" so no stripping occurs. + assert find_token() == "\"xoxb-bad'" + + +def test_find_token_dotenv_not_file(monkeypatch, tmp_path): + """.env that is a directory (not a file) is skipped without error.""" + monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) + (tmp_path / ".env").mkdir() + monkeypatch.chdir(tmp_path) + assert find_token() is None