imap-agent-cli 0.1.0__tar.gz → 0.1.2__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 (27) hide show
  1. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/PKG-INFO +27 -1
  2. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/README.md +26 -0
  3. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/pyproject.toml +1 -1
  4. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/__init__.py +1 -1
  5. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/cli.py +24 -4
  6. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/imap_client.py +5 -5
  7. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/mime.py +16 -1
  8. imap_agent_cli-0.1.2/src/imap_agent_cli/skill.py +185 -0
  9. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/test_mime.py +12 -0
  10. imap_agent_cli-0.1.2/tests/test_skill.py +76 -0
  11. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  12. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/.gitignore +0 -0
  13. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/LICENSE +0 -0
  14. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/imap_agent_cli.py +0 -0
  15. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/spec.md +0 -0
  16. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/config.py +0 -0
  17. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/errors.py +0 -0
  18. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/models.py +0 -0
  19. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/render.py +0 -0
  20. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/src/imap_agent_cli/search.py +0 -0
  21. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/__init__.py +0 -0
  22. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/_bootstrap.py +0 -0
  23. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/pymap_server_runner.py +0 -0
  24. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/test_cli.py +0 -0
  25. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/test_config.py +0 -0
  26. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/test_pymap_integration.py +0 -0
  27. {imap_agent_cli-0.1.0 → imap_agent_cli-0.1.2}/tests/test_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imap-agent-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Agent-first IMAP CLI for safe email search, read, attachment download, and draft creation
5
5
  Project-URL: Homepage, https://github.com/pseudosavant/imap-agent-cli
6
6
  Project-URL: Repository, https://github.com/pseudosavant/imap-agent-cli
@@ -104,6 +104,32 @@ Config path:
104
104
 
105
105
  Secrets should stay in environment variables, not the config file.
106
106
 
107
+ ## Codex Skill
108
+
109
+ Install or update the user-scoped Codex skill:
110
+
111
+ ```text
112
+ imap-agent-cli install-skill
113
+ ```
114
+
115
+ This writes:
116
+
117
+ ```text
118
+ ~/.agents/skills/imap/SKILL.md
119
+ ```
120
+
121
+ Remove the managed skill:
122
+
123
+ ```text
124
+ imap-agent-cli remove-skill
125
+ ```
126
+
127
+ Use `--skills-dir PATH` to install into a nonstandard skills directory, for example:
128
+
129
+ ```text
130
+ imap-agent-cli install-skill --skills-dir C:\Users\PaulEllis\.agents\skills
131
+ ```
132
+
107
133
  ## Safety Boundary
108
134
 
109
135
  Allowed:
@@ -71,6 +71,32 @@ Config path:
71
71
 
72
72
  Secrets should stay in environment variables, not the config file.
73
73
 
74
+ ## Codex Skill
75
+
76
+ Install or update the user-scoped Codex skill:
77
+
78
+ ```text
79
+ imap-agent-cli install-skill
80
+ ```
81
+
82
+ This writes:
83
+
84
+ ```text
85
+ ~/.agents/skills/imap/SKILL.md
86
+ ```
87
+
88
+ Remove the managed skill:
89
+
90
+ ```text
91
+ imap-agent-cli remove-skill
92
+ ```
93
+
94
+ Use `--skills-dir PATH` to install into a nonstandard skills directory, for example:
95
+
96
+ ```text
97
+ imap-agent-cli install-skill --skills-dir C:\Users\PaulEllis\.agents\skills
98
+ ```
99
+
74
100
  ## Safety Boundary
75
101
 
76
102
  Allowed:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "imap-agent-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Agent-first IMAP CLI for safe email search, read, attachment download, and draft creation"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Agent-first IMAP CLI."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -10,8 +10,9 @@ from . import __version__
10
10
  from .config import add_profile, config_status, init_config, load_config, remove_profile, resolve_profile, set_default_profile
11
11
  from .errors import AppError, ConfigError
12
12
  from .imap_client import ImapSession
13
- from .mime import create_draft_message, parse_message
13
+ from .mime import create_draft_message, header_value, parse_message
14
14
  from .render import write_error, write_json
15
+ from .skill import install_skill, remove_skill
15
16
 
16
17
 
17
18
  def _read_json_arg(value: str) -> dict[str, Any]:
@@ -138,6 +139,16 @@ def cmd_profiles(args: argparse.Namespace) -> int:
138
139
  return 0
139
140
 
140
141
 
