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.
- cli_dev_tip-0.1.0.dist-info/METADATA +223 -0
- cli_dev_tip-0.1.0.dist-info/RECORD +19 -0
- cli_dev_tip-0.1.0.dist-info/WHEEL +4 -0
- cli_dev_tip-0.1.0.dist-info/entry_points.txt +2 -0
- cli_dev_tip-0.1.0.dist-info/licenses/LICENSE +21 -0
- dev_tip/__init__.py +0 -0
- dev_tip/ai/__init__.py +55 -0
- dev_tip/ai/cache.py +114 -0
- dev_tip/ai/gemini.py +28 -0
- dev_tip/ai/openrouter.py +37 -0
- dev_tip/ai/prompt.py +60 -0
- dev_tip/ai/provider.py +26 -0
- dev_tip/cli.py +274 -0
- dev_tip/config.py +98 -0
- dev_tip/data/tips.yaml +2070 -0
- dev_tip/history.py +46 -0
- dev_tip/hook.py +192 -0
- dev_tip/prefetch.py +98 -0
- dev_tip/tips.py +45 -0
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")
|