imap-agent-cli 0.1.1__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.1 → imap_agent_cli-0.1.2}/PKG-INFO +27 -1
  2. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/README.md +26 -0
  3. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/pyproject.toml +1 -1
  4. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/__init__.py +1 -1
  5. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/cli.py +20 -0
  6. imap_agent_cli-0.1.2/src/imap_agent_cli/skill.py +185 -0
  7. imap_agent_cli-0.1.2/tests/test_skill.py +76 -0
  8. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  9. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/.gitignore +0 -0
  10. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/LICENSE +0 -0
  11. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/imap_agent_cli.py +0 -0
  12. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/spec.md +0 -0
  13. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/config.py +0 -0
  14. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/errors.py +0 -0
  15. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/imap_client.py +0 -0
  16. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/mime.py +0 -0
  17. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/models.py +0 -0
  18. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/render.py +0 -0
  19. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/src/imap_agent_cli/search.py +0 -0
  20. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/__init__.py +0 -0
  21. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/_bootstrap.py +0 -0
  22. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/pymap_server_runner.py +0 -0
  23. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/test_cli.py +0 -0
  24. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/test_config.py +0 -0
  25. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/test_mime.py +0 -0
  26. {imap_agent_cli-0.1.1 → imap_agent_cli-0.1.2}/tests/test_pymap_integration.py +0 -0
  27. {imap_agent_cli-0.1.1 → 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.1
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.1"
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.1"
3
+ __version__ = "0.1.2"
@@ -12,6 +12,7 @@ from .errors import AppError, ConfigError
12
12
  from .imap_client import ImapSession
13
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())
@@ -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)
@@ -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)}
@@ -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