corrkit 0.5.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.
accounts.py ADDED
@@ -0,0 +1,317 @@
1
+ """Account configuration — parse accounts.toml with provider presets."""
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import tomllib
9
+ from pathlib import Path
10
+
11
+ import msgspec
12
+
13
+ CONFIG_PATH = Path("accounts.toml")
14
+
15
+ PROVIDER_PRESETS: dict[str, dict[str, object]] = {
16
+ "gmail": {
17
+ "imap_host": "imap.gmail.com",
18
+ "imap_port": 993,
19
+ "imap_starttls": False,
20
+ "smtp_host": "smtp.gmail.com",
21
+ "smtp_port": 465,
22
+ "drafts_folder": "[Gmail]/Drafts",
23
+ },
24
+ "protonmail-bridge": {
25
+ "imap_host": "127.0.0.1",
26
+ "imap_port": 1143,
27
+ "imap_starttls": True,
28
+ "smtp_host": "127.0.0.1",
29
+ "smtp_port": 1025,
30
+ "drafts_folder": "Drafts",
31
+ },
32
+ }
33
+
34
+
35
+ _NON_ACCOUNT_KEYS = frozenset({"watch", "owner"})
36
+
37
+
38
+ class OwnerConfig(msgspec.Struct):
39
+ github_user: str
40
+ name: str = ""
41
+
42
+
43
+ class WatchConfig(msgspec.Struct):
44
+ poll_interval: int = 300
45
+ notify: bool = False
46
+
47
+
48
+ class Account(msgspec.Struct):
49
+ provider: str = "imap"
50
+ user: str = ""
51
+ password: str = ""
52
+ password_cmd: str = ""
53
+ labels: list[str] = []
54
+ imap_host: str = ""
55
+ imap_port: int = 993
56
+ imap_starttls: bool = False
57
+ smtp_host: str = ""
58
+ smtp_port: int = 465
59
+ drafts_folder: str = "Drafts"
60
+ sync_days: int = 3650
61
+ default: bool = False
62
+
63
+
64
+ def _apply_preset(account: Account) -> Account:
65
+ """Merge provider preset defaults — account values win over preset."""
66
+ preset = PROVIDER_PRESETS.get(account.provider)
67
+ if preset is None:
68
+ return account
69
+ # Only apply preset values for fields still at their Struct defaults
70
+ defaults = Account()
71
+ updates: dict[str, object] = {}
72
+ for key, preset_val in preset.items():
73
+ if getattr(account, key) == getattr(defaults, key):
74
+ updates[key] = preset_val
75
+ if updates:
76
+ return msgspec.structs.replace(account, **updates)
77
+ return account
78
+
79
+
80
+ def resolve_password(account: Account) -> str:
81
+ """Return password: inline value if set, else run password_cmd."""
82
+ if account.password:
83
+ return account.password
84
+ if account.password_cmd:
85
+ result = subprocess.run(
86
+ account.password_cmd,
87
+ shell=True,
88
+ capture_output=True,
89
+ text=True,
90
+ check=True,
91
+ )
92
+ return result.stdout.strip()
93
+ raise ValueError(f"Account {account.user!r} has no password or password_cmd")
94
+
95
+
96
+ def load_accounts(path: Path | None = None) -> dict[str, Account]:
97
+ """Parse accounts.toml → {name: Account} mapping."""
98
+ if path is None:
99
+ path = CONFIG_PATH
100
+ if not path.exists():
101
+ return {}
102
+ with open(path, "rb") as f:
103
+ raw = tomllib.load(f)
104
+ accounts_section = raw.get("accounts", raw)
105
+ result: dict[str, Account] = {}
106
+ for name, data in accounts_section.items():
107
+ if name in _NON_ACCOUNT_KEYS:
108
+ continue
109
+ account = msgspec.convert(data, Account)
110
+ account = _apply_preset(account)
111
+ result[name] = account
112
+ return result
113
+
114
+
115
+ def _legacy_account_from_env() -> Account:
116
+ """Build a synthetic Account from legacy .env GMAIL_* vars."""
117
+ from dotenv import load_dotenv
118
+
119
+ load_dotenv()
120
+
121
+ user = os.environ.get("GMAIL_USER_EMAIL", "")
122
+ password = os.environ.get("GMAIL_APP_PASSWORD", "")
123
+ labels_str = os.environ.get("GMAIL_SYNC_LABELS", "")
124
+ sync_days = int(os.environ.get("GMAIL_SYNC_DAYS", "3650"))
125
+
126
+ if not user:
127
+ raise SystemExit("No accounts.toml found and GMAIL_USER_EMAIL not set in .env")
128
+
129
+ return Account(
130
+ provider="gmail",
131
+ user=user,
132
+ password=password.replace(" ", ""),
133
+ labels=[s.strip() for s in labels_str.split(",") if s.strip()],
134
+ imap_host="imap.gmail.com",
135
+ imap_port=993,
136
+ smtp_host="smtp.gmail.com",
137
+ smtp_port=465,
138
+ drafts_folder="[Gmail]/Drafts",
139
+ sync_days=sync_days,
140
+ default=True,
141
+ )
142
+
143
+
144
+ def load_accounts_or_env(path: Path | None = None) -> dict[str, Account]:
145
+ """Load accounts.toml, falling back to .env GMAIL_* vars."""
146
+ accounts = load_accounts(path)
147
+ if accounts:
148
+ return accounts
149
+ return {"_legacy": _legacy_account_from_env()}
150
+
151
+
152
+ def load_owner(path: Path | None = None) -> OwnerConfig:
153
+ """Load [owner] section from accounts.toml."""
154
+ if path is None:
155
+ path = CONFIG_PATH
156
+ if not path.exists():
157
+ raise SystemExit(
158
+ f"accounts.toml not found at {path}.\n"
159
+ "Add an [owner] section with github_user."
160
+ )
161
+ with open(path, "rb") as f:
162
+ raw = tomllib.load(f)
163
+ owner_data = raw.get("owner")
164
+ if owner_data is None:
165
+ raise SystemExit(
166
+ "Missing [owner] section in accounts.toml.\n"
167
+ 'Add: [owner]\ngithub_user = "your-github-username"'
168
+ )
169
+ return msgspec.convert(owner_data, OwnerConfig)
170
+
171
+
172
+ def get_default_account(accounts: dict[str, Account]) -> tuple[str, Account]:
173
+ """Return (name, account) for the default account."""
174
+ for name, acct in accounts.items():
175
+ if acct.default:
176
+ return name, acct
177
+ # Fall back to first account
178
+ name = next(iter(accounts))
179
+ return name, accounts[name]
180
+
181
+
182
+ def get_account_for_email(
183
+ accounts: dict[str, Account], email_addr: str
184
+ ) -> tuple[str, Account] | None:
185
+ """Lookup account by email address."""
186
+ email_lower = email_addr.lower()
187
+ for name, acct in accounts.items():
188
+ if acct.user.lower() == email_lower:
189
+ return name, acct
190
+ return None
191
+
192
+
193
+ def add_label_to_account(
194
+ account_name: str,
195
+ label: str,
196
+ path: Path | None = None,
197
+ ) -> bool:
198
+ """Add a label to an account's labels list in accounts.toml.
199
+
200
+ Does a text-level edit to preserve comments and formatting.
201
+ Returns True if the label was added, False if already present.
202
+ """
203
+ if path is None:
204
+ path = CONFIG_PATH
205
+ if not path.exists():
206
+ print(
207
+ f"accounts.toml not found at {path}",
208
+ file=sys.stderr,
209
+ )
210
+ sys.exit(1)
211
+
212
+ # Verify account exists and label isn't already there
213
+ accounts = load_accounts(path)
214
+ if account_name not in accounts:
215
+ print(
216
+ f"Unknown account: {account_name}\nAvailable: {', '.join(accounts.keys())}",
217
+ file=sys.stderr,
218
+ )
219
+ sys.exit(1)
220
+ if label in accounts[account_name].labels:
221
+ return False
222
+
223
+ # Text-level edit: find the labels line for this account section
224
+ text = path.read_text(encoding="utf-8")
225
+
226
+ # Find the account section header
227
+ section_re = re.compile(
228
+ rf"^\[accounts\.{re.escape(account_name)}\]",
229
+ re.MULTILINE,
230
+ )
231
+ section_match = section_re.search(text)
232
+ if not section_match:
233
+ # Try flat format (no [accounts.] prefix)
234
+ section_re = re.compile(rf"^\[{re.escape(account_name)}\]", re.MULTILINE)
235
+ section_match = section_re.search(text)
236
+ if not section_match:
237
+ print(
238
+ f"Could not find [{account_name}] section in {path}",
239
+ file=sys.stderr,
240
+ )
241
+ sys.exit(1)
242
+
243
+ # Find the labels = [...] line after the section header
244
+ labels_re = re.compile(
245
+ r"^(labels\s*=\s*\[)(.*?)(\])",
246
+ re.MULTILINE,
247
+ )
248
+ # Search from the section start
249
+ labels_match = labels_re.search(text, section_match.end())
250
+ if not labels_match:
251
+ print(
252
+ f"Could not find labels line for account {account_name}",
253
+ file=sys.stderr,
254
+ )
255
+ sys.exit(1)
256
+
257
+ # Check it's within this section (before next section header)
258
+ next_section = re.search(r"^\[", text[section_match.end() :], re.MULTILINE)
259
+ beyond_section = (
260
+ next_section
261
+ and labels_match.start() > section_match.end() + next_section.start()
262
+ )
263
+ if beyond_section:
264
+ print(
265
+ f"Could not find labels line for account {account_name}",
266
+ file=sys.stderr,
267
+ )
268
+ sys.exit(1)
269
+
270
+ # Append the label
271
+ existing = labels_match.group(2).strip()
272
+ if existing:
273
+ new_labels = f'{existing}, "{label}"'
274
+ else:
275
+ new_labels = f'"{label}"'
276
+
277
+ new_text = (
278
+ text[: labels_match.start()]
279
+ + f"labels = [{new_labels}]"
280
+ + text[labels_match.end() :]
281
+ )
282
+ path.write_text(new_text, encoding="utf-8")
283
+ return True
284
+
285
+
286
+ def add_label_main() -> None:
287
+ """CLI: corrkit add-label LABEL --account ACCOUNT"""
288
+ parser = argparse.ArgumentParser(
289
+ description="Add a label to an account's sync config"
290
+ )
291
+ parser.add_argument("label", help="Label to add")
292
+ parser.add_argument(
293
+ "--account",
294
+ required=True,
295
+ help="Account name in accounts.toml",
296
+ )
297
+ args = parser.parse_args()
298
+
299
+ added = add_label_to_account(args.account, args.label)
300
+ if added:
301
+ print(f"Added '{args.label}' to account '{args.account}' in accounts.toml")
302
+ else:
303
+ print(f"Label '{args.label}' already in account '{args.account}'")
304
+
305
+
306
+ def load_watch_config(path: Path | None = None) -> WatchConfig:
307
+ """Load [watch] section from accounts.toml. Returns defaults if missing."""
308
+ if path is None:
309
+ path = CONFIG_PATH
310
+ if not path.exists():
311
+ return WatchConfig()
312
+ with open(path, "rb") as f:
313
+ raw = tomllib.load(f)
314
+ watch_data = raw.get("watch")
315
+ if watch_data is None:
316
+ return WatchConfig()
317
+ return msgspec.convert(watch_data, WatchConfig)
audit_docs.py ADDED
@@ -0,0 +1,251 @@
1
+ """Audit instruction files against the codebase.
2
+
3
+ Checks:
4
+ - Referenced paths exist on disk (from project structure tree)
5
+ - uv run scripts are registered or point to existing files
6
+ - Type conventions (msgspec vs dataclasses)
7
+ - Combined instruction file line budget
8
+ - Staleness (docs older than source)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import sys
15
+ import tomllib
16
+ from pathlib import Path
17
+ from typing import NamedTuple
18
+
19
+
20
+ def _find_root() -> Path:
21
+ """Find the project root by walking up from CWD looking for pyproject.toml."""
22
+ cwd = Path.cwd()
23
+ for parent in [cwd, *cwd.parents]:
24
+ if (parent / "pyproject.toml").exists():
25
+ return parent
26
+ print("Error: could not find pyproject.toml", file=sys.stderr)
27
+ sys.exit(2)
28
+
29
+
30
+ ROOT = _find_root()
31
+ LINE_BUDGET = 1000
32
+ SKIP_PATHS = {".env"} # Gitignored files expected to be absent
33
+ TOOL_COMMANDS = {"ruff", "ty", "pytest", "poe"} # External tools invoked via uv run
34
+
35
+
36
+ class Issue(NamedTuple):
37
+ file: str
38
+ line: int
39
+ message: str
40
+
41
+
42
+ def find_instruction_files() -> list[Path]:
43
+ patterns = ["AGENTS.md", "README.md", ".claude/**/SKILL.md", "src/**/AGENTS.md"]
44
+ found: set[Path] = set()
45
+ for p in patterns:
46
+ found.update(ROOT.glob(p))
47
+ return sorted(found)
48
+
49
+
50
+ def load_scripts() -> dict[str, str]:
51
+ with open(ROOT / "pyproject.toml", "rb") as f:
52
+ data = tomllib.load(f)
53
+ return data.get("project", {}).get("scripts", {})
54
+
55
+
56
+ # --- Extractors ---
57
+
58
+
59
+ def extract_tree_paths(content: str) -> list[tuple[int, str]]:
60
+ """Parse file paths from the Project Structure tree block."""
61
+ results: list[tuple[int, str]] = []
62
+ lines = content.splitlines()
63
+ in_section = False
64
+ in_block = False
65
+ stack: list[tuple[int, str]] = [] # (indent, dirname_with_slash)
66
+
67
+ for i, line in enumerate(lines, 1):
68
+ if line.startswith("## Project Structure"):
69
+ in_section = True
70
+ continue
71
+ if in_section and not in_block:
72
+ if line.strip().startswith("```"):
73
+ in_block = True
74
+ continue
75
+ if line.startswith("## "):
76
+ break
77
+ continue
78
+ if not in_block:
79
+ continue
80
+ if line.strip().startswith("```"):
81
+ break
82
+
83
+ stripped = line.rstrip()
84
+ if not stripped.strip():
85
+ continue
86
+ indent = len(stripped) - len(stripped.lstrip())
87
+ name = stripped.strip().split("#")[0].strip()
88
+ if not name:
89
+ continue
90
+ # Strip symlink arrow notation (e.g. "correspondence -> ~/path")
91
+ # Treat as directory since it's a symlink to a directory
92
+ if " -> " in name:
93
+ name = name.split(" -> ")[0].strip() + "/"
94
+
95
+ # Pop deeper/equal entries from stack
96
+ while stack and stack[-1][0] >= indent:
97
+ stack.pop()
98
+
99
+ if name.endswith("/"):
100
+ stack.append((indent, name))
101
+ else:
102
+ parts = [d for _, d in stack] + [name]
103
+ full = "".join(parts)
104
+ if full.startswith("correspondence-kit/"):
105
+ full = full[len("correspondence-kit/") :]
106
+ results.append((i, full))
107
+
108
+ return results
109
+
110
+
111
+ def extract_uv_commands(content: str) -> list[tuple[int, str]]:
112
+ """Extract targets from `uv run <target>` in all contexts."""
113
+ results: list[tuple[int, str]] = []
114
+ pat = re.compile(r"uv run ([\w./-]+)")
115
+ for i, line in enumerate(content.splitlines(), 1):
116
+ for m in pat.finditer(line):
117
+ results.append((i, m.group(1)))
118
+ return results
119
+
120
+
121
+ # --- Checks ---
122
+
123
+
124
+ def check_tree_paths(rel: str, content: str) -> list[Issue]:
125
+ issues: list[Issue] = []
126
+ for line_no, path in extract_tree_paths(content):
127
+ if re.search(r"\[.*?]", path):
128
+ continue
129
+ if path in SKIP_PATHS:
130
+ continue
131
+ if not (ROOT / path).exists():
132
+ issues.append(
133
+ Issue(rel, line_no, f"Referenced path does not exist: {path}")
134
+ )
135
+ return issues
136
+
137
+
138
+ def check_scripts(rel: str, content: str, registered: dict[str, str]) -> list[Issue]:
139
+ issues: list[Issue] = []
140
+ for line_no, target in extract_uv_commands(content):
141
+ if "/" in target or target.endswith(".py"):
142
+ if not (ROOT / target).exists():
143
+ issues.append(
144
+ Issue(rel, line_no, f"Script file does not exist: {target}")
145
+ )
146
+ elif target not in TOOL_COMMANDS and target not in registered:
147
+ issues.append(
148
+ Issue(
149
+ rel, line_no, f"Script not registered in pyproject.toml: {target}"
150
+ )
151
+ )
152
+ return issues
153
+
154
+
155
+ def check_type_conventions(rel: str, content: str) -> list[Issue]:
156
+ issues: list[Issue] = []
157
+ if "msgspec" not in content:
158
+ return issues
159
+ for py in sorted(ROOT.glob("src/**/*.py")):
160
+ if py.name == "audit_docs.py":
161
+ continue # skip self — contains the check string as a literal
162
+ text = py.read_text()
163
+ if "from dataclasses import" in text or "import dataclasses" in text:
164
+ py_rel = py.relative_to(ROOT)
165
+ for i, line in enumerate(content.splitlines(), 1):
166
+ if "msgspec" in line and (
167
+ "dataclass" in line.lower() or "struct" in line.lower()
168
+ ):
169
+ issues.append(
170
+ Issue(
171
+ rel,
172
+ i,
173
+ f'Docs say "msgspec" but {py_rel} uses dataclasses',
174
+ )
175
+ )
176
+ break
177
+ break # one report per doc file is enough
178
+ return issues
179
+
180
+
181
+ def check_line_budget(
182
+ files: list[Path],
183
+ ) -> tuple[list[Issue], dict[str, int], int]:
184
+ counts: dict[str, int] = {}
185
+ total = 0
186
+ for f in files:
187
+ n = len(f.read_text().splitlines())
188
+ counts[str(f.relative_to(ROOT))] = n
189
+ total += n
190
+ issues: list[Issue] = []
191
+ if total > LINE_BUDGET:
192
+ issues.append(
193
+ Issue("(all)", 0, f"Over line budget: {total} lines (max {LINE_BUDGET})")
194
+ )
195
+ return issues, counts, total
196
+
197
+
198
+ def check_staleness(files: list[Path]) -> list[Issue]:
199
+ src_files = list(ROOT.glob("src/**/*.py"))
200
+ if not src_files:
201
+ return []
202
+ newest_src = max(src_files, key=lambda f: f.stat().st_mtime)
203
+ newest_mtime = newest_src.stat().st_mtime
204
+ issues: list[Issue] = []
205
+ for doc in files:
206
+ if doc.stat().st_mtime < newest_mtime:
207
+ issues.append(
208
+ Issue(
209
+ str(doc.relative_to(ROOT)),
210
+ 0,
211
+ f"Older than {newest_src.relative_to(ROOT)} — may be stale",
212
+ )
213
+ )
214
+ return issues
215
+
216
+
217
+ def main() -> None:
218
+ print("Auditing docs...\n")
219
+ files = find_instruction_files()
220
+ scripts = load_scripts()
221
+ issues: list[Issue] = []
222
+
223
+ for doc in files:
224
+ rel = str(doc.relative_to(ROOT))
225
+ content = doc.read_text()
226
+ issues.extend(check_tree_paths(rel, content))
227
+ issues.extend(check_scripts(rel, content, scripts))
228
+ issues.extend(check_type_conventions(rel, content))
229
+
230
+ budget_issues, counts, total = check_line_budget(files)
231
+ issues.extend(budget_issues)
232
+ issues.extend(check_staleness(files))
233
+
234
+ for issue in issues:
235
+ loc = f" {issue.file}"
236
+ if issue.line:
237
+ loc += f":{issue.line}"
238
+ print(f"{loc:<35} ✗ {issue.message}")
239
+
240
+ mark = "✓" if total <= LINE_BUDGET else "✗"
241
+ print(f"\nCombined instruction files: {total} lines (budget: {LINE_BUDGET}) {mark}")
242
+ for name, n in sorted(counts.items()):
243
+ print(f" {name}: {n}")
244
+
245
+ n = len(issues)
246
+ print(f"\nFound {n} issue(s)" if n else "\nNo issues found ✓")
247
+ sys.exit(1 if n else 0)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
cloudflare/__init__.py ADDED
File without changes
collab/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """Collaborator configuration — parse collaborators.toml."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ import msgspec
7
+
8
+ CONFIG_PATH = Path("collaborators.toml")
9
+
10
+
11
+ class Collaborator(msgspec.Struct):
12
+ labels: list[str]
13
+ repo: str = ""
14
+ github_user: str = ""
15
+ name: str = ""
16
+ account: str = ""
17
+
18
+
19
+ def collab_dir(collab: Collaborator) -> Path:
20
+ """Return the local collab directory (correspondence/for/{gh_user}/)."""
21
+ return Path("correspondence") / "for" / collab.github_user.lower()
22
+
23
+
24
+ def _auto_repo(owner_gh: str, collab_gh: str) -> str:
25
+ """Derive the default repo name: {owner}/to-{collab}."""
26
+ return f"{owner_gh}/to-{collab_gh.lower()}"
27
+
28
+
29
+ def load_collaborators(path: Path | None = None) -> dict[str, Collaborator]:
30
+ """Load collaborators.toml and return {github_user: Collaborator} mapping.
31
+
32
+ The TOML section key is the collaborator's GitHub username.
33
+ ``repo`` is auto-derived from owner config if not explicitly set.
34
+ """
35
+ if path is None:
36
+ path = CONFIG_PATH
37
+ if not path.exists():
38
+ return {}
39
+ with open(path, "rb") as f:
40
+ raw = tomllib.load(f)
41
+ # Load owner for repo auto-derivation
42
+ owner_gh = ""
43
+ try:
44
+ from accounts import load_owner
45
+
46
+ owner = load_owner()
47
+ owner_gh = owner.github_user
48
+ except (SystemExit, ImportError):
49
+ pass
50
+
51
+ result: dict[str, Collaborator] = {}
52
+ for gh_user, data in raw.items():
53
+ collab = msgspec.convert(data, Collaborator)
54
+ # github_user is always the TOML key
55
+ collab = msgspec.structs.replace(collab, github_user=gh_user)
56
+ # Auto-derive repo if not explicit
57
+ if not collab.repo and owner_gh:
58
+ collab = msgspec.structs.replace(collab, repo=_auto_repo(owner_gh, gh_user))
59
+ result[gh_user] = collab
60
+ return result
61
+
62
+
63
+ def save_collaborators(
64
+ collabs: dict[str, Collaborator], path: Path | None = None
65
+ ) -> None:
66
+ """Write collaborators back to TOML. Simple serializer — no third-party dep."""
67
+ if path is None:
68
+ path = CONFIG_PATH
69
+ lines: list[str] = []
70
+ for gh_user, c in sorted(collabs.items()):
71
+ lines.append(f"[{gh_user}]")
72
+ labels = ", ".join(f'"{lbl}"' for lbl in c.labels)
73
+ lines.append(f"labels = [{labels}]")
74
+ if c.name:
75
+ lines.append(f'name = "{c.name}"')
76
+ if c.repo:
77
+ lines.append(f'repo = "{c.repo}"')
78
+ if c.account:
79
+ lines.append(f'account = "{c.account}"')
80
+ lines.append("")
81
+ path.write_text("\n".join(lines), encoding="utf-8")