speculate-cli 0.0.2__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.
- speculate/__init__.py +1 -0
- speculate/cli/__init__.py +1 -0
- speculate/cli/cli_commands.py +433 -0
- speculate/cli/cli_main.py +149 -0
- speculate/cli/cli_ui.py +76 -0
- speculate/py.typed +0 -0
- speculate_cli-0.0.2.dist-info/METADATA +44 -0
- speculate_cli-0.0.2.dist-info/RECORD +10 -0
- speculate_cli-0.0.2.dist-info/WHEEL +4 -0
- speculate_cli-0.0.2.dist-info/entry_points.txt +2 -0
speculate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Speculate CLI package
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# CLI package for speculate
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command implementations for speculate CLI.
|
|
3
|
+
|
|
4
|
+
Each command is a function with a docstring that serves as CLI help.
|
|
5
|
+
Only copier is lazy-imported (it's a large package).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from prettyfmt import fmt_count_items, fmt_size_human
|
|
18
|
+
from rich import print as rprint
|
|
19
|
+
from strif import atomic_output_file
|
|
20
|
+
|
|
21
|
+
from speculate.cli.cli_ui import (
|
|
22
|
+
print_cancelled,
|
|
23
|
+
print_detail,
|
|
24
|
+
print_error,
|
|
25
|
+
print_error_item,
|
|
26
|
+
print_header,
|
|
27
|
+
print_info,
|
|
28
|
+
print_missing,
|
|
29
|
+
print_note,
|
|
30
|
+
print_success,
|
|
31
|
+
print_warning,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
36
|
+
"""Load a YAML file and return a dictionary."""
|
|
37
|
+
with open(path) as f:
|
|
38
|
+
result = yaml.safe_load(f)
|
|
39
|
+
return cast(dict[str, Any], result) if isinstance(result, dict) else {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def init(
|
|
43
|
+
destination: str = ".",
|
|
44
|
+
overwrite: bool = False,
|
|
45
|
+
template: str = "gh:jlevy/speculate",
|
|
46
|
+
ref: str = "HEAD",
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize docs in a project using Copier.
|
|
49
|
+
|
|
50
|
+
Copies the docs/ directory from the speculate template into your project.
|
|
51
|
+
Creates a .copier-answers.yml file for future updates.
|
|
52
|
+
|
|
53
|
+
By default, always pulls from the latest commit (HEAD) so docs updates
|
|
54
|
+
don't require new CLI releases. Use --ref to update to a specific version.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
speculate init # Initialize in current directory
|
|
58
|
+
speculate init ./my-project # Initialize in specific directory
|
|
59
|
+
speculate init --overwrite # Overwrite without confirmation
|
|
60
|
+
speculate init --ref v1.0.0 # Use specific tag/commit
|
|
61
|
+
"""
|
|
62
|
+
import copier # Lazy import - large package
|
|
63
|
+
|
|
64
|
+
dst = Path(destination).resolve()
|
|
65
|
+
docs_path = dst / "docs"
|
|
66
|
+
|
|
67
|
+
print_header("Initializing Speculate docs in:", dst)
|
|
68
|
+
|
|
69
|
+
if docs_path.exists() and not overwrite:
|
|
70
|
+
print_note(
|
|
71
|
+
f"{docs_path} already exists", "Use `speculate update` to preserve local changes."
|
|
72
|
+
)
|
|
73
|
+
response = input("Reinitialize anyway? [y/N] ").strip().lower()
|
|
74
|
+
if response != "y":
|
|
75
|
+
print_cancelled()
|
|
76
|
+
raise SystemExit(0)
|
|
77
|
+
|
|
78
|
+
print_header("Docs will be copied to:", f"{docs_path}/")
|
|
79
|
+
|
|
80
|
+
if not overwrite:
|
|
81
|
+
response = input("Proceed? [Y/n] ").strip().lower()
|
|
82
|
+
if response == "n":
|
|
83
|
+
print_cancelled()
|
|
84
|
+
raise SystemExit(0)
|
|
85
|
+
|
|
86
|
+
rprint()
|
|
87
|
+
# vcs_ref=HEAD ensures we always get latest docs without needing CLI releases
|
|
88
|
+
_ = copier.run_copy(template, str(dst), overwrite=overwrite, defaults=overwrite, vcs_ref=ref)
|
|
89
|
+
|
|
90
|
+
# Copy development.sample.md to development.md if it doesn't exist
|
|
91
|
+
sample_dev_md = dst / "docs" / "project" / "development.sample.md"
|
|
92
|
+
dev_md = dst / "docs" / "development.md"
|
|
93
|
+
if sample_dev_md.exists() and not dev_md.exists():
|
|
94
|
+
shutil.copy(sample_dev_md, dev_md)
|
|
95
|
+
print_success("Created docs/development.md from template")
|
|
96
|
+
|
|
97
|
+
# Show summary of what was created
|
|
98
|
+
file_count, total_size = _get_dir_stats(docs_path)
|
|
99
|
+
rprint()
|
|
100
|
+
print_success(
|
|
101
|
+
f"Docs installed ({fmt_count_items(file_count, 'file')}, {fmt_size_human(total_size)})"
|
|
102
|
+
)
|
|
103
|
+
rprint()
|
|
104
|
+
|
|
105
|
+
# Automatically run install to set up tool configs
|
|
106
|
+
install()
|
|
107
|
+
|
|
108
|
+
# Remind user about required project-specific setup
|
|
109
|
+
rprint("[bold yellow]Required next step:[/bold yellow]")
|
|
110
|
+
print_detail("Customize docs/development.md with your project-specific setup.")
|
|
111
|
+
rprint()
|
|
112
|
+
rprint("Other commands:")
|
|
113
|
+
print_detail("speculate status # Check current status")
|
|
114
|
+
print_detail("speculate update # Pull future updates")
|
|
115
|
+
rprint()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def update() -> None:
|
|
119
|
+
"""Update docs from the upstream template.
|
|
120
|
+
|
|
121
|
+
Pulls the latest changes from the speculate template and merges them
|
|
122
|
+
with your local docs. Local customizations in docs/project/ are preserved.
|
|
123
|
+
|
|
124
|
+
Automatically runs `install` after update to refresh tool configs.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
speculate update
|
|
128
|
+
"""
|
|
129
|
+
import copier # Lazy import - large package
|
|
130
|
+
|
|
131
|
+
cwd = Path.cwd()
|
|
132
|
+
answers_file = cwd / ".copier-answers.yml"
|
|
133
|
+
|
|
134
|
+
if not answers_file.exists():
|
|
135
|
+
print_error(
|
|
136
|
+
"No .copier-answers.yml found", "Run `speculate init` first to initialize docs."
|
|
137
|
+
)
|
|
138
|
+
raise SystemExit(1)
|
|
139
|
+
|
|
140
|
+
print_header("Updating docs from upstream template...", cwd)
|
|
141
|
+
|
|
142
|
+
_ = copier.run_update(str(cwd), conflict="inline")
|
|
143
|
+
|
|
144
|
+
rprint()
|
|
145
|
+
print_success("Docs updated successfully!")
|
|
146
|
+
rprint()
|
|
147
|
+
|
|
148
|
+
# Automatically run install to refresh tool configs
|
|
149
|
+
install()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def install(
|
|
153
|
+
include: list[str] | None = None,
|
|
154
|
+
exclude: list[str] | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Generate tool configs for Cursor, Claude Code, and Codex.
|
|
157
|
+
|
|
158
|
+
Creates or updates:
|
|
159
|
+
- .speculate/settings.yml (install metadata)
|
|
160
|
+
- CLAUDE.md (for Claude Code) — adds speculate header if missing
|
|
161
|
+
- AGENTS.md (for Codex) — adds speculate header if missing
|
|
162
|
+
- .cursor/rules/ (symlinks for Cursor)
|
|
163
|
+
|
|
164
|
+
This command is idempotent and can be run multiple times safely.
|
|
165
|
+
It's automatically called by `init` and `update`.
|
|
166
|
+
|
|
167
|
+
Supports include/exclude patterns with wildcards:
|
|
168
|
+
- `*` matches any characters within a filename
|
|
169
|
+
- `**` matches any path segments
|
|
170
|
+
- Default: include all (["**/*.md"])
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
speculate install
|
|
174
|
+
speculate install --include "general-*.md"
|
|
175
|
+
speculate install --exclude "convex-*.md"
|
|
176
|
+
"""
|
|
177
|
+
cwd = Path.cwd()
|
|
178
|
+
docs_path = cwd / "docs"
|
|
179
|
+
|
|
180
|
+
if not docs_path.exists():
|
|
181
|
+
print_error(
|
|
182
|
+
"No docs/ directory found",
|
|
183
|
+
"Run `speculate init` first, or manually copy docs/ to this directory.",
|
|
184
|
+
)
|
|
185
|
+
raise SystemExit(1)
|
|
186
|
+
|
|
187
|
+
print_header("Installing tool configurations...", cwd)
|
|
188
|
+
|
|
189
|
+
# .speculate/settings.yml — track install metadata
|
|
190
|
+
_update_speculate_settings(cwd)
|
|
191
|
+
|
|
192
|
+
# CLAUDE.md — ensure speculate header at top (idempotent)
|
|
193
|
+
_ensure_speculate_header(cwd / "CLAUDE.md")
|
|
194
|
+
|
|
195
|
+
# AGENTS.md — ensure speculate header at top (idempotent)
|
|
196
|
+
_ensure_speculate_header(cwd / "AGENTS.md")
|
|
197
|
+
|
|
198
|
+
# .cursor/rules/
|
|
199
|
+
_setup_cursor_rules(cwd, include=include, exclude=exclude)
|
|
200
|
+
|
|
201
|
+
rprint()
|
|
202
|
+
print_success("Tool configs installed!")
|
|
203
|
+
rprint()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def status() -> None:
|
|
207
|
+
"""Show current template version and sync status.
|
|
208
|
+
|
|
209
|
+
Displays:
|
|
210
|
+
- Template version from .copier-answers.yml
|
|
211
|
+
- Last install info from .speculate/settings.yml
|
|
212
|
+
- Whether docs/ exists
|
|
213
|
+
- Whether development.md exists (required)
|
|
214
|
+
- Which tool configs are present
|
|
215
|
+
|
|
216
|
+
Exits with error if development.md is missing (required project setup).
|
|
217
|
+
|
|
218
|
+
Examples:
|
|
219
|
+
speculate status
|
|
220
|
+
"""
|
|
221
|
+
cwd = Path.cwd()
|
|
222
|
+
has_errors = False
|
|
223
|
+
|
|
224
|
+
print_header("Speculate Status", cwd)
|
|
225
|
+
|
|
226
|
+
# Check .copier-answers.yml (required for update)
|
|
227
|
+
answers_file = cwd / ".copier-answers.yml"
|
|
228
|
+
if answers_file.exists():
|
|
229
|
+
answers = _load_yaml(answers_file)
|
|
230
|
+
commit = answers.get("_commit", "unknown")
|
|
231
|
+
src = answers.get("_src_path", "unknown")
|
|
232
|
+
print_success(f"Template version: {commit}")
|
|
233
|
+
print_detail(f"Source: {src}")
|
|
234
|
+
else:
|
|
235
|
+
print_error_item(
|
|
236
|
+
".copier-answers.yml missing (required!)",
|
|
237
|
+
"Run `speculate init` to initialize docs.",
|
|
238
|
+
)
|
|
239
|
+
has_errors = True
|
|
240
|
+
|
|
241
|
+
# Check .speculate/settings.yml
|
|
242
|
+
settings_file = cwd / ".speculate" / "settings.yml"
|
|
243
|
+
if settings_file.exists():
|
|
244
|
+
settings = _load_yaml(settings_file)
|
|
245
|
+
last_update = settings.get("last_update", "unknown")
|
|
246
|
+
cli_version = settings.get("last_cli_version", "unknown")
|
|
247
|
+
print_success(f"Last install: {last_update} (CLI {cli_version})")
|
|
248
|
+
else:
|
|
249
|
+
print_info(".speculate/settings.yml not found")
|
|
250
|
+
|
|
251
|
+
# Check docs/
|
|
252
|
+
docs_path = cwd / "docs"
|
|
253
|
+
if docs_path.exists():
|
|
254
|
+
file_count, total_size = _get_dir_stats(docs_path)
|
|
255
|
+
print_success(
|
|
256
|
+
f"docs/ exists ({fmt_count_items(file_count, 'file')}, {fmt_size_human(total_size)})"
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
print_missing("docs/ not found")
|
|
260
|
+
|
|
261
|
+
# Check development.md (required)
|
|
262
|
+
dev_md = cwd / "docs" / "development.md"
|
|
263
|
+
if dev_md.exists():
|
|
264
|
+
print_success("docs/development.md exists")
|
|
265
|
+
else:
|
|
266
|
+
print_error_item(
|
|
267
|
+
"docs/development.md missing (required!)",
|
|
268
|
+
"Create this file using docs/project/development.sample.md as a template.",
|
|
269
|
+
)
|
|
270
|
+
has_errors = True
|
|
271
|
+
|
|
272
|
+
# Check tool configs
|
|
273
|
+
for name, path in [
|
|
274
|
+
("CLAUDE.md", cwd / "CLAUDE.md"),
|
|
275
|
+
("AGENTS.md", cwd / "AGENTS.md"),
|
|
276
|
+
(".cursor/rules/", cwd / ".cursor" / "rules"),
|
|
277
|
+
]:
|
|
278
|
+
if path.exists():
|
|
279
|
+
print_success(f"{name} exists")
|
|
280
|
+
else:
|
|
281
|
+
print_info(f"{name} not configured")
|
|
282
|
+
|
|
283
|
+
rprint()
|
|
284
|
+
|
|
285
|
+
if has_errors:
|
|
286
|
+
raise SystemExit(1)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# Helper functions
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _update_speculate_settings(project_root: Path) -> None:
|
|
293
|
+
"""Create or update .speculate/settings.yml with install metadata."""
|
|
294
|
+
settings_dir = project_root / ".speculate"
|
|
295
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
settings_file = settings_dir / "settings.yml"
|
|
297
|
+
|
|
298
|
+
# Read existing settings
|
|
299
|
+
settings: dict[str, Any] = _load_yaml(settings_file) if settings_file.exists() else {}
|
|
300
|
+
|
|
301
|
+
# Update with current info
|
|
302
|
+
settings["last_update"] = datetime.now(UTC).isoformat()
|
|
303
|
+
try:
|
|
304
|
+
settings["last_cli_version"] = version("speculate")
|
|
305
|
+
except Exception:
|
|
306
|
+
settings["last_cli_version"] = "unknown"
|
|
307
|
+
|
|
308
|
+
# Get docs version from .copier-answers.yml if available
|
|
309
|
+
answers_file = project_root / ".copier-answers.yml"
|
|
310
|
+
if answers_file.exists():
|
|
311
|
+
answers = _load_yaml(answers_file)
|
|
312
|
+
settings["last_docs_version"] = answers.get("_commit", "unknown")
|
|
313
|
+
|
|
314
|
+
with atomic_output_file(settings_file) as temp_path:
|
|
315
|
+
Path(temp_path).write_text(yaml.dump(settings, default_flow_style=False))
|
|
316
|
+
print_success("Updated .speculate/settings.yml")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _get_dir_stats(path: Path) -> tuple[int, int]:
|
|
320
|
+
"""Return (file_count, total_bytes) for all files in a directory."""
|
|
321
|
+
file_count = 0
|
|
322
|
+
total_size = 0
|
|
323
|
+
for f in path.rglob("*"):
|
|
324
|
+
if f.is_file():
|
|
325
|
+
file_count += 1
|
|
326
|
+
total_size += f.stat().st_size
|
|
327
|
+
return file_count, total_size
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
SPECULATE_MARKER = "Speculate project structure"
|
|
331
|
+
SPECULATE_HEADER = f"""IMPORTANT: You MUST read ./docs/development.md and ./docs/docs-overview.md for project documentation.
|
|
332
|
+
(This project uses {SPECULATE_MARKER}.)"""
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _ensure_speculate_header(path: Path) -> None:
|
|
336
|
+
"""Ensure SPECULATE_HEADER is at the top of the file (idempotent).
|
|
337
|
+
|
|
338
|
+
If file exists and already has the marker, do nothing.
|
|
339
|
+
If file exists without marker, prepend the header.
|
|
340
|
+
If file doesn't exist, create with just the header.
|
|
341
|
+
"""
|
|
342
|
+
if path.exists():
|
|
343
|
+
content = path.read_text()
|
|
344
|
+
if SPECULATE_MARKER in content:
|
|
345
|
+
print_info(f"{path.name} already configured")
|
|
346
|
+
return
|
|
347
|
+
# Prepend header to existing content
|
|
348
|
+
new_content = SPECULATE_HEADER + "\n\n" + content
|
|
349
|
+
action = "Updated"
|
|
350
|
+
else:
|
|
351
|
+
new_content = SPECULATE_HEADER + "\n"
|
|
352
|
+
action = "Created"
|
|
353
|
+
|
|
354
|
+
with atomic_output_file(path) as temp_path:
|
|
355
|
+
Path(temp_path).write_text(new_content)
|
|
356
|
+
print_success(f"{action} {path.name}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _matches_patterns(
|
|
360
|
+
filename: str,
|
|
361
|
+
include: list[str] | None,
|
|
362
|
+
exclude: list[str] | None,
|
|
363
|
+
) -> bool:
|
|
364
|
+
"""Check if filename matches include patterns and doesn't match exclude patterns.
|
|
365
|
+
|
|
366
|
+
Supports wildcards:
|
|
367
|
+
- `*` matches any characters within a filename
|
|
368
|
+
- `**` is treated same as `*` for simple filename matching
|
|
369
|
+
|
|
370
|
+
Default behavior: include all if no include patterns specified.
|
|
371
|
+
"""
|
|
372
|
+
import fnmatch
|
|
373
|
+
|
|
374
|
+
# Normalize ** to * for fnmatch (which doesn't support **)
|
|
375
|
+
def normalize(pattern: str) -> str:
|
|
376
|
+
return pattern.replace("**", "*")
|
|
377
|
+
|
|
378
|
+
# If include patterns specified, file must match at least one
|
|
379
|
+
if include:
|
|
380
|
+
if not any(fnmatch.fnmatch(filename, normalize(p)) for p in include):
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
# If exclude patterns specified, file must not match any
|
|
384
|
+
if exclude:
|
|
385
|
+
if any(fnmatch.fnmatch(filename, normalize(p)) for p in exclude):
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _setup_cursor_rules(
|
|
392
|
+
project_root: Path,
|
|
393
|
+
include: list[str] | None = None,
|
|
394
|
+
exclude: list[str] | None = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Set up .cursor/rules/ with symlinks to docs/general/agent-rules/.
|
|
397
|
+
|
|
398
|
+
Note: Cursor requires .mdc extension, so we create symlinks with .mdc
|
|
399
|
+
extension pointing to the source .md files.
|
|
400
|
+
|
|
401
|
+
Supports include/exclude patterns for filtering which rules to link.
|
|
402
|
+
"""
|
|
403
|
+
cursor_dir = project_root / ".cursor" / "rules"
|
|
404
|
+
cursor_dir.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
|
|
406
|
+
rules_dir = project_root / "docs" / "general" / "agent-rules"
|
|
407
|
+
if not rules_dir.exists():
|
|
408
|
+
print_warning("docs/general/agent-rules/ not found, skipping Cursor setup")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
linked_count = 0
|
|
412
|
+
skipped_count = 0
|
|
413
|
+
for rule_file in sorted(rules_dir.glob("*.md")):
|
|
414
|
+
# Check include/exclude patterns
|
|
415
|
+
if not _matches_patterns(rule_file.name, include, exclude):
|
|
416
|
+
skipped_count += 1
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# Cursor requires .mdc extension
|
|
420
|
+
link_name = rule_file.stem + ".mdc"
|
|
421
|
+
link_path = cursor_dir / link_name
|
|
422
|
+
if link_path.exists() or link_path.is_symlink():
|
|
423
|
+
link_path.unlink()
|
|
424
|
+
|
|
425
|
+
# Create relative symlink
|
|
426
|
+
relative_target = Path("..") / ".." / "docs" / "general" / "agent-rules" / rule_file.name
|
|
427
|
+
link_path.symlink_to(relative_target)
|
|
428
|
+
linked_count += 1
|
|
429
|
+
|
|
430
|
+
msg = f"Linked {linked_count} rules to .cursor/rules/"
|
|
431
|
+
if skipped_count:
|
|
432
|
+
msg += f" ({skipped_count} skipped by pattern)"
|
|
433
|
+
print_success(msg)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `speculate` command installs and syncs structured agent documentation
|
|
3
|
+
into Git-based projects for use with Cursor, Claude Code, Codex, etc.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from textwrap import dedent
|
|
12
|
+
|
|
13
|
+
from clideps.utils.readable_argparse import ReadableColorFormatter, get_readable_console_width
|
|
14
|
+
from rich import get_console
|
|
15
|
+
from rich import print as rprint
|
|
16
|
+
|
|
17
|
+
from speculate.cli.cli_commands import init, install, status, update
|
|
18
|
+
from speculate.cli.cli_ui import print_cancelled, print_error
|
|
19
|
+
|
|
20
|
+
APP_NAME = "speculate"
|
|
21
|
+
DESCRIPTION = "speculate: Install and sync agent documentation"
|
|
22
|
+
|
|
23
|
+
ALL_COMMANDS = [init, update, install, status]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_version_name() -> str:
|
|
27
|
+
try:
|
|
28
|
+
return f"{APP_NAME} v{version(APP_NAME)}"
|
|
29
|
+
except Exception:
|
|
30
|
+
return f"{APP_NAME} (unknown version)"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_short_help(func: object) -> str:
|
|
34
|
+
"""Extract the first paragraph from a function's docstring."""
|
|
35
|
+
doc = getattr(func, "__doc__", None)
|
|
36
|
+
if not doc or not isinstance(doc, str):
|
|
37
|
+
return ""
|
|
38
|
+
doc = doc.strip()
|
|
39
|
+
paragraphs = [p.strip() for p in doc.split("\n\n") if p.strip()]
|
|
40
|
+
if paragraphs:
|
|
41
|
+
return " ".join(paragraphs[0].split())
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
formatter_class=ReadableColorFormatter,
|
|
48
|
+
epilog=dedent((__doc__ or "") + "\n\n" + get_version_name()),
|
|
49
|
+
description=DESCRIPTION,
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--version", action="store_true", help="show version and exit")
|
|
52
|
+
|
|
53
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=False)
|
|
54
|
+
|
|
55
|
+
for func in ALL_COMMANDS:
|
|
56
|
+
command_name = func.__name__.replace("_", "-")
|
|
57
|
+
subparser = subparsers.add_parser(
|
|
58
|
+
command_name,
|
|
59
|
+
help=get_short_help(func),
|
|
60
|
+
description=func.__doc__,
|
|
61
|
+
formatter_class=ReadableColorFormatter,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Command-specific arguments
|
|
65
|
+
if func is init:
|
|
66
|
+
subparser.add_argument(
|
|
67
|
+
"destination",
|
|
68
|
+
nargs="?",
|
|
69
|
+
default=".",
|
|
70
|
+
help="target directory (default: current directory)",
|
|
71
|
+
)
|
|
72
|
+
subparser.add_argument(
|
|
73
|
+
"--overwrite",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="skip confirmation prompts",
|
|
76
|
+
)
|
|
77
|
+
subparser.add_argument(
|
|
78
|
+
"--template",
|
|
79
|
+
default="gh:jlevy/speculate",
|
|
80
|
+
help="template source (default: gh:jlevy/speculate)",
|
|
81
|
+
)
|
|
82
|
+
subparser.add_argument(
|
|
83
|
+
"--ref",
|
|
84
|
+
default="HEAD",
|
|
85
|
+
help="git ref for speculate docs files (tag, branch, commit); default: HEAD",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if func is install:
|
|
89
|
+
subparser.add_argument(
|
|
90
|
+
"--include",
|
|
91
|
+
action="append",
|
|
92
|
+
help="include only rules matching pattern (supports * and **)",
|
|
93
|
+
)
|
|
94
|
+
subparser.add_argument(
|
|
95
|
+
"--exclude",
|
|
96
|
+
action="append",
|
|
97
|
+
help="exclude rules matching pattern (supports * and **)",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return parser
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> None:
|
|
104
|
+
get_console().width = get_readable_console_width()
|
|
105
|
+
parser = build_parser()
|
|
106
|
+
args = parser.parse_args()
|
|
107
|
+
|
|
108
|
+
if args.version:
|
|
109
|
+
rprint(get_version_name())
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if not args.subcommand:
|
|
113
|
+
parser.print_help()
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
subcommand = args.subcommand.replace("-", "_")
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if subcommand == "init":
|
|
120
|
+
init(
|
|
121
|
+
destination=args.destination,
|
|
122
|
+
overwrite=args.overwrite,
|
|
123
|
+
template=args.template,
|
|
124
|
+
ref=args.ref,
|
|
125
|
+
)
|
|
126
|
+
elif subcommand == "update":
|
|
127
|
+
update()
|
|
128
|
+
elif subcommand == "install":
|
|
129
|
+
install(include=args.include, exclude=args.exclude)
|
|
130
|
+
elif subcommand == "status":
|
|
131
|
+
status()
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unknown subcommand: {subcommand}")
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
except KeyboardInterrupt:
|
|
136
|
+
rprint()
|
|
137
|
+
print_cancelled()
|
|
138
|
+
sys.exit(130)
|
|
139
|
+
except SystemExit as e:
|
|
140
|
+
sys.exit(e.code)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
rprint()
|
|
143
|
+
print_error(str(e))
|
|
144
|
+
rprint()
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
speculate/cli/cli_ui.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI output helpers for consistent formatting.
|
|
3
|
+
|
|
4
|
+
All user-facing output should go through these functions to ensure
|
|
5
|
+
consistent styling across the CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rich import print as rprint
|
|
13
|
+
|
|
14
|
+
# Standard indentation for continuation lines
|
|
15
|
+
_INDENT = " "
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_header(title: str, path: Path | str | None = None) -> None:
|
|
19
|
+
"""Print a section header with optional path on a new indented line."""
|
|
20
|
+
rprint(f"\n[bold]{title}[/bold]")
|
|
21
|
+
if path is not None:
|
|
22
|
+
rprint(f"{_INDENT}{path}")
|
|
23
|
+
rprint()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_success(message: str) -> None:
|
|
27
|
+
"""Print a success message with green checkmark."""
|
|
28
|
+
rprint(f"[green]✔︎[/green] {message}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_error(message: str, detail: str | None = None) -> None:
|
|
32
|
+
"""Print an error message with red label and optional detail line."""
|
|
33
|
+
rprint(f"[red]Error:[/red] {message}")
|
|
34
|
+
if detail:
|
|
35
|
+
rprint(f"{_INDENT}{detail}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def print_warning(message: str, detail: str | None = None) -> None:
|
|
39
|
+
"""Print a warning message with yellow label and optional detail line."""
|
|
40
|
+
rprint(f"[yellow]Warning:[/yellow] {message}")
|
|
41
|
+
if detail:
|
|
42
|
+
rprint(f"{_INDENT}{detail}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def print_note(message: str, detail: str | None = None) -> None:
|
|
46
|
+
"""Print a note message with yellow label and optional detail line."""
|
|
47
|
+
rprint(f"[yellow]Note:[/yellow] {message}")
|
|
48
|
+
if detail:
|
|
49
|
+
rprint(f"{_INDENT}{detail}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_missing(message: str) -> None:
|
|
53
|
+
"""Print a missing/not-found item with yellow X."""
|
|
54
|
+
rprint(f"[yellow]✘[/yellow] {message}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_error_item(message: str, detail: str | None = None) -> None:
|
|
58
|
+
"""Print an error item with red X and optional detail line."""
|
|
59
|
+
rprint(f"[red]✘[/red] {message}")
|
|
60
|
+
if detail:
|
|
61
|
+
rprint(f"{_INDENT}{detail}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_info(message: str) -> None:
|
|
65
|
+
"""Print an info/neutral message with dim circle."""
|
|
66
|
+
rprint(f"[dim]○[/dim] {message}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_detail(message: str) -> None:
|
|
70
|
+
"""Print an indented detail/continuation line."""
|
|
71
|
+
rprint(f"{_INDENT}{message}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def print_cancelled() -> None:
|
|
75
|
+
"""Print cancellation message."""
|
|
76
|
+
rprint("[yellow]Cancelled[/yellow]")
|
speculate/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: speculate-cli
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Structured agent specs, shortcuts, and doc workflows
|
|
5
|
+
Project-URL: Repository, https://github.com/jlevy/speculate-cli
|
|
6
|
+
Author-email: Joshua Levy <joshua@cal.berkeley.edu>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: <4.0,>=3.11
|
|
19
|
+
Requires-Dist: clideps>=0.1.8
|
|
20
|
+
Requires-Dist: copier>=9.4.0
|
|
21
|
+
Requires-Dist: prettyfmt>=0.4.1
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: rich>=14.0.0
|
|
24
|
+
Requires-Dist: strif>=3.0.1
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# speculate-cli
|
|
28
|
+
|
|
29
|
+
This is the Python-based CLI for the Speculate project structure.
|
|
30
|
+
|
|
31
|
+
* * *
|
|
32
|
+
|
|
33
|
+
## Project Docs
|
|
34
|
+
|
|
35
|
+
For how to install uv and Python, see [installation.md](installation.md).
|
|
36
|
+
|
|
37
|
+
For development workflows, see [development.md](development.md).
|
|
38
|
+
|
|
39
|
+
For instructions on publishing to PyPI, see [publishing.md](publishing.md).
|
|
40
|
+
|
|
41
|
+
* * *
|
|
42
|
+
|
|
43
|
+
*This project was built from
|
|
44
|
+
[simple-modern-uv](https://github.com/jlevy/simple-modern-uv).*
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
speculate/__init__.py,sha256=AEYHIp-Is3cMs9OdI6lrfZNMMExks3LYB84cSAPui98,24
|
|
2
|
+
speculate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
speculate/cli/__init__.py,sha256=fZqkBrsQpAhnVQIaaZUoaz5iQJd6b_qhLZSOSRWvgho,28
|
|
4
|
+
speculate/cli/cli_commands.py,sha256=c17I3zOQeKr2Nvi_-KHOjW5vi1kpEzQCdTTBgPKgRiE,13922
|
|
5
|
+
speculate/cli/cli_main.py,sha256=At-4t9mU8LFXfjMTDDnF4lRpWsVRbg44CtQfiRLPKy8,4397
|
|
6
|
+
speculate/cli/cli_ui.py,sha256=rK8DhefsdpZrOB1YWBcPL0xcUs0EtskrCNTMDb6YWUI,2206
|
|
7
|
+
speculate_cli-0.0.2.dist-info/METADATA,sha256=ESxyLwvyDZ1afX59lZE8HXlYMUJBbV1rsiXIWfgWZ3M,1386
|
|
8
|
+
speculate_cli-0.0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
speculate_cli-0.0.2.dist-info/entry_points.txt,sha256=3CpJKf0Lix6H804COTyRIaVw0T_PMItnVh0gWYdENyg,58
|
|
10
|
+
speculate_cli-0.0.2.dist-info/RECORD,,
|