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.
@@ -0,0 +1,3 @@
1
+ """Typefully CLI -- manage drafts, threads, and publishing from the terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m typefully_cli`."""
2
+
3
+ from typefully_cli.cli import cli
4
+
5
+ cli()
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)