Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# AGENTS.md

Project guide for agents working on `wechat-cli`.

## Scope

`wechat-cli` is a local-first personal WeChat CLI for reading local chat data. Keep the scope on personal WeChat local databases. Do not mix in Enterprise WeChat, public account APIs, or cloud OpenAPI work unless the task explicitly asks for that.

The CLI is agent-oriented: `json` is the default output for query commands and should remain backward compatible.

## Architecture

```text
wechat-cli
-> wechat_cli.main:cli
-> wechat_cli.commands.<command>
-> AppContext
-> ~/.wechat-cli/config.json
-> ~/.wechat-cli/all_keys.json
-> DBCache decrypts DB/WAL on demand
-> wechat_cli.core query helpers
-> wechat_cli.output.formatter
```

Key paths:

- `wechat_cli/main.py`: Click root command and subcommand registration.
- `wechat_cli/commands/`: thin command wrappers.
- `wechat_cli/core/`: config, context, crypto, DB cache, contacts, messages.
- `wechat_cli/keys/`: platform-specific key extraction.
- `wechat_cli/output/formatter.py`: shared JSON, NDJSON, table, and text rendering.
- `npm/`: PyInstaller build script and npm wrapper/platform packages.

## Output Contract

Use shared constants from `wechat_cli.output.formatter`:

- Query commands: `json|ndjson|table|text`.
- Export command: `markdown|txt|json|ndjson`.
- Init command: `text|json`.

Rules:

- Preserve `json` as the default for query commands.
- Keep JSON envelopes and existing keys stable unless a breaking change is intentional.
- `ndjson` emits one primary record per line.
- `table` is display-only and may truncate; JSON and NDJSON must not truncate.
- Primary machine-readable output goes to stdout. Progress, warnings, and export success messages go to stderr.
- Never output raw encryption keys.

## Development Guidelines

- Keep command modules thin. Put reusable parsing, query, and formatting behavior in `core/` or `output/`.
- Validate pagination, time ranges, and user inputs before querying.
- Guard dynamic SQL table names with `_is_safe_msg_table_name()`.
- Keep XML parsing defensive; reject unsafe or oversized XML before parsing.
- Runtime state belongs in `~/.wechat-cli` or temp cache directories, never in the repo.
- Be careful with macOS key extraction; it may require Full Disk Access, sudo, and WeChat re-signing.

## Common Commands

```bash
python -m pip install -e .
python entry.py --help
python entry.py sessions --help
python npm/scripts/build.py
```

Runtime commands require an initialized local WeChat environment:

```bash
wechat-cli init
wechat-cli sessions --format table
wechat-cli history "联系人" --format ndjson
wechat-cli export "联系人" --format markdown --output chat.md
```

## Testing Notes

There is no dedicated test suite currently.

- For pure helper changes, run import/compile checks.
- For CLI surface changes, run `python entry.py --help` and changed command help where dependencies are installed.
- For packaging changes, run `python npm/scripts/build.py <platform>` when feasible.
- Do not claim runtime DB behavior is verified unless tested against a real initialized WeChat profile.

## Real Initialized Profile Verification

When the user has already installed the npm package globally and completed `wechat-cli init`, prefer verifying the current source branch against that existing init state instead of rebuilding the global npm package.

The init state is shared across install methods because runtime commands read:

- `~/.wechat-cli/config.json`
- `~/.wechat-cli/all_keys.json`
- `~/.wechat-cli/last_check.json` for `new-messages`

Use the current checkout's Python entry point after installing local Python dependencies:

```bash
python -m venv .venv
. .venv/bin/activate
python -m pip install -e .
python entry.py sessions --help
```

Verification order for option flag changes:

```bash
python entry.py sessions --limit 5
python entry.py sessions --is-group true --limit 5
python entry.py sessions --is-group false --limit 5
python entry.py sessions --msg-type text --limit 5
python entry.py sessions --msg-type link --limit 5
python entry.py sessions --chat "聊天名" --limit 5
python entry.py sessions --unread true --limit 5

python entry.py unread --limit 5
python entry.py unread --chat "聊天名" --limit 5
python entry.py unread --is-group true --limit 5
python entry.py unread --msg-type text --limit 5

python entry.py search "关键词" --chat "聊天名" --msg-type text --limit 5
python entry.py search "关键词" --is-group true --unread true --limit 5
python entry.py search "关键词" --msg-type link --limit 5
python entry.py search "关键词" --type text --msg-type link
```

