email-cli-tool 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/PKG-INFO +16 -1
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/README.md +15 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/README_CN.md +15 -0
- email_cli_tool-0.2.0/docs/superpowers/plans/2026-06-10-emailcli-skill-install.md +497 -0
- email_cli_tool-0.2.0/docs/superpowers/specs/2026-06-10-emailcli-skill-install-design.md +122 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/pyproject.toml +1 -1
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/cli.py +33 -0
- email_cli_tool-0.2.0/src/emailcli/skill_install.py +65 -0
- email_cli_tool-0.2.0/src/emailcli/skills/send-email/SKILL.md +84 -0
- email_cli_tool-0.2.0/tests/test_skill_install.py +88 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/uv.lock +2 -2
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.github/workflows/publish.yml +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.github/workflows/test.yml +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.gitignore +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.python-version +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/LICENSE +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/docs/superpowers/plans/2026-03-25-emailcli.md +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/__init__.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/config.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/exceptions.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/message.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/py.typed +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/sender.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/__init__.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_cli.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_config.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_message.py +0 -0
- {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_sender.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: email-cli-tool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A simple CLI tool for sending emails with plain text, HTML, and file attachments
|
|
5
5
|
Project-URL: Homepage, https://github.com/ClaymanTwinkle/email-cli-tool
|
|
6
6
|
Project-URL: Repository, https://github.com/ClaymanTwinkle/email-cli-tool
|
|
@@ -123,6 +123,21 @@ Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
|
123
123
|
|
|
124
124
|
Display current configuration (password is masked).
|
|
125
125
|
|
|
126
|
+
### `emailcli skill install`
|
|
127
|
+
|
|
128
|
+
Install the `send-email` agent skill so Claude Code or Codex can send mail for you.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Install for both Claude Code and Codex (default)
|
|
132
|
+
emailcli skill install
|
|
133
|
+
|
|
134
|
+
# Only one agent
|
|
135
|
+
emailcli skill install --target claude
|
|
136
|
+
emailcli skill install --target codex
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Writes `SKILL.md` to `~/.claude/skills/send-email/` and/or `~/.codex/skills/send-email/`.
|
|
140
|
+
|
|
126
141
|
## Configuration
|
|
127
142
|
|
|
128
143
|
Path: `~/.emailcli/config.yaml`
|
|
@@ -97,6 +97,21 @@ Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
|
97
97
|
|
|
98
98
|
Display current configuration (password is masked).
|
|
99
99
|
|
|
100
|
+
### `emailcli skill install`
|
|
101
|
+
|
|
102
|
+
Install the `send-email` agent skill so Claude Code or Codex can send mail for you.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Install for both Claude Code and Codex (default)
|
|
106
|
+
emailcli skill install
|
|
107
|
+
|
|
108
|
+
# Only one agent
|
|
109
|
+
emailcli skill install --target claude
|
|
110
|
+
emailcli skill install --target codex
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Writes `SKILL.md` to `~/.claude/skills/send-email/` and/or `~/.codex/skills/send-email/`.
|
|
114
|
+
|
|
100
115
|
## Configuration
|
|
101
116
|
|
|
102
117
|
Path: `~/.emailcli/config.yaml`
|
|
@@ -97,6 +97,21 @@ echo "邮件内容" | emailcli send \
|
|
|
97
97
|
|
|
98
98
|
查看当前配置(密码已脱敏)。
|
|
99
99
|
|
|
100
|
+
### `emailcli skill install`
|
|
101
|
+
|
|
102
|
+
安装 `send-email` 技能,让 Claude Code 或 Codex 可以直接帮你发邮件。
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# 默认同时安装到 Claude Code 和 Codex
|
|
106
|
+
emailcli skill install
|
|
107
|
+
|
|
108
|
+
# 只装其中一个
|
|
109
|
+
emailcli skill install --target claude
|
|
110
|
+
emailcli skill install --target codex
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
会把 `SKILL.md` 写入 `~/.claude/skills/send-email/` 和/或 `~/.codex/skills/send-email/`。
|
|
114
|
+
|
|
100
115
|
## 配置文件
|
|
101
116
|
|
|
102
117
|
路径:`~/.emailcli/config.yaml`
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# emailcli skill install Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add an `emailcli skill install` command that installs the bundled `send-email` agent skill into Claude Code (`~/.claude/skills`) and Codex (`~/.codex/skills`), defaulting to both.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Ship `SKILL.md` as package data inside `emailcli`. A standalone `skill_install` module reads the bundled file via `importlib.resources` and writes it to per-target user-level directories. `cli.py` adds a thin `skill install` click command that delegates to the module.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Python 3.10+, click, hatchling (build), pytest + click.testing.CliRunner.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
- **Create** `src/emailcli/skills/send-email/SKILL.md` — canonical skill content (single source of truth, shipped as package data).
|
|
16
|
+
- **Create** `src/emailcli/skill_install.py` — install logic: load bundled content, compute destinations, write per target, return results.
|
|
17
|
+
- **Modify** `pyproject.toml` — force-include the `skills/` data dir into the wheel.
|
|
18
|
+
- **Modify** `src/emailcli/cli.py` — add the `skill` group + `install` subcommand.
|
|
19
|
+
- **Create** `tests/test_skill_install.py` — unit tests for packaging + install logic + CLI command.
|
|
20
|
+
- **Modify** `README.md`, `README_CN.md` — document the command.
|
|
21
|
+
|
|
22
|
+
Reference spec: `docs/superpowers/specs/2026-06-10-emailcli-skill-install-design.md`
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
### Task 1: Bundle the skill content as package data
|
|
27
|
+
|
|
28
|
+
**Files:**
|
|
29
|
+
- Create: `src/emailcli/skills/send-email/SKILL.md`
|
|
30
|
+
- Modify: `pyproject.toml:41-42`
|
|
31
|
+
- Test: `tests/test_skill_install.py`
|
|
32
|
+
|
|
33
|
+
- [ ] **Step 1: Write the failing test**
|
|
34
|
+
|
|
35
|
+
Create `tests/test_skill_install.py`:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from importlib.resources import files
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_bundled_skill_md_is_packaged():
|
|
42
|
+
content = files("emailcli").joinpath("skills/send-email/SKILL.md").read_text(encoding="utf-8")
|
|
43
|
+
assert content.startswith("---")
|
|
44
|
+
assert "name: send-email" in content
|
|
45
|
+
assert "emailcli send" in content
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
49
|
+
|
|
50
|
+
Run: `uv run pytest tests/test_skill_install.py::test_bundled_skill_md_is_packaged -v`
|
|
51
|
+
Expected: FAIL — `FileNotFoundError` / `IsADirectoryError` (the data file does not exist yet).
|
|
52
|
+
|
|
53
|
+
- [ ] **Step 3: Create the bundled SKILL.md**
|
|
54
|
+
|
|
55
|
+
Create `src/emailcli/skills/send-email/SKILL.md` with exactly this content:
|
|
56
|
+
|
|
57
|
+
````markdown
|
|
58
|
+
---
|
|
59
|
+
name: send-email
|
|
60
|
+
description: "Send emails with attachments via emailcli. Supports plain text, HTML, multiple recipients, and file/image attachments."
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
# Send Email
|
|
64
|
+
|
|
65
|
+
Send emails directly from Claude Code using the `emailcli` CLI tool.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
Invoke with `/send-email` followed by natural language describing what to send:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
/send-email 发送测试邮件给 test@example.com
|
|
73
|
+
/send-email 把 report.pdf 发给 alice@example.com,主题是"月度报告"
|
|
74
|
+
/send-email 给 a@example.com 和 b@example.com 发一封 HTML 邮件
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Workflow
|
|
78
|
+
|
|
79
|
+
### 1. Parse user intent
|
|
80
|
+
|
|
81
|
+
Extract from the user's message:
|
|
82
|
+
- **Recipients** (`--to`): one or more email addresses
|
|
83
|
+
- **Subject** (`--subject`): email subject line
|
|
84
|
+
- **Body** (`--body` or `--html`): email content
|
|
85
|
+
- **Attachments** (`--attach`): file paths if mentioned
|
|
86
|
+
|
|
87
|
+
### 2. Validate inputs
|
|
88
|
+
|
|
89
|
+
- If recipients are missing, ask the user.
|
|
90
|
+
- If subject is missing, ask the user.
|
|
91
|
+
- If body is missing, ask the user.
|
|
92
|
+
- If attachments are mentioned, verify the files exist using Glob or ls before sending.
|
|
93
|
+
|
|
94
|
+
### 3. Send (no confirmation needed)
|
|
95
|
+
|
|
96
|
+
Run the `emailcli send` command with the appropriate arguments.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
emailcli send \
|
|
100
|
+
--to recipient@example.com \
|
|
101
|
+
--subject "测试邮件" \
|
|
102
|
+
--body "Hello World" \
|
|
103
|
+
--attach report.pdf \
|
|
104
|
+
--attach photo.png
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 5. Report result
|
|
108
|
+
|
|
109
|
+
Tell the user whether the email was sent successfully or if there was an error.
|
|
110
|
+
|
|
111
|
+
## Command Reference
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Plain text
|
|
115
|
+
emailcli send --to <email> --subject <subject> --body <text>
|
|
116
|
+
|
|
117
|
+
# HTML
|
|
118
|
+
emailcli send --to <email> --subject <subject> --html <html>
|
|
119
|
+
|
|
120
|
+
# HTML from file
|
|
121
|
+
emailcli send --to <email> --subject <subject> --html-file <path>
|
|
122
|
+
|
|
123
|
+
# With attachments (repeatable)
|
|
124
|
+
emailcli send --to <email> --subject <subject> --body <text> --attach <file>
|
|
125
|
+
|
|
126
|
+
# Multiple recipients (repeatable)
|
|
127
|
+
emailcli send --to <email1> --to <email2> --subject <subject> --body <text>
|
|
128
|
+
|
|
129
|
+
# Override sender
|
|
130
|
+
emailcli send --to <email> --subject <subject> --body <text> --from <sender>
|
|
131
|
+
|
|
132
|
+
# Body from stdin
|
|
133
|
+
echo "content" | emailcli send --to <email> --subject <subject> --body -
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Important
|
|
137
|
+
|
|
138
|
+
- Do NOT ask for confirmation — send directly once inputs are ready.
|
|
139
|
+
- Verify attachment files exist before running the command.
|
|
140
|
+
- If the command fails, show the error message to the user.
|
|
141
|
+
- The tool must be configured first via `emailcli init`. If sending fails with a config error, tell the user to run `emailcli init`.
|
|
142
|
+
````
|
|
143
|
+
|
|
144
|
+
- [ ] **Step 4: Force-include the data dir in the wheel**
|
|
145
|
+
|
|
146
|
+
In `pyproject.toml`, the wheel target currently is:
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[tool.hatch.build.targets.wheel]
|
|
150
|
+
packages = ["src/emailcli"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Replace it with:
|
|
154
|
+
|
|
155
|
+
```toml
|
|
156
|
+
[tool.hatch.build.targets.wheel]
|
|
157
|
+
packages = ["src/emailcli"]
|
|
158
|
+
|
|
159
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
160
|
+
"src/emailcli/skills" = "emailcli/skills"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
164
|
+
|
|
165
|
+
Run: `uv run pytest tests/test_skill_install.py::test_bundled_skill_md_is_packaged -v`
|
|
166
|
+
Expected: PASS.
|
|
167
|
+
|
|
168
|
+
- [ ] **Step 6: Verify the wheel actually contains the data file**
|
|
169
|
+
|
|
170
|
+
Run: `uv build 2>/dev/null && python -c "import zipfile,glob; w=sorted(glob.glob('dist/*.whl'))[-1]; names=zipfile.ZipFile(w).namelist(); print('emailcli/skills/send-email/SKILL.md' in names)"`
|
|
171
|
+
Expected: prints `True`. Then clean up: `rm -rf dist`.
|
|
172
|
+
|
|
173
|
+
- [ ] **Step 7: Commit**
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git add src/emailcli/skills/send-email/SKILL.md pyproject.toml tests/test_skill_install.py
|
|
177
|
+
git commit -m "feat: bundle send-email SKILL.md as package data
|
|
178
|
+
|
|
179
|
+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### Task 2: Implement the install logic in `skill_install.py`
|
|
185
|
+
|
|
186
|
+
**Files:**
|
|
187
|
+
- Create: `src/emailcli/skill_install.py`
|
|
188
|
+
- Test: `tests/test_skill_install.py`
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 1: Write the failing tests**
|
|
191
|
+
|
|
192
|
+
Append to `tests/test_skill_install.py`:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from emailcli.skill_install import install_skill, load_skill_content
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_install_all_writes_both_targets(tmp_path):
|
|
199
|
+
results = install_skill(tmp_path, ["claude", "codex"])
|
|
200
|
+
|
|
201
|
+
claude_path = tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md"
|
|
202
|
+
codex_path = tmp_path / ".codex" / "skills" / "send-email" / "SKILL.md"
|
|
203
|
+
bundled = load_skill_content()
|
|
204
|
+
|
|
205
|
+
assert claude_path.read_text(encoding="utf-8") == bundled
|
|
206
|
+
assert codex_path.read_text(encoding="utf-8") == bundled
|
|
207
|
+
assert {r.target for r in results} == {"claude", "codex"}
|
|
208
|
+
assert all(r.status == "created" for r in results)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_install_claude_only(tmp_path):
|
|
212
|
+
install_skill(tmp_path, ["claude"])
|
|
213
|
+
|
|
214
|
+
assert (tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md").exists()
|
|
215
|
+
assert not (tmp_path / ".codex").exists()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_install_is_idempotent(tmp_path):
|
|
219
|
+
install_skill(tmp_path, ["claude"])
|
|
220
|
+
results = install_skill(tmp_path, ["claude"])
|
|
221
|
+
|
|
222
|
+
assert results[0].status == "updated"
|
|
223
|
+
path = tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md"
|
|
224
|
+
assert path.read_text(encoding="utf-8") == load_skill_content()
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
228
|
+
|
|
229
|
+
Run: `uv run pytest tests/test_skill_install.py -k "install" -v`
|
|
230
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'emailcli.skill_install'`.
|
|
231
|
+
|
|
232
|
+
- [ ] **Step 3: Write the implementation**
|
|
233
|
+
|
|
234
|
+
Create `src/emailcli/skill_install.py`:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from dataclasses import dataclass
|
|
238
|
+
from importlib.resources import files
|
|
239
|
+
from pathlib import Path
|
|
240
|
+
|
|
241
|
+
SKILL_NAME = "send-email"
|
|
242
|
+
|
|
243
|
+
# target name -> home subdirectory that holds the agent's skills
|
|
244
|
+
TARGET_HOME_DIRS = {
|
|
245
|
+
"claude": ".claude",
|
|
246
|
+
"codex": ".codex",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# human-facing label per target
|
|
250
|
+
TARGET_LABELS = {
|
|
251
|
+
"claude": "Claude",
|
|
252
|
+
"codex": "Codex",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def load_skill_content() -> str:
|
|
257
|
+
"""Read the bundled SKILL.md shipped as package data."""
|
|
258
|
+
return (
|
|
259
|
+
files("emailcli")
|
|
260
|
+
.joinpath(f"skills/{SKILL_NAME}/SKILL.md")
|
|
261
|
+
.read_text(encoding="utf-8")
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def skill_dest(home: Path, target: str) -> Path:
|
|
266
|
+
"""Destination SKILL.md path for a target under the given home dir."""
|
|
267
|
+
return home / TARGET_HOME_DIRS[target] / "skills" / SKILL_NAME / "SKILL.md"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class InstallResult:
|
|
272
|
+
target: str
|
|
273
|
+
path: Path
|
|
274
|
+
status: str # "created" | "updated" | "failed"
|
|
275
|
+
error: str | None = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def install_skill(home: Path, targets: list[str]) -> list[InstallResult]:
|
|
279
|
+
"""Write the bundled skill into each target's user-level skills dir.
|
|
280
|
+
|
|
281
|
+
Each target is handled independently; a write failure on one target is
|
|
282
|
+
recorded and does not stop the others.
|
|
283
|
+
"""
|
|
284
|
+
content = load_skill_content()
|
|
285
|
+
results: list[InstallResult] = []
|
|
286
|
+
for target in targets:
|
|
287
|
+
dest = skill_dest(home, target)
|
|
288
|
+
try:
|
|
289
|
+
existed = dest.exists()
|
|
290
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
dest.write_text(content, encoding="utf-8")
|
|
292
|
+
results.append(
|
|
293
|
+
InstallResult(target, dest, "updated" if existed else "created")
|
|
294
|
+
)
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
results.append(InstallResult(target, dest, "failed", str(exc)))
|
|
297
|
+
return results
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
301
|
+
|
|
302
|
+
Run: `uv run pytest tests/test_skill_install.py -k "install" -v`
|
|
303
|
+
Expected: PASS (3 tests).
|
|
304
|
+
|
|
305
|
+
- [ ] **Step 5: Commit**
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
git add src/emailcli/skill_install.py tests/test_skill_install.py
|
|
309
|
+
git commit -m "feat: add skill_install module for writing SKILL.md to agent dirs
|
|
310
|
+
|
|
311
|
+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
### Task 3: Wire up the `skill install` CLI command
|
|
317
|
+
|
|
318
|
+
**Files:**
|
|
319
|
+
- Modify: `src/emailcli/cli.py` (add imports near `cli.py:1-9`; add new group/command after the `config` group at `cli.py:121-142`)
|
|
320
|
+
- Test: `tests/test_skill_install.py`
|
|
321
|
+
|
|
322
|
+
- [ ] **Step 1: Write the failing tests**
|
|
323
|
+
|
|
324
|
+
Append to `tests/test_skill_install.py`:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
from click.testing import CliRunner
|
|
328
|
+
|
|
329
|
+
from emailcli.cli import cli
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_cli_skill_install_default_writes_both(tmp_path):
|
|
333
|
+
runner = CliRunner()
|
|
334
|
+
result = runner.invoke(cli, ["skill", "install", "--home", str(tmp_path)])
|
|
335
|
+
|
|
336
|
+
assert result.exit_code == 0
|
|
337
|
+
assert (tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md").exists()
|
|
338
|
+
assert (tmp_path / ".codex" / "skills" / "send-email" / "SKILL.md").exists()
|
|
339
|
+
assert "Claude" in result.output
|
|
340
|
+
assert "Codex" in result.output
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_cli_skill_install_target_codex(tmp_path):
|
|
344
|
+
runner = CliRunner()
|
|
345
|
+
result = runner.invoke(
|
|
346
|
+
cli, ["skill", "install", "--target", "codex", "--home", str(tmp_path)]
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
assert result.exit_code == 0
|
|
350
|
+
assert (tmp_path / ".codex" / "skills" / "send-email" / "SKILL.md").exists()
|
|
351
|
+
assert not (tmp_path / ".claude").exists()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_cli_skill_install_invalid_target(tmp_path):
|
|
355
|
+
runner = CliRunner()
|
|
356
|
+
result = runner.invoke(
|
|
357
|
+
cli, ["skill", "install", "--target", "vim", "--home", str(tmp_path)]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
assert result.exit_code != 0
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
364
|
+
|
|
365
|
+
Run: `uv run pytest tests/test_skill_install.py -k "cli_skill" -v`
|
|
366
|
+
Expected: FAIL — `No such command 'skill'` (exit code 2 / usage error), so the `exit_code == 0` assertions fail.
|
|
367
|
+
|
|
368
|
+
- [ ] **Step 3: Add the import**
|
|
369
|
+
|
|
370
|
+
In `src/emailcli/cli.py`, add to the import block (after the existing `from emailcli.sender import SmtpSender` line):
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
from emailcli import skill_install
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
- [ ] **Step 4: Add the `skill` group and `install` command**
|
|
377
|
+
|
|
378
|
+
In `src/emailcli/cli.py`, after the `config_group`'s `show` command (end of file), append:
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
@cli.group(name="skill")
|
|
382
|
+
def skill_group():
|
|
383
|
+
"""Manage the emailcli agent skill."""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@skill_group.command(name="install")
|
|
387
|
+
@click.option(
|
|
388
|
+
"--target",
|
|
389
|
+
type=click.Choice(["claude", "codex", "all"]),
|
|
390
|
+
default="all",
|
|
391
|
+
show_default=True,
|
|
392
|
+
help="Which agent to install the skill for.",
|
|
393
|
+
)
|
|
394
|
+
@click.option("--home", default=None, type=click.Path(), hidden=True, help="Home directory (for testing).")
|
|
395
|
+
def install(target, home):
|
|
396
|
+
"""Install the send-email skill into Claude Code and/or Codex."""
|
|
397
|
+
home_dir = Path(home) if home else Path.home()
|
|
398
|
+
targets = ["claude", "codex"] if target == "all" else [target]
|
|
399
|
+
|
|
400
|
+
results = skill_install.install_skill(home_dir, targets)
|
|
401
|
+
|
|
402
|
+
for r in results:
|
|
403
|
+
label = skill_install.TARGET_LABELS[r.target]
|
|
404
|
+
if r.status == "failed":
|
|
405
|
+
click.echo(f"✗ {label}\t{r.path}\t(failed: {r.error})", err=True)
|
|
406
|
+
else:
|
|
407
|
+
click.echo(f"✓ {label}\t{r.path}\t({r.status})")
|
|
408
|
+
|
|
409
|
+
if any(r.status == "failed" for r in results):
|
|
410
|
+
raise SystemExit(1)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
- [ ] **Step 5: Run tests to verify they pass**
|
|
414
|
+
|
|
415
|
+
Run: `uv run pytest tests/test_skill_install.py -k "cli_skill" -v`
|
|
416
|
+
Expected: PASS (3 tests).
|
|
417
|
+
|
|
418
|
+
- [ ] **Step 6: Run the whole test file**
|
|
419
|
+
|
|
420
|
+
Run: `uv run pytest tests/test_skill_install.py -v`
|
|
421
|
+
Expected: PASS (all tests).
|
|
422
|
+
|
|
423
|
+
- [ ] **Step 7: Commit**
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
git add src/emailcli/cli.py tests/test_skill_install.py
|
|
427
|
+
git commit -m "feat: add 'emailcli skill install' command
|
|
428
|
+
|
|
429
|
+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
### Task 4: Document the command
|
|
435
|
+
|
|
436
|
+
**Files:**
|
|
437
|
+
- Modify: `README.md`
|
|
438
|
+
- Modify: `README_CN.md`
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 1: Add a section to `README.md`**
|
|
441
|
+
|
|
442
|
+
In `README.md`, after the `### emailcli config show` block and before `## Configuration`, insert:
|
|
443
|
+
|
|
444
|
+
````markdown
|
|
445
|
+
### `emailcli skill install`
|
|
446
|
+
|
|
447
|
+
Install the `send-email` agent skill so Claude Code or Codex can send mail for you.
|
|
448
|
+
|
|
449
|
+
```bash
|
|
450
|
+
# Install for both Claude Code and Codex (default)
|
|
451
|
+
emailcli skill install
|
|
452
|
+
|
|
453
|
+
# Only one agent
|
|
454
|
+
emailcli skill install --target claude
|
|
455
|
+
emailcli skill install --target codex
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Writes `SKILL.md` to `~/.claude/skills/send-email/` and/or `~/.codex/skills/send-email/`.
|
|
459
|
+
````
|
|
460
|
+
|
|
461
|
+
- [ ] **Step 2: Add the equivalent section to `README_CN.md`**
|
|
462
|
+
|
|
463
|
+
In `README_CN.md`, add a matching section in the command reference area (Chinese):
|
|
464
|
+
|
|
465
|
+
````markdown
|
|
466
|
+
### `emailcli skill install`
|
|
467
|
+
|
|
468
|
+
安装 `send-email` 技能,让 Claude Code 或 Codex 可以直接帮你发邮件。
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
# 默认同时安装到 Claude Code 和 Codex
|
|
472
|
+
emailcli skill install
|
|
473
|
+
|
|
474
|
+
# 只装其中一个
|
|
475
|
+
emailcli skill install --target claude
|
|
476
|
+
emailcli skill install --target codex
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
会把 `SKILL.md` 写入 `~/.claude/skills/send-email/` 和/或 `~/.codex/skills/send-email/`。
|
|
480
|
+
````
|
|
481
|
+
|
|
482
|
+
- [ ] **Step 3: Commit**
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
git add README.md README_CN.md
|
|
486
|
+
git commit -m "docs: document 'emailcli skill install' command
|
|
487
|
+
|
|
488
|
+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## Self-Review Notes
|
|
494
|
+
|
|
495
|
+
- **Spec coverage:** command interface (Task 3), bundled package data (Task 1), `skill_install` module + per-target independent error handling (Task 2), user-level paths for Claude/Codex (Task 2 `skill_dest`), idempotent overwrite with created/updated reporting (Task 2 + 3), hidden `--home` test hook (Task 3), packaging via force-include + wheel verification (Task 1), docs (Task 4). All spec sections mapped.
|
|
496
|
+
- **Type consistency:** `install_skill(home, targets) -> list[InstallResult]`, `InstallResult(target, path, status, error)`, `load_skill_content()`, `skill_dest()`, `TARGET_LABELS` used identically across module and CLI.
|
|
497
|
+
- **Out of scope (per spec):** no `uninstall`, no project-level install, no Codex legacy prompt format.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# emailcli skill install — Design
|
|
2
|
+
|
|
3
|
+
Date: 2026-06-10
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Add a CLI command that installs the `send-email` agent skill into both
|
|
8
|
+
Claude Code and Codex, so users don't have to manually place `SKILL.md`
|
|
9
|
+
into agent directories.
|
|
10
|
+
|
|
11
|
+
## Background
|
|
12
|
+
|
|
13
|
+
The `send-email` skill currently lives only as a hand-placed file at
|
|
14
|
+
`~/.claude/skills/send-email/SKILL.md`. It is a thin wrapper that drives the
|
|
15
|
+
`emailcli send` command. There is no skill content shipped inside the package
|
|
16
|
+
and no command to install it.
|
|
17
|
+
|
|
18
|
+
Claude Code and Codex both load skills from a directory containing a
|
|
19
|
+
`SKILL.md` file with YAML frontmatter (`name`, `description`). The format is
|
|
20
|
+
identical between the two agents, so a single bundled `SKILL.md` can serve
|
|
21
|
+
both — only the destination directory differs.
|
|
22
|
+
|
|
23
|
+
References:
|
|
24
|
+
- Codex Agent Skills: https://developers.openai.com/codex/skills
|
|
25
|
+
- Claude skills in Codex CLI: https://www.robert-glaser.de/claude-skills-in-codex-cli/
|
|
26
|
+
|
|
27
|
+
## Scope
|
|
28
|
+
|
|
29
|
+
- **In scope:** user-level install of the `send-email` skill to Claude and
|
|
30
|
+
Codex via a new `emailcli skill install` command, default to both targets.
|
|
31
|
+
- **Out of scope:** `uninstall`, project-level install (`./.claude`,
|
|
32
|
+
`./.codex`), Codex legacy `~/.codex/prompts` format, multi-file skills with
|
|
33
|
+
`scripts/`/`references/`.
|
|
34
|
+
|
|
35
|
+
## Decisions
|
|
36
|
+
|
|
37
|
+
- **Install scope:** user-level only.
|
|
38
|
+
- Claude: `~/.claude/skills/send-email/SKILL.md`
|
|
39
|
+
- Codex: `~/.codex/skills/send-email/SKILL.md`
|
|
40
|
+
- **Default target:** both Claude and Codex. `--target` selects one.
|
|
41
|
+
- **Skill source:** bundle `SKILL.md` as package data (single source of
|
|
42
|
+
truth), read at runtime via `importlib.resources`.
|
|
43
|
+
|
|
44
|
+
## Command Interface
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
emailcli skill install # default: claude + codex
|
|
48
|
+
emailcli skill install --target claude # Claude Code only
|
|
49
|
+
emailcli skill install --target codex # Codex only
|
|
50
|
+
emailcli skill install --target all # explicit both
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- New click group `skill` with subcommand `install`.
|
|
54
|
+
- `--target` is a `click.Choice(["claude", "codex", "all"])`, default `all`.
|
|
55
|
+
- Idempotent: if the destination `SKILL.md` already exists it is overwritten
|
|
56
|
+
(this is our managed skill). For each target, print a result line, e.g.
|
|
57
|
+
`✓ Claude ~/.claude/skills/send-email/SKILL.md (updated)` / `(created)`.
|
|
58
|
+
- Hidden `--home` option (mirrors the existing hidden `--config-dir` pattern)
|
|
59
|
+
injects a base directory for tests.
|
|
60
|
+
|
|
61
|
+
## File Layout / Modules
|
|
62
|
+
|
|
63
|
+
- `src/emailcli/skills/send-email/SKILL.md` — canonical skill content (moved
|
|
64
|
+
from the existing global copy; the repo becomes the single source of truth).
|
|
65
|
+
- `src/emailcli/skill_install.py` — install logic:
|
|
66
|
+
- resolve targets from `--target`
|
|
67
|
+
- compute destination dir per target under a given `home`
|
|
68
|
+
- read bundled `SKILL.md` via `importlib.resources`
|
|
69
|
+
- write it, creating parent dirs
|
|
70
|
+
- return per-target results (target name, path, created-vs-updated)
|
|
71
|
+
- signature accepts `home: Path` so tests can inject `tmp_path`
|
|
72
|
+
- `src/emailcli/cli.py` — thin click wiring only; delegates to
|
|
73
|
+
`skill_install`.
|
|
74
|
+
|
|
75
|
+
## Packaging
|
|
76
|
+
|
|
77
|
+
Build backend is hatchling, packaging `src/emailcli`. Explicitly ensure the
|
|
78
|
+
skill data is included in the wheel:
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
82
|
+
"src/emailcli/skills" = "emailcli/skills"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
(or the equivalent `artifacts` glob). Verified during implementation by
|
|
86
|
+
running `uv build` and inspecting the wheel for `emailcli/skills/send-email/SKILL.md`.
|
|
87
|
+
|
|
88
|
+
Runtime read:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from importlib.resources import files
|
|
92
|
+
content = files("emailcli").joinpath("skills/send-email/SKILL.md").read_text(encoding="utf-8")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Error Handling
|
|
96
|
+
|
|
97
|
+
- Each target is processed independently. If one write fails (e.g.
|
|
98
|
+
permission error), report that target as failed and continue with the
|
|
99
|
+
others.
|
|
100
|
+
- If any target failed, exit with a non-zero status; otherwise exit 0.
|
|
101
|
+
- Destination parent directories are created with
|
|
102
|
+
`mkdir(parents=True, exist_ok=True)`.
|
|
103
|
+
|
|
104
|
+
## Testing (TDD)
|
|
105
|
+
|
|
106
|
+
Unit tests for `skill_install` (inject `tmp_path` as `home`):
|
|
107
|
+
- install `all` → both `~/.claude/.../SKILL.md` and `~/.codex/.../SKILL.md`
|
|
108
|
+
exist and their content equals the bundled `SKILL.md`.
|
|
109
|
+
- `--target claude` → only the Claude file is created; Codex path absent.
|
|
110
|
+
- `--target codex` → only the Codex file is created.
|
|
111
|
+
- running install twice is idempotent (second run reports `updated`, content
|
|
112
|
+
unchanged).
|
|
113
|
+
|
|
114
|
+
CLI-level tests via `click.testing.CliRunner` using the hidden `--home`
|
|
115
|
+
option pointed at `tmp_path`:
|
|
116
|
+
- default command writes both targets and prints both result lines.
|
|
117
|
+
- exit code is 0 on success.
|
|
118
|
+
|
|
119
|
+
## Documentation
|
|
120
|
+
|
|
121
|
+
Add a short section to `README.md` and `README_CN.md` documenting
|
|
122
|
+
`emailcli skill install` and what it does.
|
|
@@ -7,6 +7,7 @@ from emailcli.config import load_config
|
|
|
7
7
|
from emailcli.exceptions import EmailCliError
|
|
8
8
|
from emailcli.message import build_message
|
|
9
9
|
from emailcli.sender import SmtpSender
|
|
10
|
+
from emailcli import skill_install
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@click.group()
|
|
@@ -140,3 +141,35 @@ def show(config_dir):
|
|
|
140
141
|
except EmailCliError as e:
|
|
141
142
|
click.echo(f"Error: {e}", err=True)
|
|
142
143
|
raise SystemExit(1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cli.group(name="skill")
|
|
147
|
+
def skill_group():
|
|
148
|
+
"""Manage the emailcli agent skill."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@skill_group.command(name="install")
|
|
152
|
+
@click.option(
|
|
153
|
+
"--target",
|
|
154
|
+
type=click.Choice(["claude", "codex", "all"]),
|
|
155
|
+
default="all",
|
|
156
|
+
show_default=True,
|
|
157
|
+
help="Which agent to install the skill for.",
|
|
158
|
+
)
|
|
159
|
+
@click.option("--home", default=None, type=click.Path(), hidden=True, help="Home directory (for testing).")
|
|
160
|
+
def install(target, home):
|
|
161
|
+
"""Install the send-email skill into Claude Code and/or Codex."""
|
|
162
|
+
home_dir = Path(home) if home else Path.home()
|
|
163
|
+
targets = ["claude", "codex"] if target == "all" else [target]
|
|
164
|
+
|
|
165
|
+
results = skill_install.install_skill(home_dir, targets)
|
|
166
|
+
|
|
167
|
+
for r in results:
|
|
168
|
+
label = skill_install.TARGET_LABELS[r.target]
|
|
169
|
+
if r.status == "failed":
|
|
170
|
+
click.echo(f"✗ {label}\t{r.path}\t(failed: {r.error})", err=True)
|
|
171
|
+
else:
|
|
172
|
+
click.echo(f"✓ {label}\t{r.path}\t({r.status})")
|
|
173
|
+
|
|
174
|
+
if any(r.status == "failed" for r in results):
|
|
175
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
SKILL_NAME = "send-email"
|
|
7
|
+
|
|
8
|
+
# target name -> home subdirectory that holds the agent's skills
|
|
9
|
+
TARGET_HOME_DIRS = {
|
|
10
|
+
"claude": ".claude",
|
|
11
|
+
"codex": ".codex",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# human-facing label per target
|
|
15
|
+
TARGET_LABELS = {
|
|
16
|
+
"claude": "Claude",
|
|
17
|
+
"codex": "Codex",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_skill_content() -> str:
|
|
22
|
+
"""Read the bundled SKILL.md shipped as package data."""
|
|
23
|
+
return (
|
|
24
|
+
files("emailcli")
|
|
25
|
+
.joinpath(f"skills/{SKILL_NAME}/SKILL.md")
|
|
26
|
+
.read_text(encoding="utf-8")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def skill_dest(home: Path, target: str) -> Path:
|
|
31
|
+
"""Destination SKILL.md path for a target under the given home dir."""
|
|
32
|
+
return home / TARGET_HOME_DIRS[target] / "skills" / SKILL_NAME / "SKILL.md"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class InstallResult:
|
|
37
|
+
target: str
|
|
38
|
+
path: Path
|
|
39
|
+
status: Literal["created", "updated", "failed"]
|
|
40
|
+
error: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def install_skill(home: Path, targets: list[str]) -> list[InstallResult]:
|
|
44
|
+
"""Write the bundled skill into each target's user-level skills dir.
|
|
45
|
+
|
|
46
|
+
Each target is handled independently; a write failure on one target is
|
|
47
|
+
recorded and does not stop the others.
|
|
48
|
+
"""
|
|
49
|
+
unknown = [t for t in targets if t not in TARGET_HOME_DIRS]
|
|
50
|
+
if unknown:
|
|
51
|
+
raise ValueError(f"Unknown target(s): {', '.join(unknown)}")
|
|
52
|
+
content = load_skill_content()
|
|
53
|
+
results: list[InstallResult] = []
|
|
54
|
+
for target in targets:
|
|
55
|
+
dest = skill_dest(home, target)
|
|
56
|
+
try:
|
|
57
|
+
existed = dest.exists()
|
|
58
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
dest.write_text(content, encoding="utf-8")
|
|
60
|
+
results.append(
|
|
61
|
+
InstallResult(target, dest, "updated" if existed else "created")
|
|
62
|
+
)
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
results.append(InstallResult(target, dest, "failed", str(exc)))
|
|
65
|
+
return results
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: send-email
|
|
3
|
+
description: "Send emails with attachments via emailcli. Supports plain text, HTML, multiple recipients, and file/image attachments."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Send Email
|
|
7
|
+
|
|
8
|
+
Send emails directly from Claude Code using the `emailcli` CLI tool.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Invoke with `/send-email` followed by natural language describing what to send:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
/send-email 发送测试邮件给 test@example.com
|
|
16
|
+
/send-email 把 report.pdf 发给 alice@example.com,主题是"月度报告"
|
|
17
|
+
/send-email 给 a@example.com 和 b@example.com 发一封 HTML 邮件
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Workflow
|
|
21
|
+
|
|
22
|
+
### 1. Parse user intent
|
|
23
|
+
|
|
24
|
+
Extract from the user's message:
|
|
25
|
+
- **Recipients** (`--to`): one or more email addresses
|
|
26
|
+
- **Subject** (`--subject`): email subject line
|
|
27
|
+
- **Body** (`--body` or `--html`): email content
|
|
28
|
+
- **Attachments** (`--attach`): file paths if mentioned
|
|
29
|
+
|
|
30
|
+
### 2. Validate inputs
|
|
31
|
+
|
|
32
|
+
- If recipients are missing, ask the user.
|
|
33
|
+
- If subject is missing, ask the user.
|
|
34
|
+
- If body is missing, ask the user.
|
|
35
|
+
- If attachments are mentioned, verify the files exist using Glob or ls before sending.
|
|
36
|
+
|
|
37
|
+
### 3. Send (no confirmation needed)
|
|
38
|
+
|
|
39
|
+
Run the `emailcli send` command with the appropriate arguments.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
emailcli send \
|
|
43
|
+
--to recipient@example.com \
|
|
44
|
+
--subject "测试邮件" \
|
|
45
|
+
--body "Hello World" \
|
|
46
|
+
--attach report.pdf \
|
|
47
|
+
--attach photo.png
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 4. Report result
|
|
51
|
+
|
|
52
|
+
Tell the user whether the email was sent successfully or if there was an error.
|
|
53
|
+
|
|
54
|
+
## Command Reference
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Plain text
|
|
58
|
+
emailcli send --to <email> --subject <subject> --body <text>
|
|
59
|
+
|
|
60
|
+
# HTML
|
|
61
|
+
emailcli send --to <email> --subject <subject> --html <html>
|
|
62
|
+
|
|
63
|
+
# HTML from file
|
|
64
|
+
emailcli send --to <email> --subject <subject> --html-file <path>
|
|
65
|
+
|
|
66
|
+
# With attachments (repeatable)
|
|
67
|
+
emailcli send --to <email> --subject <subject> --body <text> --attach <file>
|
|
68
|
+
|
|
69
|
+
# Multiple recipients (repeatable)
|
|
70
|
+
emailcli send --to <email1> --to <email2> --subject <subject> --body <text>
|
|
71
|
+
|
|
72
|
+
# Override sender
|
|
73
|
+
emailcli send --to <email> --subject <subject> --body <text> --from <sender>
|
|
74
|
+
|
|
75
|
+
# Body from stdin
|
|
76
|
+
echo "content" | emailcli send --to <email> --subject <subject> --body -
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Important
|
|
80
|
+
|
|
81
|
+
- Do NOT ask for confirmation — send directly once inputs are ready.
|
|
82
|
+
- Verify attachment files exist before running the command.
|
|
83
|
+
- If the command fails, show the error message to the user.
|
|
84
|
+
- The tool must be configured first via `emailcli init`. If sending fails with a config error, tell the user to run `emailcli init`.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from emailcli.cli import cli
|
|
8
|
+
from emailcli.skill_install import InstallResult, install_skill, load_skill_content
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_bundled_skill_md_is_packaged():
|
|
12
|
+
content = files("emailcli").joinpath("skills/send-email/SKILL.md").read_text(encoding="utf-8")
|
|
13
|
+
assert content.startswith("---")
|
|
14
|
+
assert "name: send-email" in content
|
|
15
|
+
assert "emailcli send" in content
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_install_all_writes_both_targets(tmp_path):
|
|
19
|
+
results = install_skill(tmp_path, ["claude", "codex"])
|
|
20
|
+
|
|
21
|
+
claude_path = tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md"
|
|
22
|
+
codex_path = tmp_path / ".codex" / "skills" / "send-email" / "SKILL.md"
|
|
23
|
+
bundled = load_skill_content()
|
|
24
|
+
|
|
25
|
+
assert claude_path.read_text(encoding="utf-8") == bundled
|
|
26
|
+
assert codex_path.read_text(encoding="utf-8") == bundled
|
|
27
|
+
assert {r.target for r in results} == {"claude", "codex"}
|
|
28
|
+
assert all(r.status == "created" for r in results)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_install_claude_only(tmp_path):
|
|
32
|
+
install_skill(tmp_path, ["claude"])
|
|
33
|
+
|
|
34
|
+
assert (tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md").exists()
|
|
35
|
+
assert not (tmp_path / ".codex").exists()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_install_is_idempotent(tmp_path):
|
|
39
|
+
install_skill(tmp_path, ["claude"])
|
|
40
|
+
results = install_skill(tmp_path, ["claude"])
|
|
41
|
+
|
|
42
|
+
assert results[0].status == "updated"
|
|
43
|
+
path = tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md"
|
|
44
|
+
assert path.read_text(encoding="utf-8") == load_skill_content()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_install_unknown_target_raises(tmp_path):
|
|
48
|
+
with pytest.raises(ValueError):
|
|
49
|
+
install_skill(tmp_path, ["vim"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_cli_skill_install_default_writes_both(tmp_path):
|
|
53
|
+
runner = CliRunner()
|
|
54
|
+
result = runner.invoke(cli, ["skill", "install", "--home", str(tmp_path)])
|
|
55
|
+
|
|
56
|
+
assert result.exit_code == 0
|
|
57
|
+
assert (tmp_path / ".claude" / "skills" / "send-email" / "SKILL.md").exists()
|
|
58
|
+
assert (tmp_path / ".codex" / "skills" / "send-email" / "SKILL.md").exists()
|
|
59
|
+
assert "Claude" in result.output
|
|
60
|
+
assert "Codex" in result.output
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_cli_skill_install_target_codex(tmp_path):
|
|
64
|
+
runner = CliRunner()
|
|
65
|
+
result = runner.invoke(
|
|
66
|
+
cli, ["skill", "install", "--target", "codex", "--home", str(tmp_path)]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert result.exit_code == 0
|
|
70
|
+
assert (tmp_path / ".codex" / "skills" / "send-email" / "SKILL.md").exists()
|
|
71
|
+
assert not (tmp_path / ".claude").exists()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_cli_skill_install_invalid_target(tmp_path):
|
|
75
|
+
runner = CliRunner()
|
|
76
|
+
result = runner.invoke(
|
|
77
|
+
cli, ["skill", "install", "--target", "vim", "--home", str(tmp_path)]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert result.exit_code != 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_cli_skill_install_failure_exit_code(tmp_path):
|
|
84
|
+
failed = [InstallResult("claude", tmp_path / "SKILL.md", "failed", "boom")]
|
|
85
|
+
runner = CliRunner()
|
|
86
|
+
with patch("emailcli.skill_install.install_skill", return_value=failed):
|
|
87
|
+
result = runner.invoke(cli, ["skill", "install", "--home", str(tmp_path)])
|
|
88
|
+
assert result.exit_code == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|