penpal-cli 0.1.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.
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: penpal-cli
3
+ Version: 0.1.0
4
+ Summary: Async Claude CLI via Batch API — half the cost, none of the rush.
5
+ Author: JNalv
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JNalv/penpal-cli
8
+ Project-URL: Repository, https://github.com/JNalv/penpal-cli
9
+ Project-URL: Issues, https://github.com/JNalv/penpal-cli/issues
10
+ Keywords: claude,anthropic,batch-api,cli,llm,ai
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: anthropic>=0.89.0
21
+ Requires-Dist: click>=8.3.0
22
+ Requires-Dist: keyring>=25.0.0
23
+ Requires-Dist: textual>=8.2.0
24
+ Requires-Dist: rich>=14.3.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
28
+
29
+ # penpal
30
+
31
+ Async Claude via the Batch API. Half the cost, none of the rush.
32
+
33
+ Penpal is a CLI for sending prompts to Claude through Anthropic's [Batch API](https://docs.anthropic.com/en/docs/build-with-claude/batch-processing), which processes requests asynchronously at **50% off**. Submit a prompt, go do something else, come back and read the response.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pipx install penpal-cli
39
+ ```
40
+
41
+ Or with pip:
42
+
43
+ ```bash
44
+ pip install penpal-cli
45
+ ```
46
+
47
+ Requires Python 3.11+.
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ # 1. Store your API key (one-time setup)
53
+ penpal auth
54
+
55
+ # 2. Submit a prompt
56
+ penpal ask "Explain the CAP theorem in plain English"
57
+
58
+ # 3. Check if it's done
59
+ penpal status
60
+
61
+ # 4. Read the response
62
+ penpal read --latest
63
+ ```
64
+
65
+ Batch requests typically complete in minutes. Use `penpal status --watch` to auto-refresh.
66
+
67
+ ## Features
68
+
69
+ ### Model aliases
70
+
71
+ Use short names instead of full model identifiers:
72
+
73
+ | Alias | Model |
74
+ |----------|--------------------------------|
75
+ | `haiku` | `claude-haiku-4-5-20251001` |
76
+ | `sonnet` | `claude-sonnet-4-20250514` |
77
+ | `opus` | `claude-opus-4-20250514` |
78
+
79
+ ```bash
80
+ penpal ask -m haiku "Quick question"
81
+ ```
82
+
83
+ ### File attachments
84
+
85
+ Attach images, PDFs, and text files directly:
86
+
87
+ ```bash
88
+ penpal ask -f screenshot.png "What's in this image?"
89
+ penpal ask -f paper.pdf "Summarize this paper"
90
+ penpal ask -f main.py -f utils.py "Review these files"
91
+ ```
92
+
93
+ ### Batch mode
94
+
95
+ Process an entire directory of files in a single batch:
96
+
97
+ ```bash
98
+ penpal ask -b ./documents/ "Summarize this document"
99
+ ```
100
+
101
+ Each file becomes a separate request. Use `penpal read <id> -i <N>` to read individual results.
102
+
103
+ ### Skills (reusable system prompts)
104
+
105
+ Create and reuse system prompts as named skills:
106
+
107
+ ```bash
108
+ penpal skills add code-review # Opens $EDITOR
109
+ penpal ask --skill code-review -f app.py "Review this"
110
+ penpal skills # List all skills
111
+ ```
112
+
113
+ ### Code extraction
114
+
115
+ Extract fenced code blocks from responses directly to files:
116
+
117
+ ```bash
118
+ penpal read --latest --extract
119
+ ```
120
+
121
+ ### TUI dashboard
122
+
123
+ Launch an interactive terminal dashboard with live status updates, cost tracking, manual request creation, and more:
124
+
125
+ ```bash
126
+ penpal session
127
+ ```
128
+
129
+ ### History and cost tracking
130
+
131
+ ```bash
132
+ penpal history # Browse past requests
133
+ penpal history --cost # See spending summary
134
+ penpal history --since 7d # Filter by time
135
+ penpal history --search "CAP" # Search prompts
136
+ ```
137
+
138
+ ### Raw output for piping
139
+
140
+ `--raw` strips all formatting, ideal for piping into other tools or feeding back to an AI coding agent:
141
+
142
+ ```bash
143
+ penpal read --latest --raw | pbcopy
144
+ penpal read --latest --raw > response.md
145
+ ```
146
+
147
+ ## Claude Code integration
148
+
149
+ Teach [Claude Code](https://docs.anthropic.com/en/docs/claude-code) about penpal so it can use cheaper batch requests:
150
+
151
+ ```bash
152
+ penpal setup-claude-code
153
+ ```
154
+
155
+ This appends a small instruction block to `~/.claude/CLAUDE.md`. Remove it with `penpal uninstall-claude-code`.
156
+
157
+ ### AGENTS.md (cross-agent standard)
158
+
159
+ For projects using multiple AI coding agents (Copilot, Cursor, OpenCode, Codex, etc.), add penpal instructions to the [AGENTS.md](https://github.com/anthropics/agents-md) standard:
160
+
161
+ ```bash
162
+ penpal setup-agents-md
163
+ ```
164
+
165
+ This writes to `./AGENTS.md` in the current directory. Remove it with `penpal uninstall-agents-md`.
166
+
167
+ ## Configuration
168
+
169
+ Penpal uses XDG directories. Config file location:
170
+
171
+ ```bash
172
+ penpal config --path # ~/.config/penpal/config.toml
173
+ penpal config --edit # Open in $EDITOR
174
+ penpal config # Show resolved settings
175
+ ```
176
+
177
+ Example `config.toml`:
178
+
179
+ ```toml
180
+ model = "sonnet"
181
+ max_tokens = 4096
182
+ poll_interval = 180
183
+ preview_lines = 40
184
+ ```
185
+
186
+ ### Environment variables
187
+
188
+ | Variable | Description |
189
+ |----------------------|----------------------------------|
190
+ | `ANTHROPIC_API_KEY` | API key (overrides stored key) |
191
+ | `PENPAL_MODEL` | Default model |
192
+ | `PENPAL_MAX_TOKENS` | Default max output tokens |
193
+ | `PENPAL_POLL_INTERVAL` | Status polling interval (seconds) |
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,169 @@
1
+ # penpal
2
+
3
+ Async Claude via the Batch API. Half the cost, none of the rush.
4
+
5
+ Penpal is a CLI for sending prompts to Claude through Anthropic's [Batch API](https://docs.anthropic.com/en/docs/build-with-claude/batch-processing), which processes requests asynchronously at **50% off**. Submit a prompt, go do something else, come back and read the response.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pipx install penpal-cli
11
+ ```
12
+
13
+ Or with pip:
14
+
15
+ ```bash
16
+ pip install penpal-cli
17
+ ```
18
+
19
+ Requires Python 3.11+.
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ # 1. Store your API key (one-time setup)
25
+ penpal auth
26
+
27
+ # 2. Submit a prompt
28
+ penpal ask "Explain the CAP theorem in plain English"
29
+
30
+ # 3. Check if it's done
31
+ penpal status
32
+
33
+ # 4. Read the response
34
+ penpal read --latest
35
+ ```
36
+
37
+ Batch requests typically complete in minutes. Use `penpal status --watch` to auto-refresh.
38
+
39
+ ## Features
40
+
41
+ ### Model aliases
42
+
43
+ Use short names instead of full model identifiers:
44
+
45
+ | Alias | Model |
46
+ |----------|--------------------------------|
47
+ | `haiku` | `claude-haiku-4-5-20251001` |
48
+ | `sonnet` | `claude-sonnet-4-20250514` |
49
+ | `opus` | `claude-opus-4-20250514` |
50
+
51
+ ```bash
52
+ penpal ask -m haiku "Quick question"
53
+ ```
54
+
55
+ ### File attachments
56
+
57
+ Attach images, PDFs, and text files directly:
58
+
59
+ ```bash
60
+ penpal ask -f screenshot.png "What's in this image?"
61
+ penpal ask -f paper.pdf "Summarize this paper"
62
+ penpal ask -f main.py -f utils.py "Review these files"
63
+ ```
64
+
65
+ ### Batch mode
66
+
67
+ Process an entire directory of files in a single batch:
68
+
69
+ ```bash
70
+ penpal ask -b ./documents/ "Summarize this document"
71
+ ```
72
+
73
+ Each file becomes a separate request. Use `penpal read <id> -i <N>` to read individual results.
74
+
75
+ ### Skills (reusable system prompts)
76
+
77
+ Create and reuse system prompts as named skills:
78
+
79
+ ```bash
80
+ penpal skills add code-review # Opens $EDITOR
81
+ penpal ask --skill code-review -f app.py "Review this"
82
+ penpal skills # List all skills
83
+ ```
84
+
85
+ ### Code extraction
86
+
87
+ Extract fenced code blocks from responses directly to files:
88
+
89
+ ```bash
90
+ penpal read --latest --extract
91
+ ```
92
+
93
+ ### TUI dashboard
94
+
95
+ Launch an interactive terminal dashboard with live status updates, cost tracking, manual request creation, and more:
96
+
97
+ ```bash
98
+ penpal session
99
+ ```
100
+
101
+ ### History and cost tracking
102
+
103
+ ```bash
104
+ penpal history # Browse past requests
105
+ penpal history --cost # See spending summary
106
+ penpal history --since 7d # Filter by time
107
+ penpal history --search "CAP" # Search prompts
108
+ ```
109
+
110
+ ### Raw output for piping
111
+
112
+ `--raw` strips all formatting, ideal for piping into other tools or feeding back to an AI coding agent:
113
+
114
+ ```bash
115
+ penpal read --latest --raw | pbcopy
116
+ penpal read --latest --raw > response.md
117
+ ```
118
+
119
+ ## Claude Code integration
120
+
121
+ Teach [Claude Code](https://docs.anthropic.com/en/docs/claude-code) about penpal so it can use cheaper batch requests:
122
+
123
+ ```bash
124
+ penpal setup-claude-code
125
+ ```
126
+
127
+ This appends a small instruction block to `~/.claude/CLAUDE.md`. Remove it with `penpal uninstall-claude-code`.
128
+
129
+ ### AGENTS.md (cross-agent standard)
130
+
131
+ For projects using multiple AI coding agents (Copilot, Cursor, OpenCode, Codex, etc.), add penpal instructions to the [AGENTS.md](https://github.com/anthropics/agents-md) standard:
132
+
133
+ ```bash
134
+ penpal setup-agents-md
135
+ ```
136
+
137
+ This writes to `./AGENTS.md` in the current directory. Remove it with `penpal uninstall-agents-md`.
138
+
139
+ ## Configuration
140
+
141
+ Penpal uses XDG directories. Config file location:
142
+
143
+ ```bash
144
+ penpal config --path # ~/.config/penpal/config.toml
145
+ penpal config --edit # Open in $EDITOR
146
+ penpal config # Show resolved settings
147
+ ```
148
+
149
+ Example `config.toml`:
150
+
151
+ ```toml
152
+ model = "sonnet"
153
+ max_tokens = 4096
154
+ poll_interval = 180
155
+ preview_lines = 40
156
+ ```
157
+
158
+ ### Environment variables
159
+
160
+ | Variable | Description |
161
+ |----------------------|----------------------------------|
162
+ | `ANTHROPIC_API_KEY` | API key (overrides stored key) |
163
+ | `PENPAL_MODEL` | Default model |
164
+ | `PENPAL_MAX_TOKENS` | Default max output tokens |
165
+ | `PENPAL_POLL_INTERVAL` | Status polling interval (seconds) |
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,118 @@
1
+ """API key storage and retrieval via keyring with file fallback."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ import keyring
9
+ import keyring.errors
10
+
11
+ from penpal.config import credentials_file
12
+
13
+ KEYRING_SERVICE = "penpal"
14
+ KEYRING_USERNAME = "anthropic_api_key"
15
+
16
+
17
+ class AuthError(Exception):
18
+ pass
19
+
20
+
21
+ def _file_creds_path() -> Path:
22
+ return credentials_file()
23
+
24
+
25
+ def _store_in_file(key: str) -> None:
26
+ path = _file_creds_path()
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ path.write_text(key)
29
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
30
+
31
+
32
+ def _read_from_file() -> str | None:
33
+ path = _file_creds_path()
34
+ if path.exists():
35
+ return path.read_text().strip() or None
36
+ return None
37
+
38
+
39
+ def store_api_key(key: str) -> str:
40
+ """Store the API key. Returns 'keyring' or 'file' indicating storage method."""
41
+ try:
42
+ keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, key)
43
+ return "keyring"
44
+ except (keyring.errors.NoKeyringError, Exception):
45
+ _store_in_file(key)
46
+ return "file"
47
+
48
+
49
+ def get_api_key() -> str:
50
+ """Retrieve stored API key. Raises AuthError if not configured."""
51
+ # Env var takes precedence
52
+ key = os.environ.get("ANTHROPIC_API_KEY")
53
+ if key:
54
+ return key
55
+
56
+ # Try keyring
57
+ try:
58
+ key = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
59
+ if key:
60
+ return key
61
+ except (keyring.errors.NoKeyringError, Exception):
62
+ pass
63
+
64
+ # Try file fallback
65
+ key = _read_from_file()
66
+ if key:
67
+ return key
68
+
69
+ raise AuthError(
70
+ "No API key configured. Run `penpal auth` or set ANTHROPIC_API_KEY."
71
+ )
72
+
73
+
74
+ def delete_api_key() -> bool:
75
+ """Remove the stored API key. Returns True if something was deleted."""
76
+ deleted = False
77
+ try:
78
+ existing = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
79
+ if existing:
80
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
81
+ deleted = True
82
+ except (keyring.errors.NoKeyringError, Exception):
83
+ pass
84
+
85
+ path = _file_creds_path()
86
+ if path.exists():
87
+ path.unlink()
88
+ deleted = True
89
+
90
+ return deleted
91
+
92
+
93
+ def get_key_status() -> dict:
94
+ """Return info about the stored key without revealing it."""
95
+ env_key = os.environ.get("ANTHROPIC_API_KEY")
96
+
97
+ keyring_key = None
98
+ try:
99
+ keyring_key = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
100
+ except (keyring.errors.NoKeyringError, Exception):
101
+ pass
102
+
103
+ file_key = _read_from_file()
104
+
105
+ active_key = env_key or keyring_key or file_key
106
+
107
+ def mask(k: str | None) -> str:
108
+ if not k:
109
+ return "(not set)"
110
+ return k[:12] + "..." + k[-4:]
111
+
112
+ return {
113
+ "env_var": mask(env_key) if env_key else "(not set)",
114
+ "keyring": mask(keyring_key) if keyring_key else "(not set)",
115
+ "file": mask(file_key) if file_key else "(not set)",
116
+ "active": mask(active_key) if active_key else "(not set)",
117
+ "source": "env" if env_key else ("keyring" if keyring_key else ("file" if file_key else "none")),
118
+ }
@@ -0,0 +1,166 @@
1
+ """Constructs Messages API payloads from CLI inputs."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import mimetypes
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from penpal.config import MODEL_ALIASES
11
+
12
+ # Supported file types
13
+ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
14
+ PDF_EXTENSIONS = {".pdf"}
15
+ TEXT_EXTENSIONS = {
16
+ ".txt", ".csv", ".tsv", ".md", ".html", ".json", ".xml",
17
+ ".yaml", ".yml", ".py", ".js", ".ts", ".java", ".c", ".cpp",
18
+ ".rs", ".go", ".rb", ".sh", ".sql", ".r", ".swift", ".kt",
19
+ ".log", ".rtf",
20
+ }
21
+
22
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB
23
+ MAX_PDF_SIZE = 32 * 1024 * 1024 # 32 MB
24
+ MAX_TEXT_SIZE = 512 * 1024 # 512 KB
25
+
26
+
27
+ def resolve_model(name: str) -> str:
28
+ """Resolve alias to full model string. Pass through if already full."""
29
+ return MODEL_ALIASES.get(name.lower(), name)
30
+
31
+
32
+ def build_file_content_block(file_path: Path) -> tuple[str | None, dict]:
33
+ """
34
+ Build a content block for the given file.
35
+ Returns (text_prefix, content_block) where text_prefix is non-None only for
36
+ text files (to be prepended to the user prompt string).
37
+ Raises ValueError for unsupported or oversized files.
38
+ """
39
+ suffix = file_path.suffix.lower()
40
+
41
+ if suffix in IMAGE_EXTENSIONS:
42
+ data = file_path.read_bytes()
43
+ if len(data) > MAX_IMAGE_SIZE:
44
+ raise ValueError(
45
+ f"Image '{file_path.name}' is {len(data) / 1024 / 1024:.1f} MB "
46
+ f"(max 5 MB)."
47
+ )
48
+ media_type, _ = mimetypes.guess_type(str(file_path))
49
+ if not media_type:
50
+ media_type = "image/jpeg"
51
+ encoded = base64.standard_b64encode(data).decode()
52
+ return (None, {
53
+ "type": "image",
54
+ "source": {
55
+ "type": "base64",
56
+ "media_type": media_type,
57
+ "data": encoded,
58
+ },
59
+ })
60
+
61
+ elif suffix in PDF_EXTENSIONS:
62
+ data = file_path.read_bytes()
63
+ if len(data) > MAX_PDF_SIZE:
64
+ raise ValueError(
65
+ f"PDF '{file_path.name}' is {len(data) / 1024 / 1024:.1f} MB "
66
+ f"(max 32 MB)."
67
+ )
68
+ encoded = base64.standard_b64encode(data).decode()
69
+ return (None, {
70
+ "type": "document",
71
+ "source": {
72
+ "type": "base64",
73
+ "media_type": "application/pdf",
74
+ "data": encoded,
75
+ },
76
+ "title": file_path.name,
77
+ })
78
+
79
+ elif suffix in TEXT_EXTENSIONS:
80
+ data = file_path.read_bytes()
81
+ if len(data) > MAX_TEXT_SIZE:
82
+ raise ValueError(
83
+ f"Text file '{file_path.name}' is {len(data) / 1024:.1f} KB "
84
+ f"(max 512 KB)."
85
+ )
86
+ text = data.decode("utf-8", errors="replace")
87
+ size_str = f"{len(data) / 1024:.1f} KB"
88
+ prefix = (
89
+ f"--- Attached file: {file_path.name} ({size_str}) ---\n"
90
+ f"{text}\n"
91
+ f"--- End of attachment ---\n\n"
92
+ )
93
+ return (prefix, {}) # Empty dict signals text file (no block needed)
94
+
95
+ else:
96
+ supported = sorted(IMAGE_EXTENSIONS | PDF_EXTENSIONS | TEXT_EXTENSIONS)
97
+ raise ValueError(
98
+ f"Unsupported file type '{suffix}'. Supported: {', '.join(supported)}"
99
+ )
100
+
101
+
102
+ def build_batch_requests(
103
+ template_prompt: str,
104
+ files: list[Path],
105
+ model: str,
106
+ max_tokens: int,
107
+ system_prompt: Optional[str] = None,
108
+ ) -> list[dict]:
109
+ """Build one batch request per file, applying template_prompt to each."""
110
+ requests = []
111
+ for fp in files:
112
+ requests.append(
113
+ build_single_request(
114
+ prompt=template_prompt,
115
+ model=model,
116
+ max_tokens=max_tokens,
117
+ system_prompt=system_prompt,
118
+ custom_id=fp.name,
119
+ files=[fp],
120
+ )
121
+ )
122
+ return requests
123
+
124
+
125
+ def build_single_request(
126
+ prompt: str,
127
+ model: str,
128
+ max_tokens: int,
129
+ system_prompt: Optional[str] = None,
130
+ custom_id: Optional[str] = None,
131
+ files: Optional[list[Path]] = None,
132
+ ) -> dict:
133
+ """Build a single batch request object."""
134
+ if custom_id is None:
135
+ custom_id = f"req-{uuid.uuid4().hex[:8]}"
136
+
137
+ # Build content blocks
138
+ content: list[dict] | str
139
+ text_prefixes: list[str] = []
140
+ content_blocks: list[dict] = []
141
+
142
+ for fp in (files or []):
143
+ text_prefix, block = build_file_content_block(fp)
144
+ if text_prefix is not None:
145
+ text_prefixes.append(text_prefix)
146
+ else:
147
+ content_blocks.append(block)
148
+
149
+ full_prompt = "".join(text_prefixes) + prompt
150
+
151
+ if content_blocks:
152
+ # Mixed content: blocks first, then the text prompt
153
+ content_blocks.append({"type": "text", "text": full_prompt})
154
+ content = content_blocks
155
+ else:
156
+ content = full_prompt
157
+
158
+ params: dict = {
159
+ "model": model,
160
+ "max_tokens": max_tokens,
161
+ "messages": [{"role": "user", "content": content}],
162
+ }
163
+ if system_prompt:
164
+ params["system"] = system_prompt
165
+
166
+ return {"custom_id": custom_id, "params": params}