mobilecli discovers two kinds of app plugins:
- Built-in — Python modules under
mobilecli.apps.*(e.g.mobilecli.apps.douyin) - External — packages that register an
Appobject under themobilecli.appsentry-points group
The framework calls each plugin's verbs with (args, ctx) where ctx is an ExecContext. Layer 2.5 humanization, governor, and linter are pre-wired into ctx.input / ctx.governor / ctx.linter. If you stick to the ctx.input.* API, every action is humanized, governor-checked, and linted. A determined plugin author can reach the lower layers via ctx.device.shell(...) — this is by design (advanced verbs sometimes need it) but doing so means you opt out of the safety guarantees, and PRs that do it without justification will be rejected.
mobilecli-myapp/
├── pyproject.toml
└── src/mobilecli_myapp/__init__.py
pyproject.toml:
[project]
name = "mobilecli-myapp"
version = "0.1.0"
dependencies = ["everything-mobile"]
[project.entry-points."mobilecli.apps"]
myapp = "mobilecli_myapp:app"src/mobilecli_myapp/__init__.py:
from mobilecli.plugin import App, ExecContext
app = App(
name="myapp",
package="com.example.myapp",
daily_caps={"comment": 50, "like": 200},
extra_lint_patterns=[], # extra regex on top of the defaults
)
@app.verb("launch")
def launch(args, ctx: ExecContext) -> dict:
ctx.app.launch()
return {"foreground": ctx.app.foreground()}
def _search_args(p):
p.add_argument("--keyword", required=True)
@app.verb("search", add_args=_search_args)
def search(args, ctx: ExecContext) -> dict:
ctx.app.ensure_foreground()
xml_path = ctx.ui.dump()["path"]
# ... find selectors, ctx.input.tap_node(node), etc.
return {"results": []}Then:
pip install -e mobilecli-myapp/
mobilecli myapp launch
mobilecli myapp search --keyword testThis is everything plugins can call. There's no escape hatch — if you need something not here, open an issue.
| Method | Use |
|---|---|
tap_node(node) |
Humanized tap inside node["bounds"] (60% inner box) |
tap_xy(x, y) |
Humanized tap with ±8 px jitter on (x, y) |
swipe(start, end) |
Humanized swipe (bezier-shape telemetry, randomized duration) |
type_text(text) |
Humanized type (per-char delay for ASCII, ADBKeyboard for CJK) |
keyevent(code) |
Send keyevent (alias back/home/enter/... or numeric KEYCODE) |
| Method | Use |
|---|---|
dump(output_path=None) |
uiautomator dump; returns {path, size} |
find_by_resource_id(xml, rid) |
First matching node or None |
find_by_content_desc(xml, desc) |
First matching node or None |
find_by_text(xml, text) |
First matching node or None |
find_all_by_resource_id(xml, rid) |
List of nodes (for result grids) |
screenshot(output_path=None) |
Capture PNG; returns {path, size, width, height} |
A node dict has keys: resource_id, content_desc, text, class, bounds, cx, cy, clickable, focused.
| Method | Use |
|---|---|
launch() |
Launch the plugin's package |
foreground() |
Current foreground {package, activity} |
ensure_foreground() |
Launch if not already foreground |
You should call these before state-mutating actions:
@app.verb("comment", add_args=..., requires_commit_flag=True)
def comment(args, ctx):
ctx.linter.check_or_raise(args.text) # → CONTENT_BANNED
ctx.governor.check_or_raise("comment") # → RATE_LIMITED
# ... do the work, including ctx.input.tap_node(send_btn) only if --commit ...
if args.commit:
ctx.governor.record("comment")
return {...}If your verb actually changes state (post comment / send DM / submit form), use requires_commit_flag=True. The framework will then:
- Add a
--commitarg to the verb's parser - Reject the call unless
EM_ALLOW_COMMIT=1is in the environment - Wrap into a
COMMIT_REFUSEDenvelope if the gate fails
Default path (no --commit) must be dry-run: build the action, find the send button, return its coordinates, do NOT tap it.
- Prefer semantic resource-ids when the app provides them (e.g. XHS
mSearchToolBarEt). They survive minor releases. - Avoid obfuscated rids (Douyin uses 3-char
q21/gl1style — they change between releases). Pair withcontent-descor class+position fallback. - Capture UI tree of each screen you depend on. Save under
research/ui-trees/<app>/. Then when the app updates, diff the trees to find what moved. - Counts often live in
content-desc(e.g.评论3809,按钮). Use a regex extractor, not literal string match.
The library ships per-app daily_caps based on docs/anti-risk-control.md. When that doc updates, plugin defaults update in the same PR. Don't just bump caps because your script hit RATE_LIMITED — that's the system working.
- New verb has both fixture-based unit tests and a real-device integration test
- No banned use cases (see README's "What this is NOT")
- No hardcoded credentials, account IDs, or personal data in fixtures
- No marketing templates / 引流话术 in default values
- Caps come from
docs/anti-risk-control.md, not made up - Verbs that mutate state have
requires_commit_flag=True
PRs adding "convenience" verbs that sidestep the safety layers will be closed without review.