cm_bot_v2/docs/superpowers/plans/2026-05-02-local-as-dev.md
yiekheng c6742d1537 Add implementation plan for local-as-dev tier
13 bite-sized tasks: 7 TDD tasks for app/bot_cli.py (parser, six
subcommands, TUI), then mysql + init scripts, dev.sh + bot_cli.sh,
envs/dev/.env.example, AGENTS.md, and integration verification. Uses
unittest stdlib + unittest.mock; no new deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:57:17 +08:00

1559 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Local-as-Dev Tier Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up a self-contained local dev tier — `mysql` + `api-server` + `web-view` in Docker — and a stdlib Python CLI (`app/bot_cli.py`) plus two shell scripts that mirror the Telegram bot's manual-trigger surface.
**Architecture:** Add `mysql:8.0` to `docker-compose.override.yml` published to `127.0.0.1:3306`, gate `telegram-bot`/`transfer-bot` behind a compose `bots` profile, write seed/schema SQL into `docker/mysql/init.d/`. Build `app/bot_cli.py` test-first using `unittest` + `unittest.mock` to swap `CM_BOT_HAL`. Add `scripts/dev.sh` (lifecycle) and `scripts/bot_cli.sh` (env-loading wrapper that overrides `DB_HOST=127.0.0.1`). Default-on TUI menu when CLI invoked with no args.
**Tech Stack:** Python 3.9 (containers) / 3.12 (local venv), Flask 2.3.3, MySQL 8.0, Docker Compose v2, `unittest` + `unittest.mock` (stdlib), `argparse` (stdlib). No new dependencies.
**Spec:** [docs/superpowers/specs/2026-05-02-local-as-dev-design.md](../specs/2026-05-02-local-as-dev-design.md)
---
## File Map
| File | Operation | Purpose |
|---|---|---|
| `tests/test_bot_cli.py` | Create | Unittest cases for parser, every `cmd_X`, and the TUI loop. |
| `app/bot_cli.py` | Create | argparse + stdlib TUI; `python -m app.bot_cli` entry point. |
| `docker-compose.override.yml` | Modify | Add `mysql` service, `depends_on: { mysql: { condition: service_healthy } }` to api-server, `profiles: ["bots"]` on bots, `mysql-data` volume. |
| `docker/mysql/init.d/01-schema.sql` | Create | DDL for `acc` + `user` tables. |
| `docker/mysql/init.d/02-seed.sql` | Create | Four seed `acc` rows matching `CM_PREFIX_PATTERN=13c`. |
| `scripts/dev.sh` | Create | Lifecycle: `up`/`down`/`reset-db`/`logs`/`status`. |
| `scripts/bot_cli.sh` | Create | Env-loading wrapper around `python -m app.bot_cli`. |
| `envs/dev/.env.example` | Create | Committed template for dev `.env`. |
| `.gitignore` | Modify | Add `envs/dev/.env`. |
| `AGENTS.md` | Modify | Dev tier section. |
The `app/bot_cli.py` module is grown test-first across Tasks 19. Each command function is added in a single task alongside its subparser registration and its tests.
---
## Task 1: Skeleton `app/bot_cli.py` and parser sanity test
**Files:**
- Create: `tests/test_bot_cli.py`
- Create: `app/bot_cli.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_bot_cli.py`:
```python
"""Tests for the bot CLI (app.bot_cli).
The CLI mirrors the Telegram bot's manual-trigger surface (Telegram
handlers /1, /2, /3) plus the operational ops (credit, transfer,
monitor-once). With no args, it drops into a stdlib TUI menu.
These tests mock app.bot_cli.CM_BOT_HAL so they never touch the database
or cm99.net. The HAL class is imported at module load (which is a pure
import — no env reads), so we can patch the symbol bound on app.bot_cli
without affecting other tests.
"""
import argparse
import contextlib
import io
import os
import sys
import unittest
from unittest import mock
import app.bot_cli as bot_cli
class ParserSanityTests(unittest.TestCase):
def test_build_parser_returns_argument_parser(self):
parser = bot_cli.build_parser()
self.assertIsInstance(parser, argparse.ArgumentParser)
def test_main_with_no_args_dispatches_to_interactive(self):
# When invoked with no subcommand, main() should drop into the
# TUI loop. We verify the dispatch by patching cmd_interactive to
# a no-op recorder.
with mock.patch.object(bot_cli, "cmd_interactive", return_value=0) as mocked:
rc = bot_cli.main([])
mocked.assert_called_once()
self.assertEqual(rc, 0)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -8
```
Expected: `ImportError: No module named 'app.bot_cli'` (or similar).
- [ ] **Step 3: Create the skeleton module**
Create `app/bot_cli.py`:
```python
"""Local dev CLI for the CM bot. Mirrors Telegram /1, /2, /3 plus
operational commands. No-arg invocation drops into a stdlib TUI menu.
"""
import argparse
import sys
from .cm_bot_hal import CM_BOT_HAL
def cmd_interactive(_args):
# Real implementation lands in Task 9. Stub raises so a regression
# that drops into interactive mode by accident is loud, not silent.
raise NotImplementedError("cmd_interactive is implemented in a later task")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="bot_cli",
description="CM Bot dev CLI (mirrors Telegram triggers).",
)
p.add_subparsers(dest="command")
return p
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command is None:
return cmd_interactive(args) or 0
return args.func(args) or 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -8
```
Expected: `OK` (2 tests pass).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): add module skeleton with parser sanity tests"
```
---
## Task 2: `cmd_register` (Telegram /1 analog)
**Files:**
- Modify: `tests/test_bot_cli.py` (add a new test class)
- Modify: `app/bot_cli.py` (add `cmd_register` and register `register`/`get-acc` subparser)
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py`:
```python
class CmdRegisterTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_username_password_link(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_api.return_value = {
"username": "13c1234",
"password": "abc12345",
"link": "https://example.com/r/foo",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_register(argparse.Namespace())
text = out.getvalue()
self.assertIn("Username: 13c1234", text)
self.assertIn("Password: abc12345", text)
self.assertIn("Link: https://example.com/r/foo", text)
mock_hal.get_user_api.assert_called_once_with()
def test_register_subparser_dispatches_to_cmd_register(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["register"])
self.assertIs(args.func, bot_cli.cmd_register)
def test_get_acc_alias_dispatches_to_cmd_register(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["get-acc"])
self.assertIs(args.func, bot_cli.cmd_register)
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -10
```
Expected: failures for the three new tests; `cmd_register` is missing on `bot_cli`, and the parser doesn't know about `register`.
- [ ] **Step 3: Implement `cmd_register` and register the subparser**
In `app/bot_cli.py`, add the helper and command function after the `cmd_interactive` stub but before `build_parser`:
```python
def _print_user(user: dict) -> None:
print(f"Username: {user['username']}")
print(f"Password: {user['password']}")
print(f"Link: {user['link']}")
def cmd_register(_args):
bot = CM_BOT_HAL()
_print_user(bot.get_user_api())
```
Update `build_parser()` to add the subparser:
```python
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="bot_cli",
description="CM Bot dev CLI (mirrors Telegram triggers).",
)
sub = p.add_subparsers(dest="command")
sp = sub.add_parser("register", aliases=["get-acc"], help="Get next available account (Telegram /1).")
sp.set_defaults(func=cmd_register)
return p
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -10
```
Expected: `OK` (5 tests now passing).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): add register subcommand (Telegram /1 analog)"
```
---
## Task 3: `cmd_set_pin` (Telegram /2 analog) with local name resolution
**Files:**
- Modify: `tests/test_bot_cli.py`
- Modify: `app/bot_cli.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py`:
```python
class CmdSetPinTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_rejects_invalid_whatsapp_url(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x"))
self.assertEqual(cm.exception.code, 2)
# Must NOT have called set_security_pin_api
mock_hal.set_security_pin_api.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_extracts_names_locally_and_succeeds(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.get_whatsapp_link_username.return_value = ("t_user_42", "f_user_42")
# The HAL returns a bool from set_security_pin_api today (latent
# contract bug noted in the spec). Confirm the CLI tolerates it.
mock_hal.set_security_pin_api.return_value = True
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
text = out.getvalue()
self.assertIn("f_username=f_user_42", text)
self.assertIn("t_username=t_user_42", text)
mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc")
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_falsy_set_security_pin_result_exits_nonzero(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.get_whatsapp_link_username.return_value = ("t", "f")
mock_hal.set_security_pin_api.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
self.assertEqual(cm.exception.code, 1)
def test_set_pin_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"])
self.assertIs(args.func, bot_cli.cmd_set_pin)
self.assertEqual(args.link, "https://chat.whatsapp.com/abc")
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15
```
Expected: failures for `cmd_set_pin` not defined and `set-pin` subcommand unknown.
- [ ] **Step 3: Implement `cmd_set_pin`**
In `app/bot_cli.py`, add after `cmd_register`:
```python
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
# Resolve names locally so we have something useful to print regardless
# of what set_security_pin_api returns. The HAL currently returns a bool
# from the trailing insert_user_to_table_user; the Telegram handler in
# cm_telegram.py:87 has a latent bug accessing result['f_username']. We
# avoid depending on the return shape here.
t_username, f_username = bot.get_whatsapp_link_username(args.link)
success = bot.set_security_pin_api(args.link)
if not success:
print("ERROR: set_security_pin_api returned a falsy result", file=sys.stderr)
sys.exit(1)
print(f"OK: f_username={f_username} t_username={t_username}")
```
In `build_parser()`, add after the `register` subparser:
```python
sp = sub.add_parser("set-pin", help="Set security PIN from a WhatsApp link (Telegram /2).")
sp.add_argument("link")
sp.set_defaults(func=cmd_set_pin)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12
```
Expected: `OK` (9 tests now passing).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): add set-pin subcommand with local name resolution"
```
---
## Task 4: `cmd_insert_user` (Telegram /3 analog)
**Files:**
- Modify: `tests/test_bot_cli.py`
- Modify: `app/bot_cli.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py`:
```python
class CmdInsertUserTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_inserts_using_password_lookup_and_security_pin(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_pass_from_acc.return_value = "abc12345"
mock_hal.security_pin = "999111"
mock_hal.insert_user_to_table_user.return_value = True
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_insert_user(argparse.Namespace(f_username="13c1234", t_username="player_x"))
self.assertIn("OK: inserted 13c1234 → player_x", out.getvalue())
mock_hal.insert_user_to_table_user.assert_called_once_with({
"f_username": "13c1234",
"f_password": "abc12345",
"t_username": "player_x",
"t_password": "999111",
})
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_no_password_for_f_user_exits_2(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_pass_from_acc.return_value = None
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_insert_user(argparse.Namespace(f_username="missing", t_username="player_x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.insert_user_to_table_user.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_insert_failure_exits_1(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_pass_from_acc.return_value = "abc"
mock_hal.security_pin = "000"
mock_hal.insert_user_to_table_user.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_insert_user(argparse.Namespace(f_username="13c1234", t_username="player_x"))
self.assertEqual(cm.exception.code, 1)
def test_insert_user_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["insert-user", "13c1234", "player_x"])
self.assertIs(args.func, bot_cli.cmd_insert_user)
self.assertEqual(args.f_username, "13c1234")
self.assertEqual(args.t_username, "player_x")
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15
```
Expected: failures for `cmd_insert_user` not defined.
- [ ] **Step 3: Implement `cmd_insert_user`**
In `app/bot_cli.py`, add after `cmd_set_pin`:
```python
def cmd_insert_user(args):
bot = CM_BOT_HAL()
f_password = bot.get_user_pass_from_acc(args.f_username)
if not f_password:
print(f"ERROR: no password for {args.f_username}", file=sys.stderr)
sys.exit(2)
success = bot.insert_user_to_table_user({
"f_username": args.f_username,
"f_password": f_password,
"t_username": args.t_username,
"t_password": bot.security_pin,
})
if not success:
print("ERROR: insert failed", file=sys.stderr)
sys.exit(1)
print(f"OK: inserted {args.f_username}{args.t_username}")
```
In `build_parser()`, after the `set-pin` subparser:
```python
sp = sub.add_parser("insert-user", help="Insert into user table (Telegram /3).")
sp.add_argument("f_username")
sp.add_argument("t_username")
sp.set_defaults(func=cmd_insert_user)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12
```
Expected: `OK` (13 tests now passing).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): add insert-user subcommand (Telegram /3 analog)"
```
---
## Task 5: `cmd_credit` and `cmd_transfer`
**Files:**
- Modify: `tests/test_bot_cli.py`
- Modify: `app/bot_cli.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py`:
```python
class CmdCreditTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_credit(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_credit.return_value = 42.5
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_credit(argparse.Namespace(username="13c1234", password="abc"))
self.assertIn("Credit: 42.5", out.getvalue())
mock_hal.get_user_credit.assert_called_once_with("13c1234", "abc")
def test_credit_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["credit", "13c1234", "abc"])
self.assertIs(args.func, bot_cli.cmd_credit)
class CmdTransferTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_transfer_result(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.transfer_credit_api.return_value = "Successfully transfer amount: 10.0 from 13c1234 to player_x"
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_transfer(argparse.Namespace(
f_username="13c1234", f_password="abc",
t_username="player_x", t_password="0000",
))
self.assertIn("Successfully transfer", out.getvalue())
mock_hal.transfer_credit_api.assert_called_once_with("13c1234", "abc", "player_x", "0000")
def test_transfer_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["transfer", "13c1234", "abc", "player_x", "0000"])
self.assertIs(args.func, bot_cli.cmd_transfer)
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15
```
Expected: failures for `cmd_credit` / `cmd_transfer` not defined.
- [ ] **Step 3: Implement both commands**
In `app/bot_cli.py`, add after `cmd_insert_user`:
```python
def cmd_credit(args):
bot = CM_BOT_HAL()
print(f"Credit: {bot.get_user_credit(args.username, args.password)}")
def cmd_transfer(args):
bot = CM_BOT_HAL()
print(bot.transfer_credit_api(
args.f_username, args.f_password,
args.t_username, args.t_password,
))
```
In `build_parser()`, after the `insert-user` subparser:
```python
sp = sub.add_parser("credit", help="Read account credit balance.")
sp.add_argument("username")
sp.add_argument("password")
sp.set_defaults(func=cmd_credit)
sp = sub.add_parser("transfer", help="One-shot credit transfer.")
sp.add_argument("f_username")
sp.add_argument("f_password")
sp.add_argument("t_username")
sp.add_argument("t_password")
sp.set_defaults(func=cmd_transfer)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12
```
Expected: `OK` (17 tests now passing).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): add credit and transfer subcommands"
```
---
## Task 6: `cmd_monitor_once`
**Files:**
- Modify: `tests/test_bot_cli.py`
- Modify: `app/bot_cli.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py`:
```python
class CmdMonitorOnceTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_does_nothing_when_already_at_target(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_all_available_acc.return_value = [{"username": f"u{i}"} for i in range(20)]
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_monitor_once(argparse.Namespace(target=20))
text = out.getvalue()
self.assertIn("Available accounts: 20", text)
self.assertIn("Already at target", text)
mock_hal.create_new_acc.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_creates_accounts_until_target(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_all_available_acc.return_value = [{"username": "u1"}, {"username": "u2"}]
# Two existing → target 5 → create 3.
mock_hal.create_new_acc.side_effect = [
{"username": "u3", "password": "p3", "link": "l3"},
{"username": "u4", "password": "p4", "link": "l4"},
{"username": "u5", "password": "p5", "link": "l5"},
]
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_monitor_once(argparse.Namespace(target=5))
text = out.getvalue()
self.assertEqual(mock_hal.create_new_acc.call_count, 3)
self.assertIn("Created: u3", text)
self.assertIn("Created: u4", text)
self.assertIn("Created: u5", text)
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_create_failure_exits_1(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_all_available_acc.return_value = []
mock_hal.create_new_acc.side_effect = RuntimeError("fail login")
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_monitor_once(argparse.Namespace(target=1))
self.assertEqual(cm.exception.code, 1)
def test_monitor_once_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["monitor-once", "--target", "7"])
self.assertIs(args.func, bot_cli.cmd_monitor_once)
self.assertEqual(args.target, 7)
def test_monitor_alias_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["monitor"])
self.assertIs(args.func, bot_cli.cmd_monitor_once)
self.assertEqual(args.target, 20)
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15
```
Expected: failures for `cmd_monitor_once` not defined.
- [ ] **Step 3: Implement `cmd_monitor_once`**
In `app/bot_cli.py`, add after `cmd_transfer`:
```python
def cmd_monitor_once(args):
bot = CM_BOT_HAL()
available = bot.get_all_available_acc()
print(f"Available accounts: {len(available)} (target: {args.target})")
if len(available) >= args.target:
print("Already at target; nothing to do.")
return
for _ in range(len(available), args.target):
try:
user = bot.create_new_acc()
print(f"Created: {user['username']}")
except Exception as exc:
print(f"ERROR creating account: {exc}", file=sys.stderr)
sys.exit(1)
```
In `build_parser()`, after the `transfer` subparser:
```python
sp = sub.add_parser("monitor-once", aliases=["monitor"], help="One iteration of the auto-create monitor.")
sp.add_argument("--target", type=int, default=20)
sp.set_defaults(func=cmd_monitor_once)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12
```
Expected: `OK` (22 tests now passing).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): add monitor-once subcommand"
```
---
## Task 7: TUI interactive mode (replaces `cmd_interactive` stub)
**Files:**
- Modify: `tests/test_bot_cli.py`
- Modify: `app/bot_cli.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py`:
```python
class CmdInteractiveTests(unittest.TestCase):
@mock.patch("builtins.input", side_effect=["q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_q_exits_cleanly(self, mock_hal_class, mock_input):
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertIn("CM Bot CLI", out.getvalue())
@mock.patch("builtins.input", side_effect=["", "q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_blank_line_continues_loop(self, mock_hal_class, mock_input):
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
# Blank line keeps the menu visible — menu should appear at least
# twice (once on entry, once after the blank line).
self.assertGreaterEqual(out.getvalue().count("Register / get next account"), 2)
@mock.patch("builtins.input", side_effect=EOFError)
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_eof_exits_cleanly(self, mock_hal_class, mock_input):
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
# No traceback; we just exit. Menu should still have printed.
self.assertIn("CM Bot CLI", out.getvalue())
@mock.patch("builtins.input", side_effect=["1", "q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_alias_1_dispatches_to_register(self, mock_hal_class, mock_input):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_api.return_value = {
"username": "u", "password": "p", "link": "l",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertIn("Username: u", out.getvalue())
mock_hal.get_user_api.assert_called_once_with()
@mock.patch("builtins.input", side_effect=["nonsense", "q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_unknown_subcommand_keeps_loop_alive(self, mock_hal_class, mock_input):
# An invalid command should print an argparse error to stderr but
# NOT exit the REPL. We verify by reaching 'q' on the next iteration.
out = io.StringIO()
err = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
bot_cli.cmd_interactive(argparse.Namespace())
# Reached 'q' → cm_bot_cli exited normally. If the loop had died
# we would have raised before assigning `out.getvalue()`.
self.assertIn("CM Bot CLI", out.getvalue())
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15
```
Expected: failures because `cmd_interactive` currently raises `NotImplementedError`.
- [ ] **Step 3: Implement `cmd_interactive`**
In `app/bot_cli.py`, replace the `cmd_interactive` stub at the top with the real implementation:
```python
# Map TUI shortcuts to argparse subcommand names so the REPL reuses the
# same dispatch table as one-shot invocations.
_TUI_ALIASES = {"1": "register", "2": "set-pin", "3": "insert-user"}
def cmd_interactive(_args):
"""Telegram-style menu in a TTY loop. stdlib only."""
print("CM Bot CLI — interactive (type 'q' to quit, '?' for menu)")
while True:
print()
print(" 1 Register / get next account")
print(" 2 <whatsapp_link> Set security PIN")
print(" 3 <f_username> <t_username> Insert into user table")
print(" credit <username> <password> Read account credit")
print(" transfer <fu> <fp> <tu> <tp> One-shot credit transfer")
print(" monitor [N] Run monitor once (default 20)")
print(" q Quit")
try:
line = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if not line:
continue
if line in ("q", "quit", "exit"):
return
if line in ("?", "help", "menu"):
continue
argv = line.split()
argv[0] = _TUI_ALIASES.get(argv[0], argv[0])
try:
args = build_parser().parse_args(argv)
args.func(args)
except SystemExit:
# argparse calls sys.exit() on parse error; swallow it to keep
# the REPL alive.
continue
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
```
This replaces the stub from Task 1. The `_TUI_ALIASES` constant lives at module level so it's testable in isolation if a future task wants to assert on it.
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -10
```
Expected: `OK` (27 tests now passing).
- [ ] **Step 5: Smoke-test from the command line**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m app.bot_cli --help 2>&1 | head -25
```
Expected: argparse usage block listing all six subparsers (`register`, `set-pin`, `insert-user`, `credit`, `transfer`, `monitor-once`, `interactive`).
- [ ] **Step 6: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(bot_cli): implement interactive TUI menu"
```
---
## Task 8: MySQL service in `docker-compose.override.yml` plus init scripts
**Files:**
- Modify: `docker-compose.override.yml`
- Create: `docker/mysql/init.d/01-schema.sql`
- Create: `docker/mysql/init.d/02-seed.sql`
- [ ] **Step 1: Create the schema script**
Create `docker/mysql/init.d/01-schema.sql`:
```sql
-- Schema for the CM bot dev DB. Mounted at
-- /docker-entrypoint-initdb.d/01-schema.sql in the mysql:8.0 container;
-- runs once on first volume initialization.
CREATE TABLE IF NOT EXISTS acc (
username VARCHAR(64) PRIMARY KEY,
password VARCHAR(128) NOT NULL,
status VARCHAR(32) DEFAULT '',
link VARCHAR(512) DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user (
f_username VARCHAR(64) PRIMARY KEY,
f_password VARCHAR(128) NOT NULL,
t_username VARCHAR(64) NOT NULL,
t_password VARCHAR(128) NOT NULL,
last_update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
- [ ] **Step 2: Create the seed script**
Create `docker/mysql/init.d/02-seed.sql`:
```sql
-- Dev-only seed. Four acc rows matching CM_PREFIX_PATTERN=13c so
-- get_next_username has something to anchor on. Passwords are placeholder
-- strings — never real cm99.net credentials.
INSERT INTO acc (username, password, status, link) VALUES
('13c1000', 'seedpass', '', ''),
('13c1001', 'seedpass', '', ''),
('13c1002', 'seedpass', '', ''),
('13c1003', 'seedpass', '', '');
```
- [ ] **Step 3: Add the mysql service and dev-only directives to `docker-compose.override.yml`**
The current file (33 lines) only has `build:` directives plus an environment override on `transfer-bot`. Append at the bottom:
```yaml
mysql:
image: mysql:8.0
container_name: ${CM_DEPLOY_NAME:-cm}-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-devroot}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "127.0.0.1:3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init.d:/docker-entrypoint-initdb.d:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-devroot}"]
interval: 5s
timeout: 3s
retries: 12
networks:
- bot-network
api-server:
depends_on:
mysql:
condition: service_healthy
telegram-bot:
profiles: ["bots"]
transfer-bot:
profiles: ["bots"]
volumes:
mysql-data:
name: ${CM_DEPLOY_NAME:-cm}-mysql-data
```
The mysql service joins `bot-network`, which is defined in the base `docker-compose.yml`. The override does not redefine `networks:` because compose merges that section from the base file.
- [ ] **Step 4: Validate the override file parses with the base**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.override.yml') as f:
cfg = yaml.safe_load(f)
services = list(cfg.get('services', {}).keys())
print('services in override:', services)
assert 'mysql' in services, 'mysql service missing'
mysql = cfg['services']['mysql']
assert mysql['ports'] == ['127.0.0.1:3306:3306'], f'wrong port binding: {mysql[\"ports\"]}'
assert mysql['image'] == 'mysql:8.0'
assert cfg['services']['telegram-bot']['profiles'] == ['bots']
assert cfg['services']['transfer-bot']['profiles'] == ['bots']
assert cfg['services']['api-server']['depends_on']['mysql']['condition'] == 'service_healthy'
print('override structure OK')
"
```
Expected: `services in override: [...]` listing 5 services (`telegram-bot`, `api-server`, `web-view`, `transfer-bot`, `mysql`); `override structure OK`.
- [ ] **Step 5: Verify base compose file still renders alone (Portainer parity)**
The Portainer flow uses `docker-compose.yml` only, no override. Make sure base hasn't been touched:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git diff --stat docker-compose.yml
```
Expected: empty output (no changes to base file).
- [ ] **Step 6: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.override.yml docker/mysql/init.d/01-schema.sql docker/mysql/init.d/02-seed.sql && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(compose): add dev mysql service, init scripts, profile-gate bots"
```
---
## Task 9: `scripts/dev.sh` lifecycle wrapper
**Files:**
- Create: `scripts/dev.sh`
- [ ] **Step 1: Create the script**
Create `scripts/dev.sh`:
```bash
#!/usr/bin/env bash
# Lifecycle commands for the local dev stack (mysql + api-server + web-view).
# Bots (telegram-bot, transfer-bot) are gated behind a compose 'bots' profile
# and do not start with 'up'. Status is used by scripts/bot_cli.sh.
set -euo pipefail
usage() {
cat <<'EOF'
Lifecycle for the dev stack.
Usage:
scripts/dev.sh up Start mysql + api-server + web-view in the background.
scripts/dev.sh down Stop the stack. mysql volume kept (DB persists).
scripts/dev.sh reset-db Stop the stack AND drop the mysql volume; then start.
scripts/dev.sh logs Tail logs from the running stack.
scripts/dev.sh status Print 'OK' if mysql is running, else exit 1.
Environment:
NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group).
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
SUDO="sudo"
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
# shellcheck disable=SC2206
COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml)
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at repo root. Run: cp envs/dev/.env.example .env (then edit)." >&2
exit 2
fi
case "${1:-}" in
up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view
"${COMPOSE[@]}" ps
;;
down)
"${COMPOSE[@]}" down
;;
reset-db)
"${COMPOSE[@]}" down --volumes
"${COMPOSE[@]}" up -d --build mysql api-server web-view
;;
logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view
;;
status)
if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then
echo OK
else
echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2
exit 1
fi
;;
-h|--help|help)
usage
exit 0
;;
"")
usage >&2
exit 1
;;
*)
echo "unknown command: $1" >&2
usage >&2
exit 1
;;
esac
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh
```
- [ ] **Step 3: Bash syntax-check**
```bash
bash -n /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh && echo "syntax OK"
```
Expected: `syntax OK`.
- [ ] **Step 4: Help-text smoke test**
```bash
/home/yiekheng/projects/cm_bot_v2/scripts/dev.sh --help | head -8
```
Expected: usage block with the five subcommands listed.
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add scripts/dev.sh && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(scripts): add dev.sh lifecycle wrapper"
```
---
## Task 10: `scripts/bot_cli.sh` env-loading wrapper
**Files:**
- Create: `scripts/bot_cli.sh`
- [ ] **Step 1: Create the script**
Create `scripts/bot_cli.sh`:
```bash
#!/usr/bin/env bash
# Run the bot CLI in the local venv. With no args, drops into the TUI menu.
# Requires: dev stack up (run scripts/dev.sh up first), .venv with deps.
set -euo pipefail
usage() {
cat <<'EOF'
Run the bot CLI (app.bot_cli) in the local venv.
Usage:
scripts/bot_cli.sh Drop into the TUI menu.
scripts/bot_cli.sh <subcommand> [args] One-shot subcommand. Try --help.
Examples:
scripts/bot_cli.sh register
scripts/bot_cli.sh credit 13c1234 abc12345
scripts/bot_cli.sh monitor-once --target 5
Environment:
NO_SUDO=1 Skip 'sudo' when checking 'dev.sh status'.
PYTHON_BIN Override the python interpreter (default: .venv/bin/python).
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
# E2: bail if the dev stack is not running.
if ! NO_SUDO="${NO_SUDO:-0}" bash scripts/dev.sh status >/dev/null 2>&1; then
echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2
exit 2
fi
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. cp envs/dev/.env.example .env (then edit)." >&2
exit 2
fi
# Load .env into the environment (export everything between 'set -a' and 'set +a').
set -a
# shellcheck disable=SC1091
source .env
set +a
# Override DB host/port for the local CLI: docker mysql is published on
# 127.0.0.1:3306, even though api-server in-network reaches it as mysql:3306.
export DB_HOST=127.0.0.1
export DB_PORT=3306
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
if [[ ! -x "${PYTHON_BIN}" ]]; then
echo "ERROR: ${PYTHON_BIN} not found." >&2
echo "Create the venv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
exit 2
fi
exec "${PYTHON_BIN}" -m app.bot_cli "$@"
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh
```
- [ ] **Step 3: Bash syntax-check**
```bash
bash -n /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh && echo "syntax OK"
```
Expected: `syntax OK`.
- [ ] **Step 4: Help-text smoke test**
```bash
/home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh --help | head -10
```
Expected: usage block listing examples.
- [ ] **Step 5: E2 enforcement test (no .env, stack not up)**
If `.env` and the dev stack are both absent, the script must exit 2 with the dev-stack error first:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
[[ ! -f .env ]] && bash scripts/bot_cli.sh register; echo "exit $?"
```
Expected: `ERROR: dev stack not running. Run 'scripts/dev.sh up' first.` followed by `exit 2`. (If `.env` already exists, this verifies only the status check; either error message is acceptable.)
- [ ] **Step 6: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add scripts/bot_cli.sh && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(scripts): add bot_cli.sh wrapper with E2 strict-mode check"
```
---
## Task 11: `envs/dev/.env.example` and `.gitignore` update
**Files:**
- Create: `envs/dev/.env.example`
- Modify: `.gitignore`
- [ ] **Step 1: Create the env example**
Create `envs/dev/.env.example`:
```
# Local development environment variables.
# Copy this file to repo-root .env and fill in real cm99.net agent credentials
# only if you want CLI ops (register/set-pin/credit/transfer/monitor-once)
# to actually call cm99.net. The DB is local-only via docker-compose.override.yml.
# === Runtime ===
CM_DEBUG=true
# === Deployment Identity ===
CM_DEPLOY_NAME=dev-cm
CM_WEB_HOST_PORT=8000
# === Docker Registry / Build ===
CM_IMAGE_PREFIX=local
DOCKER_IMAGE_TAG=dev
# === Telegram (unused in A2 — telegram-bot is gated by 'bots' profile) ===
TELEGRAM_BOT_TOKEN=fill-only-if-running-bots-profile
TELEGRAM_ALERT_CHAT_ID=
TELEGRAM_ALERT_BOT_TOKEN=
# === Database (dev mysql in docker; bot_cli.sh overrides DB_HOST=127.0.0.1) ===
DB_HOST=mysql
DB_USER=cm
DB_PASSWORD=devpassword
DB_NAME=cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
MYSQL_ROOT_PASSWORD=devroot
# === Bot Config ===
# CM_PREFIX_PATTERN=13c MUST match the seed in docker/mysql/init.d/02-seed.sql.
CM_PREFIX_PATTERN=13c
CM_AGENT_ID=fill-with-real-agent-id-to-test-cm99-calls
CM_AGENT_PASSWORD=fill-with-real-agent-password-to-test-cm99-calls
CM_SECURITY_PIN=000000
CM_BOT_BASE_URL=https://cm99.net
```
- [ ] **Step 2: Add `envs/dev/.env` to `.gitignore`**
The current `.gitignore` is:
```
__pycache__
.DS_Store
*.html
logs
```
Append `envs/dev/.env` so an operator's filled-in copy can never be committed:
```
__pycache__
.DS_Store
*.html
logs
envs/dev/.env
```
- [ ] **Step 3: Verify `.env` itself stays untracked (existing behavior)**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git ls-files .env
```
Expected: empty output (root `.env` is not — and was not — tracked).
- [ ] **Step 4: Verify `envs/rex/.env` and `envs/siong/.env` remain tracked (R2 will rotate later)**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git ls-files envs/
```
Expected: lists `envs/rex/.env` and `envs/siong/.env`. We are intentionally NOT changing their tracking — that belongs to sub-project R2.
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add envs/dev/.env.example .gitignore && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(envs): add dev .env.example and gitignore the filled-in copy"
```
---
## Task 12: Update `AGENTS.md` for the dev tier
**Files:**
- Modify: `AGENTS.md`
- [ ] **Step 1: Replace the "Reproduce From Scratch" SQL block with a pointer to the new flow**
In `AGENTS.md`, find the existing section header `## Reproduce From Scratch (Clean Machine)` and the SQL example beneath it (lines 14-65 in the current file). Replace the steps starting with "Prepare MySQL schema" through "Configure DB connection values" with a pointer to the dev tier:
Find:
```
4. Prepare MySQL schema (minimum required):
```
Replace from that line through the line ` - For reliable reproduction, add `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` to service `environment:` in compose files (at minimum `api-server`, `telegram-bot`, `transfer-bot`).` (the end of step 6) with:
```
4. Prepare the local dev DB and stack:
```bash
cp envs/dev/.env.example .env
# Edit .env if you want the bot CLI to actually call cm99.net
# (CM_AGENT_ID / CM_AGENT_PASSWORD / CM_SECURITY_PIN).
bash scripts/dev.sh up
```
This brings up `mysql` (port `127.0.0.1:3306`), `api-server`, and
`web-view`. The schema and a 4-row seed are applied automatically from
`docker/mysql/init.d/`. Bots (`telegram-bot`, `transfer-bot`) are gated
behind a compose `bots` profile and do not start in dev.
```
- [ ] **Step 2: Add a new "Dev Tier" subsection above "Build, Test, and Development Commands"**
Insert immediately above `## Build, Test, and Development Commands`:
```
## Dev Tier (Local Development)
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
- Bot CLI: `bash scripts/bot_cli.sh` (drops into the TUI menu) or
`bash scripts/bot_cli.sh <subcommand>` (e.g., `register`, `set-pin <link>`,
`monitor-once --target 5`). The CLI runs in your local `.venv` and
connects to the dev mysql at `127.0.0.1:3306`.
- The auto-create monitor does NOT run in dev (it lives in `telegram-bot`,
which is gated by the `bots` profile). Use `bot_cli.sh monitor-once` to
exercise the same code path manually.
- Tests: `.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v`.
```
- [ ] **Step 3: Refresh the verification checklist**
In the existing `## Verification Checklist`, replace the existing line:
```
- Web UI loads: open `http://localhost:8001`
```
with:
```
- Web UI loads: open `http://localhost:8000` (dev) or `http://localhost:8001`
(rex prod) / `http://localhost:8005` (siong prod).
```
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add AGENTS.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(agents): document the local-as-dev tier and bot CLI"
```
---
## Task 13: Integration verification
This task corresponds to the verification scenarios in the spec. No commits — these are smoke checks. If anything fails, debug before declaring done.
**Files:** none modified.
**Prerequisites:** docker compose v2 plugin installed on the host. The plan author's environment did not have it, so this task's commands must be run by an engineer on the deploy/dev host with docker compose available.
- [ ] **Step 1: Cold start**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
cp envs/dev/.env.example .env && \
.venv/bin/pip install -r requirements.txt && \
bash scripts/dev.sh up
```
Wait ~15s for the mysql healthcheck. Expected: `docker compose ps` shows `mysql`, `api-server`, `web-view` all `running`. Verify status:
```bash
bash scripts/dev.sh status
```
Expected: `OK`.
- [ ] **Step 2: Schema and seed are present**
```bash
mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT username FROM acc ORDER BY username"
```
Expected: four rows: `13c1000`, `13c1001`, `13c1002`, `13c1003`.
(If the local mysql client is missing, equivalent: `docker exec dev-cm-mysql mysql -u cm -pdevpassword cm -e "SELECT username FROM acc ORDER BY username"`.)
- [ ] **Step 3: API and web-view smoke**
```bash
curl -sf http://localhost:3000/acc/ | head -c 200; echo
curl -sf "http://localhost:${CM_WEB_HOST_PORT:-8000}/api/acc/" | head -c 200; echo
```
Expected: both return JSON arrays containing the four seed usernames. If either fails with a 5xx, check `bash scripts/dev.sh logs` for the underlying error.
- [ ] **Step 4: CLI no-args drops into the TUI menu**
```bash
echo "q" | bash scripts/bot_cli.sh
```
Expected: prints `CM Bot CLI — interactive ...` and the menu, then exits cleanly on `q`.
- [ ] **Step 5: CLI subcommand parity (no cm99.net call)**
The `monitor-once --target 0` op never calls cm99.net (target is already met by the four seed rows):
```bash
bash scripts/bot_cli.sh monitor-once --target 0
```
Expected: `Available accounts: 4 (target: 0)` followed by `Already at target; nothing to do.`
- [ ] **Step 6: E2 strict-mode**
```bash
bash scripts/dev.sh down && \
bash scripts/bot_cli.sh register; echo "exit code: $?"
```
Expected: `ERROR: dev stack not running. Run 'scripts/dev.sh up' first.` followed by `exit code: 2`.
- [ ] **Step 7: Reset-db reapplies seed**
```bash
bash scripts/dev.sh up
# Insert a row that should be wiped on reset.
mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "INSERT INTO acc (username, password) VALUES ('13c9999', 'temp')"
mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT COUNT(*) AS n FROM acc"
# n should be 5
bash scripts/dev.sh reset-db
sleep 15
mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT COUNT(*) AS n FROM acc"
# n should be 4 (only the seed)
```
Expected: count goes 5 → 4. The reset wiped the manually-inserted row and re-applied the seed from `docker/mysql/init.d/`.
- [ ] **Step 8: Prod compose untouched (Portainer parity)**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
docker compose -f docker-compose.yml config | grep -E "mysql|profiles|127.0.0.1:3306" || echo "no dev artifacts in base config"
```
Expected: `no dev artifacts in base config`. The dev mysql, profile gates, and `127.0.0.1:3306` binding all live in the override only.
- [ ] **Step 9: Full unit test suite still passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v 2>&1 | tail -8
```
Expected: `OK`. Combined: 2 tests for debug-mode parity + 27 tests for bot_cli (parser, six commands, TUI).
---
## Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
| `mysql:8.0` service in `docker-compose.override.yml`, healthcheck, `127.0.0.1:3306` only | Task 8 |
| `api-server` waits for mysql healthcheck | Task 8 |
| `bots` profile gates `telegram-bot` + `transfer-bot` in dev | Task 8 |
| `docker/mysql/init.d/01-schema.sql` (acc + user, utf8mb4) | Task 8 |
| `docker/mysql/init.d/02-seed.sql` (4 rows matching `13c`) | Task 8 |
| `app/bot_cli.py` argparse + dispatch | Tasks 16 |
| `cmd_register` (Telegram /1) | Task 2 |
| `cmd_set_pin` with local name resolution (workaround for HAL bug) | Task 3 |
| `cmd_insert_user` (Telegram /3) | Task 4 |
| `cmd_credit`, `cmd_transfer` | Task 5 |
| `cmd_monitor_once` with target | Task 6 |
| `cmd_interactive` TUI with `1`/`2`/`3` aliases, q/eof exit | Task 7 |
| `scripts/dev.sh` lifecycle (up/down/reset-db/logs/status) | Task 9 |
| `scripts/bot_cli.sh` env-loading wrapper, E2 strict mode, DB_HOST override | Task 10 |
| `envs/dev/.env.example` committed | Task 11 |
| `envs/dev/.env` gitignored | Task 11 |
| `AGENTS.md` dev-tier docs | Task 12 |
| Verification: cold start, schema, API, web, CLI no-args, E2, reset, base parity, unit tests | Task 13 |
No gaps. No placeholders, no "implement later", no "similar to Task N" without code repeated. Function and class names are consistent across tasks (`build_parser`, `cmd_register`, `cmd_set_pin`, `cmd_insert_user`, `cmd_credit`, `cmd_transfer`, `cmd_monitor_once`, `cmd_interactive`, `_TUI_ALIASES`).