cli-dev-tip 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dev_tip/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import subprocess
5
+ import sys
6
+ import textwrap
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from dev_tip.config import CONFIG_DIR, load_config
14
+ from dev_tip.history import all_seen, get_unseen, mark_seen
15
+ from dev_tip.hook import disable as hook_disable
16
+ from dev_tip.hook import enable as hook_enable
17
+ from dev_tip.tips import filter_tips, load_tips
18
+
19
+ app = typer.Typer(invoke_without_command=True, add_completion=False)
20
+ console = Console()
21
+
22
+ PAUSE_FILE = CONFIG_DIR / ".paused"
23
+
24
+ TOPIC_EMOJI = {
25
+ "python": "\U0001f40d",
26
+ "git": "\U0001f500",
27
+ "docker": "\U0001f433",
28
+ "sql": "\U0001f4be",
29
+ "linux": "\U0001f427",
30
+ "kubernetes": "\u2638\ufe0f",
31
+ "vim": "\U0001f4dd",
32
+ "javascript": "\U0001f7e8",
33
+ "terraform": "\U0001f3d7\ufe0f",
34
+ "rust": "\U0001f980",
35
+ }
36
+
37
+
38
+ def _render_tip(tip: dict, quiet: bool = False) -> None:
39
+ """Display a tip as a compact, dim, right-floated block."""
40
+ console.print() # breathing room between shell output and tip
41
+
42
+ body = tip["body"].strip()
43
+ wrap_width = min(console.width, 60)
44
+ term_w = console.width
45
+ pad = term_w - wrap_width
46
+
47
+ if quiet:
48
+ body_lines = textwrap.wrap(body, width=wrap_width)
49
+ for line in body_lines:
50
+ console.print(" " * max(pad, 0) + line, style="dim", highlight=False)
51
+ return
52
+
53
+ topic = tip["topic"]
54
+ emoji = TOPIC_EMOJI.get(topic, "\U0001f4a1")
55
+ header = f"{emoji} {topic} \u00b7 {tip['level']} \u00b7 {tip['title']}"
56
+
57
+ header_lines = textwrap.wrap(header, width=wrap_width)
58
+ body_lines = textwrap.wrap(body, width=wrap_width)
59
+
60
+ block = header_lines + [""] + body_lines
61
+ for line in block:
62
+ console.print(" " * max(pad, 0) + line, style="dim", highlight=False)
63
+
64
+
65
+ def _maybe_prefetch(topic: str | None, level: str | None, unseen_count: int) -> None:
66
+ """Spawn a background prefetch if the cache is running low."""
67
+ from dev_tip.ai.cache import cache_needs_refill
68
+
69
+ if not cache_needs_refill(topic, level, unseen_count):
70
+ return
71
+
72
+ topic_arg = str(topic) if topic is not None else "null"
73
+ level_arg = str(level) if level is not None else "null"
74
+ try:
75
+ subprocess.Popen(
76
+ [sys.executable, "-m", "dev_tip.prefetch", topic_arg, level_arg],
77
+ stdout=subprocess.DEVNULL,
78
+ stderr=subprocess.DEVNULL,
79
+ stdin=subprocess.DEVNULL,
80
+ start_new_session=True,
81
+ )
82
+ except OSError:
83
+ pass
84
+
85
+
86
+ @app.callback()
87
+ def main(
88
+ ctx: typer.Context,
89
+ topic: Optional[str] = typer.Option(None, "--topic", "-t", help="Filter by topic"),
90
+ level: Optional[str] = typer.Option(None, "--level", "-l", help="Filter by level"),
91
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="AI provider (gemini, openrouter)"),
92
+ key: Optional[str] = typer.Option(None, "--key", "-k", help="API key for the AI provider"),
93
+ quiet: Optional[bool] = typer.Option(False, "--quiet", "-q", help="Show tip body only, no header"),
94
+ ) -> None:
95
+ """Show a random developer tip."""
96
+ if ctx.invoked_subcommand is not None:
97
+ return
98
+
99
+ config = load_config()
100
+ topic = topic or config.get("topic")
101
+ level = level or config.get("level")
102
+ quiet = quiet or config.get("quiet", False)
103
+ if provider:
104
+ config["ai_provider"] = provider
105
+ if key:
106
+ config["ai_key"] = key
107
+
108
+ ai_provider = config.get("ai_provider")
109
+
110
+ # Validate topic/level
111
+ from dev_tip.tips import VALID_LEVELS, VALID_TOPICS
112
+
113
+ if topic and topic not in VALID_TOPICS and not ai_provider:
114
+ console.print(
115
+ f"[yellow]Unknown topic '{topic}'. "
116
+ f"Available: {', '.join(sorted(VALID_TOPICS))}[/yellow]"
117
+ )
118
+ if level and level not in VALID_LEVELS:
119
+ console.print(
120
+ f"[yellow]Unknown level '{level}'. "
121
+ f"Available: {', '.join(sorted(VALID_LEVELS))}[/yellow]"
122
+ )
123
+
124
+ if ai_provider:
125
+ from dev_tip.ai import get_ai_tip
126
+
127
+ tip, unseen_count = get_ai_tip(topic=topic, level=level, config=config)
128
+ if tip is not None:
129
+ mark_seen(tip["id"])
130
+ _render_tip(tip, quiet=quiet)
131
+ _maybe_prefetch(topic, level, unseen_count)
132
+ return
133
+
134
+ tips = load_tips()
135
+ filtered = filter_tips(tips, topic=topic, level=level)
136
+
137
+ if not filtered:
138
+ # Topic may only exist for AI — drop topic filter, keep level
139
+ filtered = filter_tips(tips, level=level)
140
+
141
+ if not filtered:
142
+ console.print("[red]No tips found for the given filters.[/red]")
143
+ raise typer.Exit(1)
144
+
145
+ if not ai_provider and all_seen(filtered):
146
+ console.print(
147
+ "[dim]You've seen all tips! For unlimited fresh tips, set up free AI generation:"
148
+ "\nhttps://aistudio.google.com[/dim]\n"
149
+ )
150
+
151
+ unseen = get_unseen(filtered)
152
+ tip = random.choice(unseen)
153
+ mark_seen(tip["id"])
154
+ _render_tip(tip, quiet=quiet)
155
+
156
+
157
+ @app.command()
158
+ def enable(
159
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="AI provider (gemini, openrouter)"),
160
+ key: Optional[str] = typer.Option(None, "--key", "-k", help="API key for the AI provider"),
161
+ topic: Optional[str] = typer.Option(None, "--topic", "-t", help="Default topic filter"),
162
+ level: Optional[str] = typer.Option(None, "--level", "-l", help="Default level filter"),
163
+ every_commands: Optional[int] = typer.Option(None, "--every-commands", help="Show tip every N commands (default: 15)"),
164
+ every_minutes: Optional[int] = typer.Option(None, "--every-minutes", help="Show tip every N minutes (default: 30)"),
165
+ quiet: Optional[bool] = typer.Option(False, "--quiet", "-q", help="Show tip body only, no header"),
166
+ ) -> None:
167
+ """Enable the shell hook (show a tip on every new terminal)."""
168
+ hook_enable(
169
+ provider=provider,
170
+ key=key,
171
+ topic=topic,
172
+ level=level,
173
+ every_commands=every_commands,
174
+ every_minutes=every_minutes,
175
+ quiet=quiet or False,
176
+ )
177
+
178
+
179
+ @app.command()
180
+ def disable() -> None:
181
+ """Disable the shell hook."""
182
+ hook_disable()
183
+
184
+
185
+ @app.command()
186
+ def pause() -> None:
187
+ """Pause tips (keeps hook installed, stops showing tips)."""
188
+ PAUSE_FILE.parent.mkdir(parents=True, exist_ok=True)
189
+ PAUSE_FILE.touch()
190
+ console.print("[yellow]Tips paused.[/yellow] Run [bold]dev-tip resume[/bold] to continue.")
191
+
192
+
193
+ @app.command()
194
+ def resume() -> None:
195
+ """Resume showing tips."""
196
+ PAUSE_FILE.unlink(missing_ok=True)
197
+ console.print("[green]Tips resumed.[/green]")
198
+
199
+
200
+ @app.command("clear-cache")
201
+ def clear_cache() -> None:
202
+ """Clear cached AI tips (forces fresh generation on next run)."""
203
+ from dev_tip.ai.cache import clear_cache as do_clear
204
+
205
+ do_clear()
206
+ console.print("[green]AI cache cleared.[/green]")
207
+
208
+
209
+ @app.command()
210
+ def status() -> None:
211
+ """Show current dev-tip configuration and status."""
212
+ from dev_tip.ai.cache import get_cache_stats
213
+ from dev_tip.history import _load_history
214
+ from dev_tip.hook import HOOK_MARKER_START, PAUSE_FILE, _get_rc_file
215
+
216
+ config = load_config()
217
+ rc_file = _get_rc_file()
218
+
219
+ # Hook status
220
+ hook_installed = False
221
+ if rc_file.exists():
222
+ hook_installed = HOOK_MARKER_START in rc_file.read_text()
223
+
224
+ paused = PAUSE_FILE.exists()
225
+
226
+ console.print("[bold]dev-tip status[/bold]\n")
227
+
228
+ # Hook
229
+ hook_label = "[green]installed[/green]" if hook_installed else "[yellow]not installed[/yellow]"
230
+ console.print(f" Hook: {hook_label} ({rc_file.name})")
231
+
232
+ # Paused
233
+ paused_label = "[yellow]yes[/yellow]" if paused else "[green]no[/green]"
234
+ console.print(f" Paused: {paused_label}")
235
+
236
+ # Config
237
+ console.print()
238
+ console.print("[bold] Config[/bold]")
239
+ console.print(f" topic: {config.get('topic') or '[dim]any[/dim]'}")
240
+ console.print(f" level: {config.get('level') or '[dim]any[/dim]'}")
241
+ console.print(f" every_commands: {config.get('every_commands')}")
242
+ console.print(f" every_minutes: {config.get('every_minutes')}")
243
+ console.print(f" quiet: {config.get('quiet', False)}")
244
+
245
+ # AI
246
+ ai_provider = config.get("ai_provider")
247
+ console.print()
248
+ console.print("[bold] AI[/bold]")
249
+ if ai_provider:
250
+ console.print(f" provider: {ai_provider}")
251
+ console.print(f" model: {config.get('ai_model') or '[dim]default[/dim]'}")
252
+ ai_key = config.get("ai_key")
253
+ if ai_key:
254
+ masked = ai_key[:4] + "..." + ai_key[-4:] if len(ai_key) > 8 else "***"
255
+ console.print(f" key: {masked}")
256
+ else:
257
+ console.print(" key: [dim]from env var[/dim]")
258
+ else:
259
+ console.print(" [dim]not configured (using static tips)[/dim]")
260
+
261
+ # Cache
262
+ stats = get_cache_stats()
263
+ console.print()
264
+ console.print("[bold] Cache[/bold]")
265
+ console.print(f" cached keys: {stats['keys']}")
266
+ console.print(f" total tips: {stats['total_tips']}")
267
+ cooldown = "[yellow]yes[/yellow]" if stats["cooldown_active"] else "no"
268
+ console.print(f" cooldown: {cooldown}")
269
+
270
+ # History
271
+ history = _load_history()
272
+ console.print()
273
+ console.print("[bold] History[/bold]")
274
+ console.print(f" tips seen: {len(history)}")
dev_tip/config.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ CONFIG_DIR = Path.home() / ".dev-tip"
8
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
9
+
10
+ DEFAULT_CONFIG = {
11
+ "topic": None,
12
+ "level": None,
13
+ "ai_provider": None,
14
+ "ai_model": None,
15
+ "ai_key": None,
16
+ "every_commands": 15,
17
+ "every_minutes": 30,
18
+ "quiet": False,
19
+ }
20
+
21
+ _TEMPLATE = """\
22
+ # dev-tip configuration
23
+
24
+ # Default topic filter (python, git, docker, sql, linux)
25
+ # topic = "python"
26
+
27
+ # Default level filter (beginner, intermediate, advanced)
28
+ # level = "beginner"
29
+
30
+ # AI-powered tip generation (free, requires API key in env var)
31
+ # ai_provider = "gemini" # or "openrouter"
32
+ # ai_model = "gemini-2.0-flash"
33
+
34
+ # Periodic tip frequency
35
+ # every_commands = 15 # show a tip every N commands
36
+ # every_minutes = 30 # or every M minutes, whichever comes first
37
+
38
+ # Quiet mode — show tip body only, no header
39
+ # quiet = false
40
+ """
41
+
42
+
43
+ def load_config() -> dict[str, Any]:
44
+ """Load config from ~/.dev-tip/config.toml, creating it if missing."""
45
+ if not CONFIG_FILE.exists():
46
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
47
+ CONFIG_FILE.write_text(_TEMPLATE)
48
+ return dict(DEFAULT_CONFIG)
49
+
50
+ with open(CONFIG_FILE, "rb") as f:
51
+ raw = tomllib.load(f)
52
+
53
+ config = dict(DEFAULT_CONFIG)
54
+ for key in DEFAULT_CONFIG:
55
+ if key in raw:
56
+ config[key] = raw[key]
57
+ return config
58
+
59
+
60
+ def _format_value(key: str, value: Any) -> str:
61
+ """Format a config key-value pair for TOML output."""
62
+ if isinstance(value, bool):
63
+ return f'{key} = {"true" if value else "false"}'
64
+ if isinstance(value, int):
65
+ return f"{key} = {value}"
66
+ return f'{key} = "{value}"'
67
+
68
+
69
+ def save_config(updates: dict[str, Any]) -> None:
70
+ """Update specific keys in the config file, preserving comments."""
71
+ config = load_config()
72
+ config.update(updates)
73
+
74
+ if CONFIG_FILE.exists():
75
+ lines = CONFIG_FILE.read_text().splitlines()
76
+ else:
77
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
78
+ lines = _TEMPLATE.splitlines()
79
+
80
+ # Update existing keys or uncomment commented keys
81
+ written_keys: set[str] = set()
82
+ for i, line in enumerate(lines):
83
+ stripped = line.lstrip("# ").strip()
84
+ for key, value in config.items():
85
+ if value is None:
86
+ continue
87
+ if stripped.startswith(f"{key} =") or stripped.startswith(f"{key}="):
88
+ lines[i] = _format_value(key, value)
89
+ written_keys.add(key)
90
+ break
91
+
92
+ # Append any keys not found in existing lines
93
+ for key, value in config.items():
94
+ if value is not None and key not in written_keys:
95
+ lines.append(_format_value(key, value))
96
+
97
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
98
+ CONFIG_FILE.write_text("\n".join(lines) + "\n")