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/history.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
HISTORY_DIR = Path.home() / ".dev-tip"
|
|
7
|
+
HISTORY_FILE = HISTORY_DIR / "history.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_history() -> list[str]:
|
|
11
|
+
"""Load the list of seen tip IDs from disk."""
|
|
12
|
+
if not HISTORY_FILE.exists():
|
|
13
|
+
return []
|
|
14
|
+
return json.loads(HISTORY_FILE.read_text())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _save_history(seen: list[str]) -> None:
|
|
18
|
+
"""Save the list of seen tip IDs to disk."""
|
|
19
|
+
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
HISTORY_FILE.write_text(json.dumps(seen))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_unseen(tips: list[dict]) -> list[dict]:
|
|
24
|
+
"""Filter out already-seen tips. If all are seen, reset but keep the last one."""
|
|
25
|
+
seen = _load_history()
|
|
26
|
+
seen_set = set(seen)
|
|
27
|
+
unseen = [t for t in tips if t["id"] not in seen_set]
|
|
28
|
+
if not unseen:
|
|
29
|
+
# Keep only the most recent tip so it won't repeat immediately.
|
|
30
|
+
_save_history(seen[-1:])
|
|
31
|
+
return [t for t in tips if t["id"] != seen[-1]] if seen else tips
|
|
32
|
+
return unseen
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def all_seen(tips: list[dict]) -> bool:
|
|
36
|
+
"""Check if every tip has been seen."""
|
|
37
|
+
seen = set(_load_history())
|
|
38
|
+
return all(t["id"] in seen for t in tips)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def mark_seen(tip_id: str) -> None:
|
|
42
|
+
"""Append a tip ID to the history file."""
|
|
43
|
+
seen = _load_history()
|
|
44
|
+
if tip_id not in seen:
|
|
45
|
+
seen.append(tip_id)
|
|
46
|
+
_save_history(seen)
|
dev_tip/hook.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from textwrap import dedent
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from dev_tip.config import CONFIG_DIR, DEFAULT_CONFIG
|
|
11
|
+
|
|
12
|
+
HOOK_MARKER_START = "# >>> dev-tip hook >>>"
|
|
13
|
+
HOOK_MARKER_END = "# <<< dev-tip hook <<<"
|
|
14
|
+
PAUSE_FILE = CONFIG_DIR / ".paused"
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _detect_shell() -> str:
|
|
20
|
+
"""Detect whether user is running zsh or bash."""
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
shell = os.environ.get("SHELL", "")
|
|
24
|
+
if "zsh" in shell:
|
|
25
|
+
return "zsh"
|
|
26
|
+
return "bash"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_rc_file() -> Path:
|
|
30
|
+
"""Detect the user's shell rc file."""
|
|
31
|
+
shell = _detect_shell()
|
|
32
|
+
if shell == "zsh":
|
|
33
|
+
return Path.home() / ".zshrc"
|
|
34
|
+
return Path.home() / ".bashrc"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_hook_command(
|
|
38
|
+
provider: str | None = None,
|
|
39
|
+
topic: str | None = None,
|
|
40
|
+
level: str | None = None,
|
|
41
|
+
quiet: bool = False,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Build the dev-tip command for the shell hook."""
|
|
44
|
+
parts = ["dev-tip"]
|
|
45
|
+
if provider:
|
|
46
|
+
parts.append(f"--provider {provider}")
|
|
47
|
+
if topic:
|
|
48
|
+
parts.append(f"--topic {topic}")
|
|
49
|
+
if level:
|
|
50
|
+
parts.append(f"--level {level}")
|
|
51
|
+
if quiet:
|
|
52
|
+
parts.append("--quiet")
|
|
53
|
+
return " ".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_hook_block(
|
|
57
|
+
shell: str,
|
|
58
|
+
cmd: str,
|
|
59
|
+
every_commands: int,
|
|
60
|
+
every_minutes: int,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Wrap the dev-tip command in a periodic shell function."""
|
|
63
|
+
pause_path = PAUSE_FILE
|
|
64
|
+
if shell == "zsh":
|
|
65
|
+
return dedent(f"""\
|
|
66
|
+
{HOOK_MARKER_START}
|
|
67
|
+
_DEV_TIP_CMD_COUNT={every_commands}
|
|
68
|
+
_DEV_TIP_LAST_SEC=$SECONDS
|
|
69
|
+
_dev_tip_precmd() {{
|
|
70
|
+
[ -f {pause_path} ] && return
|
|
71
|
+
_DEV_TIP_CMD_COUNT=$((_DEV_TIP_CMD_COUNT + 1))
|
|
72
|
+
if (( _DEV_TIP_CMD_COUNT >= {every_commands} || (SECONDS - _DEV_TIP_LAST_SEC) / 60 >= {every_minutes} )); then
|
|
73
|
+
{cmd} 2>/dev/null
|
|
74
|
+
_DEV_TIP_CMD_COUNT=0
|
|
75
|
+
_DEV_TIP_LAST_SEC=$SECONDS
|
|
76
|
+
fi
|
|
77
|
+
}}
|
|
78
|
+
autoload -Uz add-zsh-hook
|
|
79
|
+
add-zsh-hook precmd _dev_tip_precmd
|
|
80
|
+
{HOOK_MARKER_END}
|
|
81
|
+
""")
|
|
82
|
+
# bash
|
|
83
|
+
return dedent(f"""\
|
|
84
|
+
{HOOK_MARKER_START}
|
|
85
|
+
_DEV_TIP_CMD_COUNT={every_commands}
|
|
86
|
+
_DEV_TIP_LAST_SEC=$SECONDS
|
|
87
|
+
_dev_tip_prompt() {{
|
|
88
|
+
[ -f {pause_path} ] && return
|
|
89
|
+
_DEV_TIP_CMD_COUNT=$((_DEV_TIP_CMD_COUNT + 1))
|
|
90
|
+
if (( _DEV_TIP_CMD_COUNT >= {every_commands} || (SECONDS - _DEV_TIP_LAST_SEC) / 60 >= {every_minutes} )); then
|
|
91
|
+
{cmd} 2>/dev/null
|
|
92
|
+
_DEV_TIP_CMD_COUNT=0
|
|
93
|
+
_DEV_TIP_LAST_SEC=$SECONDS
|
|
94
|
+
fi
|
|
95
|
+
}}
|
|
96
|
+
PROMPT_COMMAND="_dev_tip_prompt${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}"
|
|
97
|
+
{HOOK_MARKER_END}
|
|
98
|
+
""")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def enable(
|
|
102
|
+
provider: str | None = None,
|
|
103
|
+
key: str | None = None,
|
|
104
|
+
topic: str | None = None,
|
|
105
|
+
level: str | None = None,
|
|
106
|
+
every_commands: int | None = None,
|
|
107
|
+
every_minutes: int | None = None,
|
|
108
|
+
quiet: bool = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Install the shell hook into the user's rc file."""
|
|
111
|
+
from dev_tip.config import save_config
|
|
112
|
+
|
|
113
|
+
every_commands = every_commands or DEFAULT_CONFIG["every_commands"]
|
|
114
|
+
every_minutes = every_minutes or DEFAULT_CONFIG["every_minutes"]
|
|
115
|
+
|
|
116
|
+
# Save config if anything provided
|
|
117
|
+
updates: dict = {}
|
|
118
|
+
if provider:
|
|
119
|
+
updates["ai_provider"] = provider
|
|
120
|
+
if key:
|
|
121
|
+
updates["ai_key"] = key
|
|
122
|
+
if topic:
|
|
123
|
+
updates["topic"] = topic
|
|
124
|
+
if level:
|
|
125
|
+
updates["level"] = level
|
|
126
|
+
updates["every_commands"] = every_commands
|
|
127
|
+
updates["every_minutes"] = every_minutes
|
|
128
|
+
if quiet:
|
|
129
|
+
updates["quiet"] = True
|
|
130
|
+
save_config(updates)
|
|
131
|
+
|
|
132
|
+
# Pre-cache AI tips so the first shell prompt is instant
|
|
133
|
+
if provider and key:
|
|
134
|
+
topic_arg = str(topic) if topic is not None else "null"
|
|
135
|
+
level_arg = str(level) if level is not None else "null"
|
|
136
|
+
try:
|
|
137
|
+
subprocess.Popen(
|
|
138
|
+
[sys.executable, "-m", "dev_tip.prefetch", topic_arg, level_arg],
|
|
139
|
+
stdout=subprocess.DEVNULL,
|
|
140
|
+
stderr=subprocess.DEVNULL,
|
|
141
|
+
stdin=subprocess.DEVNULL,
|
|
142
|
+
start_new_session=True,
|
|
143
|
+
)
|
|
144
|
+
console.print("[dim]Pre-caching AI tips in the background...[/dim]")
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
rc_file = _get_rc_file()
|
|
149
|
+
shell = _detect_shell()
|
|
150
|
+
|
|
151
|
+
# Remove existing hook first (so re-running enable updates it)
|
|
152
|
+
if rc_file.exists():
|
|
153
|
+
content = rc_file.read_text()
|
|
154
|
+
if HOOK_MARKER_START in content:
|
|
155
|
+
start = content.index(HOOK_MARKER_START)
|
|
156
|
+
end = content.index(HOOK_MARKER_END) + len(HOOK_MARKER_END)
|
|
157
|
+
content = content[:start].rstrip() + content[end:].lstrip("\n")
|
|
158
|
+
else:
|
|
159
|
+
content = ""
|
|
160
|
+
|
|
161
|
+
cmd = _build_hook_command(provider, topic, level, quiet=quiet)
|
|
162
|
+
hook_block = _build_hook_block(shell, cmd, every_commands, every_minutes)
|
|
163
|
+
content = content.rstrip() + "\n\n" + hook_block
|
|
164
|
+
rc_file.write_text(content)
|
|
165
|
+
|
|
166
|
+
console.print(f"[green]Hook installed in {rc_file}[/green]")
|
|
167
|
+
console.print(
|
|
168
|
+
f"Tips will appear every {every_commands} commands "
|
|
169
|
+
f"or {every_minutes} minutes."
|
|
170
|
+
)
|
|
171
|
+
console.print(f"Restart your {shell} or run: [bold]source {rc_file}[/bold]")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def disable() -> None:
|
|
175
|
+
"""Remove the shell hook from the user's rc file."""
|
|
176
|
+
rc_file = _get_rc_file()
|
|
177
|
+
|
|
178
|
+
if not rc_file.exists():
|
|
179
|
+
console.print("[yellow]No rc file found.[/yellow]")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
content = rc_file.read_text()
|
|
183
|
+
if HOOK_MARKER_START not in content:
|
|
184
|
+
console.print("[yellow]Hook not found — nothing to remove.[/yellow]")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
start = content.index(HOOK_MARKER_START)
|
|
188
|
+
end = content.index(HOOK_MARKER_END) + len(HOOK_MARKER_END)
|
|
189
|
+
cleaned = content[:start].rstrip() + content[end:].lstrip("\n")
|
|
190
|
+
rc_file.write_text(cleaned)
|
|
191
|
+
|
|
192
|
+
console.print(f"[green]Hook removed from {rc_file}[/green]")
|
dev_tip/prefetch.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Background prefetch worker: python -m dev_tip.prefetch <topic> <level>
|
|
2
|
+
|
|
3
|
+
Fetches a fresh batch of AI tips and appends them to the cache.
|
|
4
|
+
Uses a lock file to prevent concurrent prefetch processes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
LOCK_FILE = Path.home() / ".dev-tip" / ".prefetch.lock"
|
|
15
|
+
LOCK_MAX_AGE = 120 # seconds
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _acquire_lock() -> bool:
|
|
19
|
+
"""Try to acquire the lock file. Return True on success."""
|
|
20
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
if LOCK_FILE.exists():
|
|
23
|
+
try:
|
|
24
|
+
info = json.loads(LOCK_FILE.read_text())
|
|
25
|
+
pid = info.get("pid", 0)
|
|
26
|
+
ts = info.get("time", 0)
|
|
27
|
+
|
|
28
|
+
# Check if lock is stale
|
|
29
|
+
if time.time() - ts < LOCK_MAX_AGE:
|
|
30
|
+
# Check if process is still alive
|
|
31
|
+
try:
|
|
32
|
+
os.kill(pid, 0)
|
|
33
|
+
return False # Process alive, lock held
|
|
34
|
+
except OSError:
|
|
35
|
+
pass # Process dead, stale lock
|
|
36
|
+
except (json.JSONDecodeError, KeyError):
|
|
37
|
+
pass # Corrupt lock file, take over
|
|
38
|
+
|
|
39
|
+
LOCK_FILE.write_text(json.dumps({"pid": os.getpid(), "time": time.time()}))
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _release_lock() -> None:
|
|
44
|
+
"""Remove the lock file."""
|
|
45
|
+
try:
|
|
46
|
+
LOCK_FILE.unlink(missing_ok=True)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
args = sys.argv[1:]
|
|
53
|
+
if len(args) != 2:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
topic = None if args[0] == "null" else args[0]
|
|
57
|
+
level = None if args[1] == "null" else args[1]
|
|
58
|
+
|
|
59
|
+
if not _acquire_lock():
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
from dev_tip.ai.cache import is_on_cooldown, mark_failure, save_cache
|
|
64
|
+
from dev_tip.ai.provider import create_provider
|
|
65
|
+
from dev_tip.config import load_config
|
|
66
|
+
|
|
67
|
+
if is_on_cooldown():
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
config = load_config()
|
|
71
|
+
provider_name = config.get("ai_provider")
|
|
72
|
+
if not provider_name:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
api_key = config.get("ai_key")
|
|
76
|
+
if not api_key:
|
|
77
|
+
env_keys = {"gemini": "GEMINI_API_KEY", "openrouter": "OPENROUTER_API_KEY"}
|
|
78
|
+
env_var = env_keys.get(provider_name)
|
|
79
|
+
if env_var:
|
|
80
|
+
api_key = os.environ.get(env_var)
|
|
81
|
+
if not api_key:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
provider = create_provider(provider_name, api_key, model=config.get("ai_model"))
|
|
85
|
+
try:
|
|
86
|
+
new_tips = provider.generate_tips(topic, level, 10)
|
|
87
|
+
except Exception:
|
|
88
|
+
mark_failure()
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# save_cache merges and deduplicates automatically
|
|
92
|
+
save_cache(new_tips, topic, level)
|
|
93
|
+
finally:
|
|
94
|
+
_release_lock()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
dev_tip/tips.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
VALID_TOPICS = {
|
|
10
|
+
"python", "git", "docker", "sql", "linux",
|
|
11
|
+
"kubernetes", "vim", "javascript", "terraform", "rust",
|
|
12
|
+
}
|
|
13
|
+
VALID_LEVELS = {"beginner", "intermediate", "advanced"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_tips() -> list[dict]:
|
|
17
|
+
"""Load all tips from the bundled YAML file."""
|
|
18
|
+
tip_file = files("dev_tip.data").joinpath("tips.yaml")
|
|
19
|
+
return yaml.safe_load(tip_file.read_text())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def filter_tips(
|
|
23
|
+
tips: list[dict],
|
|
24
|
+
topic: Optional[str] = None,
|
|
25
|
+
level: Optional[str] = None,
|
|
26
|
+
) -> list[dict]:
|
|
27
|
+
"""Filter tips by topic and/or level."""
|
|
28
|
+
filtered = tips
|
|
29
|
+
if topic:
|
|
30
|
+
filtered = [t for t in filtered if t["topic"] == topic]
|
|
31
|
+
if level:
|
|
32
|
+
filtered = [t for t in filtered if t["level"] == level]
|
|
33
|
+
return filtered
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_random_tip(
|
|
37
|
+
tips: list[dict],
|
|
38
|
+
topic: Optional[str] = None,
|
|
39
|
+
level: Optional[str] = None,
|
|
40
|
+
) -> Optional[dict]:
|
|
41
|
+
"""Return a random tip matching the given filters, or None if no match."""
|
|
42
|
+
filtered = filter_tips(tips, topic=topic, level=level)
|
|
43
|
+
if not filtered:
|
|
44
|
+
return None
|
|
45
|
+
return random.choice(filtered)
|