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

51 KiB
Raw Blame History

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


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:

"""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
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:

"""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
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
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:

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
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:

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:

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
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
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:

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
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:

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:

    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
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
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:

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
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:

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:

    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
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
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:

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
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:

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:

    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
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
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:

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
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:

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:

    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
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
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:

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
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:

# 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
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
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
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:

-- 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:

-- 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:


  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
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:

cd /home/yiekheng/projects/cm_bot_v2 && \
git diff --stat docker-compose.yml

Expected: empty output (no changes to base file).

  • Step 6: Commit
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:

#!/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
chmod +x /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh
  • Step 3: Bash syntax-check
bash -n /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh && echo "syntax OK"

Expected: syntax OK.

  • Step 4: Help-text smoke test
/home/yiekheng/projects/cm_bot_v2/scripts/dev.sh --help | head -8

Expected: usage block with the five subcommands listed.

  • Step 5: Commit
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:

#!/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
chmod +x /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh
  • Step 3: Bash syntax-check
bash -n /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh && echo "syntax OK"

Expected: syntax OK.

  • Step 4: Help-text smoke test
/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:

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
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)
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)
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
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, addDB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORTto serviceenvironment:in compose files (at minimumapi-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
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 scripts/dev.sh status

Expected: OK.

  • Step 2: Schema and seed are present
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
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
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 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 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 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)
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
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).