Before testing `new-messages`, back up `~/.wechat-cli/last_check.json` because the command updates incremental state:

```bash
cp ~/.wechat-cli/last_check.json ~/.wechat-cli/last_check.json.bak 2>/dev/null || true
python entry.py new-messages --chat "聊天名"
python entry.py new-messages --is-group true --unread true
python entry.py new-messages --msg-type text
mv ~/.wechat-cli/last_check.json.bak ~/.wechat-cli/last_check.json 2>/dev/null || true
```

Do not include private chat contents in reports. Summarize only exit codes, counts, filter behavior, and whether outputs are valid JSON/NDJSON/table/text.
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Changelog

## 2026-05-21

- Add `--chat-type` filter to sessions/unread/new-messages — classify chats as group/subscription/contact/openim/kefu
- Add `chat_type` field to session output; `--is-group` now only matches real group chats (excludes subscription accounts)
- Add `--sort` option to history/search — `desc` (newest first, default) or `asc` (oldest first)
- Fix `new-messages` sort order — now newest first (was oldest first)

## 2026-05-19

- Add `--schema` flag to all commands — outputs JSON schema and exits without querying
- Add `total` and `has_more` to paginated output (sessions, history, search, contacts, unread, favorites, new-messages)
- Add `--fields` selector to query commands — comma-separated field filter for JSON/NDJSON output
- Unify `--msg-type` across all commands with full 10-value choice (text/image/voice/video/sticker/location/link/file/call/system); `--type` kept as deprecated alias on history/search
- Add `--limit` to `new-messages` and `members` commands
- Unify help text — remove `INTEGER`/`TEXT` metavar, use clean descriptions with defaults and value lists
- Add ndjson and table output formats (new `output_ndjson`, `output_table`, `Column` in formatter.py)
- Add search keyword highlighting in text/table output (yellow bg, black fg); respects NO\_COLOR and non-TTY
- Add colored `warning:` prefix for deprecation messages (yellow bold on TTY)
- Fix time format inconsistency — unify to `YYYY-MM-DD HH:MM` across all commands (sessions, unread, new-messages, favorites, history, search)
- Fix `--fields` + text/table format causing KeyError — `filter_fields` now only applies to json/ndjson
- Fix `isinstance(data, 'dict')` typo in formatter.py

## 2026-04-06

- docs: add acknowledgement to wechat-decrypt (a378923)
- docs: add Full Disk Access prerequisite for macOS (1eced78)

## 2026-04-05

- docs: add system requirements (macOS ≥ 26.3.1, WeChat ≤ 4.1.8.100) (019ef4e)

## 2026-04-04

- Add --media flag to resolve media file paths for images/files/videos (v0.2.4) (f6410d3)
- Add safety notice and disclaimer to both READMEs (86590e7)
- Add re-signing safety notice: no ban risk, may affect auto-update (7b9139b)
- Preserve WeChat original entitlements when re-signing (v0.2.3) (7158422)
- Add version command, bump to 0.2.2 (b794ad1)
- Add update instructions and macOS task\_for\_pid troubleshooting to READMEs (ab890df)
- Bump version to 0.2.1 with auto re-sign support (6b36f92)
- Auto re-sign WeChat when task\_for\_pid fails on macOS (2b1fc0a)
- Sync Agent installation guide and init screenshots to English README (3d75bde)
- Add Agent installation guide and init screenshots to README\_CN (c14b8dc)
- Redesign READMEs: add badges, AI agent guides, npm as recommended install (e6f79af)
- Add npm distribution support with PyInstaller binary (f51e89c)
- Initial release: wechat-cli v0.2.0 (e64006b)

1 change: 1 addition & 0 deletions CLAUDE.md
110 changes: 72 additions & 38 deletions wechat_cli/commands/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import click

from ..core.contacts import get_contact_full, get_contact_names, resolve_username, get_contact_detail
from ..output.formatter import output
from ..output.formatter import Column, QUERY_FORMATS, render_result
from .schema_option import schema_option