142
+ def cmd_install_skill(args: argparse.Namespace) -> int:
143
+ write_json(install_skill(Path(args.skills_dir) if args.skills_dir else None))
144
+ return 0
145
+
146
+
147
+ def cmd_remove_skill(args: argparse.Namespace) -> int:
148
+ write_json(remove_skill(Path(args.skills_dir) if args.skills_dir else None, force=args.force))
149
+ return 0
150
+
151
+
141
152
  def cmd_folders(args: argparse.Namespace) -> int:
142
153
  with _session(args) as session:
143
154
  write_json(session.folders())
@@ -256,15 +267,15 @@ def cmd_draft_reply(args: argparse.Namespace) -> int:
256
267
  raw = session._fetch_raw(str(folder), int(uid))
257
268
  source_message = parse_message(raw)
258
269
  to = _parse_repeated_addresses(args.to) or _string_list(payload.get("to")) or _reply_recipients(source_message)
259
- source_message_id = str(source_message.get("message-id", ""))
260
- source_refs = str(source_message.get("references", "")).strip()
270
+ source_message_id = header_value(source_message, "message-id")
271
+ source_refs = header_value(source_message, "references").strip()
261
272
  references = f"{source_refs} {source_message_id}".strip()
262
273
  message = create_draft_message(
263
274
  sender=session.profile.username,
264
275
  to=to,
265
276
  cc=_parse_repeated_addresses(args.cc) or _string_list(payload.get("cc")),
266
277
  bcc=_parse_repeated_addresses(args.bcc) or _string_list(payload.get("bcc")),
267
- subject=args.subject or payload.get("subject") or _reply_subject(str(source_message.get("subject", ""))),
278
+ subject=args.subject or payload.get("subject") or _reply_subject(header_value(source_message, "subject")),
268
279
  body=body,
269
280
  body_format=str(body_format),
270
281
  attachments=attachments,
@@ -308,6 +319,15 @@ def build_parser() -> argparse.ArgumentParser:
308
319
  profiles = sub.add_parser("profiles")
309
320
  profiles.set_defaults(func=cmd_profiles)
310
321
 
322
+ install_skill_parser = sub.add_parser("install-skill")
323
+ install_skill_parser.add_argument("--skills-dir")
324
+ install_skill_parser.set_defaults(func=cmd_install_skill)
325
+
326
+ remove_skill_parser = sub.add_parser("remove-skill")
327
+ remove_skill_parser.add_argument("--skills-dir")
328
+ remove_skill_parser.add_argument("--force", action="store_true")
329
+ remove_skill_parser.set_defaults(func=cmd_remove_skill)
330
+
311
331
  folders = sub.add_parser("folders")
312
332
  _common_profile_args(folders)
313
333
  folders.set_defaults(func=cmd_folders)
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  from .errors import AppError
9
- from .mime import attachment_infos, message_headers, parse_message, render_body, save_attachments
9
+ from .mime import attachment_infos, header_value, message_headers, parse_message, render_body, save_attachments
10
10
  from .models import Defaults, Profile
11
11
  from .search import build_criteria
12
12
 
@@ -172,8 +172,8 @@ class ImapSession(AbstractContextManager["ImapSession"]):
172
172
  return {
173
173
  "folder": folder,
174
174
  "uid": uid,
175
- "message_id": str(message.get("message-id", "")),
176
- "date": str(message.get("date", "")),
175
+ "message_id": header_value(message, "message-id"),
176
+ "date": header_value(message, "date"),
177
177
  "from": headers["from"],
178
178
  "to": headers["to"],
179
179
  "cc": headers["cc"],
@@ -295,8 +295,8 @@ def message_payload(
295
295
  "profile": profile,
296
296
  "folder": folder,
297
297
  "uid": uid,
298
- "message_id": str(message.get("message-id", "")),
299
- "date": str(message.get("date", "")),
298
+ "message_id": header_value(message, "message-id"),
299
+ "date": header_value(message, "date"),
300
300
  "headers": message_headers(message),
301
301
  "body": render_body(message, body_format, max_body_chars),
302
302
  "attachments": [info for info in attachment_infos(message)],
@@ -28,6 +28,21 @@ def parse_message(raw: bytes) -> EmailMessage:
28
28
  return parsed
29
29
 
30
30
 
31
+ def header_value(message: Message, name: str, default: str = "") -> str:
32
+ target = name.lower()
33
+ try:
34
+ for key, value in message.raw_items():
35
+ if key.lower() == target:
36
+ return str(value)
37
+ except Exception:
38
+ pass
39
+ try:
40
+ value = message.get(name, default)
41
+ except Exception:
42
+ return default
43
+ return str(value) if value is not None else default
44
+
45
+
31
46
  def addresses(header_values: Iterable[str | None]) -> list[dict[str, str]]:
32
47
  raw = [value for value in header_values if value]
33
48
  return [{"name": name, "email": email} for name, email in getaddresses(raw)]
@@ -35,7 +50,7 @@ def addresses(header_values: Iterable[str | None]) -> list[dict[str, str]]:
35
50
 
36
51
  def message_headers(message: Message) -> dict[str, Any]:
37
52
  return {
38
- "subject": str(message.get("subject", "")),
53
+ "subject": header_value(message, "subject"),
39
54
  "from": addresses(message.get_all("from", [])),
40
55
  "to": addresses(message.get_all("to", [])),
41
56
  "cc": addresses(message.get_all("cc", [])),
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .errors import AppError
8
+
9
+
10
+ SKILL_NAME = "imap"
11
+ MANAGED_MARKER = "<!-- managed-by: imap-agent-cli -->"
12
+
13
+
14
+ SKILL_MD = f"""---
15
+ name: imap
16
+ description: Use when the user asks Codex to search, read, summarize, inspect, or draft email using the local imap-agent-cli tool. Covers safe IMAP-only email access, folder enumeration, attachment download, and draft creation; must not send, delete, move, archive, flag, label, or mark messages read/unread.
17
+ ---
18
+
19
+ {MANAGED_MARKER}
20
+
21
+ # IMAP Email Access
22
+
23
+ Use `imap-agent-cli` for safe agentic email workflows over IMAP. The tool can search and read email, list folders, inspect/download attachments, and create drafts. It cannot send email and must not be used for destructive or metadata-mutating mailbox actions.
24
+
25
+ ## Safety Rules
26
+
27
+ - Never send email. There is no SMTP support.
28
+ - Never delete, move, archive, label, flag, star, mark read, or mark unread messages.
29
+ - Prefer metadata/search before reading message bodies.
30
+ - Do not print large or sensitive email bodies unless the user asks to see the content.
31
+ - Use `--body-format metadata` when you only need headers and attachment metadata.
32
+ - Use `--body-format plain` for summaries unless HTML structure matters.
33
+ - Download attachments only when the user asks and provides or accepts an output directory.
34
+ - Create drafts only with `draft create` or `draft reply`.
35
+
36
+ ## Command Form
37
+
38
+ Prefer the published CLI:
39
+
40
+ ```powershell
41
+ $env:UV_LINK_MODE = "copy"
42
+ uvx --refresh-package imap-agent-cli imap-agent-cli <command>
43
+ ```
44
+
45
+ If working inside the `imap-agent-cli` repository, this local form is also valid:
46
+
47
+ ```powershell
48
+ uv run ./imap_agent_cli.py <command>
49
+ ```
50
+
51
+ The CLI uses these environment variables for the default profile:
52
+
53
+ ```text
54
+ IMAP_AGENT_CLI_HOST
55
+ IMAP_AGENT_CLI_PORT
56
+ IMAP_AGENT_CLI_USERNAME
57
+ IMAP_AGENT_CLI_PASSWORD
58
+ IMAP_AGENT_CLI_TLS
59
+ IMAP_AGENT_CLI_DRAFTS_FOLDER
60
+ ```
61
+
62
+ ## Folder Listing
63
+
64
+ Use this to discover folder names and Drafts naming:
65
+
66
+ ```powershell
67
+ uvx --refresh-package imap-agent-cli imap-agent-cli folders
68
+ ```
69
+
70
+ Return only relevant folder names/counts to the user unless they ask for the full list.
71
+
72
+ ## Search
73
+
74
+ Search defaults to `INBOX` when no folder is provided:
75
+
76
+ ```powershell
77
+ uvx --refresh-package imap-agent-cli imap-agent-cli search --folder INBOX --max-results 10
78
+ uvx --refresh-package imap-agent-cli imap-agent-cli search --folder INBOX --subject "invoice" --max-results 10
79
+ uvx --refresh-package imap-agent-cli imap-agent-cli search --folder INBOX --from "Justin" --max-results 10
80
+ uvx --refresh-package imap-agent-cli imap-agent-cli search --folder INBOX --since 2026-01-01 --before 2026-02-01 --max-results 10
81
+ ```
82
+
83
+ If a server-side `--from` search times out, enumerate recent messages with a bounded `--max-results` and filter locally by the returned `from` metadata.
84
+
85
+ For child folders:
86
+
87
+ ```powershell
88
+ uvx --refresh-package imap-agent-cli imap-agent-cli search --folder Projects --recursive --max-results 25
89
+ ```
90
+
91
+ For all folders, use sparingly because large mailboxes can be slow:
92
+
93
+ ```powershell
94
+ uvx --refresh-package imap-agent-cli imap-agent-cli search --all-folders --subject "contract" --max-results 25
95
+ ```
96
+
97
+ ## Read
98
+
99
+ Read a message by folder and UID from search results:
100
+
101
+ ```powershell
102
+ uvx --refresh-package imap-agent-cli imap-agent-cli read --folder INBOX --uid 12345 --body-format metadata
103
+ uvx --refresh-package imap-agent-cli imap-agent-cli read --folder INBOX --uid 12345 --body-format plain --max-body-chars 12000
104
+ uvx --refresh-package imap-agent-cli imap-agent-cli read --folder INBOX --uid 12345 --body-format html
105
+ ```
106
+
107
+ Use `metadata` first when confirming identity, attachments, or headers. Use `plain` when summarizing. Use `html` only when formatting or links matter.
108
+
109
+ ## Attachments
110
+
111
+ List attachment metadata:
112
+
113
+ ```powershell
114
+ uvx --refresh-package imap-agent-cli imap-agent-cli attachments --folder INBOX --uid 12345
115
+ ```
116
+
117
+ Download requires an explicit output directory:
118
+
119
+ ```powershell
120
+ uvx --refresh-package imap-agent-cli imap-agent-cli attachments download --folder INBOX --uid 12345 --part-id 1 --output-dir C:\\tmp\\email-attachments
121
+ uvx --refresh-package imap-agent-cli imap-agent-cli attachments download --folder INBOX --uid 12345 --all --output-dir C:\\tmp\\email-attachments
122
+ ```
123
+
124
+ Report saved paths to the user.
125
+
126
+ ## Drafts
127
+
128
+ Create a new draft:
129
+
130
+ ```powershell
131
+ uvx --refresh-package imap-agent-cli imap-agent-cli draft create --to person@example.com --subject "Subject" --body "Draft body"
132
+ ```
133
+
134
+ Create a reply draft from an existing message:
135
+
136
+ ```powershell
137
+ uvx --refresh-package imap-agent-cli imap-agent-cli draft reply --folder INBOX --uid 12345 --body "Draft reply body"
138
+ ```
139
+
140
+ For longer draft bodies, write a temporary body file and pass `--body-file`. Tell the user a draft was created; do not imply it was sent.
141
+
142
+ ## Output Handling
143
+
144
+ All CLI payloads are JSON on stdout. Diagnostics and errors are on stderr. Parse JSON before deciding what to show the user, and summarize high-signal fields instead of dumping raw output.
145
+ """
146
+
147
+
148
+ def default_skills_dir() -> Path:
149
+ return Path.home() / ".agents" / "skills"
150
+
151
+
152
+ def skill_dir(skills_dir: Path | None = None) -> Path:
153
+ return (skills_dir or default_skills_dir()) / SKILL_NAME
154
+
155
+
156
+ def install_skill(skills_dir: Path | None = None) -> dict[str, Any]:
157
+ target = skill_dir(skills_dir)
158
+ target.mkdir(parents=True, exist_ok=True)
159
+ skill_path = target / "SKILL.md"
160
+ previous = skill_path.read_text(encoding="utf-8") if skill_path.exists() else ""
161
+ updated = previous != SKILL_MD
162
+ skill_path.write_text(SKILL_MD, encoding="utf-8")
163
+ return {
164
+ "installed": True,
165
+ "updated": updated,
166
+ "skill": SKILL_NAME,
167
+ "path": str(skill_path),
168
+ }
169
+
170
+
171
+ def remove_skill(skills_dir: Path | None = None, *, force: bool = False) -> dict[str, Any]:
172
+ target = skill_dir(skills_dir)
173
+ skill_path = target / "SKILL.md"
174
+ if not target.exists():
175
+ return {"removed": False, "skill": SKILL_NAME, "path": str(target), "reason": "not_installed"}
176
+ if not skill_path.exists():
177
+ raise AppError("invalid_request", f"refusing to remove '{target}' because SKILL.md is missing.")
178
+ content = skill_path.read_text(encoding="utf-8")
179
+ if MANAGED_MARKER not in content and not force:
180
+ raise AppError(
181
+ "invalid_request",
182
+ f"refusing to remove '{target}' because it is not marked as managed by imap-agent-cli; use --force to override.",
183
+ )
184
+ shutil.rmtree(target)
185
+ return {"removed": True, "skill": SKILL_NAME, "path": str(target)}
@@ -10,6 +10,7 @@ from tests import _bootstrap # noqa: F401
10
10
  from imap_agent_cli.mime import (
11
11
  attachment_infos,
12
12
  create_draft_message,
13
+ header_value,
13
14
  parse_message,
14
15
  render_body,
15
16
  safe_filename,
@@ -71,6 +72,17 @@ class MimeTests(unittest.TestCase):
71
72
  self.assertEqual(safe_filename("../x:y?.txt"), "x_y_.txt")
72
73
  self.assertEqual(safe_filename(".."), "attachment")
73
74
 
75
+ def test_header_value_handles_malformed_message_id(self) -> None:
76
+ raw = (
77
+ b"Message-ID: <[8e15e2eaf6e3479a8a1c3d8230af2e58-JFZGS42DN5WW25LONFRWC5DJN5XFA3DBORTG64TNFVIHE33EFVGVOMKQPRAXU5LSMVCGK5SPOBZXYRLNMFUWY7CFPBXVG3LUOA======@microsoft.com]>\r\n"
78
+ b"Subject: Test\r\n"
79
+ b"\r\n"
80
+ b"Body\r\n"
81
+ )
82
+ message = parse_message(raw)
83
+ self.assertIn("@microsoft.com", header_value(message, "message-id"))
84
+ self.assertEqual(header_value(message, "subject"), "Test")
85
+
74
86
 
75
87
  if __name__ == "__main__":
76
88
  unittest.main()
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ import unittest
6
+ from io import StringIO
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ from tests import _bootstrap # noqa: F401
11
+
12
+ from imap_agent_cli.cli import main
13
+ from imap_agent_cli.errors import AppError
14
+ from imap_agent_cli.skill import MANAGED_MARKER, install_skill, remove_skill
15
+
16
+
17
+ class SkillTests(unittest.TestCase):
18
+ def test_install_skill_creates_and_updates_managed_skill(self) -> None:
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ root = Path(tmp)
21
+ first = install_skill(root)
22
+ self.assertTrue(first["installed"])
23
+ self.assertTrue(first["updated"])
24
+ skill_path = root / "imap" / "SKILL.md"
25
+ self.assertTrue(skill_path.exists())
26
+ content = skill_path.read_text(encoding="utf-8")
27
+ self.assertIn("name: imap", content)
28
+ self.assertIn(MANAGED_MARKER, content)
29
+ self.assertIn("draft reply", content)
30
+
31
+ second = install_skill(root)
32
+ self.assertTrue(second["installed"])
33
+ self.assertFalse(second["updated"])
34
+
35
+ def test_remove_skill_removes_only_managed_skill(self) -> None:
36
+ with tempfile.TemporaryDirectory() as tmp:
37
+ root = Path(tmp)
38
+ install_skill(root)
39
+ removed = remove_skill(root)
40
+ self.assertTrue(removed["removed"])
41
+ self.assertFalse((root / "imap").exists())
42
+
43
+ def test_remove_skill_refuses_unmanaged_skill_without_force(self) -> None:
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ root = Path(tmp)
46
+ skill_dir = root / "imap"
47
+ skill_dir.mkdir(parents=True)
48
+ (skill_dir / "SKILL.md").write_text("---\nname: imap\n---\ncustom\n", encoding="utf-8")
49
+ with self.assertRaises(AppError):
50
+ remove_skill(root)
51
+ removed = remove_skill(root, force=True)
52
+ self.assertTrue(removed["removed"])
53
+
54
+ def test_cli_install_skill_outputs_json(self) -> None:
55
+ with tempfile.TemporaryDirectory() as tmp:
56
+ stdout = StringIO()
57
+ with patch("sys.stdout", stdout):
58
+ code = main(["install-skill", "--skills-dir", tmp])
59
+ self.assertEqual(code, 0)
60
+ payload = json.loads(stdout.getvalue())
61
+ self.assertEqual(payload["skill"], "imap")
62
+ self.assertTrue((Path(tmp) / "imap" / "SKILL.md").exists())
63
+
64
+ def test_cli_remove_skill_outputs_json(self) -> None:
65
+ with tempfile.TemporaryDirectory() as tmp:
66
+ install_skill(Path(tmp))
67
+ stdout = StringIO()
68
+ with patch("sys.stdout", stdout):
69
+ code = main(["remove-skill", "--skills-dir", tmp])
70
+ self.assertEqual(code, 0)
71
+ payload = json.loads(stdout.getvalue())
72
+ self.assertTrue(payload["removed"])
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
File without changes
File without changes