repr-cli 0.1.0__py3-none-any.whl → 0.2.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.
- repr/__init__.py +1 -1
- repr/__main__.py +6 -0
- repr/api.py +127 -1
- repr/auth.py +66 -2
- repr/cli.py +2143 -663
- repr/config.py +658 -32
- repr/discovery.py +5 -0
- repr/doctor.py +458 -0
- repr/hooks.py +634 -0
- repr/keychain.py +255 -0
- repr/llm.py +506 -0
- repr/openai_analysis.py +92 -21
- repr/privacy.py +333 -0
- repr/storage.py +527 -0
- repr/templates.py +229 -0
- repr/tools.py +202 -0
- repr/ui.py +79 -364
- repr_cli-0.2.2.dist-info/METADATA +263 -0
- repr_cli-0.2.2.dist-info/RECORD +24 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/licenses/LICENSE +1 -1
- repr/analyzer.py +0 -915
- repr/highlights.py +0 -712
- repr_cli-0.1.0.dist-info/METADATA +0 -326
- repr_cli-0.1.0.dist-info/RECORD +0 -18
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/WHEEL +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/entry_points.txt +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/top_level.txt +0 -0
repr/cli.py
CHANGED
|
@@ -1,52 +1,33 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
Commands:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
repr CLI - understand what you've actually worked on.
|
|
3
|
+
|
|
4
|
+
Commands follow the CLI_SPECIFICATION.md structure:
|
|
5
|
+
- init: First-time setup
|
|
6
|
+
- generate: Create stories from commits
|
|
7
|
+
- week/since/standup: Quick reflection summaries
|
|
8
|
+
- stories: Manage stories
|
|
9
|
+
- push/sync/pull: Cloud operations
|
|
10
|
+
- repos/hooks: Repository management
|
|
11
|
+
- login/logout/whoami: Authentication
|
|
12
|
+
- llm: LLM configuration
|
|
13
|
+
- privacy/config/data: Privacy and data management
|
|
14
|
+
- profile: Profile management
|
|
15
|
+
- doctor: Health check
|
|
11
16
|
"""
|
|
12
17
|
|
|
13
18
|
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
14
21
|
import sys
|
|
22
|
+
from datetime import datetime, timedelta
|
|
15
23
|
from pathlib import Path
|
|
16
24
|
from typing import Optional, List
|
|
17
25
|
|
|
18
26
|
import typer
|
|
19
|
-
from rich.
|
|
20
|
-
from rich.
|
|
21
|
-
from rich.prompt import Confirm
|
|
27
|
+
from rich.prompt import Confirm, Prompt
|
|
28
|
+
from rich.table import Table
|
|
22
29
|
|
|
23
30
|
from . import __version__
|
|
24
|
-
from .config import (
|
|
25
|
-
list_profiles,
|
|
26
|
-
get_latest_profile,
|
|
27
|
-
get_profile,
|
|
28
|
-
get_profile_metadata,
|
|
29
|
-
save_profile,
|
|
30
|
-
save_repo_profile,
|
|
31
|
-
get_sync_info,
|
|
32
|
-
update_sync_info,
|
|
33
|
-
clear_cache,
|
|
34
|
-
is_authenticated,
|
|
35
|
-
CONFIG_DIR,
|
|
36
|
-
PROFILES_DIR,
|
|
37
|
-
set_dev_mode,
|
|
38
|
-
is_dev_mode,
|
|
39
|
-
get_api_base,
|
|
40
|
-
get_litellm_config,
|
|
41
|
-
get_llm_config,
|
|
42
|
-
set_llm_config,
|
|
43
|
-
clear_llm_config,
|
|
44
|
-
)
|
|
45
|
-
from .discovery import discover_repos, is_config_only_repo, RepoInfo
|
|
46
|
-
from .extractor import get_primary_language, detect_languages
|
|
47
|
-
from .auth import AuthFlow, AuthError, logout as auth_logout, get_current_user
|
|
48
|
-
from .api import push_repo_profile, APIError
|
|
49
|
-
from .openai_analysis import analyze_repo_openai, DEFAULT_EXTRACTION_MODEL, DEFAULT_SYNTHESIS_MODEL
|
|
50
31
|
from .ui import (
|
|
51
32
|
console,
|
|
52
33
|
print_header,
|
|
@@ -55,32 +36,96 @@ from .ui import (
|
|
|
55
36
|
print_warning,
|
|
56
37
|
print_info,
|
|
57
38
|
print_next_steps,
|
|
58
|
-
print_panel,
|
|
59
39
|
print_markdown,
|
|
60
40
|
print_auth_code,
|
|
61
|
-
|
|
62
|
-
|
|
41
|
+
create_spinner,
|
|
42
|
+
create_table,
|
|
43
|
+
format_relative_time,
|
|
63
44
|
format_bytes,
|
|
64
|
-
|
|
45
|
+
confirm,
|
|
65
46
|
BRAND_PRIMARY,
|
|
66
47
|
BRAND_SUCCESS,
|
|
48
|
+
BRAND_WARNING,
|
|
49
|
+
BRAND_ERROR,
|
|
67
50
|
BRAND_MUTED,
|
|
68
51
|
)
|
|
69
|
-
from .
|
|
70
|
-
|
|
52
|
+
from .config import (
|
|
53
|
+
CONFIG_DIR,
|
|
54
|
+
CONFIG_FILE,
|
|
55
|
+
load_config,
|
|
56
|
+
save_config,
|
|
57
|
+
is_authenticated,
|
|
58
|
+
get_access_token,
|
|
59
|
+
get_llm_config,
|
|
60
|
+
get_litellm_config,
|
|
61
|
+
set_dev_mode,
|
|
62
|
+
is_dev_mode,
|
|
63
|
+
get_api_base,
|
|
64
|
+
get_tracked_repos,
|
|
65
|
+
add_tracked_repo,
|
|
66
|
+
remove_tracked_repo,
|
|
67
|
+
set_llm_config,
|
|
68
|
+
get_config_value,
|
|
69
|
+
set_config_value,
|
|
70
|
+
is_cloud_allowed,
|
|
71
|
+
lock_local_only,
|
|
72
|
+
unlock_local_only,
|
|
73
|
+
get_privacy_settings,
|
|
74
|
+
add_byok_provider,
|
|
75
|
+
remove_byok_provider,
|
|
76
|
+
get_byok_config,
|
|
77
|
+
list_byok_providers,
|
|
78
|
+
BYOK_PROVIDERS,
|
|
79
|
+
get_default_llm_mode,
|
|
80
|
+
set_repo_hook_status,
|
|
81
|
+
set_repo_paused,
|
|
82
|
+
get_profile_config,
|
|
83
|
+
set_profile_config,
|
|
84
|
+
)
|
|
85
|
+
from .storage import (
|
|
86
|
+
save_story,
|
|
87
|
+
load_story,
|
|
88
|
+
delete_story,
|
|
89
|
+
list_stories,
|
|
90
|
+
get_story_count,
|
|
91
|
+
get_unpushed_stories,
|
|
92
|
+
mark_story_pushed,
|
|
93
|
+
update_story_metadata,
|
|
94
|
+
STORIES_DIR,
|
|
95
|
+
backup_all_data,
|
|
96
|
+
restore_from_backup,
|
|
97
|
+
get_storage_stats,
|
|
98
|
+
)
|
|
99
|
+
from .auth import AuthFlow, AuthError, logout as auth_logout, get_current_user, migrate_plaintext_auth
|
|
100
|
+
from .api import APIError
|
|
71
101
|
|
|
72
102
|
# Create Typer app
|
|
73
103
|
app = typer.Typer(
|
|
74
104
|
name="repr",
|
|
75
|
-
help="
|
|
105
|
+
help="repr - understand what you've actually worked on",
|
|
76
106
|
add_completion=False,
|
|
77
107
|
no_args_is_help=True,
|
|
78
108
|
)
|
|
79
109
|
|
|
110
|
+
# Sub-apps for command groups
|
|
111
|
+
hooks_app = typer.Typer(help="Manage git post-commit hooks")
|
|
112
|
+
llm_app = typer.Typer(help="Configure LLM (local/cloud/BYOK)")
|
|
113
|
+
privacy_app = typer.Typer(help="Privacy audit and controls")
|
|
114
|
+
config_app = typer.Typer(help="View and modify configuration")
|
|
115
|
+
data_app = typer.Typer(help="Backup, restore, and manage data")
|
|
116
|
+
profile_app = typer.Typer(help="View and manage profile")
|
|
117
|
+
|
|
118
|
+
app.add_typer(hooks_app, name="hooks")
|
|
119
|
+
app.add_typer(llm_app, name="llm")
|
|
120
|
+
app.add_typer(privacy_app, name="privacy")
|
|
121
|
+
app.add_typer(config_app, name="config")
|
|
122
|
+
app.add_typer(data_app, name="data")
|
|
123
|
+
app.add_typer(profile_app, name="profile")
|
|
124
|
+
|
|
80
125
|
|
|
81
126
|
def version_callback(value: bool):
|
|
82
127
|
if value:
|
|
83
|
-
console.print(f"
|
|
128
|
+
console.print(f"repr v{__version__}")
|
|
84
129
|
raise typer.Exit()
|
|
85
130
|
|
|
86
131
|
|
|
@@ -92,765 +137,2200 @@ def dev_callback(value: bool):
|
|
|
92
137
|
@app.callback()
|
|
93
138
|
def main(
|
|
94
139
|
version: bool = typer.Option(
|
|
95
|
-
False,
|
|
96
|
-
"--version",
|
|
97
|
-
"-v",
|
|
140
|
+
False, "--version", "-v",
|
|
98
141
|
callback=version_callback,
|
|
99
142
|
is_eager=True,
|
|
100
143
|
help="Show version and exit.",
|
|
101
144
|
),
|
|
102
145
|
dev: bool = typer.Option(
|
|
103
|
-
False,
|
|
104
|
-
"--dev",
|
|
146
|
+
False, "--dev",
|
|
105
147
|
callback=dev_callback,
|
|
106
148
|
is_eager=True,
|
|
107
|
-
help="Use localhost backend
|
|
149
|
+
help="Use localhost backend.",
|
|
108
150
|
),
|
|
109
151
|
):
|
|
110
|
-
"""
|
|
111
|
-
|
|
152
|
+
"""repr - understand what you've actually worked on.
|
|
153
|
+
|
|
154
|
+
Cloud features require sign-in. Local generation always works offline.
|
|
155
|
+
"""
|
|
156
|
+
# Migrate plaintext auth tokens on startup
|
|
157
|
+
migrate_plaintext_auth()
|
|
112
158
|
|
|
113
159
|
|
|
160
|
+
# =============================================================================
|
|
161
|
+
# INIT
|
|
162
|
+
# =============================================================================
|
|
163
|
+
|
|
114
164
|
@app.command()
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
help="
|
|
165
|
+
def init(
|
|
166
|
+
path: Optional[Path] = typer.Argument(
|
|
167
|
+
None,
|
|
168
|
+
help="Directory to scan for repositories (default: ~/code)",
|
|
119
169
|
exists=True,
|
|
120
|
-
file_okay=False,
|
|
121
170
|
dir_okay=True,
|
|
122
171
|
resolve_path=True,
|
|
123
172
|
),
|
|
124
|
-
verbose: bool = typer.Option(
|
|
125
|
-
False,
|
|
126
|
-
"--verbose",
|
|
127
|
-
"-V",
|
|
128
|
-
help="Show detailed logs during analysis.",
|
|
129
|
-
),
|
|
130
|
-
no_cache: bool = typer.Option(
|
|
131
|
-
False,
|
|
132
|
-
"--no-cache",
|
|
133
|
-
help="Re-analyze all repositories (ignore cache).",
|
|
134
|
-
),
|
|
135
|
-
local: bool = typer.Option(
|
|
136
|
-
False,
|
|
137
|
-
"--local",
|
|
138
|
-
"-l",
|
|
139
|
-
help="Use local LLM (Ollama) instead of cloud.",
|
|
140
|
-
),
|
|
141
|
-
model: Optional[str] = typer.Option(
|
|
142
|
-
None,
|
|
143
|
-
"--model",
|
|
144
|
-
"-m",
|
|
145
|
-
help="Local model to use (default: llama3.2).",
|
|
146
|
-
),
|
|
147
|
-
api_base: Optional[str] = typer.Option(
|
|
148
|
-
None,
|
|
149
|
-
"--api-base",
|
|
150
|
-
help="Custom local LLM API endpoint.",
|
|
151
|
-
),
|
|
152
|
-
offline: bool = typer.Option(
|
|
153
|
-
False,
|
|
154
|
-
"--offline",
|
|
155
|
-
help="Stats only, no AI analysis (no push).",
|
|
156
|
-
),
|
|
157
|
-
min_commits: int = typer.Option(
|
|
158
|
-
10,
|
|
159
|
-
"--min-commits",
|
|
160
|
-
help="Minimum number of commits required.",
|
|
161
|
-
),
|
|
162
173
|
):
|
|
163
174
|
"""
|
|
164
|
-
|
|
175
|
+
Initialize repr - scan for repositories and set up local config.
|
|
165
176
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Examples:
|
|
170
|
-
repr analyze ~/code # OpenAI (default)
|
|
171
|
-
repr analyze ~/code --local --model llama3.2 # Local LLM (Ollama)
|
|
172
|
-
repr analyze ~/code --offline # Stats only, no push
|
|
177
|
+
Example:
|
|
178
|
+
repr init
|
|
179
|
+
repr init ~/projects
|
|
173
180
|
"""
|
|
174
181
|
print_header()
|
|
182
|
+
console.print("Works locally first — sign in later for sync and sharing.")
|
|
183
|
+
console.print()
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
local_base_url = None
|
|
181
|
-
local_extraction_model = None
|
|
182
|
-
local_synthesis_model = None
|
|
183
|
-
|
|
184
|
-
# Require authentication for non-offline modes (auto-push)
|
|
185
|
-
if mode != "offline":
|
|
186
|
-
if not is_authenticated():
|
|
187
|
-
print_error("Not logged in. Please run 'repr login' first.")
|
|
188
|
-
raise typer.Exit(1)
|
|
189
|
-
|
|
190
|
-
if mode == "openai":
|
|
191
|
-
_, litellm_key = get_litellm_config()
|
|
192
|
-
if not litellm_key:
|
|
193
|
-
print_error("Not authenticated for LLM access. Please run 'repr login' first.")
|
|
194
|
-
raise typer.Exit(1)
|
|
195
|
-
|
|
196
|
-
if mode == "local":
|
|
197
|
-
local_api_key = llm_config["local_api_key"] or "ollama"
|
|
198
|
-
local_base_url = api_base or llm_config["local_api_url"] or "http://localhost:11434/v1"
|
|
199
|
-
local_extraction_model = model or llm_config["extraction_model"] or "llama3.2"
|
|
200
|
-
local_synthesis_model = model or llm_config["synthesis_model"] or "llama3.2"
|
|
201
|
-
print_info(f"Using local LLM: {local_extraction_model} at {local_base_url}")
|
|
202
|
-
|
|
203
|
-
if no_cache:
|
|
204
|
-
clear_cache()
|
|
205
|
-
if verbose:
|
|
206
|
-
print_info("Cache cleared")
|
|
185
|
+
# Determine scan path
|
|
186
|
+
scan_path = path or Path.home() / "code"
|
|
187
|
+
if not scan_path.exists():
|
|
188
|
+
scan_path = Path.cwd()
|
|
207
189
|
|
|
208
|
-
console.print("
|
|
190
|
+
console.print(f"Scanning {scan_path} for repositories...")
|
|
209
191
|
console.print()
|
|
210
192
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
193
|
+
# Import discovery
|
|
194
|
+
from .discovery import discover_repos
|
|
195
|
+
|
|
196
|
+
with create_spinner() as progress:
|
|
197
|
+
task = progress.add_task("Scanning...", total=None)
|
|
198
|
+
repos = discover_repos([scan_path], min_commits=5)
|
|
214
199
|
|
|
215
200
|
if not repos:
|
|
216
|
-
print_warning("No repositories found in
|
|
217
|
-
print_info(
|
|
201
|
+
print_warning(f"No repositories found in {scan_path}")
|
|
202
|
+
print_info("Try running: repr init <path-to-your-code>")
|
|
218
203
|
raise typer.Exit(1)
|
|
219
204
|
|
|
220
|
-
|
|
221
|
-
console.print(f"Found [bold]{len(repos)}[/bold] repositories in {total_paths} path(s)")
|
|
205
|
+
console.print(f"Found [bold]{len(repos)}[/] repositories")
|
|
222
206
|
console.print()
|
|
223
207
|
|
|
224
|
-
for repo in repos:
|
|
225
|
-
|
|
226
|
-
repo.
|
|
227
|
-
repo.primary_language = max(languages, key=languages.get) if languages else None
|
|
208
|
+
for repo in repos[:10]: # Show first 10
|
|
209
|
+
lang = repo.primary_language or "Unknown"
|
|
210
|
+
console.print(f"✓ {repo.name} ({repo.commit_count} commits) [{lang}]")
|
|
228
211
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
for repo in repos:
|
|
233
|
-
if is_config_only_repo(repo.path):
|
|
234
|
-
skipped_repos.append(repo)
|
|
235
|
-
else:
|
|
236
|
-
filtered_repos.append(repo)
|
|
212
|
+
if len(repos) > 10:
|
|
213
|
+
console.print(f" ... and {len(repos) - 10} more")
|
|
237
214
|
|
|
238
|
-
|
|
239
|
-
console.print(f"Skipping {len(skipped_repos)} config-only repos: {', '.join(r.name for r in skipped_repos)}")
|
|
240
|
-
console.print()
|
|
215
|
+
console.print()
|
|
241
216
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
217
|
+
# Ask to track
|
|
218
|
+
if confirm("Track these repositories?", default=True):
|
|
219
|
+
for repo in repos:
|
|
220
|
+
add_tracked_repo(str(repo.path))
|
|
221
|
+
print_success(f"Tracking {len(repos)} repositories")
|
|
245
222
|
|
|
246
|
-
|
|
247
|
-
console.print()
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
console.print(f"
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
"remote_url": repo.remote_url,
|
|
255
|
-
"commit_count": repo.commit_count,
|
|
256
|
-
"user_commit_count": repo.user_commit_count,
|
|
257
|
-
"primary_language": repo.primary_language,
|
|
258
|
-
"languages": repo.languages,
|
|
259
|
-
"contributors": repo.contributors,
|
|
260
|
-
"first_commit_date": repo.first_commit_date.isoformat() if repo.first_commit_date else None,
|
|
261
|
-
"last_commit_date": repo.last_commit_date.isoformat() if repo.last_commit_date else None,
|
|
262
|
-
"is_fork": repo.is_fork,
|
|
263
|
-
"description": repo.description,
|
|
264
|
-
"frameworks": repo.frameworks,
|
|
265
|
-
"has_tests": repo.has_tests,
|
|
266
|
-
"has_ci": repo.has_ci,
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
# Generate profile for this single repo
|
|
270
|
-
if mode == "offline":
|
|
271
|
-
profile_content = _generate_single_repo_offline_profile(repo, verbose=verbose)
|
|
272
|
-
else:
|
|
273
|
-
profile_content = asyncio.run(_run_single_repo_analysis(
|
|
274
|
-
repo,
|
|
275
|
-
api_key=local_api_key,
|
|
276
|
-
base_url=local_base_url,
|
|
277
|
-
extraction_model=local_extraction_model or llm_config["extraction_model"],
|
|
278
|
-
synthesis_model=local_synthesis_model or llm_config["synthesis_model"],
|
|
279
|
-
verbose=verbose,
|
|
280
|
-
))
|
|
281
|
-
|
|
282
|
-
if not profile_content:
|
|
283
|
-
print_error(f"Failed to generate profile for {repo.name}. Halting.")
|
|
284
|
-
raise typer.Exit(1)
|
|
285
|
-
|
|
286
|
-
# Save locally as {repo}_{date}.md
|
|
287
|
-
profile_path = save_repo_profile(profile_content, repo.name, repo_metadata)
|
|
288
|
-
console.print(f" Saved: {profile_path.name}")
|
|
289
|
-
|
|
290
|
-
# Push to backend (skip for offline mode)
|
|
291
|
-
if mode != "offline":
|
|
292
|
-
try:
|
|
293
|
-
result = asyncio.run(push_repo_profile(profile_content, repo.name, repo_metadata))
|
|
294
|
-
console.print(f" [green]Pushed to repr.dev[/green]")
|
|
295
|
-
|
|
296
|
-
# Mark as synced
|
|
297
|
-
update_sync_info(profile_path.name)
|
|
298
|
-
except (APIError, AuthError) as e:
|
|
299
|
-
print_error(f"Failed to push {repo.name}: {e}. Halting.")
|
|
300
|
-
raise typer.Exit(1)
|
|
301
|
-
|
|
302
|
-
console.print()
|
|
223
|
+
# Check for local LLM
|
|
224
|
+
console.print()
|
|
225
|
+
from .llm import detect_local_llm
|
|
226
|
+
llm_info = detect_local_llm()
|
|
227
|
+
if llm_info:
|
|
228
|
+
console.print(f"Local LLM: detected {llm_info.name} at {llm_info.url}")
|
|
229
|
+
else:
|
|
230
|
+
console.print(f"[{BRAND_MUTED}]Local LLM: not detected (install Ollama for offline generation)[/]")
|
|
303
231
|
|
|
304
|
-
print_success(f"Completed: {len(filtered_repos)} repositories analyzed and pushed")
|
|
305
232
|
console.print()
|
|
306
|
-
print_next_steps([
|
|
233
|
+
print_next_steps([
|
|
234
|
+
"repr week See what you worked on this week",
|
|
235
|
+
"repr generate --local Save stories permanently",
|
|
236
|
+
"repr login Unlock cloud sync and publishing",
|
|
237
|
+
])
|
|
307
238
|
|
|
308
239
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if verbose:
|
|
314
|
-
console.print(f" Analyzing {repo.name}...", style=BRAND_MUTED)
|
|
315
|
-
|
|
316
|
-
try:
|
|
317
|
-
analysis = analyze_repo(repo.path)
|
|
318
|
-
highlights = get_highlights(repo.path)
|
|
319
|
-
except Exception as e:
|
|
320
|
-
if verbose:
|
|
321
|
-
console.print(f" Error: {e}", style="red")
|
|
322
|
-
return f"# {repo.name}\n\nFailed to analyze repository: {e}"
|
|
323
|
-
|
|
324
|
-
meta = []
|
|
325
|
-
if repo.primary_language:
|
|
326
|
-
meta.append(f"**{repo.primary_language}**")
|
|
327
|
-
meta.append(f"{repo.commit_count} commits")
|
|
328
|
-
meta.append(repo.age_display)
|
|
329
|
-
lines.append(" | ".join(meta))
|
|
330
|
-
lines.append("")
|
|
331
|
-
|
|
332
|
-
if highlights and highlights.get("domain"):
|
|
333
|
-
lines.append(f"*{highlights['domain'].replace('-', ' ').title()} Application*")
|
|
334
|
-
lines.append("")
|
|
335
|
-
|
|
336
|
-
frameworks = analysis.get("frameworks", [])
|
|
337
|
-
if frameworks:
|
|
338
|
-
lines.append(f"**Frameworks:** {', '.join(frameworks)}")
|
|
339
|
-
|
|
340
|
-
code_lines = analysis.get("summary", {}).get("code_lines", 0)
|
|
341
|
-
lines.append(f"**Codebase:** {code_lines:,} lines of code")
|
|
342
|
-
|
|
343
|
-
has_tests = analysis.get("testing", {}).get("has_tests", False)
|
|
344
|
-
lines.append(f"**Testing:** {'Has tests' if has_tests else 'No tests detected'}")
|
|
345
|
-
lines.append("")
|
|
346
|
-
|
|
347
|
-
if highlights and highlights.get("features"):
|
|
348
|
-
lines.append("## What was built")
|
|
349
|
-
lines.append("")
|
|
350
|
-
for f in highlights["features"][:6]:
|
|
351
|
-
lines.append(f"- {f['description']}")
|
|
352
|
-
lines.append("")
|
|
353
|
-
|
|
354
|
-
if highlights and highlights.get("integrations"):
|
|
355
|
-
integration_names = [i["name"] for i in highlights["integrations"][:8]]
|
|
356
|
-
lines.append(f"**Integrations:** {', '.join(integration_names)}")
|
|
357
|
-
lines.append("")
|
|
358
|
-
|
|
359
|
-
if highlights and highlights.get("achievements"):
|
|
360
|
-
lines.append("## Key contributions")
|
|
361
|
-
lines.append("")
|
|
362
|
-
for achievement in highlights["achievements"][:5]:
|
|
363
|
-
clean = achievement.strip()
|
|
364
|
-
if len(clean) > 100:
|
|
365
|
-
clean = clean[:97] + "..."
|
|
366
|
-
lines.append(f"- {clean}")
|
|
367
|
-
lines.append("")
|
|
368
|
-
|
|
369
|
-
lines.append("---")
|
|
370
|
-
lines.append("*Generated by Repr CLI (offline mode)*")
|
|
371
|
-
|
|
372
|
-
return "\n".join(lines)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
async def _run_single_repo_analysis(
|
|
376
|
-
repo: RepoInfo,
|
|
377
|
-
api_key: str = None,
|
|
378
|
-
base_url: str = None,
|
|
379
|
-
extraction_model: str = None,
|
|
380
|
-
synthesis_model: str = None,
|
|
381
|
-
verbose: bool = False,
|
|
382
|
-
) -> str | None:
|
|
383
|
-
"""Run analysis for a single repository using OpenAI-compatible API."""
|
|
384
|
-
display = AnalysisDisplay()
|
|
385
|
-
display.repos.append({
|
|
386
|
-
"name": repo.name,
|
|
387
|
-
"language": repo.primary_language or "-",
|
|
388
|
-
"commits": str(repo.commit_count),
|
|
389
|
-
"age": repo.age_display,
|
|
390
|
-
"status": "pending",
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
def progress_callback(step: str, detail: str, repo: str, progress: float):
|
|
394
|
-
display.update_progress(step=step, detail=detail, repo=repo, progress=progress)
|
|
395
|
-
for repo_data in display.repos:
|
|
396
|
-
if repo_data["name"] == repo:
|
|
397
|
-
if step == "Complete":
|
|
398
|
-
repo_data["status"] = "completed"
|
|
399
|
-
elif step in ("Extracting", "Preparing", "Analyzing", "Synthesizing"):
|
|
400
|
-
repo_data["status"] = "analyzing"
|
|
401
|
-
|
|
402
|
-
try:
|
|
403
|
-
display.start()
|
|
404
|
-
|
|
405
|
-
profile = await analyze_repo_openai(
|
|
406
|
-
repo,
|
|
407
|
-
api_key=api_key,
|
|
408
|
-
base_url=base_url,
|
|
409
|
-
extraction_model=extraction_model,
|
|
410
|
-
synthesis_model=synthesis_model,
|
|
411
|
-
verbose=verbose,
|
|
412
|
-
progress_callback=progress_callback,
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
display.stop()
|
|
416
|
-
return profile
|
|
417
|
-
|
|
418
|
-
except Exception as e:
|
|
419
|
-
display.stop()
|
|
420
|
-
print_error(f"Analysis failed: {e}")
|
|
421
|
-
if verbose:
|
|
422
|
-
import traceback
|
|
423
|
-
console.print(traceback.format_exc(), style="red dim")
|
|
424
|
-
return None
|
|
425
|
-
|
|
240
|
+
# =============================================================================
|
|
241
|
+
# GENERATE
|
|
242
|
+
# =============================================================================
|
|
426
243
|
|
|
427
244
|
@app.command()
|
|
428
|
-
def
|
|
429
|
-
|
|
430
|
-
False,
|
|
431
|
-
"
|
|
432
|
-
"-r",
|
|
433
|
-
help="Output plain markdown (for piping).",
|
|
245
|
+
def generate(
|
|
246
|
+
local: bool = typer.Option(
|
|
247
|
+
False, "--local", "-l",
|
|
248
|
+
help="Use local LLM (Ollama)",
|
|
434
249
|
),
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
"
|
|
438
|
-
|
|
439
|
-
|
|
250
|
+
cloud: bool = typer.Option(
|
|
251
|
+
False, "--cloud",
|
|
252
|
+
help="Use cloud LLM (requires login)",
|
|
253
|
+
),
|
|
254
|
+
repo: Optional[Path] = typer.Option(
|
|
255
|
+
None, "--repo",
|
|
256
|
+
help="Generate for specific repo",
|
|
257
|
+
exists=True,
|
|
258
|
+
dir_okay=True,
|
|
259
|
+
),
|
|
260
|
+
commits: Optional[str] = typer.Option(
|
|
261
|
+
None, "--commits",
|
|
262
|
+
help="Generate from specific commits (comma-separated)",
|
|
263
|
+
),
|
|
264
|
+
batch_size: int = typer.Option(
|
|
265
|
+
5, "--batch-size",
|
|
266
|
+
help="Commits per story",
|
|
267
|
+
),
|
|
268
|
+
dry_run: bool = typer.Option(
|
|
269
|
+
False, "--dry-run",
|
|
270
|
+
help="Preview what would be sent",
|
|
271
|
+
),
|
|
272
|
+
template: str = typer.Option(
|
|
273
|
+
"resume", "--template", "-t",
|
|
274
|
+
help="Template: resume, changelog, narrative, interview",
|
|
275
|
+
),
|
|
276
|
+
prompt: Optional[str] = typer.Option(
|
|
277
|
+
None, "--prompt", "-p",
|
|
278
|
+
help="Custom prompt to append",
|
|
440
279
|
),
|
|
441
280
|
):
|
|
442
281
|
"""
|
|
443
|
-
|
|
282
|
+
Generate stories from commits.
|
|
444
283
|
|
|
445
|
-
|
|
446
|
-
repr
|
|
447
|
-
repr
|
|
448
|
-
repr
|
|
284
|
+
Examples:
|
|
285
|
+
repr generate --local
|
|
286
|
+
repr generate --cloud
|
|
287
|
+
repr generate --template changelog
|
|
288
|
+
repr generate --commits abc123,def456
|
|
449
289
|
"""
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
290
|
+
from .privacy import check_cloud_permission, log_cloud_operation
|
|
291
|
+
|
|
292
|
+
# Determine mode
|
|
293
|
+
if cloud:
|
|
294
|
+
allowed, reason = check_cloud_permission("cloud_generation")
|
|
295
|
+
if not allowed:
|
|
296
|
+
print_error("Cloud generation blocked")
|
|
297
|
+
print_info(reason)
|
|
298
|
+
console.print()
|
|
299
|
+
print_info("Local generation is available:")
|
|
300
|
+
console.print(" repr generate --local")
|
|
461
301
|
raise typer.Exit(1)
|
|
462
302
|
|
|
463
|
-
|
|
303
|
+
if not local and not cloud:
|
|
304
|
+
# Default: local if not signed in, cloud if signed in
|
|
305
|
+
if is_authenticated() and is_cloud_allowed():
|
|
306
|
+
cloud = True
|
|
307
|
+
else:
|
|
308
|
+
local = True
|
|
464
309
|
|
|
465
|
-
|
|
466
|
-
# Raw output for piping
|
|
467
|
-
print(content)
|
|
468
|
-
else:
|
|
469
|
-
# Pretty display
|
|
470
|
-
sync_info = get_sync_info()
|
|
471
|
-
is_synced = sync_info.get("last_profile") == profile_path.name
|
|
472
|
-
|
|
473
|
-
status = f"[{BRAND_SUCCESS}]✓ Synced[/]" if is_synced else f"[{BRAND_MUTED}]○ Local only[/]"
|
|
474
|
-
|
|
475
|
-
print_panel(
|
|
476
|
-
f"Profile: {profile_path.name}",
|
|
477
|
-
f"{status}",
|
|
478
|
-
border_color=BRAND_PRIMARY,
|
|
479
|
-
)
|
|
480
|
-
console.print()
|
|
481
|
-
print_markdown(content)
|
|
482
|
-
console.print()
|
|
483
|
-
console.print(f"[{BRAND_MUTED}]Press q to quit, ↑↓ to scroll[/]")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
@app.command()
|
|
487
|
-
def login():
|
|
488
|
-
"""
|
|
489
|
-
Authenticate with Repr using device code flow.
|
|
310
|
+
print_header()
|
|
490
311
|
|
|
491
|
-
|
|
492
|
-
|
|
312
|
+
mode_str = "local LLM" if local else "cloud LLM"
|
|
313
|
+
console.print(f"Generating stories ({mode_str})...")
|
|
314
|
+
console.print()
|
|
493
315
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
316
|
+
# Get repos to analyze
|
|
317
|
+
if repo:
|
|
318
|
+
repo_paths = [repo]
|
|
319
|
+
else:
|
|
320
|
+
tracked = get_tracked_repos()
|
|
321
|
+
if not tracked:
|
|
322
|
+
print_warning("No repositories tracked.")
|
|
323
|
+
print_info("Run `repr init` or `repr repos add <path>` first.")
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
repo_paths = [Path(r["path"]) for r in tracked if Path(r["path"]).exists()]
|
|
498
326
|
|
|
499
|
-
if
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
print_info(f"Already authenticated as {email}")
|
|
503
|
-
|
|
504
|
-
if not Confirm.ask("Re-authenticate?"):
|
|
505
|
-
raise typer.Exit()
|
|
327
|
+
if not repo_paths:
|
|
328
|
+
print_error("No valid repositories found")
|
|
329
|
+
raise typer.Exit(1)
|
|
506
330
|
|
|
507
|
-
|
|
508
|
-
|
|
331
|
+
# Get commits
|
|
332
|
+
from .tools import get_commits_with_diffs, get_commits_by_shas
|
|
333
|
+
from .discovery import analyze_repo
|
|
509
334
|
|
|
510
|
-
|
|
511
|
-
|
|
335
|
+
total_stories = 0
|
|
336
|
+
|
|
337
|
+
for repo_path in repo_paths:
|
|
338
|
+
repo_info = analyze_repo(repo_path)
|
|
339
|
+
console.print(f"[bold]{repo_info.name}[/]")
|
|
512
340
|
|
|
513
|
-
|
|
514
|
-
|
|
341
|
+
if commits:
|
|
342
|
+
# Specific commits
|
|
343
|
+
commit_shas = [s.strip() for s in commits.split(",")]
|
|
344
|
+
commit_list = get_commits_by_shas(repo_path, commit_shas)
|
|
345
|
+
else:
|
|
346
|
+
# Recent commits
|
|
347
|
+
commit_list = get_commits_with_diffs(repo_path, count=50, days=90)
|
|
348
|
+
|
|
349
|
+
if not commit_list:
|
|
350
|
+
console.print(f" [{BRAND_MUTED}]No commits found[/]")
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Dry run: show what would be sent
|
|
354
|
+
if dry_run:
|
|
355
|
+
from .openai_analysis import estimate_tokens, get_batch_size
|
|
356
|
+
from .config import load_config
|
|
357
|
+
|
|
358
|
+
config = load_config()
|
|
359
|
+
max_commits = config.get("generation", {}).get("max_commits_per_batch", 50)
|
|
360
|
+
token_limit = config.get("generation", {}).get("token_limit", 100000)
|
|
361
|
+
|
|
362
|
+
console.print(f" [bold]Dry Run Preview[/]")
|
|
363
|
+
console.print(f" Commits to analyze: {len(commit_list)}")
|
|
364
|
+
console.print(f" Template: {template}")
|
|
515
365
|
console.print()
|
|
516
|
-
|
|
366
|
+
|
|
367
|
+
# Estimate tokens
|
|
368
|
+
estimated_tokens = estimate_tokens(commit_list)
|
|
369
|
+
console.print(f" Estimated tokens: ~{estimated_tokens:,}")
|
|
370
|
+
|
|
371
|
+
# Check if we need to split into batches
|
|
372
|
+
if len(commit_list) > max_commits:
|
|
373
|
+
num_batches = (len(commit_list) + max_commits - 1) // max_commits
|
|
374
|
+
console.print()
|
|
375
|
+
console.print(f" ⚠ {len(commit_list)} commits exceeds {max_commits}-commit limit")
|
|
376
|
+
console.print()
|
|
377
|
+
console.print(f" Will split into {num_batches} batches:")
|
|
378
|
+
for batch_num in range(num_batches):
|
|
379
|
+
start = batch_num * max_commits + 1
|
|
380
|
+
end = min((batch_num + 1) * max_commits, len(commit_list))
|
|
381
|
+
batch_commits = commit_list[batch_num * max_commits:end]
|
|
382
|
+
batch_tokens = estimate_tokens(batch_commits)
|
|
383
|
+
console.print(f" Batch {batch_num + 1}: commits {start}-{end} (est. {batch_tokens // 1000}k tokens)")
|
|
384
|
+
|
|
517
385
|
console.print()
|
|
518
|
-
console.print("
|
|
519
|
-
|
|
386
|
+
console.print(" Sample commits:")
|
|
387
|
+
for c in commit_list[:5]:
|
|
388
|
+
console.print(f" • {c['sha'][:7]} {c['message'][:50]}")
|
|
389
|
+
if len(commit_list) > 5:
|
|
390
|
+
console.print(f" ... and {len(commit_list) - 5} more")
|
|
391
|
+
continue
|
|
520
392
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
393
|
+
# Check token limits and prompt user if needed
|
|
394
|
+
from .openai_analysis import estimate_tokens
|
|
395
|
+
from .config import load_config
|
|
396
|
+
|
|
397
|
+
config = load_config()
|
|
398
|
+
max_commits = config.get("generation", {}).get("max_commits_per_batch", 50)
|
|
399
|
+
token_limit = config.get("generation", {}).get("token_limit", 100000)
|
|
400
|
+
|
|
401
|
+
# Check if we exceed limits (only warn for cloud generation)
|
|
402
|
+
if cloud and len(commit_list) > max_commits:
|
|
403
|
+
num_batches = (len(commit_list) + max_commits - 1) // max_commits
|
|
531
404
|
console.print()
|
|
405
|
+
console.print(f" ⚠ {len(commit_list)} commits exceeds {max_commits}-commit limit")
|
|
532
406
|
console.print()
|
|
533
|
-
|
|
407
|
+
console.print(f" Will split into {num_batches} batches:")
|
|
408
|
+
for batch_num in range(num_batches):
|
|
409
|
+
start = batch_num * max_commits + 1
|
|
410
|
+
end = min((batch_num + 1) * max_commits, len(commit_list))
|
|
411
|
+
batch_commits = commit_list[batch_num * max_commits:end]
|
|
412
|
+
batch_tokens = estimate_tokens(batch_commits)
|
|
413
|
+
console.print(f" Batch {batch_num + 1}: commits {start}-{end} (est. {batch_tokens // 1000}k tokens)")
|
|
534
414
|
console.print()
|
|
535
|
-
|
|
415
|
+
|
|
416
|
+
if not confirm("Continue with generation?"):
|
|
417
|
+
console.print(f" [{BRAND_MUTED}]Skipped {repo_info.name}[/]")
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
# Generate stories
|
|
421
|
+
stories = _generate_stories(
|
|
422
|
+
commits=commit_list,
|
|
423
|
+
repo_info=repo_info,
|
|
424
|
+
batch_size=batch_size,
|
|
425
|
+
local=local,
|
|
426
|
+
template=template,
|
|
427
|
+
custom_prompt=prompt,
|
|
428
|
+
)
|
|
536
429
|
|
|
537
|
-
|
|
538
|
-
console.print()
|
|
539
|
-
|
|
430
|
+
for story in stories:
|
|
431
|
+
console.print(f" • {story['summary']}")
|
|
432
|
+
total_stories += 1
|
|
540
433
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
434
|
+
# Log cloud operation if using cloud
|
|
435
|
+
if cloud and stories:
|
|
436
|
+
log_cloud_operation(
|
|
437
|
+
operation="cloud_generation",
|
|
438
|
+
destination="repr.dev",
|
|
439
|
+
payload_summary={
|
|
440
|
+
"repo": repo_info.name,
|
|
441
|
+
"commits": len(commit_list),
|
|
442
|
+
"stories_generated": len(stories),
|
|
443
|
+
},
|
|
444
|
+
bytes_sent=len(str(commit_list)) // 2, # Rough estimate
|
|
445
|
+
)
|
|
545
446
|
|
|
447
|
+
console.print()
|
|
448
|
+
|
|
449
|
+
if dry_run:
|
|
450
|
+
console.print()
|
|
451
|
+
console.print("Continue with generation? (run without --dry-run)")
|
|
452
|
+
else:
|
|
453
|
+
print_success(f"Generated {total_stories} stories")
|
|
454
|
+
console.print()
|
|
455
|
+
console.print(f"Stories saved to: {STORIES_DIR}")
|
|
456
|
+
print_next_steps([
|
|
457
|
+
"repr stories View your stories",
|
|
458
|
+
"repr push Publish to repr.dev (requires login)",
|
|
459
|
+
])
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _generate_stories(
|
|
463
|
+
commits: list[dict],
|
|
464
|
+
repo_info,
|
|
465
|
+
batch_size: int,
|
|
466
|
+
local: bool,
|
|
467
|
+
template: str = "resume",
|
|
468
|
+
custom_prompt: str | None = None,
|
|
469
|
+
) -> list[dict]:
|
|
470
|
+
"""Generate stories from commits using LLM."""
|
|
471
|
+
from .openai_analysis import get_openai_client, extract_commit_batch
|
|
472
|
+
from .templates import build_generation_prompt
|
|
473
|
+
|
|
474
|
+
stories = []
|
|
475
|
+
|
|
476
|
+
# Split into batches
|
|
477
|
+
batches = [
|
|
478
|
+
commits[i:i + batch_size]
|
|
479
|
+
for i in range(0, len(commits), batch_size)
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
for i, batch in enumerate(batches):
|
|
546
483
|
try:
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
484
|
+
# Build prompt with template
|
|
485
|
+
system_prompt, user_prompt = build_generation_prompt(
|
|
486
|
+
template_name=template,
|
|
487
|
+
repo_name=repo_info.name,
|
|
488
|
+
commits=batch,
|
|
489
|
+
custom_prompt=custom_prompt,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Get LLM client
|
|
493
|
+
if local:
|
|
494
|
+
llm_config = get_llm_config()
|
|
495
|
+
client = get_openai_client(
|
|
496
|
+
api_key=llm_config.get("local_api_key") or "ollama",
|
|
497
|
+
base_url=llm_config.get("local_api_url") or "http://localhost:11434/v1",
|
|
498
|
+
)
|
|
499
|
+
model = llm_config.get("local_model") or llm_config.get("extraction_model") or "llama3.2"
|
|
500
|
+
else:
|
|
501
|
+
client = get_openai_client()
|
|
502
|
+
model = None # Use default
|
|
503
|
+
|
|
504
|
+
# Extract story from batch
|
|
505
|
+
content = asyncio.run(extract_commit_batch(
|
|
506
|
+
client=client,
|
|
507
|
+
commits=batch,
|
|
508
|
+
batch_num=i + 1,
|
|
509
|
+
total_batches=len(batches),
|
|
510
|
+
model=model,
|
|
511
|
+
system_prompt=system_prompt,
|
|
512
|
+
user_prompt=user_prompt,
|
|
513
|
+
))
|
|
514
|
+
|
|
515
|
+
if not content or content.startswith("[Batch"):
|
|
516
|
+
continue
|
|
517
|
+
|
|
518
|
+
# Extract summary (first non-empty line)
|
|
519
|
+
lines = [l.strip() for l in content.split("\n") if l.strip()]
|
|
520
|
+
summary = lines[0][:100] if lines else "Story"
|
|
521
|
+
# Clean up summary
|
|
522
|
+
summary = summary.lstrip("#-•* ").strip()
|
|
523
|
+
|
|
524
|
+
# Build metadata
|
|
525
|
+
commit_shas = [c["full_sha"] for c in batch]
|
|
526
|
+
first_date = min(c["date"] for c in batch)
|
|
527
|
+
last_date = max(c["date"] for c in batch)
|
|
528
|
+
total_files = sum(len(c.get("files", [])) for c in batch)
|
|
529
|
+
total_adds = sum(c.get("insertions", 0) for c in batch)
|
|
530
|
+
total_dels = sum(c.get("deletions", 0) for c in batch)
|
|
531
|
+
|
|
532
|
+
metadata = {
|
|
533
|
+
"summary": summary,
|
|
534
|
+
"repo_name": repo_info.name,
|
|
535
|
+
"repo_path": str(repo_info.path),
|
|
536
|
+
"commit_shas": commit_shas,
|
|
537
|
+
"first_commit_at": first_date,
|
|
538
|
+
"last_commit_at": last_date,
|
|
539
|
+
"files_changed": total_files,
|
|
540
|
+
"lines_added": total_adds,
|
|
541
|
+
"lines_removed": total_dels,
|
|
542
|
+
"generated_locally": local,
|
|
543
|
+
"template": template,
|
|
544
|
+
"needs_review": False,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
# Save story
|
|
548
|
+
story_id = save_story(content, metadata)
|
|
549
|
+
metadata["id"] = story_id
|
|
550
|
+
stories.append(metadata)
|
|
551
|
+
|
|
552
|
+
except Exception as e:
|
|
553
|
+
console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
|
|
550
554
|
|
|
551
|
-
|
|
555
|
+
return stories
|
|
556
|
+
|
|
552
557
|
|
|
558
|
+
# =============================================================================
|
|
559
|
+
# QUICK REFLECTION COMMANDS
|
|
560
|
+
# =============================================================================
|
|
553
561
|
|
|
554
562
|
@app.command()
|
|
555
|
-
def
|
|
563
|
+
def week(
|
|
564
|
+
save: bool = typer.Option(
|
|
565
|
+
False, "--save",
|
|
566
|
+
help="Save as a permanent story",
|
|
567
|
+
),
|
|
568
|
+
):
|
|
556
569
|
"""
|
|
557
|
-
|
|
570
|
+
Show what you worked on this week.
|
|
558
571
|
|
|
559
572
|
Example:
|
|
560
|
-
repr
|
|
573
|
+
repr week
|
|
574
|
+
repr week --save
|
|
561
575
|
"""
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
576
|
+
_show_summary_since("this week", days=7, save=save)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.command()
|
|
580
|
+
def since(
|
|
581
|
+
date: str = typer.Argument(..., help="Date or time reference (e.g., 'monday', '2026-01-01', '3 days ago')"),
|
|
582
|
+
save: bool = typer.Option(
|
|
583
|
+
False, "--save",
|
|
584
|
+
help="Save as a permanent story",
|
|
585
|
+
),
|
|
586
|
+
):
|
|
587
|
+
"""
|
|
588
|
+
Show work since a specific date or time.
|
|
567
589
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
590
|
+
Examples:
|
|
591
|
+
repr since monday
|
|
592
|
+
repr since "3 days ago"
|
|
593
|
+
repr since 2026-01-01
|
|
594
|
+
"""
|
|
595
|
+
_show_summary_since(date, save=save)
|
|
572
596
|
|
|
573
597
|
|
|
574
598
|
@app.command()
|
|
575
|
-
def
|
|
599
|
+
def standup(
|
|
600
|
+
days: int = typer.Option(
|
|
601
|
+
3, "--days",
|
|
602
|
+
help="Number of days to look back",
|
|
603
|
+
),
|
|
604
|
+
):
|
|
576
605
|
"""
|
|
577
|
-
|
|
606
|
+
Quick summary for daily standup (last 3 days).
|
|
578
607
|
|
|
579
608
|
Example:
|
|
580
|
-
repr
|
|
609
|
+
repr standup
|
|
610
|
+
repr standup --days 5
|
|
581
611
|
"""
|
|
582
|
-
|
|
612
|
+
_show_summary_since(f"last {days} days", days=days, save=False)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _show_summary_since(label: str, days: int = None, save: bool = False):
|
|
616
|
+
"""Show summary of work since a date."""
|
|
617
|
+
from .tools import get_commits_with_diffs
|
|
618
|
+
from .discovery import analyze_repo
|
|
583
619
|
|
|
584
|
-
|
|
585
|
-
print_info("No profiles found.")
|
|
586
|
-
print_info("Run 'repr analyze <paths>' to generate a profile.")
|
|
587
|
-
raise typer.Exit()
|
|
620
|
+
print_header()
|
|
588
621
|
|
|
589
|
-
|
|
622
|
+
tracked = get_tracked_repos()
|
|
623
|
+
if not tracked:
|
|
624
|
+
print_warning("No repositories tracked.")
|
|
625
|
+
print_info("Run `repr init` first.")
|
|
626
|
+
raise typer.Exit(1)
|
|
627
|
+
|
|
628
|
+
console.print(f"📊 Work since {label}")
|
|
590
629
|
console.print()
|
|
591
630
|
|
|
592
|
-
|
|
631
|
+
total_commits = 0
|
|
632
|
+
all_summaries = []
|
|
593
633
|
|
|
594
|
-
for
|
|
595
|
-
|
|
634
|
+
for repo_info in tracked:
|
|
635
|
+
repo_path = Path(repo_info["path"])
|
|
636
|
+
if not repo_path.exists():
|
|
637
|
+
continue
|
|
596
638
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
)
|
|
639
|
+
commits = get_commits_with_diffs(repo_path, count=100, days=days or 30)
|
|
640
|
+
if not commits:
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
repo_name = repo_path.name
|
|
644
|
+
console.print(f"[bold]{repo_name}[/] ({len(commits)} commits):")
|
|
645
|
+
|
|
646
|
+
# Group commits by rough topic (simple heuristic)
|
|
647
|
+
for c in commits[:5]:
|
|
648
|
+
msg = c["message"].split("\n")[0][:60]
|
|
649
|
+
console.print(f" • {msg}")
|
|
650
|
+
|
|
651
|
+
if len(commits) > 5:
|
|
652
|
+
console.print(f" [{BRAND_MUTED}]... and {len(commits) - 5} more[/]")
|
|
653
|
+
|
|
654
|
+
total_commits += len(commits)
|
|
655
|
+
console.print()
|
|
603
656
|
|
|
604
|
-
console.print(
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
console.print(f" repr view --profile {saved_profiles[0]['name']}")
|
|
610
|
-
if not saved_profiles[0]["synced"]:
|
|
611
|
-
console.print(f" repr push --profile {saved_profiles[0]['name']}")
|
|
657
|
+
console.print(f"Total: {total_commits} commits")
|
|
658
|
+
|
|
659
|
+
if not save:
|
|
660
|
+
console.print()
|
|
661
|
+
console.print(f"[{BRAND_MUTED}]This summary wasn't saved. Run with --save to create a story.[/]")
|
|
612
662
|
|
|
613
663
|
|
|
664
|
+
# =============================================================================
|
|
665
|
+
# STORIES MANAGEMENT
|
|
666
|
+
# =============================================================================
|
|
667
|
+
|
|
614
668
|
@app.command()
|
|
615
|
-
def
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
"-p",
|
|
620
|
-
help="Push a specific profile by name (default: all unsynced profiles).",
|
|
621
|
-
),
|
|
669
|
+
def stories(
|
|
670
|
+
repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repository"),
|
|
671
|
+
needs_review: bool = typer.Option(False, "--needs-review", help="Show only stories needing review"),
|
|
672
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
622
673
|
):
|
|
623
674
|
"""
|
|
624
|
-
|
|
675
|
+
List all stories.
|
|
625
676
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
repr
|
|
630
|
-
repr push --profile myrepo_2025-12-26 # Push specific profile
|
|
677
|
+
Example:
|
|
678
|
+
repr stories
|
|
679
|
+
repr stories --repo myproject
|
|
680
|
+
repr stories --needs-review
|
|
631
681
|
"""
|
|
632
|
-
|
|
633
|
-
print_error("Not logged in. Please run 'repr login' first.")
|
|
634
|
-
raise typer.Exit(1)
|
|
635
|
-
|
|
636
|
-
print_header()
|
|
682
|
+
story_list = list_stories(repo_name=repo, needs_review=needs_review)
|
|
637
683
|
|
|
638
|
-
|
|
639
|
-
|
|
684
|
+
if json_output:
|
|
685
|
+
print(json.dumps(story_list, indent=2, default=str))
|
|
686
|
+
return
|
|
640
687
|
|
|
641
|
-
if not
|
|
642
|
-
print_info("No
|
|
643
|
-
print_info("Run
|
|
688
|
+
if not story_list:
|
|
689
|
+
print_info("No stories found.")
|
|
690
|
+
print_info("Run `repr generate` to create stories from your commits.")
|
|
644
691
|
raise typer.Exit()
|
|
645
692
|
|
|
646
|
-
|
|
647
|
-
# Push specific profile
|
|
648
|
-
profiles_to_push = [p for p in all_profiles if p["name"] == profile_name]
|
|
649
|
-
if not profiles_to_push:
|
|
650
|
-
print_error(f"Profile not found: {profile_name}")
|
|
651
|
-
raise typer.Exit(1)
|
|
652
|
-
else:
|
|
653
|
-
# Push all unsynced profiles
|
|
654
|
-
profiles_to_push = [p for p in all_profiles if not p["synced"]]
|
|
655
|
-
|
|
656
|
-
if not profiles_to_push:
|
|
657
|
-
print_success("All profiles are already synced!")
|
|
658
|
-
raise typer.Exit()
|
|
659
|
-
|
|
660
|
-
console.print(f"Pushing {len(profiles_to_push)} profile(s) to repr.dev...")
|
|
693
|
+
console.print(f"[bold]Stories[/] ({len(story_list)} total)")
|
|
661
694
|
console.print()
|
|
662
695
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
696
|
+
for story in story_list[:20]:
|
|
697
|
+
# Status indicator
|
|
698
|
+
if story.get("needs_review"):
|
|
699
|
+
status = f"[{BRAND_WARNING}]⚠[/]"
|
|
700
|
+
elif story.get("pushed_at"):
|
|
701
|
+
status = f"[{BRAND_SUCCESS}]✓[/]"
|
|
702
|
+
else:
|
|
703
|
+
status = f"[{BRAND_MUTED}]○[/]"
|
|
669
704
|
|
|
670
|
-
|
|
671
|
-
|
|
705
|
+
summary = story.get("summary", "Untitled")[:60]
|
|
706
|
+
repo_name = story.get("repo_name", "unknown")
|
|
707
|
+
created = format_relative_time(story.get("created_at", ""))
|
|
672
708
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
repo_metadata = metadata["repo"]
|
|
676
|
-
repo_name = repo_metadata.get("repo_name") or profile["name"].rsplit("_", 1)[0]
|
|
677
|
-
|
|
678
|
-
console.print(f" Pushing {repo_name}...", end=" ")
|
|
679
|
-
|
|
680
|
-
try:
|
|
681
|
-
asyncio.run(push_repo_profile(content, repo_name, repo_metadata))
|
|
682
|
-
console.print("[green]✓[/]")
|
|
683
|
-
|
|
684
|
-
# Mark as synced
|
|
685
|
-
update_sync_info(profile["name"])
|
|
686
|
-
pushed_count += 1
|
|
687
|
-
|
|
688
|
-
except (APIError, AuthError) as e:
|
|
689
|
-
console.print(f"[red]✗ {e}[/]")
|
|
690
|
-
failed_count += 1
|
|
691
|
-
else:
|
|
692
|
-
# Legacy profile without metadata - skip with warning
|
|
693
|
-
console.print(f" [yellow]⚠[/] Skipping {profile['name']} (no metadata)")
|
|
694
|
-
console.print(f" Re-analyze to create proper repo profiles")
|
|
695
|
-
failed_count += 1
|
|
696
|
-
|
|
697
|
-
console.print()
|
|
698
|
-
|
|
699
|
-
if pushed_count > 0:
|
|
700
|
-
print_success(f"Pushed {pushed_count} profile(s)")
|
|
701
|
-
|
|
702
|
-
if failed_count > 0:
|
|
703
|
-
print_warning(f"Failed to push {failed_count} profile(s)")
|
|
704
|
-
|
|
705
|
-
if pushed_count > 0 and failed_count == 0:
|
|
709
|
+
console.print(f"{status} {summary}")
|
|
710
|
+
console.print(f" [{BRAND_MUTED}]{repo_name} • {created}[/]")
|
|
706
711
|
console.print()
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
])
|
|
712
|
+
|
|
713
|
+
if len(story_list) > 20:
|
|
714
|
+
console.print(f"[{BRAND_MUTED}]... and {len(story_list) - 20} more[/]")
|
|
711
715
|
|
|
712
716
|
|
|
713
|
-
@app.command(
|
|
714
|
-
def
|
|
717
|
+
@app.command()
|
|
718
|
+
def story(
|
|
719
|
+
action: str = typer.Argument(..., help="Action: view, edit, delete, hide, feature, regenerate"),
|
|
720
|
+
story_id: str = typer.Argument(..., help="Story ID (ULID)"),
|
|
721
|
+
):
|
|
715
722
|
"""
|
|
716
|
-
|
|
723
|
+
Manage a single story.
|
|
717
724
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
repr config
|
|
722
|
-
repr --dev config
|
|
725
|
+
Examples:
|
|
726
|
+
repr story view 01ARYZ6S41TSV4RRFFQ69G5FAV
|
|
727
|
+
repr story delete 01ARYZ6S41TSV4RRFFQ69G5FAV
|
|
723
728
|
"""
|
|
729
|
+
result = load_story(story_id)
|
|
724
730
|
|
|
725
|
-
|
|
726
|
-
|
|
731
|
+
if not result:
|
|
732
|
+
print_error(f"Story not found: {story_id}")
|
|
733
|
+
raise typer.Exit(1)
|
|
727
734
|
|
|
728
|
-
|
|
729
|
-
console.print(f" Mode: {mode}")
|
|
730
|
-
console.print(f" API Base: {get_api_base()}")
|
|
731
|
-
console.print()
|
|
735
|
+
content, metadata = result
|
|
732
736
|
|
|
733
|
-
if
|
|
734
|
-
|
|
737
|
+
if action == "view":
|
|
738
|
+
print_markdown(content)
|
|
739
|
+
console.print()
|
|
740
|
+
console.print(f"[{BRAND_MUTED}]ID: {story_id}[/]")
|
|
741
|
+
console.print(f"[{BRAND_MUTED}]Created: {metadata.get('created_at', 'unknown')}[/]")
|
|
742
|
+
|
|
743
|
+
elif action == "edit":
|
|
744
|
+
# Open in $EDITOR
|
|
745
|
+
import subprocess
|
|
746
|
+
|
|
747
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
748
|
+
md_path = STORIES_DIR / f"{story_id}.md"
|
|
749
|
+
|
|
750
|
+
subprocess.run([editor, str(md_path)])
|
|
751
|
+
print_success("Story updated")
|
|
752
|
+
|
|
753
|
+
elif action == "delete":
|
|
754
|
+
if confirm(f"Delete story '{metadata.get('summary', story_id)}'?"):
|
|
755
|
+
delete_story(story_id)
|
|
756
|
+
print_success("Story deleted")
|
|
757
|
+
else:
|
|
758
|
+
print_info("Cancelled")
|
|
759
|
+
|
|
760
|
+
elif action == "hide":
|
|
761
|
+
update_story_metadata(story_id, {"is_hidden": True})
|
|
762
|
+
print_success("Story hidden from profile")
|
|
763
|
+
|
|
764
|
+
elif action == "feature":
|
|
765
|
+
update_story_metadata(story_id, {"is_featured": True})
|
|
766
|
+
print_success("Story featured on profile")
|
|
767
|
+
|
|
768
|
+
elif action == "regenerate":
|
|
769
|
+
print_info("Regeneration not yet implemented")
|
|
770
|
+
print_info("Use `repr generate --commits <sha>` with specific commits")
|
|
771
|
+
|
|
772
|
+
else:
|
|
773
|
+
print_error(f"Unknown action: {action}")
|
|
774
|
+
print_info("Valid actions: view, edit, delete, hide, feature, regenerate")
|
|
775
|
+
raise typer.Exit(1)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@app.command("review")
|
|
779
|
+
def stories_review():
|
|
780
|
+
"""
|
|
781
|
+
Interactive review workflow for stories needing review.
|
|
782
|
+
|
|
783
|
+
Example:
|
|
784
|
+
repr review
|
|
785
|
+
"""
|
|
786
|
+
from .storage import get_stories_needing_review
|
|
787
|
+
|
|
788
|
+
needs_review = get_stories_needing_review()
|
|
789
|
+
|
|
790
|
+
if not needs_review:
|
|
791
|
+
print_success("No stories need review!")
|
|
792
|
+
raise typer.Exit()
|
|
793
|
+
|
|
794
|
+
console.print(f"[bold]{len(needs_review)} stories need review[/]")
|
|
795
|
+
console.print()
|
|
796
|
+
|
|
797
|
+
for i, story in enumerate(needs_review):
|
|
798
|
+
console.print(f"[bold]Story {i+1} of {len(needs_review)}[/]")
|
|
799
|
+
console.print()
|
|
800
|
+
|
|
801
|
+
# Show story summary
|
|
802
|
+
console.print(f"⚠ \"{story.get('summary', 'Untitled')}\"")
|
|
803
|
+
console.print(f" {story.get('repo_name', 'unknown')} • {len(story.get('commit_shas', []))} commits")
|
|
804
|
+
console.print()
|
|
805
|
+
|
|
806
|
+
# Load and show content preview
|
|
807
|
+
result = load_story(story["id"])
|
|
808
|
+
if result:
|
|
809
|
+
content, metadata = result
|
|
810
|
+
|
|
811
|
+
# Check if this is a STAR format story (interview template)
|
|
812
|
+
template = metadata.get('template', '')
|
|
813
|
+
if template == 'interview':
|
|
814
|
+
# Display STAR format
|
|
815
|
+
console.print("[bold]What was built:[/]")
|
|
816
|
+
# Extract "what was built" section from content
|
|
817
|
+
lines = content.split('\n')
|
|
818
|
+
for line in lines[:10]: # Show first 10 lines as preview
|
|
819
|
+
console.print(f" {line}")
|
|
820
|
+
|
|
821
|
+
console.print()
|
|
822
|
+
console.print("[bold]Technologies:[/]")
|
|
823
|
+
# Try to extract technologies from metadata or content
|
|
824
|
+
techs = metadata.get('technologies', [])
|
|
825
|
+
if techs:
|
|
826
|
+
console.print(f" {', '.join(techs)}")
|
|
827
|
+
else:
|
|
828
|
+
console.print(" (Not specified)")
|
|
829
|
+
|
|
830
|
+
console.print()
|
|
831
|
+
console.print("[bold]Confidence:[/] {0}".format(
|
|
832
|
+
metadata.get('confidence', 'Medium')
|
|
833
|
+
))
|
|
834
|
+
else:
|
|
835
|
+
# Show first 500 chars as preview
|
|
836
|
+
preview = content[:500]
|
|
837
|
+
if len(content) > 500:
|
|
838
|
+
preview += "..."
|
|
839
|
+
console.print(preview)
|
|
840
|
+
|
|
841
|
+
console.print()
|
|
842
|
+
|
|
843
|
+
# Prompt for action (added regenerate option)
|
|
844
|
+
action = Prompt.ask(
|
|
845
|
+
"[a] Approve [e] Edit [r] Regenerate [d] Delete [s] Skip [q] Quit",
|
|
846
|
+
choices=["a", "e", "r", "d", "s", "q"],
|
|
847
|
+
default="s",
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
if action == "a":
|
|
851
|
+
update_story_metadata(story["id"], {"needs_review": False})
|
|
852
|
+
print_success("Story approved")
|
|
853
|
+
elif action == "e":
|
|
854
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
855
|
+
md_path = STORIES_DIR / f"{story['id']}.md"
|
|
856
|
+
import subprocess
|
|
857
|
+
subprocess.run([editor, str(md_path)])
|
|
858
|
+
update_story_metadata(story["id"], {"needs_review": False})
|
|
859
|
+
print_success("Story updated and approved")
|
|
860
|
+
elif action == "r":
|
|
861
|
+
# Regenerate story with different template
|
|
862
|
+
print_info("Regenerate action not yet implemented")
|
|
863
|
+
print_info("This feature is planned for a future release")
|
|
864
|
+
print_info("For now, you can delete and re-generate the story manually")
|
|
865
|
+
elif action == "d":
|
|
866
|
+
if confirm("Delete this story?"):
|
|
867
|
+
delete_story(story["id"])
|
|
868
|
+
print_success("Story deleted")
|
|
869
|
+
elif action == "q":
|
|
870
|
+
break
|
|
871
|
+
|
|
872
|
+
console.print()
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# =============================================================================
|
|
876
|
+
# CLOUD OPERATIONS
|
|
877
|
+
# =============================================================================
|
|
878
|
+
|
|
879
|
+
@app.command()
|
|
880
|
+
def push(
|
|
881
|
+
story_id: Optional[str] = typer.Option(None, "--story", help="Push specific story"),
|
|
882
|
+
all_stories: bool = typer.Option(False, "--all", help="Push all unpushed stories"),
|
|
883
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview what would be pushed"),
|
|
884
|
+
):
|
|
885
|
+
"""
|
|
886
|
+
Publish stories to repr.dev.
|
|
887
|
+
|
|
888
|
+
Examples:
|
|
889
|
+
repr push
|
|
890
|
+
repr push --story 01ARYZ6S41TSV4RRFFQ69G5FAV
|
|
891
|
+
"""
|
|
892
|
+
from .privacy import check_cloud_permission, log_cloud_operation
|
|
893
|
+
|
|
894
|
+
allowed, reason = check_cloud_permission("push")
|
|
895
|
+
if not allowed:
|
|
896
|
+
print_error("Publishing blocked")
|
|
897
|
+
print_info(reason)
|
|
898
|
+
raise typer.Exit(1)
|
|
899
|
+
|
|
900
|
+
# Get stories to push
|
|
901
|
+
if story_id:
|
|
902
|
+
result = load_story(story_id)
|
|
903
|
+
if not result:
|
|
904
|
+
print_error(f"Story not found: {story_id}")
|
|
905
|
+
raise typer.Exit(1)
|
|
906
|
+
content, metadata = result
|
|
907
|
+
to_push = [{"id": story_id, "content": content, **metadata}]
|
|
908
|
+
else:
|
|
909
|
+
to_push = get_unpushed_stories()
|
|
910
|
+
|
|
911
|
+
if not to_push:
|
|
912
|
+
print_success("All stories already synced!")
|
|
913
|
+
raise typer.Exit()
|
|
914
|
+
|
|
915
|
+
console.print(f"Publishing {len(to_push)} story(ies) to repr.dev...")
|
|
916
|
+
console.print()
|
|
917
|
+
|
|
918
|
+
if dry_run:
|
|
919
|
+
for s in to_push:
|
|
920
|
+
console.print(f" • {s.get('summary', s.get('id'))}")
|
|
921
|
+
console.print()
|
|
922
|
+
console.print("Run without --dry-run to publish")
|
|
923
|
+
raise typer.Exit()
|
|
924
|
+
|
|
925
|
+
# Actually push
|
|
926
|
+
from .api import push_story as api_push_story
|
|
927
|
+
|
|
928
|
+
pushed = 0
|
|
929
|
+
for s in to_push:
|
|
930
|
+
try:
|
|
931
|
+
content, meta = load_story(s["id"])
|
|
932
|
+
asyncio.run(api_push_story({**meta, "content": content}))
|
|
933
|
+
mark_story_pushed(s["id"])
|
|
934
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] {s.get('summary', s.get('id'))[:50]}")
|
|
935
|
+
pushed += 1
|
|
936
|
+
except (APIError, AuthError) as e:
|
|
937
|
+
console.print(f" [{BRAND_ERROR}]✗[/] {s.get('summary', s.get('id'))[:50]}: {e}")
|
|
938
|
+
|
|
939
|
+
# Log operation
|
|
940
|
+
if pushed > 0:
|
|
941
|
+
log_cloud_operation(
|
|
942
|
+
operation="push",
|
|
943
|
+
destination="repr.dev",
|
|
944
|
+
payload_summary={"stories_pushed": pushed},
|
|
945
|
+
bytes_sent=0,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
console.print()
|
|
949
|
+
print_success(f"Pushed {pushed}/{len(to_push)} stories")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
@app.command()
|
|
953
|
+
def sync():
|
|
954
|
+
"""
|
|
955
|
+
Sync stories across devices.
|
|
956
|
+
|
|
957
|
+
Example:
|
|
958
|
+
repr sync
|
|
959
|
+
"""
|
|
960
|
+
from .privacy import check_cloud_permission
|
|
961
|
+
|
|
962
|
+
allowed, reason = check_cloud_permission("sync")
|
|
963
|
+
if not allowed:
|
|
964
|
+
print_error("Sync blocked")
|
|
965
|
+
print_info(reason)
|
|
966
|
+
raise typer.Exit(1)
|
|
967
|
+
|
|
968
|
+
console.print("Syncing with repr.dev...")
|
|
969
|
+
console.print()
|
|
970
|
+
|
|
971
|
+
unpushed = get_unpushed_stories()
|
|
972
|
+
|
|
973
|
+
if unpushed:
|
|
974
|
+
console.print(f"↑ {len(unpushed)} local stories to push")
|
|
975
|
+
if confirm("Upload these stories?"):
|
|
976
|
+
# Reuse push logic
|
|
977
|
+
from .api import push_story as api_push_story
|
|
978
|
+
for s in unpushed:
|
|
979
|
+
try:
|
|
980
|
+
content, meta = load_story(s["id"])
|
|
981
|
+
asyncio.run(api_push_story({**meta, "content": content}))
|
|
982
|
+
mark_story_pushed(s["id"])
|
|
983
|
+
except Exception:
|
|
984
|
+
pass
|
|
985
|
+
print_success(f"Pushed {len(unpushed)} stories")
|
|
986
|
+
else:
|
|
987
|
+
console.print(f"[{BRAND_SUCCESS}]✓[/] All stories synced")
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
@app.command()
|
|
991
|
+
def pull():
|
|
992
|
+
"""
|
|
993
|
+
Pull remote stories to local device.
|
|
994
|
+
|
|
995
|
+
Example:
|
|
996
|
+
repr pull
|
|
997
|
+
"""
|
|
998
|
+
from .privacy import check_cloud_permission
|
|
999
|
+
|
|
1000
|
+
allowed, reason = check_cloud_permission("pull")
|
|
1001
|
+
if not allowed:
|
|
1002
|
+
print_error("Pull blocked")
|
|
1003
|
+
print_info(reason)
|
|
1004
|
+
raise typer.Exit(1)
|
|
1005
|
+
|
|
1006
|
+
console.print("Pulling from repr.dev...")
|
|
1007
|
+
console.print()
|
|
1008
|
+
|
|
1009
|
+
# TODO: Implement actual pull from API
|
|
1010
|
+
print_info("Pull from cloud not yet implemented")
|
|
1011
|
+
print_info("Local stories are preserved")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
# =============================================================================
|
|
1015
|
+
# COMMITS
|
|
1016
|
+
# =============================================================================
|
|
1017
|
+
|
|
1018
|
+
@app.command("commits")
|
|
1019
|
+
def list_commits(
|
|
1020
|
+
repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repo name"),
|
|
1021
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Number of commits"),
|
|
1022
|
+
since: Optional[str] = typer.Option(None, "--since", help="Since date"),
|
|
1023
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1024
|
+
):
|
|
1025
|
+
"""
|
|
1026
|
+
List recent commits across all tracked repos.
|
|
1027
|
+
|
|
1028
|
+
Example:
|
|
1029
|
+
repr commits --limit 10
|
|
1030
|
+
repr commits --repo myproject
|
|
1031
|
+
"""
|
|
1032
|
+
from .tools import get_commits_with_diffs
|
|
1033
|
+
|
|
1034
|
+
tracked = get_tracked_repos()
|
|
1035
|
+
if not tracked:
|
|
1036
|
+
print_warning("No repositories tracked.")
|
|
1037
|
+
raise typer.Exit(1)
|
|
1038
|
+
|
|
1039
|
+
all_commits = []
|
|
1040
|
+
|
|
1041
|
+
for repo_info in tracked:
|
|
1042
|
+
repo_path = Path(repo_info["path"])
|
|
1043
|
+
if repo and repo_path.name != repo:
|
|
1044
|
+
continue
|
|
1045
|
+
if not repo_path.exists():
|
|
1046
|
+
continue
|
|
1047
|
+
|
|
1048
|
+
commits = get_commits_with_diffs(repo_path, count=limit, days=90)
|
|
1049
|
+
for c in commits:
|
|
1050
|
+
c["repo_name"] = repo_path.name
|
|
1051
|
+
all_commits.extend(commits)
|
|
1052
|
+
|
|
1053
|
+
# Sort by date
|
|
1054
|
+
all_commits.sort(key=lambda c: c.get("date", ""), reverse=True)
|
|
1055
|
+
all_commits = all_commits[:limit]
|
|
1056
|
+
|
|
1057
|
+
if json_output:
|
|
1058
|
+
print(json.dumps(all_commits, indent=2, default=str))
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
if not all_commits:
|
|
1062
|
+
print_info("No commits found")
|
|
1063
|
+
raise typer.Exit()
|
|
1064
|
+
|
|
1065
|
+
console.print(f"[bold]Recent Commits[/] ({len(all_commits)})")
|
|
1066
|
+
console.print()
|
|
1067
|
+
|
|
1068
|
+
current_repo = None
|
|
1069
|
+
for c in all_commits:
|
|
1070
|
+
if c["repo_name"] != current_repo:
|
|
1071
|
+
current_repo = c["repo_name"]
|
|
1072
|
+
console.print(f"[bold]{current_repo}:[/]")
|
|
1073
|
+
|
|
1074
|
+
sha = c.get("sha", "")[:7]
|
|
1075
|
+
msg = c.get("message", "").split("\n")[0][:50]
|
|
1076
|
+
date = format_relative_time(c.get("date", ""))
|
|
1077
|
+
console.print(f" {sha} {msg} [{BRAND_MUTED}]{date}[/]")
|
|
1078
|
+
|
|
1079
|
+
console.print()
|
|
1080
|
+
print_info("Generate stories: repr generate --commits <sha1>,<sha2>")
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
# =============================================================================
|
|
1084
|
+
# AUTHENTICATION
|
|
1085
|
+
# =============================================================================
|
|
1086
|
+
|
|
1087
|
+
@app.command()
|
|
1088
|
+
def login():
|
|
1089
|
+
"""
|
|
1090
|
+
Authenticate with repr.dev.
|
|
1091
|
+
|
|
1092
|
+
Example:
|
|
1093
|
+
repr login
|
|
1094
|
+
"""
|
|
1095
|
+
print_header()
|
|
1096
|
+
|
|
1097
|
+
if is_authenticated():
|
|
1098
|
+
user = get_current_user()
|
|
735
1099
|
email = user.get("email", "unknown") if user else "unknown"
|
|
736
|
-
|
|
1100
|
+
print_info(f"Already authenticated as {email}")
|
|
1101
|
+
|
|
1102
|
+
if not confirm("Re-authenticate?"):
|
|
1103
|
+
raise typer.Exit()
|
|
1104
|
+
|
|
1105
|
+
console.print("[bold]Authenticate with repr[/]")
|
|
1106
|
+
console.print()
|
|
1107
|
+
|
|
1108
|
+
async def run_auth():
|
|
1109
|
+
flow = AuthFlow()
|
|
1110
|
+
|
|
1111
|
+
def on_code_received(device_code):
|
|
1112
|
+
console.print("To sign in, visit:")
|
|
1113
|
+
console.print()
|
|
1114
|
+
console.print(f" [bold {BRAND_PRIMARY}]{device_code.verification_url}[/]")
|
|
1115
|
+
console.print()
|
|
1116
|
+
console.print("And enter this code:")
|
|
1117
|
+
print_auth_code(device_code.user_code)
|
|
1118
|
+
|
|
1119
|
+
def on_progress(remaining):
|
|
1120
|
+
mins = int(remaining // 60)
|
|
1121
|
+
secs = int(remaining % 60)
|
|
1122
|
+
console.print(f"\rWaiting for authorization... {mins}:{secs:02d} ", end="")
|
|
1123
|
+
|
|
1124
|
+
def on_success(token):
|
|
1125
|
+
console.print()
|
|
1126
|
+
console.print()
|
|
1127
|
+
print_success(f"Authenticated as {token.email}")
|
|
1128
|
+
|
|
1129
|
+
def on_error(error):
|
|
1130
|
+
console.print()
|
|
1131
|
+
print_error(str(error))
|
|
1132
|
+
|
|
1133
|
+
flow.on_code_received = on_code_received
|
|
1134
|
+
flow.on_progress = on_progress
|
|
1135
|
+
flow.on_success = on_success
|
|
1136
|
+
flow.on_error = on_error
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
await flow.run()
|
|
1140
|
+
except AuthError:
|
|
1141
|
+
raise typer.Exit(1)
|
|
1142
|
+
|
|
1143
|
+
asyncio.run(run_auth())
|
|
1144
|
+
|
|
1145
|
+
# Check for local stories
|
|
1146
|
+
local_count = get_story_count()
|
|
1147
|
+
if local_count > 0:
|
|
1148
|
+
console.print()
|
|
1149
|
+
console.print(f"You have {local_count} local stories.")
|
|
1150
|
+
console.print("Run `repr sync` to upload them.")
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
@app.command()
|
|
1154
|
+
def logout():
|
|
1155
|
+
"""
|
|
1156
|
+
Sign out and clear credentials.
|
|
1157
|
+
|
|
1158
|
+
Example:
|
|
1159
|
+
repr logout
|
|
1160
|
+
"""
|
|
1161
|
+
if not is_authenticated():
|
|
1162
|
+
print_info("Not currently authenticated.")
|
|
1163
|
+
raise typer.Exit()
|
|
1164
|
+
|
|
1165
|
+
auth_logout()
|
|
1166
|
+
|
|
1167
|
+
print_success("Signed out")
|
|
1168
|
+
console.print()
|
|
1169
|
+
console.print(f" Local stories preserved in {STORIES_DIR}")
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
@app.command()
|
|
1173
|
+
def whoami(
|
|
1174
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1175
|
+
):
|
|
1176
|
+
"""
|
|
1177
|
+
Show current user.
|
|
1178
|
+
|
|
1179
|
+
Example:
|
|
1180
|
+
repr whoami
|
|
1181
|
+
"""
|
|
1182
|
+
if not is_authenticated():
|
|
1183
|
+
if json_output:
|
|
1184
|
+
print(json.dumps({"error": "Not authenticated"}))
|
|
1185
|
+
else:
|
|
1186
|
+
print_info("Not signed in")
|
|
1187
|
+
print_info("Run `repr login` to sign in")
|
|
1188
|
+
raise typer.Exit(1)
|
|
1189
|
+
|
|
1190
|
+
user = get_current_user()
|
|
1191
|
+
|
|
1192
|
+
if json_output:
|
|
1193
|
+
print(json.dumps(user, indent=2))
|
|
1194
|
+
return
|
|
1195
|
+
|
|
1196
|
+
email = user.get("email", "unknown")
|
|
1197
|
+
console.print(f"Signed in as: [bold]{email}[/]")
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
# =============================================================================
|
|
1201
|
+
# REPOSITORY MANAGEMENT
|
|
1202
|
+
# =============================================================================
|
|
1203
|
+
|
|
1204
|
+
@app.command()
|
|
1205
|
+
def repos(
|
|
1206
|
+
action: str = typer.Argument("list", help="Action: list, add, remove, pause, resume"),
|
|
1207
|
+
path: Optional[Path] = typer.Argument(None, help="Repository path (for add/remove/pause/resume)"),
|
|
1208
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1209
|
+
):
|
|
1210
|
+
"""
|
|
1211
|
+
Manage tracked repositories.
|
|
1212
|
+
|
|
1213
|
+
Examples:
|
|
1214
|
+
repr repos
|
|
1215
|
+
repr repos add ~/code/myproject
|
|
1216
|
+
repr repos remove ~/code/old-project
|
|
1217
|
+
repr repos pause ~/code/work-project
|
|
1218
|
+
"""
|
|
1219
|
+
if action == "list":
|
|
1220
|
+
tracked = get_tracked_repos()
|
|
1221
|
+
|
|
1222
|
+
if json_output:
|
|
1223
|
+
print(json.dumps(tracked, indent=2))
|
|
1224
|
+
return
|
|
1225
|
+
|
|
1226
|
+
if not tracked:
|
|
1227
|
+
print_info("No repositories tracked.")
|
|
1228
|
+
print_info("Run `repr repos add <path>` to track a repository.")
|
|
1229
|
+
raise typer.Exit()
|
|
1230
|
+
|
|
1231
|
+
console.print(f"[bold]Tracked Repositories[/] ({len(tracked)})")
|
|
1232
|
+
console.print()
|
|
1233
|
+
|
|
1234
|
+
for repo in tracked:
|
|
1235
|
+
repo_path = Path(repo["path"])
|
|
1236
|
+
exists = repo_path.exists()
|
|
1237
|
+
paused = repo.get("paused", False)
|
|
1238
|
+
hook = repo.get("hook_installed", False)
|
|
1239
|
+
|
|
1240
|
+
if not exists:
|
|
1241
|
+
status = f"[{BRAND_ERROR}]✗[/]"
|
|
1242
|
+
elif paused:
|
|
1243
|
+
status = f"[{BRAND_WARNING}]○[/]"
|
|
1244
|
+
else:
|
|
1245
|
+
status = f"[{BRAND_SUCCESS}]✓[/]"
|
|
1246
|
+
|
|
1247
|
+
console.print(f" {status} {repo_path.name}")
|
|
1248
|
+
details = []
|
|
1249
|
+
if hook:
|
|
1250
|
+
details.append("hook: on")
|
|
1251
|
+
if paused:
|
|
1252
|
+
details.append("paused")
|
|
1253
|
+
if not exists:
|
|
1254
|
+
details.append("missing")
|
|
1255
|
+
|
|
1256
|
+
info_str = " • ".join(details) if details else repo["path"]
|
|
1257
|
+
console.print(f" [{BRAND_MUTED}]{info_str}[/]")
|
|
1258
|
+
|
|
1259
|
+
elif action == "add":
|
|
1260
|
+
if not path:
|
|
1261
|
+
print_error("Path required: repr repos add <path>")
|
|
1262
|
+
raise typer.Exit(1)
|
|
1263
|
+
|
|
1264
|
+
if not (path / ".git").exists():
|
|
1265
|
+
print_error(f"Not a git repository: {path}")
|
|
1266
|
+
raise typer.Exit(1)
|
|
1267
|
+
|
|
1268
|
+
add_tracked_repo(str(path))
|
|
1269
|
+
print_success(f"Added: {path.name}")
|
|
1270
|
+
|
|
1271
|
+
elif action == "remove":
|
|
1272
|
+
if not path:
|
|
1273
|
+
print_error("Path required: repr repos remove <path>")
|
|
1274
|
+
raise typer.Exit(1)
|
|
1275
|
+
|
|
1276
|
+
if remove_tracked_repo(str(path)):
|
|
1277
|
+
print_success(f"Removed: {path.name}")
|
|
1278
|
+
else:
|
|
1279
|
+
print_warning(f"Not tracked: {path.name}")
|
|
1280
|
+
|
|
1281
|
+
elif action == "pause":
|
|
1282
|
+
if not path:
|
|
1283
|
+
print_error("Path required: repr repos pause <path>")
|
|
1284
|
+
raise typer.Exit(1)
|
|
1285
|
+
|
|
1286
|
+
set_repo_paused(str(path), True)
|
|
1287
|
+
print_success(f"Paused auto-tracking for: {path.name}")
|
|
1288
|
+
|
|
1289
|
+
elif action == "resume":
|
|
1290
|
+
if not path:
|
|
1291
|
+
print_error("Path required: repr repos resume <path>")
|
|
1292
|
+
raise typer.Exit(1)
|
|
1293
|
+
|
|
1294
|
+
set_repo_paused(str(path), False)
|
|
1295
|
+
print_success(f"Resumed auto-tracking for: {path.name}")
|
|
1296
|
+
|
|
1297
|
+
else:
|
|
1298
|
+
print_error(f"Unknown action: {action}")
|
|
1299
|
+
print_info("Valid actions: list, add, remove, pause, resume")
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
# =============================================================================
|
|
1303
|
+
# HOOKS MANAGEMENT
|
|
1304
|
+
# =============================================================================
|
|
1305
|
+
|
|
1306
|
+
@hooks_app.command("install")
|
|
1307
|
+
def hooks_install(
|
|
1308
|
+
all_repos: bool = typer.Option(False, "--all", help="Install in all tracked repos"),
|
|
1309
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Install in specific repo"),
|
|
1310
|
+
):
|
|
1311
|
+
"""
|
|
1312
|
+
Install git post-commit hooks for auto-tracking.
|
|
1313
|
+
|
|
1314
|
+
Example:
|
|
1315
|
+
repr hooks install --all
|
|
1316
|
+
repr hooks install --repo ~/code/myproject
|
|
1317
|
+
"""
|
|
1318
|
+
from .hooks import install_hook
|
|
1319
|
+
|
|
1320
|
+
if repo:
|
|
1321
|
+
repos_to_install = [{"path": str(repo)}]
|
|
1322
|
+
elif all_repos:
|
|
1323
|
+
repos_to_install = get_tracked_repos()
|
|
1324
|
+
else:
|
|
1325
|
+
print_error("Specify --all or --repo <path>")
|
|
1326
|
+
raise typer.Exit(1)
|
|
1327
|
+
|
|
1328
|
+
if not repos_to_install:
|
|
1329
|
+
print_warning("No repositories to install hooks in")
|
|
1330
|
+
raise typer.Exit(1)
|
|
1331
|
+
|
|
1332
|
+
console.print(f"Installing hooks in {len(repos_to_install)} repositories...")
|
|
1333
|
+
console.print()
|
|
1334
|
+
|
|
1335
|
+
installed = 0
|
|
1336
|
+
already = 0
|
|
1337
|
+
|
|
1338
|
+
for repo_info in repos_to_install:
|
|
1339
|
+
repo_path = Path(repo_info["path"])
|
|
1340
|
+
result = install_hook(repo_path)
|
|
1341
|
+
|
|
1342
|
+
if result["success"]:
|
|
1343
|
+
if result["already_installed"]:
|
|
1344
|
+
console.print(f" [{BRAND_MUTED}]○[/] {repo_path.name}: already installed")
|
|
1345
|
+
already += 1
|
|
1346
|
+
else:
|
|
1347
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] {repo_path.name}: hook installed")
|
|
1348
|
+
set_repo_hook_status(str(repo_path), True)
|
|
1349
|
+
installed += 1
|
|
1350
|
+
else:
|
|
1351
|
+
console.print(f" [{BRAND_ERROR}]✗[/] {repo_path.name}: {result['message']}")
|
|
1352
|
+
|
|
1353
|
+
console.print()
|
|
1354
|
+
print_success(f"Hooks installed: {installed}, Already installed: {already}")
|
|
1355
|
+
console.print()
|
|
1356
|
+
console.print(f"[{BRAND_MUTED}]Hooks queue commits locally. Run `repr generate` to create stories.[/]")
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
@hooks_app.command("remove")
|
|
1360
|
+
def hooks_remove(
|
|
1361
|
+
all_repos: bool = typer.Option(False, "--all", help="Remove from all tracked repos"),
|
|
1362
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Remove from specific repo"),
|
|
1363
|
+
):
|
|
1364
|
+
"""
|
|
1365
|
+
Remove git post-commit hooks.
|
|
1366
|
+
|
|
1367
|
+
Example:
|
|
1368
|
+
repr hooks remove --all
|
|
1369
|
+
"""
|
|
1370
|
+
from .hooks import remove_hook
|
|
1371
|
+
|
|
1372
|
+
if repo:
|
|
1373
|
+
repos_to_remove = [{"path": str(repo)}]
|
|
1374
|
+
elif all_repos:
|
|
1375
|
+
repos_to_remove = get_tracked_repos()
|
|
1376
|
+
else:
|
|
1377
|
+
print_error("Specify --all or --repo <path>")
|
|
1378
|
+
raise typer.Exit(1)
|
|
1379
|
+
|
|
1380
|
+
removed = 0
|
|
1381
|
+
for repo_info in repos_to_remove:
|
|
1382
|
+
repo_path = Path(repo_info["path"])
|
|
1383
|
+
result = remove_hook(repo_path)
|
|
1384
|
+
|
|
1385
|
+
if result["success"]:
|
|
1386
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] {repo_path.name}: {result['message']}")
|
|
1387
|
+
set_repo_hook_status(str(repo_path), False)
|
|
1388
|
+
removed += 1
|
|
1389
|
+
else:
|
|
1390
|
+
console.print(f" [{BRAND_MUTED}]○[/] {repo_path.name}: {result['message']}")
|
|
1391
|
+
|
|
1392
|
+
print_success(f"Hooks removed: {removed}")
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
@hooks_app.command("status")
|
|
1396
|
+
def hooks_status(
|
|
1397
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1398
|
+
):
|
|
1399
|
+
"""
|
|
1400
|
+
Show hook status for all tracked repos.
|
|
1401
|
+
|
|
1402
|
+
Example:
|
|
1403
|
+
repr hooks status
|
|
1404
|
+
"""
|
|
1405
|
+
from .hooks import get_hook_status, get_queue_stats
|
|
1406
|
+
|
|
1407
|
+
tracked = get_tracked_repos()
|
|
1408
|
+
|
|
1409
|
+
if not tracked:
|
|
1410
|
+
print_warning("No repositories tracked")
|
|
1411
|
+
raise typer.Exit()
|
|
1412
|
+
|
|
1413
|
+
results = []
|
|
1414
|
+
for repo_info in tracked:
|
|
1415
|
+
repo_path = Path(repo_info["path"])
|
|
1416
|
+
if not repo_path.exists():
|
|
1417
|
+
continue
|
|
1418
|
+
|
|
1419
|
+
status = get_hook_status(repo_path)
|
|
1420
|
+
queue = get_queue_stats(repo_path)
|
|
1421
|
+
|
|
1422
|
+
results.append({
|
|
1423
|
+
"name": repo_path.name,
|
|
1424
|
+
"path": str(repo_path),
|
|
1425
|
+
"installed": status["installed"],
|
|
1426
|
+
"executable": status["executable"],
|
|
1427
|
+
"queue_count": queue["count"],
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
if json_output:
|
|
1431
|
+
print(json.dumps(results, indent=2))
|
|
1432
|
+
return
|
|
1433
|
+
|
|
1434
|
+
console.print("[bold]Hook Status[/]")
|
|
1435
|
+
console.print()
|
|
1436
|
+
|
|
1437
|
+
for r in results:
|
|
1438
|
+
if r["installed"]:
|
|
1439
|
+
status = f"[{BRAND_SUCCESS}]✓[/]"
|
|
1440
|
+
msg = "Hook installed and active"
|
|
1441
|
+
else:
|
|
1442
|
+
status = f"[{BRAND_MUTED}]○[/]"
|
|
1443
|
+
msg = "No hook installed"
|
|
1444
|
+
|
|
1445
|
+
console.print(f"{status} {r['name']}")
|
|
1446
|
+
console.print(f" [{BRAND_MUTED}]{msg}[/]")
|
|
1447
|
+
if r["queue_count"] > 0:
|
|
1448
|
+
console.print(f" [{BRAND_MUTED}]Queue: {r['queue_count']} commits[/]")
|
|
1449
|
+
console.print()
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
@hooks_app.command("queue")
|
|
1453
|
+
def hooks_queue(
|
|
1454
|
+
commit_sha: str = typer.Argument(..., help="Commit SHA to queue"),
|
|
1455
|
+
repo: Path = typer.Option(..., "--repo", help="Repository path"),
|
|
1456
|
+
):
|
|
1457
|
+
"""
|
|
1458
|
+
Queue a commit (called by git hook).
|
|
1459
|
+
|
|
1460
|
+
This is an internal command used by the git post-commit hook.
|
|
1461
|
+
"""
|
|
1462
|
+
from .hooks import queue_commit
|
|
1463
|
+
|
|
1464
|
+
success = queue_commit(repo, commit_sha)
|
|
1465
|
+
if not success:
|
|
1466
|
+
# Silent failure - don't block git commit
|
|
1467
|
+
pass
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
# =============================================================================
|
|
1471
|
+
# LLM CONFIGURATION
|
|
1472
|
+
# =============================================================================
|
|
1473
|
+
|
|
1474
|
+
@llm_app.command("add")
|
|
1475
|
+
def llm_add(
|
|
1476
|
+
provider: str = typer.Argument(..., help="Provider: openai, anthropic, groq, together"),
|
|
1477
|
+
):
|
|
1478
|
+
"""
|
|
1479
|
+
Configure a BYOK provider.
|
|
1480
|
+
|
|
1481
|
+
Example:
|
|
1482
|
+
repr llm add openai
|
|
1483
|
+
"""
|
|
1484
|
+
if provider not in BYOK_PROVIDERS:
|
|
1485
|
+
print_error(f"Unknown provider: {provider}")
|
|
1486
|
+
print_info(f"Available: {', '.join(BYOK_PROVIDERS.keys())}")
|
|
1487
|
+
raise typer.Exit(1)
|
|
1488
|
+
|
|
1489
|
+
provider_info = BYOK_PROVIDERS[provider]
|
|
1490
|
+
console.print(f"Configure {provider_info['name']}")
|
|
1491
|
+
console.print()
|
|
1492
|
+
|
|
1493
|
+
api_key = Prompt.ask("API Key", password=True)
|
|
1494
|
+
if not api_key:
|
|
1495
|
+
print_error("API key required")
|
|
1496
|
+
raise typer.Exit(1)
|
|
1497
|
+
|
|
1498
|
+
# Test the key
|
|
1499
|
+
console.print("Testing connection...")
|
|
1500
|
+
from .llm import test_byok_provider
|
|
1501
|
+
result = test_byok_provider(provider, api_key)
|
|
1502
|
+
|
|
1503
|
+
if result.success:
|
|
1504
|
+
add_byok_provider(provider, api_key)
|
|
1505
|
+
print_success(f"Added {provider_info['name']}")
|
|
1506
|
+
console.print(f" Response time: {result.response_time_ms:.0f}ms")
|
|
1507
|
+
else:
|
|
1508
|
+
print_error(f"Connection failed: {result.error}")
|
|
1509
|
+
if confirm("Save anyway?"):
|
|
1510
|
+
add_byok_provider(provider, api_key)
|
|
1511
|
+
print_success(f"Added {provider_info['name']}")
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
@llm_app.command("remove")
|
|
1515
|
+
def llm_remove(
|
|
1516
|
+
provider: str = typer.Argument(..., help="Provider to remove"),
|
|
1517
|
+
):
|
|
1518
|
+
"""
|
|
1519
|
+
Remove a BYOK provider key.
|
|
1520
|
+
|
|
1521
|
+
Example:
|
|
1522
|
+
repr llm remove openai
|
|
1523
|
+
"""
|
|
1524
|
+
if remove_byok_provider(provider):
|
|
1525
|
+
print_success(f"Removed {provider}")
|
|
1526
|
+
else:
|
|
1527
|
+
print_warning(f"Provider not configured: {provider}")
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
@llm_app.command("use")
|
|
1531
|
+
def llm_use(
|
|
1532
|
+
mode: str = typer.Argument(..., help="Mode: local, cloud, byok:<provider>"),
|
|
1533
|
+
):
|
|
1534
|
+
"""
|
|
1535
|
+
Set default LLM mode.
|
|
1536
|
+
|
|
1537
|
+
Examples:
|
|
1538
|
+
repr llm use local
|
|
1539
|
+
repr llm use cloud
|
|
1540
|
+
repr llm use byok:openai
|
|
1541
|
+
"""
|
|
1542
|
+
valid_modes = ["local", "cloud"]
|
|
1543
|
+
byok_providers = list_byok_providers()
|
|
1544
|
+
valid_modes.extend([f"byok:{p}" for p in byok_providers])
|
|
1545
|
+
|
|
1546
|
+
if mode not in valid_modes and not mode.startswith("byok:"):
|
|
1547
|
+
print_error(f"Invalid mode: {mode}")
|
|
1548
|
+
print_info(f"Valid modes: {', '.join(valid_modes)}")
|
|
1549
|
+
raise typer.Exit(1)
|
|
1550
|
+
|
|
1551
|
+
if mode == "cloud" and not is_authenticated():
|
|
1552
|
+
print_warning("Cloud mode requires sign-in")
|
|
1553
|
+
print_info("When not signed in, local mode will be used instead")
|
|
1554
|
+
|
|
1555
|
+
if mode.startswith("byok:"):
|
|
1556
|
+
provider = mode.split(":", 1)[1]
|
|
1557
|
+
if provider not in byok_providers:
|
|
1558
|
+
print_error(f"BYOK provider not configured: {provider}")
|
|
1559
|
+
print_info(f"Run `repr llm add {provider}` first")
|
|
1560
|
+
raise typer.Exit(1)
|
|
1561
|
+
|
|
1562
|
+
set_llm_config(default=mode)
|
|
1563
|
+
print_success(f"Default LLM mode: {mode}")
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
@llm_app.command("configure")
|
|
1567
|
+
def llm_configure():
|
|
1568
|
+
"""
|
|
1569
|
+
Configure local LLM endpoint interactively.
|
|
1570
|
+
|
|
1571
|
+
Example:
|
|
1572
|
+
repr llm configure
|
|
1573
|
+
"""
|
|
1574
|
+
from .llm import detect_all_local_llms, list_ollama_models
|
|
1575
|
+
|
|
1576
|
+
console.print("[bold]Configure Local LLM[/]")
|
|
1577
|
+
console.print()
|
|
1578
|
+
|
|
1579
|
+
# Detect available LLMs
|
|
1580
|
+
detected = detect_all_local_llms()
|
|
1581
|
+
|
|
1582
|
+
if detected:
|
|
1583
|
+
console.print("Detected local LLMs:")
|
|
1584
|
+
for i, llm in enumerate(detected, 1):
|
|
1585
|
+
console.print(f" {i}. {llm.name} ({llm.url})")
|
|
1586
|
+
console.print(f" {len(detected) + 1}. Custom endpoint")
|
|
1587
|
+
console.print()
|
|
1588
|
+
|
|
1589
|
+
choice = Prompt.ask(
|
|
1590
|
+
"Select",
|
|
1591
|
+
choices=[str(i) for i in range(1, len(detected) + 2)],
|
|
1592
|
+
default="1",
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
if int(choice) <= len(detected):
|
|
1596
|
+
selected = detected[int(choice) - 1]
|
|
1597
|
+
url = selected.url
|
|
1598
|
+
models = selected.models
|
|
1599
|
+
else:
|
|
1600
|
+
url = Prompt.ask("Custom endpoint URL")
|
|
1601
|
+
models = []
|
|
1602
|
+
else:
|
|
1603
|
+
console.print(f"[{BRAND_MUTED}]No local LLMs detected[/]")
|
|
1604
|
+
url = Prompt.ask("Endpoint URL", default="http://localhost:11434")
|
|
1605
|
+
models = []
|
|
1606
|
+
|
|
1607
|
+
# Select model
|
|
1608
|
+
if models:
|
|
1609
|
+
console.print()
|
|
1610
|
+
console.print("Available models:")
|
|
1611
|
+
for i, model in enumerate(models[:10], 1):
|
|
1612
|
+
console.print(f" {i}. {model}")
|
|
1613
|
+
console.print()
|
|
1614
|
+
|
|
1615
|
+
model_choice = Prompt.ask("Select model", default="1")
|
|
1616
|
+
try:
|
|
1617
|
+
model = models[int(model_choice) - 1]
|
|
1618
|
+
except (ValueError, IndexError):
|
|
1619
|
+
model = model_choice # Use as literal model name
|
|
1620
|
+
else:
|
|
1621
|
+
model = Prompt.ask("Model name", default="llama3.2")
|
|
1622
|
+
|
|
1623
|
+
# Save config
|
|
1624
|
+
set_llm_config(
|
|
1625
|
+
local_api_url=f"{url}/v1",
|
|
1626
|
+
local_model=model,
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
print_success(f"Configured local LLM")
|
|
1630
|
+
console.print(f" Endpoint: {url}")
|
|
1631
|
+
console.print(f" Model: {model}")
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
@llm_app.command("test")
|
|
1635
|
+
def llm_test():
|
|
1636
|
+
"""
|
|
1637
|
+
Test LLM connection and generation.
|
|
1638
|
+
|
|
1639
|
+
Example:
|
|
1640
|
+
repr llm test
|
|
1641
|
+
"""
|
|
1642
|
+
from .llm import test_local_llm, get_llm_status
|
|
1643
|
+
|
|
1644
|
+
console.print("Testing LLM connection...")
|
|
1645
|
+
console.print()
|
|
1646
|
+
|
|
1647
|
+
status = get_llm_status()
|
|
1648
|
+
|
|
1649
|
+
# Test local
|
|
1650
|
+
console.print("[bold]Local LLM:[/]")
|
|
1651
|
+
if status["local"]["available"]:
|
|
1652
|
+
result = test_local_llm(
|
|
1653
|
+
url=status["local"]["url"],
|
|
1654
|
+
model=status["local"]["model"],
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
if result.success:
|
|
1658
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] Connection successful")
|
|
1659
|
+
console.print(f" Provider: {result.provider}")
|
|
1660
|
+
console.print(f" Model: {result.model}")
|
|
1661
|
+
console.print(f" Response time: {result.response_time_ms:.0f}ms")
|
|
1662
|
+
else:
|
|
1663
|
+
console.print(f" [{BRAND_ERROR}]✗[/] {result.error}")
|
|
1664
|
+
else:
|
|
1665
|
+
console.print(f" [{BRAND_MUTED}]Not detected[/]")
|
|
1666
|
+
|
|
1667
|
+
console.print()
|
|
1668
|
+
|
|
1669
|
+
# Show cloud status
|
|
1670
|
+
console.print("[bold]Cloud LLM:[/]")
|
|
1671
|
+
if status["cloud"]["available"]:
|
|
1672
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] Available")
|
|
1673
|
+
console.print(f" Model: {status['cloud']['model']}")
|
|
737
1674
|
else:
|
|
738
|
-
console.print(f"
|
|
1675
|
+
console.print(f" [{BRAND_MUTED}]✗[/] {status['cloud']['blocked_reason']}")
|
|
1676
|
+
|
|
1677
|
+
# Show BYOK status
|
|
1678
|
+
if status["byok"]["providers"]:
|
|
1679
|
+
console.print()
|
|
1680
|
+
console.print("[bold]BYOK Providers:[/]")
|
|
1681
|
+
for provider in status["byok"]["providers"]:
|
|
1682
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] {provider}")
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
# =============================================================================
|
|
1686
|
+
# PRIVACY
|
|
1687
|
+
# =============================================================================
|
|
1688
|
+
|
|
1689
|
+
@privacy_app.command("explain")
|
|
1690
|
+
def privacy_explain():
|
|
1691
|
+
"""
|
|
1692
|
+
One-screen summary of privacy guarantees.
|
|
1693
|
+
|
|
1694
|
+
Example:
|
|
1695
|
+
repr privacy explain
|
|
1696
|
+
"""
|
|
1697
|
+
from .privacy import get_privacy_explanation
|
|
739
1698
|
|
|
1699
|
+
explanation = get_privacy_explanation()
|
|
1700
|
+
|
|
1701
|
+
console.print("[bold]repr Privacy Guarantees[/]")
|
|
1702
|
+
console.print("━" * 50)
|
|
1703
|
+
console.print()
|
|
1704
|
+
|
|
1705
|
+
console.print("[bold]ARCHITECTURE:[/]")
|
|
1706
|
+
console.print(f" ✓ {explanation['architecture']['guarantee']}")
|
|
1707
|
+
console.print(" ✓ No background daemons or silent uploads")
|
|
1708
|
+
console.print(" ✓ All network calls are foreground, user-initiated")
|
|
1709
|
+
console.print(" ✓ No telemetry by default (opt-in only)")
|
|
740
1710
|
console.print()
|
|
741
1711
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
console.print("[
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
console.print(f"
|
|
748
|
-
console.print(f" Synthesis Model: {synthesis_model_display}")
|
|
749
|
-
console.print(f" Local API URL: {llm_config['local_api_url'] or '[dim]not set[/]'}")
|
|
750
|
-
console.print(f" Local API Key: {'[green]✓ set[/]' if llm_config['local_api_key'] else '[dim]not set[/]'}")
|
|
1712
|
+
console.print("[bold]CURRENT SETTINGS:[/]")
|
|
1713
|
+
state = explanation["current_state"]
|
|
1714
|
+
console.print(f" Auth: {'signed in' if state['authenticated'] else 'not signed in'}")
|
|
1715
|
+
console.print(f" Mode: {state['mode']}")
|
|
1716
|
+
console.print(f" Privacy lock: {'enabled' if state['privacy_lock'] else 'disabled'}")
|
|
1717
|
+
console.print(f" Telemetry: {'enabled' if state['telemetry_enabled'] else 'disabled'}")
|
|
751
1718
|
console.print()
|
|
752
1719
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
console.print("
|
|
756
|
-
console.print(f" Status: {'[green]✓ authenticated[/]' if litellm_key else '[dim]not authenticated - run repr login[/]'}")
|
|
1720
|
+
console.print("[bold]LOCAL MODE NETWORK POLICY:[/]")
|
|
1721
|
+
console.print(" Allowed: 127.0.0.1, localhost, ::1 (loopback only)")
|
|
1722
|
+
console.print(" Blocked: All external network, DNS, HTTP(S) to internet")
|
|
757
1723
|
console.print()
|
|
758
1724
|
|
|
759
|
-
console.print(
|
|
760
|
-
|
|
1725
|
+
console.print("[bold]CLOUD MODE SETTINGS:[/]")
|
|
1726
|
+
cloud = explanation["cloud_mode_settings"]
|
|
1727
|
+
console.print(f" Path redaction: {'enabled' if cloud['path_redaction_enabled'] else 'disabled'}")
|
|
1728
|
+
console.print(f" Diffs: {'disabled' if cloud['diffs_disabled'] else 'enabled'}")
|
|
1729
|
+
console.print(f" Email redaction: {'enabled' if cloud['email_redaction_enabled'] else 'disabled'}")
|
|
761
1730
|
console.print()
|
|
762
1731
|
|
|
763
|
-
console.print("[
|
|
764
|
-
console.print("
|
|
765
|
-
console.print(" REPR_API_BASE=URL Override API base URL")
|
|
1732
|
+
console.print(f"[{BRAND_MUTED}]Run `repr privacy audit` to see all data sent[/]")
|
|
1733
|
+
console.print(f"[{BRAND_MUTED}]Run `repr privacy lock-local` to disable cloud permanently[/]")
|
|
766
1734
|
|
|
767
1735
|
|
|
768
|
-
@
|
|
769
|
-
def
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
"--extraction-model",
|
|
773
|
-
help="Model for extracting accomplishments from commits (e.g., gpt-4o-mini, llama3.2).",
|
|
774
|
-
),
|
|
775
|
-
synthesis_model: Optional[str] = typer.Option(
|
|
776
|
-
None,
|
|
777
|
-
"--synthesis-model",
|
|
778
|
-
help="Model for synthesizing the final profile (e.g., gpt-4o, llama3.2).",
|
|
779
|
-
),
|
|
780
|
-
local_api_url: Optional[str] = typer.Option(
|
|
781
|
-
None,
|
|
782
|
-
"--local-api-url",
|
|
783
|
-
help="Local LLM API base URL (e.g., http://localhost:11434/v1).",
|
|
784
|
-
),
|
|
785
|
-
local_api_key: Optional[str] = typer.Option(
|
|
786
|
-
None,
|
|
787
|
-
"--local-api-key",
|
|
788
|
-
help="Local LLM API key (e.g., 'ollama' for Ollama).",
|
|
789
|
-
),
|
|
790
|
-
clear: bool = typer.Option(
|
|
791
|
-
False,
|
|
792
|
-
"--clear",
|
|
793
|
-
help="Clear all LLM configuration.",
|
|
794
|
-
),
|
|
1736
|
+
@privacy_app.command("audit")
|
|
1737
|
+
def privacy_audit(
|
|
1738
|
+
days: int = typer.Option(30, "--days", help="Number of days to show"),
|
|
1739
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
795
1740
|
):
|
|
796
1741
|
"""
|
|
797
|
-
|
|
1742
|
+
Show what data was sent to cloud.
|
|
798
1743
|
|
|
799
|
-
|
|
800
|
-
|
|
1744
|
+
Example:
|
|
1745
|
+
repr privacy audit
|
|
1746
|
+
"""
|
|
1747
|
+
from .privacy import get_audit_summary, get_data_sent_history
|
|
801
1748
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1749
|
+
summary = get_audit_summary(days=days)
|
|
1750
|
+
|
|
1751
|
+
if json_output:
|
|
1752
|
+
print(json.dumps(summary, indent=2, default=str))
|
|
1753
|
+
return
|
|
1754
|
+
|
|
1755
|
+
console.print("[bold]Privacy Audit[/]")
|
|
1756
|
+
console.print()
|
|
1757
|
+
console.print(f"Last {days} days:")
|
|
1758
|
+
console.print()
|
|
1759
|
+
|
|
1760
|
+
if summary["total_operations"] == 0:
|
|
1761
|
+
if is_authenticated():
|
|
1762
|
+
console.print(f" [{BRAND_MUTED}]No data sent to cloud[/]")
|
|
1763
|
+
else:
|
|
1764
|
+
console.print(f" [{BRAND_MUTED}]None (not signed in)[/]")
|
|
1765
|
+
console.print()
|
|
1766
|
+
console.print("No data has been sent to repr.dev.")
|
|
1767
|
+
return
|
|
1768
|
+
|
|
1769
|
+
for op_name, op_data in summary["by_operation"].items():
|
|
1770
|
+
console.print(f"[bold]{op_name}[/] ({op_data['count']} times):")
|
|
1771
|
+
for entry in op_data["recent"]:
|
|
1772
|
+
payload = entry.get("payload_summary", {})
|
|
1773
|
+
console.print(f" • {entry.get('timestamp', '')[:10]}: {payload}")
|
|
1774
|
+
console.print()
|
|
1775
|
+
|
|
1776
|
+
console.print(f"Total data sent: ~{format_bytes(summary['total_bytes_sent'])}")
|
|
1777
|
+
console.print(f"Destinations: {', '.join(summary['destinations'])}")
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
@privacy_app.command("lock-local")
|
|
1781
|
+
def privacy_lock_local(
|
|
1782
|
+
permanent: bool = typer.Option(False, "--permanent", help="Make lock irreversible"),
|
|
1783
|
+
):
|
|
1784
|
+
"""
|
|
1785
|
+
Disable cloud features (even after login).
|
|
1786
|
+
|
|
1787
|
+
Example:
|
|
1788
|
+
repr privacy lock-local
|
|
1789
|
+
repr privacy lock-local --permanent
|
|
1790
|
+
"""
|
|
1791
|
+
if permanent:
|
|
1792
|
+
console.print(f"[{BRAND_WARNING}]⚠ This will PERMANENTLY disable cloud features.[/]")
|
|
1793
|
+
console.print(" You will not be able to unlock cloud mode.")
|
|
1794
|
+
console.print(" Local-only mode will be enforced forever.")
|
|
1795
|
+
console.print()
|
|
805
1796
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1797
|
+
if not confirm("Continue?"):
|
|
1798
|
+
print_info("Cancelled")
|
|
1799
|
+
raise typer.Exit()
|
|
1800
|
+
|
|
1801
|
+
lock_local_only(permanent=permanent)
|
|
1802
|
+
|
|
1803
|
+
if permanent:
|
|
1804
|
+
print_success("Local-only mode PERMANENTLY enabled")
|
|
1805
|
+
else:
|
|
1806
|
+
print_success("Local-only mode enabled")
|
|
1807
|
+
console.print(f" [{BRAND_MUTED}]To unlock: repr privacy unlock-local[/]")
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
@privacy_app.command("unlock-local")
|
|
1811
|
+
def privacy_unlock_local():
|
|
1812
|
+
"""
|
|
1813
|
+
Unlock from local-only mode.
|
|
1814
|
+
|
|
1815
|
+
Example:
|
|
1816
|
+
repr privacy unlock-local
|
|
1817
|
+
"""
|
|
1818
|
+
if unlock_local_only():
|
|
1819
|
+
print_success("Local-only mode disabled")
|
|
1820
|
+
print_info("Cloud features are now available")
|
|
1821
|
+
else:
|
|
1822
|
+
print_error("Cannot unlock: local-only mode is permanently locked")
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
# =============================================================================
|
|
1826
|
+
# CONFIG
|
|
1827
|
+
# =============================================================================
|
|
1828
|
+
|
|
1829
|
+
@config_app.command("show")
|
|
1830
|
+
def config_show(
|
|
1831
|
+
key: Optional[str] = typer.Argument(None, help="Specific key (dot notation)"),
|
|
1832
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1833
|
+
):
|
|
1834
|
+
"""
|
|
1835
|
+
Display configuration.
|
|
1836
|
+
|
|
1837
|
+
Examples:
|
|
1838
|
+
repr config show
|
|
1839
|
+
repr config show llm.default
|
|
1840
|
+
"""
|
|
1841
|
+
if key:
|
|
1842
|
+
value = get_config_value(key)
|
|
1843
|
+
if json_output:
|
|
1844
|
+
print(json.dumps(value, indent=2, default=str))
|
|
1845
|
+
else:
|
|
1846
|
+
console.print(f"{key} = {value}")
|
|
1847
|
+
else:
|
|
1848
|
+
config = load_config()
|
|
1849
|
+
# Remove sensitive data
|
|
1850
|
+
if config.get("auth"):
|
|
1851
|
+
config["auth"] = {"signed_in": True, "email": config["auth"].get("email")}
|
|
809
1852
|
|
|
810
|
-
|
|
811
|
-
|
|
1853
|
+
if json_output:
|
|
1854
|
+
print(json.dumps(config, indent=2, default=str))
|
|
1855
|
+
else:
|
|
1856
|
+
console.print("[bold]Configuration[/]")
|
|
1857
|
+
console.print()
|
|
1858
|
+
console.print(json.dumps(config, indent=2, default=str))
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
@config_app.command("set")
|
|
1862
|
+
def config_set(
|
|
1863
|
+
key: str = typer.Argument(..., help="Key (dot notation)"),
|
|
1864
|
+
value: str = typer.Argument(..., help="Value"),
|
|
1865
|
+
):
|
|
1866
|
+
"""
|
|
1867
|
+
Set a configuration value.
|
|
1868
|
+
|
|
1869
|
+
Examples:
|
|
1870
|
+
repr config set llm.default local
|
|
1871
|
+
repr config set generation.batch_size 10
|
|
1872
|
+
"""
|
|
1873
|
+
# Parse value type
|
|
1874
|
+
if value.lower() == "true":
|
|
1875
|
+
parsed_value = True
|
|
1876
|
+
elif value.lower() == "false":
|
|
1877
|
+
parsed_value = False
|
|
1878
|
+
elif value.isdigit():
|
|
1879
|
+
parsed_value = int(value)
|
|
1880
|
+
else:
|
|
1881
|
+
try:
|
|
1882
|
+
parsed_value = float(value)
|
|
1883
|
+
except ValueError:
|
|
1884
|
+
parsed_value = value
|
|
1885
|
+
|
|
1886
|
+
set_config_value(key, parsed_value)
|
|
1887
|
+
print_success(f"Set {key} = {parsed_value}")
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
@config_app.command("edit")
|
|
1891
|
+
def config_edit():
|
|
1892
|
+
"""
|
|
1893
|
+
Open config file in $EDITOR.
|
|
1894
|
+
|
|
1895
|
+
Example:
|
|
1896
|
+
repr config edit
|
|
1897
|
+
"""
|
|
1898
|
+
import subprocess
|
|
1899
|
+
|
|
1900
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
1901
|
+
subprocess.run([editor, str(CONFIG_FILE)])
|
|
1902
|
+
print_success("Config file updated")
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
# =============================================================================
|
|
1906
|
+
# DATA
|
|
1907
|
+
# =============================================================================
|
|
1908
|
+
|
|
1909
|
+
@data_app.callback(invoke_without_command=True)
|
|
1910
|
+
def data_default(ctx: typer.Context):
|
|
1911
|
+
"""Show local data storage info."""
|
|
1912
|
+
if ctx.invoked_subcommand is None:
|
|
1913
|
+
stats = get_storage_stats()
|
|
812
1914
|
|
|
813
|
-
|
|
814
|
-
|
|
1915
|
+
console.print("[bold]Local Data[/]")
|
|
1916
|
+
console.print()
|
|
1917
|
+
console.print(f"Stories: {stats['stories']['count']} files ({format_bytes(stats['stories']['size_bytes'])})")
|
|
1918
|
+
console.print(f"Profiles: {stats['profiles']['count']} files ({format_bytes(stats['profiles']['size_bytes'])})")
|
|
1919
|
+
console.print(f"Cache: {format_bytes(stats['cache']['size_bytes'])}")
|
|
1920
|
+
console.print(f"Config: {format_bytes(stats['config']['size_bytes'])}")
|
|
1921
|
+
console.print()
|
|
1922
|
+
console.print(f"Total: {format_bytes(stats['total_size_bytes'])}")
|
|
1923
|
+
console.print()
|
|
1924
|
+
console.print(f"[{BRAND_MUTED}]Location: {stats['stories']['path']}[/]")
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
@data_app.command("backup")
|
|
1928
|
+
def data_backup(
|
|
1929
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
|
|
1930
|
+
):
|
|
815
1931
|
"""
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1932
|
+
Backup all local data to JSON.
|
|
1933
|
+
|
|
1934
|
+
Example:
|
|
1935
|
+
repr data backup > backup.json
|
|
1936
|
+
repr data backup --output backup.json
|
|
1937
|
+
"""
|
|
1938
|
+
backup_data = backup_all_data()
|
|
1939
|
+
json_str = json.dumps(backup_data, indent=2, default=str)
|
|
1940
|
+
|
|
1941
|
+
if output:
|
|
1942
|
+
output.write_text(json_str)
|
|
1943
|
+
print_success(f"Backup saved to {output}")
|
|
1944
|
+
console.print(f" Stories: {len(backup_data['stories'])}")
|
|
1945
|
+
console.print(f" Profiles: {len(backup_data['profiles'])}")
|
|
1946
|
+
else:
|
|
1947
|
+
print(json_str)
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
@data_app.command("restore")
|
|
1951
|
+
def data_restore(
|
|
1952
|
+
input_file: Path = typer.Argument(..., help="Backup file to restore"),
|
|
1953
|
+
merge: bool = typer.Option(True, "--merge/--replace", help="Merge with existing data"),
|
|
1954
|
+
):
|
|
1955
|
+
"""
|
|
1956
|
+
Restore from backup.
|
|
1957
|
+
|
|
1958
|
+
Example:
|
|
1959
|
+
repr data restore backup.json
|
|
1960
|
+
"""
|
|
1961
|
+
if not input_file.exists():
|
|
1962
|
+
print_error(f"File not found: {input_file}")
|
|
1963
|
+
raise typer.Exit(1)
|
|
1964
|
+
|
|
1965
|
+
try:
|
|
1966
|
+
backup_data = json.loads(input_file.read_text())
|
|
1967
|
+
except json.JSONDecodeError:
|
|
1968
|
+
print_error("Invalid JSON file")
|
|
1969
|
+
raise typer.Exit(1)
|
|
1970
|
+
|
|
1971
|
+
console.print("This will restore:")
|
|
1972
|
+
console.print(f" • {len(backup_data.get('stories', []))} stories")
|
|
1973
|
+
console.print(f" • {len(backup_data.get('profiles', []))} profiles")
|
|
1974
|
+
console.print(f" • Configuration settings")
|
|
1975
|
+
console.print()
|
|
1976
|
+
console.print(f"Mode: {'merge' if merge else 'replace'}")
|
|
820
1977
|
|
|
821
|
-
if not
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1978
|
+
if not confirm("Continue?"):
|
|
1979
|
+
print_info("Cancelled")
|
|
1980
|
+
raise typer.Exit()
|
|
1981
|
+
|
|
1982
|
+
result = restore_from_backup(backup_data, merge=merge)
|
|
1983
|
+
|
|
1984
|
+
print_success("Restore complete")
|
|
1985
|
+
console.print(f" Stories: {result['stories']}")
|
|
1986
|
+
console.print(f" Profiles: {result['profiles']}")
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
@data_app.command("clear-cache")
|
|
1990
|
+
def data_clear_cache():
|
|
1991
|
+
"""
|
|
1992
|
+
Clear local cache.
|
|
1993
|
+
|
|
1994
|
+
Example:
|
|
1995
|
+
repr data clear-cache
|
|
1996
|
+
"""
|
|
1997
|
+
from .config import clear_cache, get_cache_size
|
|
1998
|
+
|
|
1999
|
+
size = get_cache_size()
|
|
2000
|
+
clear_cache()
|
|
2001
|
+
print_success(f"Cache cleared ({format_bytes(size)} freed)")
|
|
2002
|
+
console.print(" Stories preserved")
|
|
2003
|
+
console.print(" Config preserved")
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
# =============================================================================
|
|
2007
|
+
# PROFILE
|
|
2008
|
+
# =============================================================================
|
|
2009
|
+
|
|
2010
|
+
@profile_app.callback(invoke_without_command=True)
|
|
2011
|
+
def profile_default(ctx: typer.Context):
|
|
2012
|
+
"""View profile in terminal."""
|
|
2013
|
+
if ctx.invoked_subcommand is None:
|
|
2014
|
+
profile_config = get_profile_config()
|
|
2015
|
+
story_list = list_stories(limit=5)
|
|
2016
|
+
|
|
2017
|
+
console.print("[bold]Profile[/]")
|
|
825
2018
|
console.print()
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
console.print(f"
|
|
829
|
-
|
|
2019
|
+
|
|
2020
|
+
username = profile_config.get("username") or "Not set"
|
|
2021
|
+
console.print(f"Username: {username}")
|
|
2022
|
+
|
|
2023
|
+
if profile_config.get("bio"):
|
|
2024
|
+
console.print(f"Bio: {profile_config['bio']}")
|
|
2025
|
+
if profile_config.get("location"):
|
|
2026
|
+
console.print(f"Location: {profile_config['location']}")
|
|
2027
|
+
|
|
830
2028
|
console.print()
|
|
831
|
-
console.print("
|
|
2029
|
+
console.print(f"[bold]Recent Stories ({len(story_list)}):[/]")
|
|
2030
|
+
for s in story_list:
|
|
2031
|
+
console.print(f" • {s.get('summary', 'Untitled')[:60]}")
|
|
2032
|
+
|
|
2033
|
+
if is_authenticated():
|
|
2034
|
+
console.print()
|
|
2035
|
+
# TODO: Get profile URL from API
|
|
2036
|
+
console.print(f"[{BRAND_MUTED}]Profile URL: https://repr.dev/@{username}[/]")
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
@profile_app.command("update")
|
|
2040
|
+
def profile_update(
|
|
2041
|
+
preview: bool = typer.Option(False, "--preview", help="Show changes before publishing"),
|
|
2042
|
+
local_only: bool = typer.Option(False, "--local", help="Update locally only"),
|
|
2043
|
+
):
|
|
2044
|
+
"""
|
|
2045
|
+
Generate/update profile from stories.
|
|
2046
|
+
|
|
2047
|
+
Example:
|
|
2048
|
+
repr profile update --preview
|
|
2049
|
+
"""
|
|
2050
|
+
story_list = list_stories()
|
|
2051
|
+
|
|
2052
|
+
if not story_list:
|
|
2053
|
+
print_warning("No stories to build profile from")
|
|
2054
|
+
print_info("Run `repr generate` first")
|
|
2055
|
+
raise typer.Exit(1)
|
|
2056
|
+
|
|
2057
|
+
console.print(f"Building profile from {len(story_list)} stories...")
|
|
2058
|
+
|
|
2059
|
+
# TODO: Implement actual profile generation
|
|
2060
|
+
print_info("Profile generation not yet fully implemented")
|
|
2061
|
+
print_info("Stories are available for manual profile creation")
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
@profile_app.command("set-bio")
|
|
2065
|
+
def profile_set_bio(
|
|
2066
|
+
bio: str = typer.Argument(..., help="Bio text"),
|
|
2067
|
+
):
|
|
2068
|
+
"""Set profile bio."""
|
|
2069
|
+
set_profile_config(bio=bio)
|
|
2070
|
+
print_success("Bio updated")
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
@profile_app.command("set-location")
|
|
2074
|
+
def profile_set_location(
|
|
2075
|
+
location: str = typer.Argument(..., help="Location"),
|
|
2076
|
+
):
|
|
2077
|
+
"""Set profile location."""
|
|
2078
|
+
set_profile_config(location=location)
|
|
2079
|
+
print_success("Location updated")
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
@profile_app.command("set-available")
|
|
2083
|
+
def profile_set_available(
|
|
2084
|
+
available: bool = typer.Argument(..., help="Available for work (true/false)"),
|
|
2085
|
+
):
|
|
2086
|
+
"""Set availability status."""
|
|
2087
|
+
set_profile_config(available=available)
|
|
2088
|
+
print_success(f"Availability: {'available' if available else 'not available'}")
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
@profile_app.command("export")
|
|
2092
|
+
def profile_export(
|
|
2093
|
+
format: str = typer.Option("md", "--format", "-f", help="Format: md, html, json, pdf"),
|
|
2094
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
|
|
2095
|
+
since: Optional[str] = typer.Option(None, "--since", help="Export only recent work (date filter)"),
|
|
2096
|
+
):
|
|
2097
|
+
"""
|
|
2098
|
+
Export profile to different formats.
|
|
2099
|
+
|
|
2100
|
+
Example:
|
|
2101
|
+
repr profile export --format md > profile.md
|
|
2102
|
+
repr profile export --format json --output profile.json
|
|
2103
|
+
repr profile export --format html --output profile.html
|
|
2104
|
+
"""
|
|
2105
|
+
story_list = list_stories()
|
|
2106
|
+
|
|
2107
|
+
# Apply date filter if specified
|
|
2108
|
+
if since and isinstance(since, str):
|
|
2109
|
+
from datetime import datetime
|
|
2110
|
+
try:
|
|
2111
|
+
# Parse the date (support ISO format)
|
|
2112
|
+
since_date = datetime.fromisoformat(since)
|
|
2113
|
+
story_list = [
|
|
2114
|
+
s for s in story_list
|
|
2115
|
+
if s.get('created_at') and datetime.fromisoformat(s['created_at'][:10]) >= since_date
|
|
2116
|
+
]
|
|
2117
|
+
except ValueError:
|
|
2118
|
+
print_error(f"Invalid date format: {since}")
|
|
2119
|
+
print_info("Use ISO format: YYYY-MM-DD")
|
|
2120
|
+
raise typer.Exit(1)
|
|
2121
|
+
|
|
2122
|
+
profile_config = get_profile_config()
|
|
2123
|
+
|
|
2124
|
+
if format == "json":
|
|
2125
|
+
data = {
|
|
2126
|
+
"profile": profile_config,
|
|
2127
|
+
"stories": story_list,
|
|
2128
|
+
}
|
|
2129
|
+
content = json.dumps(data, indent=2, default=str)
|
|
2130
|
+
elif format == "md":
|
|
2131
|
+
# Generate markdown
|
|
2132
|
+
lines = [f"# {profile_config.get('username', 'Developer Profile')}", ""]
|
|
2133
|
+
if profile_config.get("bio"):
|
|
2134
|
+
lines.extend([profile_config["bio"], ""])
|
|
2135
|
+
lines.extend(["## Stories", ""])
|
|
2136
|
+
for s in story_list:
|
|
2137
|
+
lines.append(f"### {s.get('summary', 'Untitled')}")
|
|
2138
|
+
lines.append(f"*{s.get('repo_name', 'unknown')} • {s.get('created_at', '')[:10]}*")
|
|
2139
|
+
lines.append("")
|
|
2140
|
+
content = "\n".join(lines)
|
|
2141
|
+
elif format == "html":
|
|
2142
|
+
# HTML export not yet implemented
|
|
2143
|
+
print_error("HTML export not yet implemented")
|
|
2144
|
+
print_info("Supported formats: md, json")
|
|
2145
|
+
print_info("HTML export is planned for a future release")
|
|
2146
|
+
raise typer.Exit(1)
|
|
2147
|
+
elif format == "pdf":
|
|
2148
|
+
# PDF export not yet implemented
|
|
2149
|
+
print_error("PDF export not yet implemented")
|
|
2150
|
+
print_info("Supported formats: md, json")
|
|
2151
|
+
print_info("PDF export is planned for a future release")
|
|
2152
|
+
raise typer.Exit(1)
|
|
2153
|
+
else:
|
|
2154
|
+
print_error(f"Unsupported format: {format}")
|
|
2155
|
+
print_info("Supported formats: md, json")
|
|
2156
|
+
raise typer.Exit(1)
|
|
2157
|
+
|
|
2158
|
+
if output:
|
|
2159
|
+
output.write_text(content)
|
|
2160
|
+
print_success(f"Exported to {output}")
|
|
2161
|
+
else:
|
|
2162
|
+
print(content)
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
@profile_app.command("link")
|
|
2166
|
+
def profile_link():
|
|
2167
|
+
"""
|
|
2168
|
+
Get shareable profile link.
|
|
2169
|
+
|
|
2170
|
+
Example:
|
|
2171
|
+
repr profile link
|
|
2172
|
+
"""
|
|
2173
|
+
if not is_authenticated():
|
|
2174
|
+
print_error("Profile link requires sign-in")
|
|
2175
|
+
print_info("Run `repr login` first")
|
|
2176
|
+
raise typer.Exit(1)
|
|
2177
|
+
|
|
2178
|
+
profile_config = get_profile_config()
|
|
2179
|
+
username = profile_config.get("username")
|
|
2180
|
+
|
|
2181
|
+
if username:
|
|
2182
|
+
console.print(f"Your profile: https://repr.dev/@{username}")
|
|
2183
|
+
else:
|
|
2184
|
+
print_info("Username not set")
|
|
2185
|
+
print_info("Run `repr profile set-username <name>`")
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
# =============================================================================
|
|
2189
|
+
# STATUS & INFO
|
|
2190
|
+
# =============================================================================
|
|
2191
|
+
|
|
2192
|
+
@app.command()
|
|
2193
|
+
def status(
|
|
2194
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
2195
|
+
):
|
|
2196
|
+
"""
|
|
2197
|
+
Show repr status and health.
|
|
2198
|
+
|
|
2199
|
+
Example:
|
|
2200
|
+
repr status
|
|
2201
|
+
"""
|
|
2202
|
+
authenticated = is_authenticated()
|
|
2203
|
+
user = get_current_user() if authenticated else None
|
|
2204
|
+
tracked = get_tracked_repos()
|
|
2205
|
+
story_count = get_story_count()
|
|
2206
|
+
unpushed = len(get_unpushed_stories())
|
|
2207
|
+
|
|
2208
|
+
if json_output:
|
|
2209
|
+
print(json.dumps({
|
|
2210
|
+
"version": __version__,
|
|
2211
|
+
"authenticated": authenticated,
|
|
2212
|
+
"email": user.get("email") if user else None,
|
|
2213
|
+
"repos_tracked": len(tracked),
|
|
2214
|
+
"stories_total": story_count,
|
|
2215
|
+
"stories_unpushed": unpushed,
|
|
2216
|
+
}, indent=2))
|
|
832
2217
|
return
|
|
833
2218
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
2219
|
+
print_header()
|
|
2220
|
+
|
|
2221
|
+
# Auth status
|
|
2222
|
+
if authenticated:
|
|
2223
|
+
email = user.get("email", "unknown")
|
|
2224
|
+
console.print(f"Auth: [{BRAND_SUCCESS}]✓ Signed in as {email}[/]")
|
|
2225
|
+
else:
|
|
2226
|
+
console.print(f"Auth: [{BRAND_MUTED}]○ Not signed in[/]")
|
|
2227
|
+
|
|
2228
|
+
console.print()
|
|
2229
|
+
|
|
2230
|
+
# Stats
|
|
2231
|
+
console.print(f"Tracked repos: {len(tracked)}")
|
|
2232
|
+
console.print(f"Stories: {story_count} ({unpushed} unpushed)")
|
|
2233
|
+
|
|
2234
|
+
console.print()
|
|
2235
|
+
|
|
2236
|
+
# Next steps
|
|
2237
|
+
if not authenticated:
|
|
2238
|
+
print_info("Run `repr login` to enable cloud sync")
|
|
2239
|
+
elif unpushed > 0:
|
|
2240
|
+
print_info(f"Run `repr push` to publish {unpushed} stories")
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
@app.command()
|
|
2244
|
+
def mode():
|
|
2245
|
+
"""
|
|
2246
|
+
Show current execution mode and settings.
|
|
2247
|
+
|
|
2248
|
+
Example:
|
|
2249
|
+
repr mode
|
|
2250
|
+
"""
|
|
2251
|
+
from .llm import get_llm_status
|
|
2252
|
+
|
|
2253
|
+
authenticated = is_authenticated()
|
|
2254
|
+
privacy = get_privacy_settings()
|
|
2255
|
+
llm_status = get_llm_status()
|
|
2256
|
+
|
|
2257
|
+
console.print("[bold]Mode[/]")
|
|
2258
|
+
console.print()
|
|
2259
|
+
|
|
2260
|
+
if privacy.get("lock_local_only"):
|
|
2261
|
+
if privacy.get("lock_permanent"):
|
|
2262
|
+
console.print("Data boundary: local only (permanently locked)")
|
|
2263
|
+
else:
|
|
2264
|
+
console.print("Data boundary: local only (locked)")
|
|
2265
|
+
elif authenticated:
|
|
2266
|
+
console.print("Data boundary: cloud-enabled")
|
|
2267
|
+
else:
|
|
2268
|
+
console.print("Data boundary: local only")
|
|
2269
|
+
|
|
2270
|
+
console.print(f"Default inference: {llm_status['default_mode']}")
|
|
2271
|
+
|
|
2272
|
+
console.print()
|
|
2273
|
+
|
|
2274
|
+
# LLM info
|
|
2275
|
+
if llm_status["local"]["available"]:
|
|
2276
|
+
console.print(f"Local LLM: {llm_status['local']['name']} ({llm_status['local']['model']})")
|
|
2277
|
+
else:
|
|
2278
|
+
console.print(f"[{BRAND_MUTED}]Local LLM: not detected[/]")
|
|
2279
|
+
|
|
2280
|
+
console.print()
|
|
2281
|
+
|
|
2282
|
+
console.print("Available:")
|
|
2283
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] Local generation")
|
|
2284
|
+
if authenticated and is_cloud_allowed():
|
|
2285
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] Cloud generation")
|
|
2286
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] Sync")
|
|
2287
|
+
console.print(f" [{BRAND_SUCCESS}]✓[/] Publishing")
|
|
2288
|
+
else:
|
|
2289
|
+
reason = "requires login" if not authenticated else "locked"
|
|
2290
|
+
console.print(f" [{BRAND_MUTED}]✗[/] Cloud generation ({reason})")
|
|
2291
|
+
console.print(f" [{BRAND_MUTED}]✗[/] Sync ({reason})")
|
|
2292
|
+
console.print(f" [{BRAND_MUTED}]✗[/] Publishing ({reason})")
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
@app.command()
|
|
2296
|
+
def doctor():
|
|
2297
|
+
"""
|
|
2298
|
+
Health check and diagnostics.
|
|
2299
|
+
|
|
2300
|
+
Example:
|
|
2301
|
+
repr doctor
|
|
2302
|
+
"""
|
|
2303
|
+
from .doctor import run_all_checks
|
|
2304
|
+
|
|
2305
|
+
console.print("Checking repr health...")
|
|
2306
|
+
console.print()
|
|
2307
|
+
|
|
2308
|
+
report = run_all_checks()
|
|
2309
|
+
|
|
2310
|
+
for check in report.checks:
|
|
2311
|
+
if check.status == "ok":
|
|
2312
|
+
icon = f"[{BRAND_SUCCESS}]✓[/]"
|
|
2313
|
+
elif check.status == "warning":
|
|
2314
|
+
icon = f"[{BRAND_WARNING}]⚠[/]"
|
|
2315
|
+
else:
|
|
2316
|
+
icon = f"[{BRAND_ERROR}]✗[/]"
|
|
2317
|
+
|
|
2318
|
+
console.print(f"{icon} {check.name}: {check.message}")
|
|
841
2319
|
|
|
842
|
-
print_success("LLM configuration updated")
|
|
843
2320
|
console.print()
|
|
844
2321
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
console.print(
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
2322
|
+
if report.recommendations:
|
|
2323
|
+
console.print("[bold]Recommendations:[/]")
|
|
2324
|
+
for i, rec in enumerate(report.recommendations, 1):
|
|
2325
|
+
console.print(f" {i}. {rec}")
|
|
2326
|
+
console.print()
|
|
2327
|
+
|
|
2328
|
+
if report.overall_status == "healthy":
|
|
2329
|
+
print_success("All systems healthy")
|
|
2330
|
+
elif report.overall_status == "warnings":
|
|
2331
|
+
print_warning("Some items need attention")
|
|
2332
|
+
else:
|
|
2333
|
+
print_error("Issues found - see recommendations above")
|
|
854
2334
|
|
|
855
2335
|
|
|
856
2336
|
# Entry point
|