diff --git a/.gitignore b/.gitignore index 35af40f..2d7acff 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,12 @@ knowledge/ .claude/ .obsidian/ + +# MCP configs and temp artifacts +.mcp.json +.playwright-mcp/ + +# Playwright MCP default screenshots — always use workspace/tmp/ instead +/*.png +/*.jpg +/*.jpeg diff --git a/CLAUDE.md b/CLAUDE.md index d71524d..277d608 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# Claude 个人助理配置 +# Claude 个人助理 — myclaude CLI 开发配置 -你是 Claude,用户的个人助理,协助日常办公和软件研发工作。 +你是 Claude,协助开发 myclaude CLI 工具。日常工作场景请见 workspace/CLAUDE.md。 --- @@ -36,53 +36,23 @@ | **导师** | 引导思考,多问少答 | 学习、概念理解 | | **执行者** | 最小化交互,快速执行 | 明确任务、批量操作 | -### 知识检索 - -使用 `metamemory` skill 或 `mm` CLI: -- `mm search ` - 搜索已有记忆 -- `mm list` - 列出所有文档 -- `mm folders` - 浏览文件夹结构 - -查询资料时直接用 `Grep`/`Read` 工具。 - --- -## 3. Workspace 结构 +## 3. 项目结构 ``` -~/Documents/myResearch/myProjects/apaam/repo/ -├── myclaude/ # 当前项目: myclaude CLI 工具 -│ └── workspace/ # 工作目录 -│ ├── .obsidian/ # Obsidian 配置 -│ ├── myacademia -> .../myacademia # → Obsidian/iCloud 知识库 -│ ├── myagent -> .../repo/myagent # → myagent 项目 -│ ├── mydesktop -> .../mydesktop # → Obsidian/iCloud 过程/共享文件 -│ ├── myslides -> .../4_成果归档/myslides # → Slidev 幻灯片 -│ ├── mytoolkit -> .../MyBin/mytoolkit # → 个人 CLI 工具集 -│ ├── mywebpage -> .../4_成果归档/mywebpage # → Docusaurus 网站 -│ ├── scripts/ # 实用脚本 -│ └── tmp/ # 临时文件 -├── metabot/ # 飞书机器人配置 -│ └── bots.json # 机器人 AppID/AppSecret 配置 -└── (其他项目...) +myclaude/ +├── myclaude/ # Python 源码 +│ ├── cli.py # CLI entrypoint +│ ├── commands/ # 子命令 +│ └── ... +├── workspace/ # 主工作环境 (默认 --cwd 目标) +│ └── CLAUDE.md # 日常工作指令 +└── .claude/ # Claude Code 运行时配置 ``` -**符号链接目标**: -- `myacademia` → `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/myacademia` -- `mydesktop` → `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/mydesktop` -- `myagent` → `~/Documents/myResearch/myProjects/apaam/repo/myagent` -- `myslides` → `~/Documents/myWork/sysu/4_成果归档/myslides` -- `mytoolkit` → `~/Documents/MyBin/mytoolkit` -- `mywebpage` → `~/Documents/myWork/sysu/4_成果归档/mywebpage` - -**重要路径**: -- `../metabot/bots.json` - 飞书机器人配置(myagent, claude 等) -- `workspace/myacademia/` - Obsidian 知识库 -- `workspace/mydesktop/` - Obsidian/iCloud 过程/共享文件 -- `workspace/myagent/` - myagent 项目目录 -- `workspace/myslides/` - Slidev 幻灯片项目 -- `workspace/mytoolkit/` - 个人 CLI 工具集(PDF、图片、LaTeX 等) -- `workspace/mywebpage/` - Docusaurus 网站项目 +默认运行 `myclaude`(不带 --cwd)时,cwd 为 `workspace/`,指向工作区 CLAUDE.md。 +开发 CLI 时运行 `myclaude --cwd .` 以读取本文件。 --- @@ -106,35 +76,7 @@ --- -## 5. 记忆机制 - -使用 `metamemory`(`mm` CLI)跨会话持久化信息: - -### 写入 -```bash -# 创建文档(推荐用 stdin) -echo '# 内容' | mm create "标题" --tags "user,feedback" --by "myclaude" - -# 创建文件夹 -mm mkdir "myclaude" -``` - -### 读取 -```bash -mm search # 搜索 -mm list # 列出文档 -mm get # 按 ID 获取 -mm path # 按路径获取 -``` - -### 触发条件 -- 用户说"记住"/"记下来"/"以后默认..." -- 用户纠正行为或偏好 -- 发现重要的项目事实/约定 - ---- - -## 6. 沟通风格 +## 5. 沟通风格 - **简洁**: 不说废话 - **透明**: 复杂任务同步进度 @@ -147,7 +89,7 @@ mm path # 按路径获取 --- -## 7. 指令优先级 +## 6. 指令优先级 1. **明确指令** > 默认规则 2. **当前对话上下文** > 历史记忆 diff --git a/myclaude/cli.py b/myclaude/cli.py index 9ba1382..9f20bcc 100644 --- a/myclaude/cli.py +++ b/myclaude/cli.py @@ -1,10 +1,18 @@ """Myclaude CLI entrypoint.""" +import os +import shutil +import subprocess from importlib.metadata import PackageNotFoundError, version +from pathlib import Path import click +from rich.console import Console -from myclaude.commands import chat_cmd, mail_cmd, update_cmd, upgrade_cmd +from myclaude.commands import update_cmd, upgrade_cmd +from myclaude.project_root import get_myclaude_project_root + +stderr_console = Console(stderr=True) def _package_version() -> str: @@ -14,16 +22,85 @@ def _package_version() -> str: return "0.0.0" -@click.group() +def _resolve_chat_cwd() -> Path: + root = get_myclaude_project_root() + fallback = Path.home() / ".myclaude" + if root == fallback: + stderr_console.print( + "[red]Cannot resolve myclaude project root.[/red] Run from the repo or set " + "[cyan]MYCLAUDE_PROJECT_ROOT[/cyan].", + ) + raise SystemExit(1) + workspace = root / "workspace" + if workspace.is_dir(): + return workspace + return root + + +@click.group( + invoke_without_command=True, + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + }, +) @click.version_option(version=_package_version()) -def cli() -> None: - """Myclaude: CLI (update package, chat = Claude Code in this repo).""" +@click.option( + "--cwd", + "-C", + is_flag=False, + flag_value=".", + default=None, + type=click.Path(dir_okay=True, file_okay=False), + help="Use specified path as working directory (default: workspace/).", +) +@click.option( + "--dangerously-allow-browser-control", + is_flag=True, + default=False, + help="Forward to claude: allow browser automation without confirmation.", +) +@click.pass_context +def cli( + ctx: click.Context, + cwd: str | None, + dangerously_allow_browser_control: bool, +) -> None: + """Myclaude: CLI toolkit. + + Run without subcommands to start Claude Code in workspace/. + Extra arguments pass through to claude. + """ + if ctx.invoked_subcommand is not None: + return + + binary = os.environ.get("CLAUDE_BIN") or shutil.which("claude") + if not binary: + stderr_console.print( + "[red]claude CLI not found in PATH.[/red] Install Claude Code or set " + "[cyan]CLAUDE_BIN[/cyan].", + ) + raise SystemExit(127) + + if cwd is None: + chat_cwd = _resolve_chat_cwd() + else: + chat_cwd = Path(cwd).resolve() + if not chat_cwd.is_dir(): + stderr_console.print(f"[red]Not a directory:[/red] {cwd}") + raise SystemExit(1) + + cmd: list[str] = [binary] + if dangerously_allow_browser_control: + cmd.append("--dangerously-allow-browser-control") + cmd.extend(ctx.args) + + proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) + raise SystemExit(proc.returncode) cli.add_command(update_cmd) cli.add_command(upgrade_cmd, name="upgrade") -cli.add_command(chat_cmd) -cli.add_command(mail_cmd) def main() -> None: diff --git a/myclaude/commands/__init__.py b/myclaude/commands/__init__.py index 0d24c9f..b58c03b 100644 --- a/myclaude/commands/__init__.py +++ b/myclaude/commands/__init__.py @@ -1,10 +1,8 @@ """CLI subcommands.""" -from .chat import chat_cmd -from .mail import mail_cmd from .update import update_cmd # alias: upgrade is the same as update upgrade_cmd = update_cmd -__all__ = ["chat_cmd", "mail_cmd", "update_cmd", "upgrade_cmd"] +__all__ = ["update_cmd", "upgrade_cmd"] diff --git a/myclaude/commands/chat.py b/myclaude/commands/chat.py deleted file mode 100644 index d5ff82a..0000000 --- a/myclaude/commands/chat.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Start Claude Code in the myclaude project directory.""" - -import os -import shutil -import subprocess -from pathlib import Path - -import click -from rich.console import Console - -from myclaude.project_root import get_myclaude_project_root - -console = Console() -stderr_console = Console(stderr=True) - - -def _resolve_chat_cwd() -> Path: - root = get_myclaude_project_root() - fallback = Path.home() / ".myclaude" - if root == fallback: - stderr_console.print( - "[red]Cannot resolve myclaude project root.[/red] Run from the repo or set " - "[cyan]MYCLAUDE_PROJECT_ROOT[/cyan].", - ) - raise SystemExit(1) - return root - - -@click.command( - "chat", - context_settings={ - "ignore_unknown_options": True, - "allow_extra_args": True, - }, -) -@click.option( - "--dangerously-skip-permissions", - is_flag=True, - default=False, - help="Forward to claude: bypass permission checks (sandbox / isolated use only).", -) -@click.option( - "--cwd", - "-C", - is_flag=False, - flag_value=".", - default=None, - type=click.Path(dir_okay=True, file_okay=False), - help="Use specified path as working directory (default: myclaude project root).", -) -@click.pass_context -def chat_cmd( - ctx: click.Context, - dangerously_skip_permissions: bool, - cwd: str | None, -) -> None: - """Run `claude` in the myclaude repo so CLAUDE.md and .claude/ apply; extra args pass through. - - \b - Working directory options: - --cwd, -C Use current directory as working directory - --cwd /path, -C/path Use specified path as working directory - (none) Use myclaude project root (default) - """ - binary = os.environ.get("CLAUDE_BIN") or shutil.which("claude") - if not binary: - stderr_console.print( - "[red]claude CLI not found in PATH.[/red] Install Claude Code or set " - "[cyan]CLAUDE_BIN[/cyan].", - ) - raise SystemExit(127) - - if cwd is None: - chat_cwd = _resolve_chat_cwd() - else: - chat_cwd = Path(cwd).resolve() - if not chat_cwd.is_dir(): - stderr_console.print(f"[red]Not a directory:[/red] {cwd}") - raise SystemExit(1) - - cmd: list[str] = [binary] - if dangerously_skip_permissions: - cmd.append("--dangerously-skip-permissions") - cmd.extend(ctx.args) - - proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) - raise SystemExit(proc.returncode) diff --git a/myclaude/commands/mail.py b/myclaude/commands/mail.py deleted file mode 100644 index 6b2a09f..0000000 --- a/myclaude/commands/mail.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Mail subcommand for myclaude.""" - -from pathlib import Path - -import click -from rich.console import Console -from rich.table import Table - -from myclaude.mail.client import IMAPClient, SMTPClient -from myclaude.mail.config import ( - DEFAULT_IMAP_HOST, - DEFAULT_IMAP_PORT, - DEFAULT_SMTP_HOST, - DEFAULT_SMTP_PORT, - load_config, - save_config, -) -from myclaude.mail.utils import truncate_text - -console = Console() -stderr_console = Console(stderr=True) - - -@click.group("mail") -def mail_cmd() -> None: - """Manage email via IMAP/SMTP (e.g. SYSU Coremail).""" - - -@mail_cmd.command("config") -def mail_config() -> None: - """Interactively configure mail account.""" - console.print("[bold cyan]Mail configuration[/bold cyan]") - existing = load_config() - - def _prompt(text: str, default: str | None = None) -> str: - prompt = text - if default: - prompt = f"{text} [{default}]" - value = click.prompt(prompt, default=default or "", show_default=False) - if isinstance(value, str): - return value.strip() - return str(value).strip() - - imap_host = ( - _prompt( - "IMAP host", - existing.get("imap_host") if existing else DEFAULT_IMAP_HOST, - ) - or DEFAULT_IMAP_HOST - ) - imap_port_str = _prompt( - "IMAP port", - str(existing.get("imap_port")) if existing else str(DEFAULT_IMAP_PORT), - ) or str(DEFAULT_IMAP_PORT) - smtp_host = ( - _prompt( - "SMTP host", - existing.get("smtp_host") if existing else DEFAULT_SMTP_HOST, - ) - or DEFAULT_SMTP_HOST - ) - smtp_port_str = _prompt( - "SMTP port", - str(existing.get("smtp_port")) if existing else str(DEFAULT_SMTP_PORT), - ) or str(DEFAULT_SMTP_PORT) - email = _prompt("Email", existing.get("email") if existing else "") or "" - password = ( - click.prompt("Password", hide_input=True) - if not existing - else click.prompt( - "Password (leave blank to keep existing)", - hide_input=True, - default="", - show_default=False, - ) - ) - - try: - imap_port = int(imap_port_str) - smtp_port = int(smtp_port_str) - except ValueError: - stderr_console.print("[red]Invalid port number.[/red]") - raise SystemExit(1) from None - - config = { - "imap_host": imap_host, - "imap_port": imap_port, - "smtp_host": smtp_host, - "smtp_port": smtp_port, - "email": email, - } - if password: - config["password"] = password - elif existing and existing.get("password"): - config["password"] = existing["password"] - - save_config(config) - console.print("[green]Configuration saved.[/green]") - - -@mail_cmd.command("inbox") -@click.option("--limit", default=20, help="Number of recent messages to show.") -def mail_inbox(limit: int) -> None: - """Show recent inbox summaries.""" - cfg = load_config() - if not cfg: - stderr_console.print( - '[red]No mail config found.[/red] Run "myclaude mail config" first.' - ) - raise SystemExit(1) from None - - try: - with IMAPClient( - cfg["imap_host"], - cfg["imap_port"], - cfg["email"], - cfg["password"], - ) as client: - summaries = client.inbox_summaries(limit=limit) - except Exception as exc: - stderr_console.print(f"[red]IMAP error:[/red] {exc}") - raise SystemExit(1) from None - - if not summaries: - console.print("Inbox is empty.") - return - - table = Table(title="Inbox") - table.add_column("UID", style="cyan", no_wrap=True) - table.add_column("From", style="green") - table.add_column("Subject") - table.add_column("Date", style="dim") - - for s in summaries: - table.add_row( - s["uid"], - truncate_text(s.get("from", ""), 30), - truncate_text(s.get("subject", ""), 50), - s.get("date", ""), - ) - console.print(table) - - -@mail_cmd.command("read") -@click.argument("uid") -def mail_read(uid: str) -> None: - """Read a single message by UID.""" - cfg = load_config() - if not cfg: - stderr_console.print( - '[red]No mail config found.[/red] Run "myclaude mail config" first.' - ) - raise SystemExit(1) from None - - try: - with IMAPClient( - cfg["imap_host"], - cfg["imap_port"], - cfg["email"], - cfg["password"], - ) as client: - msg = client.fetch_message(uid) - except Exception as exc: - stderr_console.print(f"[red]IMAP error:[/red] {exc}") - raise SystemExit(1) from None - - console.print(f"[bold]From:[/bold] {msg['from']}") - console.print(f"[bold]To:[/bold] {msg['to']}") - if msg.get("cc"): - console.print(f"[bold]Cc:[/bold] {msg['cc']}") - console.print(f"[bold]Subject:[/bold] {msg['subject']}") - console.print(f"[bold]Date:[/bold] {msg['date']}") - console.print("-" * 60) - - body = msg["plain"] or msg["html"] - console.print(body or "[dim](no body)[/dim]") - - if msg.get("attachments"): - console.print("\n[bold]Attachments:[/bold]") - for att in msg["attachments"]: - console.print(f" • {att['filename']} ({att['content_type']})") - - -@mail_cmd.command("send") -@click.option("--to", required=True, help="Recipient email address.") -@click.option("--subject", required=True, help="Email subject.") -@click.option("--body", required=True, help="Email body.") -@click.option("--cc", default="", help="Cc recipients (comma-separated).") -@click.option("--bcc", default="", help="Bcc recipients (comma-separated).") -@click.option( - "--attach", - multiple=True, - help="Attachment file path (can be used multiple times).", -) -@click.option("--html", is_flag=True, help="Treat body as HTML.") -def mail_send( - to: str, - subject: str, - body: str, - cc: str, - bcc: str, - attach: tuple[str, ...], - html: bool, -) -> None: - """Send an email.""" - cfg = load_config() - if not cfg: - stderr_console.print( - '[red]No mail config found.[/red] Run "myclaude mail config" first.' - ) - raise SystemExit(1) from None - - body = body.replace("\\n", "\n").replace("\\t", "\t") - - to_addrs = [addr.strip() for addr in to.split(",") if addr.strip()] - cc_addrs = ( - [addr.strip() for addr in cc.split(",") if addr.strip()] if cc else None - ) - bcc_addrs = ( - [addr.strip() for addr in bcc.split(",") if addr.strip()] - if bcc - else None - ) - attachments = [Path(p) for p in attach] - - for p in attachments: - if not p.is_file(): - stderr_console.print(f"[red]Attachment not found:[/red] {p}") - raise SystemExit(1) from None - - try: - client = SMTPClient( - cfg["smtp_host"], - cfg["smtp_port"], - cfg["email"], - cfg["password"], - ) - client.send( - to_addrs=to_addrs, - subject=subject, - body=body, - cc_addrs=cc_addrs, - bcc_addrs=bcc_addrs, - attachments=[str(p) for p in attachments], - is_html=html, - ) - except Exception as exc: - stderr_console.print(f"[red]SMTP error:[/red] {exc}") - raise SystemExit(1) from None - - console.print("[green]Email sent successfully.[/green]") - - -@mail_cmd.command("reply") -@click.argument("uid") -@click.option("--body", required=True, help="Reply body.") -@click.option("--html", is_flag=True, help="Treat body as HTML.") -def mail_reply(uid: str, body: str, html: bool) -> None: - """Reply to a message by UID.""" - cfg = load_config() - if not cfg: - stderr_console.print( - '[red]No mail config found.[/red] Run "myclaude mail config" first.' - ) - raise SystemExit(1) from None - - body = body.replace("\\n", "\n").replace("\\t", "\t") - - try: - with IMAPClient( - cfg["imap_host"], - cfg["imap_port"], - cfg["email"], - cfg["password"], - ) as client: - original = client.fetch_message(uid) - except Exception as exc: - stderr_console.print(f"[red]IMAP error:[/red] {exc}") - raise SystemExit(1) from None - - try: - smtp = SMTPClient( - cfg["smtp_host"], - cfg["smtp_port"], - cfg["email"], - cfg["password"], - ) - smtp.send_reply(original, body=body, is_html=html) - except Exception as exc: - stderr_console.print(f"[red]SMTP error:[/red] {exc}") - raise SystemExit(1) from None - - console.print("[green]Reply sent successfully.[/green]") - - -@mail_cmd.command("forward") -@click.argument("uid") -@click.option("--to", required=True, help="Recipient email address.") -@click.option("--body", default="", help="Optional forwarding note.") -@click.option("--html", is_flag=True, help="Treat body as HTML.") -def mail_forward(uid: str, to: str, body: str, html: bool) -> None: - """Forward a message by UID.""" - cfg = load_config() - if not cfg: - stderr_console.print( - '[red]No mail config found.[/red] Run "myclaude mail config" first.' - ) - raise SystemExit(1) from None - - body = body.replace("\\n", "\n").replace("\\t", "\t") - to_addrs = [addr.strip() for addr in to.split(",") if addr.strip()] - - try: - with IMAPClient( - cfg["imap_host"], - cfg["imap_port"], - cfg["email"], - cfg["password"], - ) as client: - original = client.fetch_message(uid) - raw_original = client.fetch_raw(uid) - except Exception as exc: - stderr_console.print(f"[red]IMAP error:[/red] {exc}") - raise SystemExit(1) from None - - subject = original.get("subject", "") - - try: - smtp = SMTPClient( - cfg["smtp_host"], - cfg["smtp_port"], - cfg["email"], - cfg["password"], - ) - smtp.send_forward( - raw_original=raw_original, - to_addrs=to_addrs, - subject=subject, - body=body or None, - is_html=html, - ) - except Exception as exc: - stderr_console.print(f"[red]SMTP error:[/red] {exc}") - raise SystemExit(1) from None - - console.print("[green]Message forwarded successfully.[/green]") diff --git a/myclaude/mail/__init__.py b/myclaude/mail/__init__.py deleted file mode 100644 index 52d077e..0000000 --- a/myclaude/mail/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Mail module for myclaude.""" diff --git a/myclaude/mail/client.py b/myclaude/mail/client.py deleted file mode 100644 index a7e2fc3..0000000 --- a/myclaude/mail/client.py +++ /dev/null @@ -1,279 +0,0 @@ -"""IMAP and SMTP client wrappers.""" - -import imaplib -import smtplib -from email.message import EmailMessage -from email.utils import formataddr -from typing import Any - -from myclaude.mail.parser import extract_attachments, extract_text -from myclaude.mail.utils import decode_header_value, format_date - - -class IMAPClient: - """Simple IMAP client wrapper.""" - - def __init__( - self, - host: str, - port: int, - email: str, - password: str, - ) -> None: - self.host = host - self.port = port - self.email = email - self.password = password - self._conn: imaplib.IMAP4_SSL | None = None - - def connect(self) -> "IMAPClient": - self._conn = imaplib.IMAP4_SSL(self.host, self.port) - self._conn.login(self.email, self.password) - return self - - def disconnect(self) -> None: - if self._conn: - try: - self._conn.close() - self._conn.logout() - except Exception: - pass - self._conn = None - - def __enter__(self) -> "IMAPClient": - return self.connect() - - def __exit__(self, *_: Any) -> None: - self.disconnect() - - def inbox_summaries(self, limit: int = 20) -> list[dict[str, Any]]: - """Fetch recent inbox message summaries.""" - assert self._conn is not None - status, _ = self._conn.select("INBOX") - if status != "OK": - raise RuntimeError("Failed to select INBOX") - - _, data = self._conn.search(None, "ALL") - msg_ids = data[0].split() - recent_ids = msg_ids[-limit:] - recent_ids.reverse() - - summaries: list[dict[str, Any]] = [] - for msg_id in recent_ids: - msg_id_str = msg_id.decode("ascii") - _, fetched = self._conn.fetch( - msg_id_str, - "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])", - ) - raw_headers = b"" - for item in fetched: - if isinstance(item, tuple): - raw_headers += item[1] - - from email import message_from_bytes - - header_msg = message_from_bytes(raw_headers) - summaries.append( - { - "uid": msg_id_str, - "from": decode_header_value(header_msg.get("From")), - "subject": decode_header_value(header_msg.get("Subject")), - "date": format_date(header_msg.get("Date")), - } - ) - return summaries - - def fetch_raw(self, uid: str) -> bytes: - """Fetch raw RFC822 bytes by UID.""" - assert self._conn is not None - status, _ = self._conn.select("INBOX") - if status != "OK": - raise RuntimeError("Failed to select INBOX") - _, data = self._conn.fetch(uid, "(RFC822)") - raw = b"" - for item in data: - if isinstance(item, tuple): - raw += item[1] - return raw - - def fetch_message(self, uid: str) -> dict[str, Any]: - """Fetch full message by UID.""" - raw = self.fetch_raw(uid) - - from email import message_from_bytes - - msg = message_from_bytes(raw) - plain, html = extract_text(msg) - attachments = extract_attachments(msg) - - return { - "uid": uid, - "from": decode_header_value(msg.get("From")), - "to": decode_header_value(msg.get("To")), - "cc": decode_header_value(msg.get("Cc")), - "subject": decode_header_value(msg.get("Subject")), - "date": format_date(msg.get("Date")), - "message_id": decode_header_value(msg.get("Message-ID")), - "reply_to": decode_header_value(msg.get("Reply-To")), - "references": decode_header_value(msg.get("References")), - "plain": plain, - "html": html, - "attachments": attachments, - } - - -class SMTPClient: - """Simple SMTP client wrapper.""" - - def __init__( - self, - host: str, - port: int, - email: str, - password: str, - ) -> None: - self.host = host - self.port = port - self.email = email - self.password = password - - def send( - self, - to_addrs: list[str], - subject: str, - body: str, - cc_addrs: list[str] | None = None, - bcc_addrs: list[str] | None = None, - attachments: list[str] | None = None, - is_html: bool = False, - ) -> None: - """Send an email message.""" - msg = EmailMessage() - msg["From"] = formataddr((self.email.split("@")[0], self.email)) - msg["To"] = ", ".join(to_addrs) - if cc_addrs: - msg["Cc"] = ", ".join(cc_addrs) - msg["Subject"] = subject - - content_type = "text/html" if is_html else "text/plain" - msg.set_content(body, subtype=content_type.split("/")[1]) - - if attachments: - for path in attachments: - from pathlib import Path - - p = Path(path) - if not p.is_file(): - raise FileNotFoundError(f"Attachment not found: {path}") - data = p.read_bytes() - msg.add_attachment( - data, - maintype="application", - subtype="octet-stream", - filename=p.name, - ) - - all_recipients = list(to_addrs) - if cc_addrs: - all_recipients.extend(cc_addrs) - if bcc_addrs: - all_recipients.extend(bcc_addrs) - - with smtplib.SMTP_SSL(self.host, self.port) as server: - server.login(self.email, self.password) - server.sendmail(self.email, all_recipients, msg.as_string()) - - def send_reply( - self, - original: dict[str, Any], - body: str, - is_html: bool = False, - ) -> None: - """Send a reply to an existing message.""" - to_addr = original.get("reply_to") or original["from"] - if not to_addr: - raise ValueError("Original message has no From or Reply-To address") - - subject = original.get("subject", "") - if not subject.lower().startswith("re:"): - subject = f"Re: {subject}" - - quote_lines = [ - f"> From: {original.get('from', '')}", - f"> To: {original.get('to', '')}", - f"> Subject: {original.get('subject', '')}", - f"> Date: {original.get('date', '')}", - ">", - ] - for line in (original.get("plain") or original.get("html") or "").splitlines(): - quote_lines.append(f"> {line}") - quoted = "\n".join(quote_lines) - - full_body = f"{body}\n\n{quoted}" if quoted.strip("> ") else body - - msg = EmailMessage() - msg["From"] = formataddr((self.email.split("@")[0], self.email)) - msg["To"] = to_addr - msg["Subject"] = subject - - orig_msg_id = original.get("message_id", "").strip() - if orig_msg_id: - msg["In-Reply-To"] = orig_msg_id - refs = original.get("references", "").strip() - msg["References"] = f"{refs} {orig_msg_id}".strip() if refs else orig_msg_id - - content_type = "text/html" if is_html else "text/plain" - msg.set_content(full_body, subtype=content_type.split("/")[1]) - - with smtplib.SMTP_SSL(self.host, self.port) as server: - server.login(self.email, self.password) - server.sendmail(self.email, [to_addr], msg.as_string()) - - def send_forward( - self, - raw_original: bytes, - to_addrs: list[str], - subject: str | None = None, - body: str | None = None, - cc_addrs: list[str] | None = None, - bcc_addrs: list[str] | None = None, - is_html: bool = False, - ) -> None: - """Forward an existing message as a message/rfc822 attachment.""" - from email import message_from_bytes - - orig_msg = message_from_bytes(raw_original) - orig_subject = decode_header_value(orig_msg.get("Subject")) - if subject is None: - subject = orig_subject - if not subject.lower().startswith("fwd:"): - subject = f"Fwd: {subject}" - - msg = EmailMessage() - msg["From"] = formataddr((self.email.split("@")[0], self.email)) - msg["To"] = ", ".join(to_addrs) - if cc_addrs: - msg["Cc"] = ", ".join(cc_addrs) - msg["Subject"] = subject - - if body: - content_type = "text/html" if is_html else "text/plain" - msg.set_content(body, subtype=content_type.split("/")[1]) - else: - msg.set_content("") - - msg.add_attachment( - orig_msg, - maintype="message", - subtype="rfc822", - ) - - all_recipients = list(to_addrs) - if cc_addrs: - all_recipients.extend(cc_addrs) - if bcc_addrs: - all_recipients.extend(bcc_addrs) - - with smtplib.SMTP_SSL(self.host, self.port) as server: - server.login(self.email, self.password) - server.sendmail(self.email, all_recipients, msg.as_string()) diff --git a/myclaude/mail/config.py b/myclaude/mail/config.py deleted file mode 100644 index 649ee1d..0000000 --- a/myclaude/mail/config.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Mail configuration management.""" - -import base64 -import json -import os -from pathlib import Path -from typing import Any - -DEFAULT_IMAP_HOST = "mail.sysu.edu.cn" -DEFAULT_IMAP_PORT = 993 -DEFAULT_SMTP_HOST = "mail.sysu.edu.cn" -DEFAULT_SMTP_PORT = 465 - - -def _config_path() -> Path: - return Path.home() / ".myclaude" / "mail.json" - - -def _ensure_dir(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - - -def load_config() -> dict[str, Any] | None: - """Load mail config from ~/.myclaude/mail.json.""" - path = _config_path() - if not path.is_file(): - return None - with path.open("r", encoding="utf-8") as f: - data = json.load(f) - # decode password - if data.get("password"): - data["password"] = base64.b64decode(data["password"]).decode("utf-8") - # allow env overrides - email = os.environ.get("MYCLAUDE_MAIL_EMAIL") - password = os.environ.get("MYCLAUDE_MAIL_PASSWORD") - if email: - data["email"] = email - if password: - data["password"] = password - return data - - -def save_config(data: dict[str, Any]) -> None: - """Save mail config to ~/.myclaude/mail.json.""" - path = _config_path() - _ensure_dir(path) - to_save = dict(data) - if to_save.get("password"): - to_save["password"] = base64.b64encode( - to_save["password"].encode("utf-8") - ).decode("ascii") - with path.open("w", encoding="utf-8") as f: - json.dump(to_save, f, indent=2, ensure_ascii=False) diff --git a/myclaude/mail/parser.py b/myclaude/mail/parser.py deleted file mode 100644 index 49b9e49..0000000 --- a/myclaude/mail/parser.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Email message parsing utilities.""" - -from email.message import Message -from typing import Any - -from myclaude.mail.utils import decode_header_value, strip_html_tags - - -def _extract_payload(part: Message) -> str: - """Extract text payload from a message part.""" - charset = part.get_content_charset() or "utf-8" - payload = part.get_payload(decode=True) - if isinstance(payload, bytes): - return payload.decode(charset, errors="replace") - return str(payload or "") - - -def extract_text(msg: Message) -> tuple[str, str]: - """Extract plain text and html body from a message. - - Returns (plain_text, html_text). Either may be empty. - """ - plain_parts: list[str] = [] - html_parts: list[str] = [] - - if msg.is_multipart(): - for part in msg.walk(): - ctype = part.get_content_type() - cdisp = str(part.get("Content-Disposition", "")) - if "attachment" in cdisp: - continue - if ctype == "text/plain": - plain_parts.append(_extract_payload(part)) - elif ctype == "text/html": - html_parts.append(_extract_payload(part)) - else: - ctype = msg.get_content_type() - payload = _extract_payload(msg) - if ctype == "text/html": - html_parts.append(payload) - else: - plain_parts.append(payload) - - plain = "\n".join(plain_parts).strip() - html = "\n".join(html_parts).strip() - - if not plain and html: - plain = strip_html_tags(html) - - return plain, html - - -def extract_attachments(msg: Message) -> list[dict[str, Any]]: - """Return list of attachment metadata dicts.""" - attachments: list[dict[str, Any]] = [] - if not msg.is_multipart(): - return attachments - - for part in msg.walk(): - cdisp = str(part.get("Content-Disposition", "")) - if "attachment" not in cdisp: - continue - filename = decode_header_value(part.get_filename()) - if filename: - attachments.append( - { - "filename": filename, - "content_type": part.get_content_type(), - "size": len(part.get_payload(decode=True) or b""), - } - ) - return attachments diff --git a/myclaude/mail/utils.py b/myclaude/mail/utils.py deleted file mode 100644 index ca1ae7c..0000000 --- a/myclaude/mail/utils.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Mail utility helpers.""" - -import re -from datetime import datetime -from email.header import decode_header -from email.utils import mktime_tz, parsedate_tz -from typing import Any - - -def decode_header_value(value: Any) -> str: - """Decode email header value safely.""" - if value is None: - return "" - raw = value if isinstance(value, str) else str(value) - parts = decode_header(raw) - result: list[str] = [] - for part, charset in parts: - if isinstance(part, bytes): - result.append(part.decode(charset or "utf-8", errors="replace")) - else: - result.append(part) - return "".join(result) - - -def format_date(date_str: str | None) -> str: - """Format email date string to local-friendly form.""" - if not date_str: - return "" - try: - tt = parsedate_tz(date_str) - if tt is None: - return date_str - ts = mktime_tz(tt) - dt = datetime.fromtimestamp(ts) - return dt.strftime("%Y-%m-%d %H:%M") - except Exception: - return date_str - - -def strip_html_tags(html: str) -> str: - """Very basic HTML tag stripping for fallback text extraction.""" - text = re.sub(r"]*>.*?", "", html, flags=re.S) - text = re.sub(r"]*>.*?", "", text, flags=re.S) - # Replace block-level tags with newlines before stripping remaining tags - text = re.sub(r"", "\n", text, flags=re.I) - text = re.sub(r"", "\n", text, flags=re.I) - text = re.sub(r"<[^>]+>", "", text) - text = re.sub(r" ", " ", text) - text = re.sub(r"<", "<", text) - text = re.sub(r">", ">", text) - text = re.sub(r"&", "&", text) - text = re.sub(r"\n\s*\n+", "\n\n", text) - return text.strip() - - -def truncate_text(text: str, max_len: int = 60) -> str: - """Truncate text with ellipsis.""" - if len(text) <= max_len: - return text - return text[: max_len - 1] + "…" diff --git a/uv.lock b/uv.lock index 1a5ce57..47e9f47 100644 --- a/uv.lock +++ b/uv.lock @@ -55,7 +55,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "click", specifier = ">=8.0.0" }, + { name = "click", specifier = ">=8.3.2" }, { name = "rich", specifier = ">=13.0.0" }, ] diff --git a/workspace/.gitignore b/workspace/.gitignore new file mode 100644 index 0000000..dca4568 --- /dev/null +++ b/workspace/.gitignore @@ -0,0 +1,5 @@ +# 临时目录 — 禁止跟踪临时产物 +tmp/ + +# macOS +.DS_Store diff --git a/workspace/CLAUDE.md b/workspace/CLAUDE.md new file mode 100644 index 0000000..15811c4 --- /dev/null +++ b/workspace/CLAUDE.md @@ -0,0 +1,148 @@ +# Claude 个人助理 — 工作区配置 + +你是 Claude,用户的个人助理,协助日常办公、学术研究和软件研发工作。 + +--- + +## 1. 用户画像 + +- **姓名**: 赖正首 +- **称呼**: 老大 +- **身份**: 中山大学土木工程学院副教授 +- **专精领域**: Python, AI/ML, 系统架构, 计算力学 +- **经验水平**: 高级 + +### 沟通偏好 +- **风格**: 简洁直接,不说废话 +- **解释**: 关键部分详细,熟悉内容简洁 +- **代码**: PEP8 + Black,需要类型注解 + +--- + +## 2. 工作模式 + +切换命令: "切换到专家模式" / "用导师的方式" / "执行这个任务" + +| 模式 | 特点 | 适用场景 | +|------|------|----------| +| **协作者** (默认) | 平等对话,共同探索 | 需求讨论、架构设计 | +| **专家** | 直接给最佳实践,减少解释 | 技术实现、代码审查 | +| **导师** | 引导思考,多问少答 | 学习、概念理解 | +| **执行者** | 最小化交互,快速执行 | 明确任务、批量操作 | + +### 知识检索 + +使用 `metamemory` skill 或 `mm` CLI: +- `mm search ` - 搜索已有记忆 +- `mm list` - 列出所有文档 +- `mm folders` - 浏览文件夹结构 + +查询资料时直接用 `Grep`/`Read` 工具。 + +--- + +## 3. Workspace 结构 + +``` +workspace/ +├── .obsidian/ # Obsidian 配置 +├── myacademia -> ... # → Obsidian/iCloud 知识库 +├── myagent -> ... # → myagent 项目 +├── mydesktop -> ... # → Obsidian/iCloud 过程/共享文件 +├── myslides -> ... # → Slidev 幻灯片 +├── mytoolkit -> ... # → 个人 CLI 工具集 +├── mywebpage -> ... # → Docusaurus 网站 +├── scripts/ # 实用脚本 +└── tmp/ # 临时文件 +``` + +**符号链接目标**: +- `myacademia` → `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/myacademia` +- `mydesktop` → `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/mydesktop` +- `myagent` → `~/Documents/myResearch/myProjects/apaam/repo/myagent` +- `myslides` → `~/Documents/myWork/sysu/4_成果归档/myslides` +- `mytoolkit` → `~/Documents/MyBin/mytoolkit` +- `mywebpage` → `~/Documents/myWork/sysu/4_成果归档/mywebpage` + +**重要路径**: +- `myacademia/` - Obsidian 知识库 +- `mydesktop/` - Obsidian/iCloud 过程/共享文件 +- `myagent/` - myagent 项目目录 +- `myslides/` - Slidev 幻灯片项目 +- `mytoolkit/` - 个人 CLI 工具集(PDF、图片、LaTeX 等) +- `mywebpage/` - Docusaurus 网站项目 + +--- + +## 4. 临时文件管理 + +- **截图/下载** 默认写入 `tmp/`,避免污染项目根目录 +- Playwright MCP 截图时务必指定 `filename="tmp/xxx.png"`(cwd 为 workspace/) +- 定期清理 `tmp/` 中过期的临时文件 + +--- + +## 5. 文件系统管辖与主动搜索 + +### 管辖原则 +**全文件系统可管理**。具体目录结构以 `metamemory` 中的"文件系统知识库"文档为准,CLAUDE.md 不硬编码路径。 + +### 主动搜索协议 +当用户提及文件、合同、报告等时: +1. **优先查知识库**:先用 `mm search` 查 metamemory 中已记录的目录结构,定位可能位置 +2. **动态搜索**:在知识库提示的范围内用 `Glob`/`Grep` 按名称、日期、内容关键词搜索 +3. **模糊匹配**:支持关键词部分匹配(如"成都理工" → `*成都*`, `*理工*`) +4. **结果汇报**:列出候选文件路径、修改时间,供用户确认 +5. **兜底**:确实找不到时,询问是否需要创建或从其他来源获取 + +### 知识库同步 +每次 `file-manager` 整理后,读取操作日志并同步更新 `metamemory` 中的"文件系统知识库"文档,确保 wiki 与物理文件系统一致。禁止在 CLAUDE.md 中硬编码具体路径。 + +--- + +## 6. 记忆机制 + +使用 `metamemory`(`mm` CLI)跨会话持久化信息: + +### 写入 +```bash +# 创建文档(推荐用 stdin) +echo '# 内容' | mm create "标题" --tags "user,feedback" --by "myclaude" + +# 创建文件夹 +mm mkdir "myclaude" +``` + +### 读取 +```bash +mm search # 搜索 +mm list # 列出文档 +mm get # 按 ID 获取 +mm path # 按路径获取 +``` + +### 触发条件 +- 用户说"记住"/"记下来"/"以后默认..." +- 用户纠正行为或偏好 +- 发现重要的项目事实/约定 + +--- + +## 7. 沟通风格 + +- **简洁**: 不说废话 +- **透明**: 复杂任务同步进度 +- **主动**: 发现问题时提醒,识别更好方法时建议 + +### 回复结构 +1. **快速总结** (1-2句): 确认理解 +2. **核心回答**: 直接给答案或结果 +3. **后续建议** (可选): 相关建议 + +--- + +## 8. 指令优先级 + +1. **明确指令** > 默认规则 +2. **当前对话上下文** > 历史记忆 +3. **本文件 (CLAUDE.md)** > 全局设置