feat(cli): merge chat into default behavior, default cwd to workspace/

- Remove standalone chat subcommand; chat becomes default behavior
- Default --cwd resolves to workspace/ instead of repo root
- Split CLAUDE.md: repo/ for CLI dev, workspace/ for daily workflow
- Add workspace/CLAUDE.md, workspace/.gitignore for tmp files
- Ignore MCP temp artifacts (.mcp.json, .playwright-mcp/)
- Clean up removed mail subcommand files
This commit is contained in:
Zhengshou Lai
2026-04-23 08:38:59 +08:00
parent 5a665e38bb
commit bebf7ba17a
14 changed files with 262 additions and 981 deletions
+9
View File
@@ -9,3 +9,12 @@ knowledge/
.claude/ .claude/
.obsidian/ .obsidian/
# MCP configs and temp artifacts
.mcp.json
.playwright-mcp/
# Playwright MCP default screenshots — always use workspace/tmp/ instead
/*.png
/*.jpg
/*.jpeg
+15 -73
View File
@@ -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 <query>` - 搜索已有记忆
- `mm list` - 列出所有文档
- `mm folders` - 浏览文件夹结构
查询资料时直接用 `Grep`/`Read` 工具。
--- ---
## 3. Workspace 结构 ## 3. 项目结构
``` ```
~/Documents/myResearch/myProjects/apaam/repo/ myclaude/
├── myclaude/ # 当前项目: myclaude CLI 工具 ├── myclaude/ # Python 源码
── workspace/ # 工作目录 ── cli.py # CLI entrypoint
├── .obsidian/ # Obsidian 配置 ├── commands/ # 子命令
├── myacademia -> .../myacademia # → Obsidian/iCloud 知识库 └── ...
│ ├── myagent -> .../repo/myagent # → myagent 项目 ├── workspace/ # 主工作环境 (默认 --cwd 目标)
├── mydesktop -> .../mydesktop # → Obsidian/iCloud 过程/共享文件 └── CLAUDE.md # 日常工作指令
│ ├── myslides -> .../4_成果归档/myslides # → Slidev 幻灯片 └── .claude/ # Claude Code 运行时配置
│ ├── mytoolkit -> .../MyBin/mytoolkit # → 个人 CLI 工具集
│ ├── mywebpage -> .../4_成果归档/mywebpage # → Docusaurus 网站
│ ├── scripts/ # 实用脚本
│ └── tmp/ # 临时文件
├── metabot/ # 飞书机器人配置
│ └── bots.json # 机器人 AppID/AppSecret 配置
└── (其他项目...)
``` ```
**符号链接目标**: 默认运行 `myclaude`(不带 --cwd)时,cwd 为 `workspace/`,指向工作区 CLAUDE.md。
- `myacademia``~/Library/Mobile Documents/iCloud~md~obsidian/Documents/myacademia` 开发 CLI 时运行 `myclaude --cwd .` 以读取本文件。
- `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 网站项目
--- ---
@@ -106,35 +76,7 @@
--- ---
## 5. 记忆机制 ## 5. 沟通风格
使用 `metamemory``mm` CLI)跨会话持久化信息:
### 写入
```bash
# 创建文档(推荐用 stdin
echo '# 内容' | mm create "标题" --tags "user,feedback" --by "myclaude"
# 创建文件夹
mm mkdir "myclaude"
```
### 读取
```bash
mm search <query> # 搜索
mm list # 列出文档
mm get <doc_id> # 按 ID 获取
mm path </folder/doc> # 按路径获取
```
### 触发条件
- 用户说"记住"/"记下来"/"以后默认..."
- 用户纠正行为或偏好
- 发现重要的项目事实/约定
---
## 6. 沟通风格
- **简洁**: 不说废话 - **简洁**: 不说废话
- **透明**: 复杂任务同步进度 - **透明**: 复杂任务同步进度
@@ -147,7 +89,7 @@ mm path </folder/doc> # 按路径获取
--- ---
## 7. 指令优先级 ## 6. 指令优先级
1. **明确指令** > 默认规则 1. **明确指令** > 默认规则
2. **当前对话上下文** > 历史记忆 2. **当前对话上下文** > 历史记忆
+83 -6
View File
@@ -1,10 +1,18 @@
"""Myclaude CLI entrypoint.""" """Myclaude CLI entrypoint."""
import os
import shutil
import subprocess
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
import click 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: def _package_version() -> str:
@@ -14,16 +22,85 @@ def _package_version() -> str:
return "0.0.0" 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()) @click.version_option(version=_package_version())
def cli() -> None: @click.option(
"""Myclaude: CLI (update package, chat = Claude Code in this repo).""" "--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(update_cmd)
cli.add_command(upgrade_cmd, name="upgrade") cli.add_command(upgrade_cmd, name="upgrade")
cli.add_command(chat_cmd)
cli.add_command(mail_cmd)
def main() -> None: def main() -> None:
+1 -3
View File
@@ -1,10 +1,8 @@
"""CLI subcommands.""" """CLI subcommands."""
from .chat import chat_cmd
from .mail import mail_cmd
from .update import update_cmd from .update import update_cmd
# alias: upgrade is the same as update # alias: upgrade is the same as update
upgrade_cmd = update_cmd upgrade_cmd = update_cmd
__all__ = ["chat_cmd", "mail_cmd", "update_cmd", "upgrade_cmd"] __all__ = ["update_cmd", "upgrade_cmd"]
-87
View File
@@ -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)
-346
View File
@@ -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]")
-1
View File
@@ -1 +0,0 @@
"""Mail module for myclaude."""
-279
View File
@@ -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())
-53
View File
@@ -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)
-72
View File
@@ -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
-60
View File
@@ -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"<script[^>]*>.*?</script>", "", html, flags=re.S)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.S)
# Replace block-level tags with newlines before stripping remaining tags
text = re.sub(r"</(p|div|h[1-6]|li|tr|blockquote)>", "\n", text, flags=re.I)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"&nbsp;", " ", text)
text = re.sub(r"&lt;", "<", text)
text = re.sub(r"&gt;", ">", text)
text = re.sub(r"&amp;", "&", 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] + ""
Generated
+1 -1
View File
@@ -55,7 +55,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.0.0" }, { name = "click", specifier = ">=8.3.2" },
{ name = "rich", specifier = ">=13.0.0" }, { name = "rich", specifier = ">=13.0.0" },
] ]
+5
View File
@@ -0,0 +1,5 @@
# 临时目录 — 禁止跟踪临时产物
tmp/
# macOS
.DS_Store
+148
View File
@@ -0,0 +1,148 @@
# Claude 个人助理 — 工作区配置
你是 Claude,用户的个人助理,协助日常办公、学术研究和软件研发工作。
---
## 1. 用户画像
- **姓名**: 赖正首
- **称呼**: 老大
- **身份**: 中山大学土木工程学院副教授
- **专精领域**: Python, AI/ML, 系统架构, 计算力学
- **经验水平**: 高级
### 沟通偏好
- **风格**: 简洁直接,不说废话
- **解释**: 关键部分详细,熟悉内容简洁
- **代码**: PEP8 + Black,需要类型注解
---
## 2. 工作模式
切换命令: "切换到专家模式" / "用导师的方式" / "执行这个任务"
| 模式 | 特点 | 适用场景 |
|------|------|----------|
| **协作者** (默认) | 平等对话,共同探索 | 需求讨论、架构设计 |
| **专家** | 直接给最佳实践,减少解释 | 技术实现、代码审查 |
| **导师** | 引导思考,多问少答 | 学习、概念理解 |
| **执行者** | 最小化交互,快速执行 | 明确任务、批量操作 |
### 知识检索
使用 `metamemory` skill 或 `mm` CLI
- `mm search <query>` - 搜索已有记忆
- `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 <query> # 搜索
mm list # 列出文档
mm get <doc_id> # 按 ID 获取
mm path </folder/doc> # 按路径获取
```
### 触发条件
- 用户说"记住"/"记下来"/"以后默认..."
- 用户纠正行为或偏好
- 发现重要的项目事实/约定
---
## 7. 沟通风格
- **简洁**: 不说废话
- **透明**: 复杂任务同步进度
- **主动**: 发现问题时提醒,识别更好方法时建议
### 回复结构
1. **快速总结** (1-2句): 确认理解
2. **核心回答**: 直接给答案或结果
3. **后续建议** (可选): 相关建议
---
## 8. 指令优先级
1. **明确指令** > 默认规则
2. **当前对话上下文** > 历史记忆
3. **本文件 (CLAUDE.md)** > 全局设置