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/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)