@click.command("contacts")
@click.option("--query", default="", help="搜索关键词(匹配昵称、备注、wxid)")
@click.option("--detail", default=None, help="查看联系人详情(传入昵称/备注/wxid)")
@click.option("--limit", default=50, help="返回数量")
@click.option("--format", "fmt", default="json", type=click.Choice(["json", "text"]), help="输出格式")
@schema_option("contacts")
@click.option("--query", metavar="", default="", help="搜索关键词")
@click.option("--detail", metavar="", default=None, help="查看联系人详情 (传入昵称/备注/wxid)")
@click.option("--limit", metavar="", default=50, help="返回数量 (默认 50)")
@click.option("--format", "fmt", default="json", type=click.Choice(QUERY_FORMATS), metavar="", help="输出格式: json, ndjson, table, text (默认 json)")
@click.option("--fields", metavar="", default=None, help="字段选择器 (逗号分隔)")
@click.pass_context
def contacts(ctx, query, detail, limit, fmt):
def contacts(ctx, query, detail, limit, fmt, fields):
"""搜索或列出联系人

\b
Expand All @@ -38,20 +41,29 @@ def contacts(ctx, query, detail, limit, fmt):
else:
matched = full

total = len(matched)
has_more = total > limit
matched = matched[:limit]

if fmt == 'json':
output(matched, 'json')
else:
header = f"找到 {len(matched)} 个联系人:"
lines = []
for c in matched:
display = c['remark'] or c['nick_name'] or c['username']
line = f"{display} ({c['username']})"
if c['remark']:
line += f" 备注: {c['remark']}"
lines.append(line)
output(header + "\n\n" + "\n".join(lines), 'text')
for c in matched:
c['display_name'] = c['remark'] or c['nick_name'] or c['username']

data = {
'total': total,
'has_more': has_more,
'limit': limit,
'contacts': matched,
}
render_result(
data, fmt, records_key='contacts', fields=fields,
columns=[
Column("display_name", "DISPLAY NAME", min_width=12, max_width=28),
Column("username", "USERNAME", min_width=16, max_width=32),
Column("remark", "REMARK", min_width=8, max_width=24),
Column("nick_name", "NICK", min_width=8, max_width=24),
],
text_fn=_format_contacts_text,
)


def _show_detail(app, name_or_id, fmt):
Expand All @@ -69,23 +81,45 @@ def _show_detail(app, name_or_id, fmt):
click.echo(f"找不到联系人: {name_or_id}", err=True)
return

if fmt == 'json':
output(info, 'json')
else:
lines = [f"联系人详情: {info['nick_name']}"]
if info['remark']:
lines.append(f"备注: {info['remark']}")
if info['alias']:
lines.append(f"微信号: {info['alias']}")
lines.append(f"wxid: {info['username']}")
if info['description']:
lines.append(f"个性签名: {info['description']}")
if info['is_group']:
lines.append("类型: 群聊")
elif info['is_subscription']:
lines.append("类型: 公众号")
elif info['verify_flag'] and info['verify_flag'] >= 8:
lines.append("类型: 企业认证")
if info['avatar']:
lines.append(f"头像: {info['avatar']}")
output("\n".join(lines), 'text')
rows = [{'field': k, 'value': v} for k, v in info.items()]
if fmt == 'table':
render_result(rows, fmt, columns=[
Column("field", "FIELD", min_width=12, max_width=20),
Column("value", "VALUE", min_width=20, max_width=80),
])
return
render_result(info, fmt, text_fn=_format_contact_detail_text)


def _format_contacts_text(data):
results = data['contacts']
header = f"找到 {len(results)} 个联系人(共 {data['total']} 个)"
if data['has_more']:
header += ",还有更多"
lines = []
for c in results:
line = f"{c['display_name']} ({c['username']})"
if c['remark']:
line += f" 备注: {c['remark']}"
lines.append(line)
return header + ":\n\n" + "\n".join(lines)


def _format_contact_detail_text(info):
lines = [f"联系人详情: {info['nick_name']}"]
if info['remark']:
lines.append(f"备注: {info['remark']}")
if info['alias']:
lines.append(f"微信号: {info['alias']}")
lines.append(f"wxid: {info['username']}")
if info['description']:
lines.append(f"个性签名: {info['description']}")
if info['is_group']:
lines.append("类型: 群聊")
elif info['is_subscription']:
lines.append("类型: 公众号")
elif info['verify_flag'] and info['verify_flag'] >= 8:
lines.append("类型: 企业认证")
if info['avatar']:
lines.append(f"头像: {info['avatar']}")
return "\n".join(lines)
Loading