typefully-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.
- typefully_cli/__init__.py +3 -0
- typefully_cli/__main__.py +5 -0
- typefully_cli/auth.py +57 -0
- typefully_cli/batch.py +126 -0
- typefully_cli/cli.py +876 -0
- typefully_cli/client.py +267 -0
- typefully_cli/config.py +104 -0
- typefully_cli/console.py +56 -0
- typefully_cli/exceptions.py +80 -0
- typefully_cli/media.py +70 -0
- typefully_cli/output.py +188 -0
- typefully_cli-0.1.0.dist-info/METADATA +263 -0
- typefully_cli-0.1.0.dist-info/RECORD +16 -0
- typefully_cli-0.1.0.dist-info/WHEEL +4 -0
- typefully_cli-0.1.0.dist-info/entry_points.txt +2 -0
- typefully_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
typefully_cli/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""API key resolution chain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from typefully_cli.config import Config
|
|
8
|
+
from typefully_cli.exceptions import AuthError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_api_key(
|
|
12
|
+
cli_flag: str | None,
|
|
13
|
+
env_var: str | None,
|
|
14
|
+
config: Config,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Resolve API key from the priority chain.
|
|
17
|
+
|
|
18
|
+
1. --api-key CLI flag
|
|
19
|
+
2. TYPEFULLY_API_KEY env var
|
|
20
|
+
3. Config file [auth] api_key
|
|
21
|
+
4. 1Password (if configured)
|
|
22
|
+
"""
|
|
23
|
+
if cli_flag:
|
|
24
|
+
return cli_flag
|
|
25
|
+
if env_var:
|
|
26
|
+
return env_var
|
|
27
|
+
if config.api_key:
|
|
28
|
+
return config.api_key
|
|
29
|
+
if config.onepassword_item:
|
|
30
|
+
return _fetch_from_1password(config.onepassword_item)
|
|
31
|
+
raise AuthError()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fetch_from_1password(item_name: str) -> str:
|
|
35
|
+
"""Fetch API key from 1Password CLI."""
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["op", "item", "get", item_name, "--fields", "password", "--reveal"],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
timeout=30,
|
|
42
|
+
)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise AuthError(
|
|
45
|
+
"1Password CLI (op) not found",
|
|
46
|
+
hint="Install 1Password CLI or use a different auth method",
|
|
47
|
+
)
|
|
48
|
+
except subprocess.TimeoutExpired:
|
|
49
|
+
raise AuthError("1Password CLI timed out")
|
|
50
|
+
|
|
51
|
+
key = result.stdout.strip()
|
|
52
|
+
if not key or result.returncode != 0:
|
|
53
|
+
raise AuthError(
|
|
54
|
+
f"Could not retrieve key from 1Password item '{item_name}'",
|
|
55
|
+
hint="Check that the item exists and 1Password CLI is unlocked",
|
|
56
|
+
)
|
|
57
|
+
return key
|
typefully_cli/batch.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Batch file parser for the ---/=== format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from typefully_cli.exceptions import BatchParseError
|
|
9
|
+
|
|
10
|
+
METADATA_KEYS = {"schedule", "tag", "title", "scratchpad", "media"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class BatchEntry:
|
|
15
|
+
posts: list[str]
|
|
16
|
+
schedule: str | None = None
|
|
17
|
+
tags: list[str] = field(default_factory=list)
|
|
18
|
+
title: str | None = None
|
|
19
|
+
scratchpad: str | None = None
|
|
20
|
+
media_ids: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
d: dict = {"posts": self.posts}
|
|
24
|
+
if self.schedule:
|
|
25
|
+
d["schedule"] = self.schedule
|
|
26
|
+
if self.tags:
|
|
27
|
+
d["tags"] = self.tags
|
|
28
|
+
if self.title:
|
|
29
|
+
d["title"] = self.title
|
|
30
|
+
if self.scratchpad:
|
|
31
|
+
d["scratchpad"] = self.scratchpad
|
|
32
|
+
if self.media_ids:
|
|
33
|
+
d["media_ids"] = self.media_ids
|
|
34
|
+
d["type"] = "thread" if len(self.posts) > 1 else "single"
|
|
35
|
+
d["post_count"] = len(self.posts)
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_batch_file(content: str) -> list[BatchEntry]:
|
|
40
|
+
"""Parse a batch file into entries.
|
|
41
|
+
|
|
42
|
+
Format:
|
|
43
|
+
- Blocks separated by --- on its own line
|
|
44
|
+
- Thread posts separated by === on its own line within a block
|
|
45
|
+
- Metadata lines at top of each block: schedule:, tag:, title:, scratchpad:, media:
|
|
46
|
+
"""
|
|
47
|
+
blocks = re.split(r"\n---\n", content.strip())
|
|
48
|
+
entries: list[BatchEntry] = []
|
|
49
|
+
|
|
50
|
+
for block in blocks:
|
|
51
|
+
block = block.strip()
|
|
52
|
+
if not block:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
lines = block.split("\n")
|
|
56
|
+
schedule = None
|
|
57
|
+
tags: list[str] = []
|
|
58
|
+
title = None
|
|
59
|
+
scratchpad = None
|
|
60
|
+
media_ids: list[str] = []
|
|
61
|
+
post_lines: list[str] = []
|
|
62
|
+
|
|
63
|
+
in_header = True
|
|
64
|
+
for line in lines:
|
|
65
|
+
stripped = line.strip()
|
|
66
|
+
if in_header:
|
|
67
|
+
match = re.match(r"^(\w+):\s*(.+)$", stripped)
|
|
68
|
+
if match and match.group(1).lower() in METADATA_KEYS:
|
|
69
|
+
key = match.group(1).lower()
|
|
70
|
+
val = match.group(2).strip()
|
|
71
|
+
if key == "schedule":
|
|
72
|
+
schedule = val
|
|
73
|
+
elif key == "tag":
|
|
74
|
+
tags.append(val)
|
|
75
|
+
elif key == "title":
|
|
76
|
+
title = val
|
|
77
|
+
elif key == "scratchpad":
|
|
78
|
+
scratchpad = val
|
|
79
|
+
elif key == "media":
|
|
80
|
+
media_ids = [m.strip() for m in val.split(",") if m.strip()]
|
|
81
|
+
continue
|
|
82
|
+
else:
|
|
83
|
+
in_header = False
|
|
84
|
+
post_lines.append(line)
|
|
85
|
+
|
|
86
|
+
post_text = "\n".join(post_lines).strip()
|
|
87
|
+
if not post_text:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
thread_parts = re.split(r"\n===\n", post_text)
|
|
91
|
+
posts = [p.strip() for p in thread_parts if p.strip()]
|
|
92
|
+
|
|
93
|
+
if not posts:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
entries.append(
|
|
97
|
+
BatchEntry(
|
|
98
|
+
posts=posts,
|
|
99
|
+
schedule=schedule,
|
|
100
|
+
tags=tags,
|
|
101
|
+
title=title,
|
|
102
|
+
scratchpad=scratchpad,
|
|
103
|
+
media_ids=media_ids,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not entries:
|
|
108
|
+
raise BatchParseError("No valid entries found in batch file")
|
|
109
|
+
|
|
110
|
+
return entries
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def merge_defaults(
|
|
114
|
+
entries: list[BatchEntry],
|
|
115
|
+
default_schedule: str | None = None,
|
|
116
|
+
default_tags: tuple[str, ...] = (),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Apply command-line defaults to entries missing metadata."""
|
|
119
|
+
for entry in entries:
|
|
120
|
+
if not entry.schedule and default_schedule:
|
|
121
|
+
entry.schedule = default_schedule
|
|
122
|
+
if default_tags:
|
|
123
|
+
existing = {t.lower() for t in entry.tags}
|
|
124
|
+
for t in default_tags:
|
|
125
|
+
if t.lower() not in existing:
|
|
126
|
+
entry.tags.append(t)
|