penpal-cli 0.1.0__py3-none-any.whl
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.
- penpal/__init__.py +1 -0
- penpal/auth.py +118 -0
- penpal/builder.py +166 -0
- penpal/cli.py +931 -0
- penpal/client.py +155 -0
- penpal/config.py +109 -0
- penpal/cost.py +29 -0
- penpal/db.py +339 -0
- penpal/extractor.py +92 -0
- penpal/models.py +61 -0
- penpal/skills.py +56 -0
- penpal/tui/__init__.py +0 -0
- penpal/tui/app.py +533 -0
- penpal/tui/dashboard.py +192 -0
- penpal/tui/detail.py +67 -0
- penpal_cli-0.1.0.dist-info/METADATA +197 -0
- penpal_cli-0.1.0.dist-info/RECORD +20 -0
- penpal_cli-0.1.0.dist-info/WHEEL +5 -0
- penpal_cli-0.1.0.dist-info/entry_points.txt +2 -0
- penpal_cli-0.1.0.dist-info/top_level.txt +1 -0
penpal/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
penpal/auth.py
ADDED
|
@@ -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
|
+
}
|
penpal/builder.py
ADDED
|
@@ -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}
|