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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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"]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""Mail module for myclaude."""
|
|
||||||
@@ -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())
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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" ", " ", 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] + "…"
|
|
||||||
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 临时目录 — 禁止跟踪临时产物
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
@@ -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)** > 全局设置
|
||||||
Reference in New Issue
Block a user