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.
Files changed (29) hide show
  1. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/PKG-INFO +16 -1
  2. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/README.md +15 -0
  3. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/README_CN.md +15 -0
  4. email_cli_tool-0.2.0/docs/superpowers/plans/2026-06-10-emailcli-skill-install.md +497 -0
  5. email_cli_tool-0.2.0/docs/superpowers/specs/2026-06-10-emailcli-skill-install-design.md +122 -0
  6. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/pyproject.toml +1 -1
  7. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/cli.py +33 -0
  8. email_cli_tool-0.2.0/src/emailcli/skill_install.py +65 -0
  9. email_cli_tool-0.2.0/src/emailcli/skills/send-email/SKILL.md +84 -0
  10. email_cli_tool-0.2.0/tests/test_skill_install.py +88 -0
  11. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/uv.lock +2 -2
  12. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.github/workflows/publish.yml +0 -0
  13. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.github/workflows/test.yml +0 -0
  14. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.gitignore +0 -0
  15. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/.python-version +0 -0
  16. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/LICENSE +0 -0
  17. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/docs/superpowers/plans/2026-03-25-emailcli.md +0 -0
  18. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md +0 -0
  19. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/__init__.py +0 -0
  20. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/config.py +0 -0
  21. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/exceptions.py +0 -0
  22. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/message.py +0 -0
  23. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/py.typed +0 -0
  24. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/src/emailcli/sender.py +0 -0
  25. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/__init__.py +0 -0
  26. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_cli.py +0 -0
  27. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_config.py +0 -0
  28. {email_cli_tool-0.1.0 → email_cli_tool-0.2.0}/tests/test_message.py +0 -0
  29. {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.1.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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "email-cli-tool"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A simple CLI tool for sending emails with plain text, HTML, and file attachments"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
@@ -24,8 +24,8 @@ wheels = [
24
24
  ]
25
25
 
26
26
  [[package]]
27
- name = "emailcli"
28
- version = "0.1.0"
27
+ name = "email-cli-tool"
28
+ version = "0.2.0"
29
29
  source = { editable = "." }
30
30
  dependencies = [
31
31
  { name = "click" },
File without changes