nbclaw 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 @@
1
+ {"sessionId":"1e776140-e63d-4779-bc4e-98c7f710c1ff","pid":77930,"procStart":"Thu Jun 25 23:11:53 2026","acquiredAt":1782429113867}
@@ -0,0 +1,10 @@
1
+ .swival
2
+ tmp
3
+ __pycache__/
4
+ *.py[oc]
5
+ build/
6
+ dist/
7
+ wheels/
8
+ *.egg-info
9
+ .venv
10
+ *~
nbclaw-0.1.0/LOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Log
2
+
3
+ ## Switch swival to the PyPI release
4
+
5
+ The user asked to depend on the published swival release rather than the relative
6
+ local path. Removed the `[tool.uv.sources]` override in `pyproject.toml` that pointed
7
+ swival at `../swival` as an editable install, then re-ran `uv lock` so the lockfile
8
+ resolves swival 1.0.33 from PyPI. Confirmed with `uv sync` that the editable install
9
+ was replaced by the registry wheel and that `import swival` still works.
10
+
11
+ ## Add a Makefile
12
+
13
+ The user asked for a convenient Makefile modeled on the one in `~/src/swival`. Adapted
14
+ that file to nbclaw: kept the `install`, `test`, `lint`, `format`, `check`, `clean`, and
15
+ `dist` targets but pointed them at the `nbclaw/` package, and dropped swival's `website`
16
+ and Homebrew-formula steps since this project has no `build.py` or `scripts/`. Verified
17
+ `make check` and `make test` both pass.
nbclaw-0.1.0/Makefile ADDED
@@ -0,0 +1,25 @@
1
+ .PHONY: all install test lint format check clean dist
2
+
3
+ all: check
4
+
5
+ install:
6
+ uv sync
7
+
8
+ test:
9
+ uv run python -m pytest tests/ -v --durations=25
10
+
11
+ lint:
12
+ uv run ruff check nbclaw/ tests/
13
+
14
+ format:
15
+ uv run ruff format nbclaw/ tests/
16
+
17
+ check: lint
18
+ uv run ruff format --check nbclaw/ tests/
19
+
20
+ clean:
21
+ rm -rf dist/ build/ __pycache__ nbclaw/__pycache__ tests/__pycache__ .pytest_cache .ruff_cache
22
+ find . -name '*.pyc' -delete
23
+
24
+ dist: clean
25
+ uv build
nbclaw-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: nbclaw
3
+ Version: 0.1.0
4
+ Summary: No Bullshit Claw — a 24/7 Signal-driven Swival agent daemon
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: croniter>=3.0.0
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: swival
9
+ Description-Content-Type: text/markdown
10
+
11
+ # nbclaw
12
+
13
+ No Bullshit Claw: a small daemon that puts a [Swival](https://swival.dev/) agent n the other end of a Signal conversation.
14
+
15
+ You text it. It does the work and texts back. You can also tell it to do things on a schedule ("every weekday at 9, summarize the git log") and cancel those later.
16
+
17
+ ## Prerequisites
18
+
19
+ - Python 3.13+ and [uv](https://docs.astral.sh/uv/).
20
+ - A running model. Anything Swival supports works; the quickest is to use [LM Studio](https://lmstudio.ai/) with a tool-calling model loaded.
21
+ - `signal-cli` registered to a phone number and running as an HTTP daemon:
22
+
23
+ ```sh
24
+ signal-cli --account XXXXXXXXX daemon --http 127.0.0.1:3080
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```sh
30
+ cd nbclaw
31
+ uv sync
32
+
33
+ uv run nbclaw \
34
+ --allow YOURPHONE \
35
+ --model ornith-1.0-9b \
36
+ --notify YOURPHONE
37
+ ```
38
+
39
+ `--allow` is the number that's permitted to drive the agent (your phone).
40
+ `--notify` is optional and just sends a "nbclaw is online" message on start.
41
+
42
+ Now message the signal-cli number from your phone:
43
+
44
+ ```text
45
+ you : list the python files in ~/src and count them
46
+ nbclaw: There are 42 .py files under ~/src. ...
47
+ ```
48
+
49
+ ## Authorization (read this)
50
+
51
+ By default the agent runs in **autonomous** mode: it can run shell commands and read or edit files anywhere the account it runs as can reach. The workspace is just where it starts, not a fence. But it means **anyone on the allowlist effectively has a shell on this machine**.
52
+
53
+ - Always set `--allow` to the specific numbers you trust. With no allowlist set, every incoming message is ignored (fail closed).
54
+ - `--allow-all` exists for testing only. Don't use it on a machine you care about.
55
+ - `--safe` makes the agent read-only: no shell commands, no file edits. Good for a "just answer questions" bot.
56
+ - In a group chat, authorization is checked against the **sender**, but the reply goes to the **whole group**. So one allowed member can make the bot post agent output to everyone in that group. Keep the allowlist to people you trust with that, or only message the bot in 1:1 chats / Note to Self.
57
+
58
+ ## Commands
59
+
60
+ Send these as Signal messages. Anything not starting with `/` goes to the agent.
61
+
62
+ | Command | What it does |
63
+ | ----------------------- | ------------------------------------- |
64
+ | `/help` | List the commands. |
65
+ | `/status` | Model, mode, uptime, number of crons. |
66
+ | `/reset` | Forget this conversation's context. |
67
+ | `/cron <plain English>` | Schedule a task, described naturally. |
68
+ | `/cron list` | Show scheduled tasks. |
69
+ | `/cron del <name>` | Cancel a scheduled task. |
70
+ | `/cron run <name>` | Run a scheduled task right now. |
71
+
72
+ ### Scheduling
73
+
74
+ Just say it after `/cron` in plain English. The model works out the timing and gives the task a short name:
75
+
76
+ ```text
77
+ /cron every weekday at 9am summarize my git log in ~/src/app
78
+ /cron remind me to stretch every 2 hours
79
+ /cron tomorrow at 8am say good morning
80
+ ```
81
+
82
+ Both recurring schedules and one-time reminders ("in 10 minutes…", "tomorrow at 8am…") are understood; one-time jobs delete themselves after they fire.
83
+
84
+ Results are delivered to the conversation that created the cron, prefixed with its name. Crons run as independent one-shots, so they never pollute your chat's context.
85
+
86
+ Use `/cron list` to see names, then `/cron del <name>` to cancel.
87
+
88
+ If you'd rather be exact, the power-user form takes a literal schedule:
89
+
90
+ ```text
91
+ /cron add standup 0 9 * * 1-5 | summarize today's commits in ~/src/myrepo
92
+ ```
93
+
94
+ where the schedule is a 5-field cron expression, `@every 30m` (`30s`/`5m`/`2h`/`1d`), or `@hourly` / `@daily` / `@weekly` / `@monthly`.
95
+
96
+ ## Configuration file
97
+
98
+ Flags cover the common cases. For anything else, point `--config` at a TOML file.
99
+
100
+ Top-level keys mirror the settings; a `[swival]` table is passed straight through to `swival.Session`, so the full agent is configurable.
101
+
102
+ ```toml
103
+ # nbclaw.toml
104
+ signal_url = "http://127.0.0.1:3080"
105
+ allow = ["YOURPHONE"]
106
+ notify = "YOURPHONE"
107
+
108
+ provider = "lmstudio"
109
+ model = "ornith-1.0-9b"
110
+ # base_url = "http://127.0.0.1:1234" # only if not the provider default
111
+ max_turns = 60
112
+
113
+ state_dir = "~/.nbclaw"
114
+
115
+ # MCP servers, in swival's format.
116
+ [mcp_servers.fetch]
117
+ command = "uvx"
118
+ args = ["mcp-server-fetch"]
119
+
120
+ # Anything here is forwarded verbatim to swival.Session.
121
+ [swival]
122
+ temperature = 0.2
123
+ reasoning_effort = "medium"
124
+ ```
125
+
126
+ Run it:
127
+
128
+ ```sh
129
+ uv run nbclaw --config nbclaw.toml
130
+ ```
131
+
132
+ CLI flags override the file.
133
+
134
+ ## Running 24/7
135
+
136
+ The agent is meant to stay up.
137
+
138
+ On macOS, a launchd job keeps it alive across logouts and reboots. A template is in `deploy/com.nbclaw.daemon.plist`; edit the paths and numbers, then:
139
+
140
+ ```sh
141
+ cp deploy/com.nbclaw.daemon.plist ~/Library/LaunchAgents/
142
+ launchctl load ~/Library/LaunchAgents/com.nbclaw.daemon.plist
143
+ ```
144
+
145
+ On Linux, a user systemd unit does the same job; the plist documents the same command line.
146
+
147
+ ## State
148
+
149
+ Everything lives under `state_dir` (default `~/.nbclaw`):
150
+
151
+ - `crons.json` — scheduled tasks, written atomically.
152
+ - `workspace/` — the agent's working directory (its `base_dir`).
153
+
154
+ ## Environment variables
155
+
156
+ - `NBCLAW_LOG` sets the log level (default `INFO`; try `DEBUG`).
157
+
158
+ ## But why this since there's already XYZ?
159
+
160
+ NBClaw uses Swival as a Python library, so the CLI isn't required. This lets it work well even with small, local models and short context windows. No large models needed.
161
+
162
+ More importantly, NBClaw is ridiculously lightweight and incredibly easy to install and use.
163
+
164
+ No bloat. It intentionally ships with a minimal set of tools, but it gets the job done. And if you need more, you can extend it with skills, MCP servers, and whatever else fits your workflow.
165
+
166
+ It may not be for you, but this is the minimal claw-style agent I always wanted.
nbclaw-0.1.0/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # nbclaw
2
+
3
+ No Bullshit Claw: a small daemon that puts a [Swival](https://swival.dev/) agent n the other end of a Signal conversation.
4
+
5
+ You text it. It does the work and texts back. You can also tell it to do things on a schedule ("every weekday at 9, summarize the git log") and cancel those later.
6
+
7
+ ## Prerequisites
8
+
9
+ - Python 3.13+ and [uv](https://docs.astral.sh/uv/).
10
+ - A running model. Anything Swival supports works; the quickest is to use [LM Studio](https://lmstudio.ai/) with a tool-calling model loaded.
11
+ - `signal-cli` registered to a phone number and running as an HTTP daemon:
12
+
13
+ ```sh
14
+ signal-cli --account XXXXXXXXX daemon --http 127.0.0.1:3080
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```sh
20
+ cd nbclaw
21
+ uv sync
22
+
23
+ uv run nbclaw \
24
+ --allow YOURPHONE \
25
+ --model ornith-1.0-9b \
26
+ --notify YOURPHONE
27
+ ```
28
+
29
+ `--allow` is the number that's permitted to drive the agent (your phone).
30
+ `--notify` is optional and just sends a "nbclaw is online" message on start.
31
+
32
+ Now message the signal-cli number from your phone:
33
+
34
+ ```text
35
+ you : list the python files in ~/src and count them
36
+ nbclaw: There are 42 .py files under ~/src. ...
37
+ ```
38
+
39
+ ## Authorization (read this)
40
+
41
+ By default the agent runs in **autonomous** mode: it can run shell commands and read or edit files anywhere the account it runs as can reach. The workspace is just where it starts, not a fence. But it means **anyone on the allowlist effectively has a shell on this machine**.
42
+
43
+ - Always set `--allow` to the specific numbers you trust. With no allowlist set, every incoming message is ignored (fail closed).
44
+ - `--allow-all` exists for testing only. Don't use it on a machine you care about.
45
+ - `--safe` makes the agent read-only: no shell commands, no file edits. Good for a "just answer questions" bot.
46
+ - In a group chat, authorization is checked against the **sender**, but the reply goes to the **whole group**. So one allowed member can make the bot post agent output to everyone in that group. Keep the allowlist to people you trust with that, or only message the bot in 1:1 chats / Note to Self.
47
+
48
+ ## Commands
49
+
50
+ Send these as Signal messages. Anything not starting with `/` goes to the agent.
51
+
52
+ | Command | What it does |
53
+ | ----------------------- | ------------------------------------- |
54
+ | `/help` | List the commands. |
55
+ | `/status` | Model, mode, uptime, number of crons. |
56
+ | `/reset` | Forget this conversation's context. |
57
+ | `/cron <plain English>` | Schedule a task, described naturally. |
58
+ | `/cron list` | Show scheduled tasks. |
59
+ | `/cron del <name>` | Cancel a scheduled task. |
60
+ | `/cron run <name>` | Run a scheduled task right now. |
61
+
62
+ ### Scheduling
63
+
64
+ Just say it after `/cron` in plain English. The model works out the timing and gives the task a short name:
65
+
66
+ ```text
67
+ /cron every weekday at 9am summarize my git log in ~/src/app
68
+ /cron remind me to stretch every 2 hours
69
+ /cron tomorrow at 8am say good morning
70
+ ```
71
+
72
+ Both recurring schedules and one-time reminders ("in 10 minutes…", "tomorrow at 8am…") are understood; one-time jobs delete themselves after they fire.
73
+
74
+ Results are delivered to the conversation that created the cron, prefixed with its name. Crons run as independent one-shots, so they never pollute your chat's context.
75
+
76
+ Use `/cron list` to see names, then `/cron del <name>` to cancel.
77
+
78
+ If you'd rather be exact, the power-user form takes a literal schedule:
79
+
80
+ ```text
81
+ /cron add standup 0 9 * * 1-5 | summarize today's commits in ~/src/myrepo
82
+ ```
83
+
84
+ where the schedule is a 5-field cron expression, `@every 30m` (`30s`/`5m`/`2h`/`1d`), or `@hourly` / `@daily` / `@weekly` / `@monthly`.
85
+
86
+ ## Configuration file
87
+
88
+ Flags cover the common cases. For anything else, point `--config` at a TOML file.
89
+
90
+ Top-level keys mirror the settings; a `[swival]` table is passed straight through to `swival.Session`, so the full agent is configurable.
91
+
92
+ ```toml
93
+ # nbclaw.toml
94
+ signal_url = "http://127.0.0.1:3080"
95
+ allow = ["YOURPHONE"]
96
+ notify = "YOURPHONE"
97
+
98
+ provider = "lmstudio"
99
+ model = "ornith-1.0-9b"
100
+ # base_url = "http://127.0.0.1:1234" # only if not the provider default
101
+ max_turns = 60
102
+
103
+ state_dir = "~/.nbclaw"
104
+
105
+ # MCP servers, in swival's format.
106
+ [mcp_servers.fetch]
107
+ command = "uvx"
108
+ args = ["mcp-server-fetch"]
109
+
110
+ # Anything here is forwarded verbatim to swival.Session.
111
+ [swival]
112
+ temperature = 0.2
113
+ reasoning_effort = "medium"
114
+ ```
115
+
116
+ Run it:
117
+
118
+ ```sh
119
+ uv run nbclaw --config nbclaw.toml
120
+ ```
121
+
122
+ CLI flags override the file.
123
+
124
+ ## Running 24/7
125
+
126
+ The agent is meant to stay up.
127
+
128
+ On macOS, a launchd job keeps it alive across logouts and reboots. A template is in `deploy/com.nbclaw.daemon.plist`; edit the paths and numbers, then:
129
+
130
+ ```sh
131
+ cp deploy/com.nbclaw.daemon.plist ~/Library/LaunchAgents/
132
+ launchctl load ~/Library/LaunchAgents/com.nbclaw.daemon.plist
133
+ ```
134
+
135
+ On Linux, a user systemd unit does the same job; the plist documents the same command line.
136
+
137
+ ## State
138
+
139
+ Everything lives under `state_dir` (default `~/.nbclaw`):
140
+
141
+ - `crons.json` — scheduled tasks, written atomically.
142
+ - `workspace/` — the agent's working directory (its `base_dir`).
143
+
144
+ ## Environment variables
145
+
146
+ - `NBCLAW_LOG` sets the log level (default `INFO`; try `DEBUG`).
147
+
148
+ ## But why this since there's already XYZ?
149
+
150
+ NBClaw uses Swival as a Python library, so the CLI isn't required. This lets it work well even with small, local models and short context windows. No large models needed.
151
+
152
+ More importantly, NBClaw is ridiculously lightweight and incredibly easy to install and use.
153
+
154
+ No bloat. It intentionally ships with a minimal set of tools, but it gets the job done. And if you need more, you can extend it with skills, MCP servers, and whatever else fits your workflow.
155
+
156
+ It may not be for you, but this is the minimal claw-style agent I always wanted.
@@ -0,0 +1,51 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ launchd template for running nbclaw 24/7 on macOS.
4
+
5
+ Edit the paths and phone numbers below, then:
6
+ cp deploy/com.nbclaw.daemon.plist ~/Library/LaunchAgents/
7
+ launchctl load ~/Library/LaunchAgents/com.nbclaw.daemon.plist
8
+
9
+ KeepAlive restarts nbclaw if it ever exits. It assumes signal-cli and the
10
+ model server are already running (run those as their own launchd jobs).
11
+
12
+ The equivalent command line for a Linux systemd user unit is:
13
+ ExecStart=/opt/homebrew/bin/uv run --project %h/src/nbclaw nbclaw --config %h/.nbclaw/nbclaw.toml
14
+ -->
15
+ <plist version="1.0">
16
+ <dict>
17
+ <key>Label</key>
18
+ <string>com.nbclaw.daemon</string>
19
+
20
+ <key>ProgramArguments</key>
21
+ <array>
22
+ <string>/opt/homebrew/bin/uv</string>
23
+ <string>run</string>
24
+ <string>--project</string>
25
+ <string>/Users/YOU/src/nbclaw</string>
26
+ <string>nbclaw</string>
27
+ <string>--config</string>
28
+ <string>/Users/YOU/.nbclaw/nbclaw.toml</string>
29
+ </array>
30
+
31
+ <key>WorkingDirectory</key>
32
+ <string>/Users/YOU/src/nbclaw</string>
33
+
34
+ <key>EnvironmentVariables</key>
35
+ <dict>
36
+ <key>NBCLAW_LOG</key>
37
+ <string>INFO</string>
38
+ </dict>
39
+
40
+ <key>RunAtLoad</key>
41
+ <true/>
42
+
43
+ <key>KeepAlive</key>
44
+ <true/>
45
+
46
+ <key>StandardOutPath</key>
47
+ <string>/Users/YOU/.nbclaw/nbclaw.log</string>
48
+ <key>StandardErrorPath</key>
49
+ <string>/Users/YOU/.nbclaw/nbclaw.log</string>
50
+ </dict>
51
+ </plist>
@@ -0,0 +1,10 @@
1
+ """nbclaw — No Bullshit Claw.
2
+
3
+ A 24/7 daemon that drives a swival agent from Signal: send it commands, get
4
+ answers, and schedule or cancel recurring tasks.
5
+ """
6
+
7
+ from .config import Config
8
+
9
+ __all__ = ["Config"]
10
+ __version__ = "0.1.0"
@@ -0,0 +1,28 @@
1
+ """Entry point: ``python -m nbclaw`` / ``nbclaw``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+
9
+ from .config import build_config
10
+ from .daemon import Daemon
11
+
12
+
13
+ def main() -> None:
14
+ logging.basicConfig(
15
+ level=os.environ.get("NBCLAW_LOG", "INFO").upper(),
16
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
17
+ datefmt="%H:%M:%S",
18
+ )
19
+ config = build_config()
20
+ daemon = Daemon(config)
21
+ try:
22
+ asyncio.run(daemon.run())
23
+ except KeyboardInterrupt:
24
+ pass
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,83 @@
1
+ """Bridges Signal conversations to the swival agent.
2
+
3
+ ``swival.Session`` is synchronous and CPU/IO heavy, so every call is run in a
4
+ thread pool. Because the target here is a single local model, the daemon funnels
5
+ all agent work through one worker (see daemon.py); this class is therefore not
6
+ trying to be concurrency-safe across many simultaneous runs.
7
+
8
+ Each conversation keeps its own long-lived ``Session`` so chat context carries
9
+ across messages. Cron jobs run as independent one-shots and never touch chat
10
+ context.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from typing import Any
18
+
19
+ from swival import AgentError, Result, Session
20
+
21
+ log = logging.getLogger("nbclaw.agent")
22
+
23
+
24
+ def _safe_close(session: Session) -> None:
25
+ try:
26
+ session.close()
27
+ except Exception as exc: # pragma: no cover - cleanup best effort
28
+ log.debug("session close error: %s", exc)
29
+
30
+
31
+ class AgentRunner:
32
+ def __init__(self, session_kwargs: dict[str, Any]) -> None:
33
+ self._kwargs = session_kwargs
34
+ self._sessions: dict[str, Session] = {}
35
+
36
+ def _session_for(self, key: str) -> Session:
37
+ session = self._sessions.get(key)
38
+ if session is None:
39
+ log.info("creating session for %s", key)
40
+ session = Session(**self._kwargs)
41
+ self._sessions[key] = session
42
+ return session
43
+
44
+ def reset(self, key: str) -> bool:
45
+ """Drop a conversation's context. Returns True if there was one."""
46
+ session = self._sessions.pop(key, None)
47
+ if session is None:
48
+ return False
49
+ _safe_close(session)
50
+ return True
51
+
52
+ # --- blocking primitives (run inside the executor) -----------------
53
+ def _ask_blocking(self, key: str, prompt: str) -> str:
54
+ session = self._session_for(key)
55
+ result = session.ask(prompt)
56
+ return _answer_text(result)
57
+
58
+ def _once_blocking(self, prompt: str) -> str:
59
+ session = Session(**self._kwargs)
60
+ try:
61
+ return _answer_text(session.run(prompt))
62
+ finally:
63
+ _safe_close(session)
64
+
65
+ # --- async wrappers ------------------------------------------------
66
+ async def chat(self, key: str, prompt: str) -> str:
67
+ return await asyncio.to_thread(self._ask_blocking, key, prompt)
68
+
69
+ async def once(self, prompt: str) -> str:
70
+ return await asyncio.to_thread(self._once_blocking, prompt)
71
+
72
+ def close_all(self) -> None:
73
+ for session in self._sessions.values():
74
+ _safe_close(session)
75
+ self._sessions.clear()
76
+
77
+
78
+ def _answer_text(result: Result) -> str:
79
+ if result.answer:
80
+ return result.answer
81
+ if result.exhausted:
82
+ raise AgentError("agent ran out of turns without an answer")
83
+ raise AgentError("agent returned no answer")
@@ -0,0 +1,56 @@
1
+ """Parsing helpers and help text for the slash-command interface.
2
+
3
+ Anything that doesn't start with ``/`` is treated as a prompt for the agent.
4
+ The command dispatch itself lives in :mod:`nbclaw.daemon` because it needs the
5
+ daemon's live state (scheduler, sessions, start time).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ HELP_TEXT = """nbclaw — commands
11
+
12
+ Plain text is sent to the agent. Slash commands:
13
+
14
+ /help show this help
15
+ /status model, uptime, active crons
16
+ /reset forget this conversation's context
17
+
18
+ Scheduling — just say it in plain English after /cron:
19
+ /cron every weekday at 9am summarize my git log in ~/src/app
20
+ /cron remind me to stretch every 2 hours
21
+ /cron tomorrow at 8am say good morning
22
+
23
+ /cron list list scheduled tasks
24
+ /cron del <name> cancel a scheduled task
25
+ /cron run <name> run a scheduled task right now
26
+
27
+ Power-user form (exact cron expression):
28
+ /cron add <name> <schedule> | <prompt>
29
+ schedules: 0 9 * * 1-5 · @every 30m · @hourly @daily @weekly @monthly
30
+ """.strip()
31
+
32
+
33
+ class CronAddError(ValueError):
34
+ pass
35
+
36
+
37
+ def parse_cron_add(args: str) -> tuple[str, str, str]:
38
+ """Parse the body of ``/cron add`` into (name, schedule, prompt).
39
+
40
+ Grammar: ``<name> <schedule...> | <prompt>``
41
+ The ``|`` separates the (space-containing) schedule from the prompt.
42
+ """
43
+ if "|" not in args:
44
+ raise CronAddError("missing '|' separating the schedule from the prompt")
45
+ head, prompt = args.split("|", 1)
46
+ prompt = prompt.strip()
47
+ head_parts = head.split()
48
+ if len(head_parts) < 2:
49
+ raise CronAddError("expected: <name> <schedule> | <prompt>")
50
+ name = head_parts[0]
51
+ schedule = " ".join(head_parts[1:])
52
+ if not prompt:
53
+ raise CronAddError("the prompt is empty")
54
+ if not schedule:
55
+ raise CronAddError("the schedule is empty")
56
+ return name, schedule, prompt