How to Build zsh-tips-agent: A Step-by-Step Journey

Robert CollinsRobert Collins
44 min read

This tutorial was generated with autoblogger by analyzing the git history of the project.

GitHub url: https://github.com/robbiemu/zsh-tips-agent

Project Overview

This project is a Zsh enhancement tool called zsh-tips-agent that provides personalized CLI usage tips based on command history and local language models. The key concept is underused tool suggestions. It does this with background tip caching for fast terminal startup, and integration with Ollama for local model execution. The project uses smolagents and ollama as dependencies, with configurable parameters in JSON files. This project is designed to keep your CLI tips fresh and relevant by periodically checking for updates in the background. It smartly adapts to your unique system setup with path-aware tool discovery and configuration, all while integrating cleanly through a standard, prefix-based installation.

Development Journey

Let's walk through how this project was built, commit by commit:

Step 1: Initializing Project Scaffold for zsh-tips-agent

This commit establishes the foundational structure for the zsh-tips-agent project, creating essential files to enable core functionality and project organization.

We can scaffold some basic project structure.

  • .zshrc_snippet -- lines to add to the user's .zshrc

  • README.md

  • agent/generate_tip.py -- core logic implementation

  • bin/update_tip_cache.sh -- to manage tip data

Finally, the data storage structure was initialized with:

File: data/tip_cache.json

The data/tip_cache.json file was added to serve as a cache storage mechanism for tip-related data.

This initial scaffold creates a clear foundation for developing Zsh tip generation and management capabilities in subsequent steps.

Step 2: Initializing Core Project Files

This commit introduces foundational files to establish the project's structure and governance.

Let's wrap up. First with version control hygiene:

File: .gitignore

This commit introduces a new .gitignore file to define patterns for files and directories that should be excluded from version control. The file categorizes ignored items into operating system artifacts, editor-specific files, runtime data, and custom exclusions.

We need to define the project's licensing terms:

File: LICENSE

The LICENSE file was newly added in this commit, specifying the terms under which the software is distributed. The GNU Lesser General Public License Version 3 was chosen because it allows for the use, modification, and distribution of the software while preserving certain freedoms for users and developers.

And we need to make a plan. We'll sketch what we want in the README (this may seem strange but the git history makes it clear it was done in this order):

File: README.md

# 💡 zsh-tips-agent

A Zsh tool that provides useful command-line tips based on your actual usage history, leveraging local language models via ollama without slowing down your terminal.

## 🔧 Features

- Suggests underused CLI tools from directories such as `/usr/local/bin` or `brew` packages
- Generates personalized tips based on your command history (`~/.zsh_history`)
- Uses local language models (e.g., via `ollama`) to intelligently generate tips
- Updates tip cache in the background to ensure quick terminal startup
- Tips are cached and updated in the background (default: `~/.local/share/zsh-tips-agent/data/`)
- Automatically refreshes tips periodically

## 🚀 Quickstart

```bash
git clone https://github.com/YOURUSER/zsh-tips-agent.git $GH/zsh-tips-agent
cd $GH/zsh-tips-agent
uv pip install .
zsh-tips-agent install
zsh-tips-agent init --apply
source ~/.zshrc
\`\`\`

Alternatively, directly add this to your `~/.zshrc`:

```bash
eval "$(zsh-tips-agent init --print)"
\`\`\`

## ✅ Prerequisites

- [`ollama`](https://ollama.com/) installed and configured locally
- At least one local language model available (`ollama ls`, e.g., `llama3`, `phi3`)
- Python dependencies installed via `uv pip install .`
- CLI tools available on your system (typically `/usr/local/bin`, `brew`)

## 🧠 Tip Generation

Tips são geradas usando modelos de linguagem locais. Elas são armazenadas em cache e atualizadas periodicamente em segundo plano, garantindo um terminal responsivo. Os arquivos de cache ficam em `~/.local/share/zsh-tips-agent/data/`.

## ⚙️ Customization

Customize the model and parameters by editing:

```bash
~/.local/share/zsh-tips-agent/config.json
\`\`\`

Example:

```json
{
  "model_id": "llama3",
  "model_params": {
    "temperature": 0.7,
    "top_p": 0.9
  }
}
\`\`\`

If absent, defaults from `agent/config.json` are used. Environment variables can temporarily override these settings:

```bash
ZSH_TIP_MODEL=phi3 python agent/generate_tip.py ...
\`\`\`

Here’s an updated section for your **README.md** that clearly explains custom installations and how the agent’s environment variables ensure everything works out of the box, even for non-standard install locations.

### 🛠️ Custom Installation & Non-Standard Prefixes

You can install **zsh-tips-agent** to a custom directory by specifying a `--prefix`:

```bash
zsh-tips-agent install --prefix /opt/zsh-tips-agent
zsh-tips-agent init --prefix /opt/zsh-tips-agent --apply
source ~/.zshrc
\`\`\`

This ensures all scripts and data will use `/opt/zsh-tips-agent` as the base instead of `/usr/local`.

* The initialization step (`init`) will insert a block into your `.zshrc` that references your custom install path.
* When updating the tip cache, the agent now passes the correct path (`PROJECT_DIR`) automatically—**you do not need to set any extra environment variables yourself.**
* All scripts, cache, and background updates will work with your chosen prefix transparently.

If you ever need to move or reinstall to a different location, simply rerun the `init` step with your new prefix to update `.zshrc`.

### Example for a Home Directory Install

```bash
zsh-tips-agent install --prefix $HOME/.local/zsh-tips-agent
zsh-tips-agent init --prefix $HOME/.local/zsh-tips-agent --apply
source ~/.zshrc
\`\`\`

---

### ⚠️ Notes

* The `.zshrc` snippet manages all necessary environment variables for you.
* Manual edits or environment exports (like `PROJECT_DIR`) are **not needed**.
* All agent scripts and cache updates are path-aware based on your chosen prefix.

## 📜 License

[LGPLv3](LICENSE)These additions create a standard starting point for contributors and set clear expectations for code management and collaboration.

Step 3: Proof-of-Concept version 0.1.0

With this commit, we mark the significant milestone of an initial release, bringing together core functionality. Key updates include configuration file additions, script enhancements, and structural improvements.

This file implements a smart CLI tip generator using a CodeAgent.:

File: agent/generate_tip.py

In this commit, the file was completely rewritten from an empty state to a full implementation. The new code introduces:

  • Configuration loading for LLM models

  • Binary detection utilities

  • Seven CLI inspection tools (brew_info, man_page, info_page, tldr_page, help_flag, probe, script_source)

  • A TipAgent class with structured planning for generating CLI tips

  • An orchestrator function for tip generation

  • CLI entrypoint for command-line usage

Here's the full implementation of the modified file:

#!/usr/bin/env python3
"""Smart tip generator for under‑used CLI tools with a single CodeAgent."""
from __future__ import annotations
import json, os, shutil, subprocess, sys, textwrap
from pathlib import Path
from typing import Optional
from smolagents import CodeAgent, LiteLLMModel, Tool


# ---------------- Config ----------------

def load_model_cfg() -> tuple[str, dict, int]:
    cfg = Path(__file__).parent / "config.json"
    model_id = os.getenv("ZSH_TIP_MODEL", "gemma3")
    params: dict = {}
    tokens = 4096
    if cfg.exists():
        c = json.loads(cfg.read_text())
        model_id = c.get("model_id", model_id)
        params = c.get("model_params", {})
        tokens = int(params.get("num_ctx", tokens))
    if tokens == 4096:
        try:
            show = subprocess.check_output(["ollama", "show", model_id], text=True)
            for ln in show.splitlines():
                if any(k in ln.lower() for k in ("num_ctx", "context length")):
                    tokens = int(ln.split()[-1])
                    break
        except Exception:
            pass
    return model_id, params, tokens

def truncate(txt: str, limit: int, ratio: float = 3.5) -> str:
    return txt[-int(limit * ratio):]

def looks_binary(p: str, n: int = 1024) -> bool:
    with open(p, "rb") as f:
        return b"\0" in f.read(n)

# ------------- Tool base helpers -------------

BASE_INPUTS = {"name": {"type": "string", "description": "tool name"}}

class _BaseTool(Tool):
    inputs = BASE_INPUTS
    output_type = "string"


class BrewInfoTool(_BaseTool):
    name = "brew_info"
    description = "Full `brew info` output"
    def forward(self, name: str) -> Optional[str]:
        try: return subprocess.check_output(["brew", "info", name], text=True)
        except Exception: return None


class ManTool(_BaseTool):
    name = "man_page"
    description = "Rendered man page via `col -bx`"
    def forward(self, name: str) -> Optional[str]:
        try:
            raw = subprocess.check_output(["man", name], text=True, stderr=subprocess.STDOUT)
            return subprocess.run(["col", "-bx"], input=raw, text=True, capture_output=True).stdout
        except Exception: return None


class InfoTool(_BaseTool):
    name = "info_page"
    description = "GNU info page"
    def forward(self, name: str) -> Optional[str]:
        try: return subprocess.check_output(["info", name], text=True, stderr=subprocess.STDOUT)
        except Exception: return None


class TldrTool(_BaseTool):
    name = "tldr_page"
    description = "Local TLDR examples"
    def forward(self, name: str) -> Optional[str]:
        for cmd in (["tlrc", "--no-color", "--quiet", name], ["tldr", "-q", name]):
            if shutil.which(cmd[0]):
                try: return subprocess.check_output(cmd, text=True)
                except subprocess.CalledProcessError: return None
        return None


class HelpTool(_BaseTool):
    name = "help_flag"
    description = "Output from the `<command> --help` if available."
    def forward(self, name: str) -> Optional[str]:
        try: return subprocess.check_output([name, "--help"], text=True, stderr=subprocess.STDOUT)
        except Exception: return None


class ProbeTool(_BaseTool):
    name = "probe"
    description = "Show the location and type of a command"

    def forward(self, name: str) -> Optional[str]:
        # Locate the command using shutil.which
        path = shutil.which(name)
        if not path:
            return f"Command '{name}' not found in PATH."

        # Get the absolute path
        abs_path = os.path.realpath(path)
        directory = os.path.dirname(abs_path)

        # Determine the file type using the 'file' command
        try:
            file_output = subprocess.check_output(['file', '--brief', '--mime', abs_path], text=True).strip()
        except subprocess.CalledProcessError:
            file_output = "Unknown file type"

        # Determine if the file is binary or text
        is_binary = 'charset=binary' in file_output

        # Format the output
        output = (
            f"Command: {name}\n"
            f"Full Path: {abs_path}\n"
            f"Directory: {directory}\n"
            f"File Type: {file_output}\n"
            f"Is Binary: {'Yes' if is_binary else 'No'}"
        )
        return output


class ScriptTool(_BaseTool):
    name = "script_source"
    description = "Inspect the entire command, if is text."
    def forward(self, name: str) -> Optional[str]:
        path = shutil.which(name)
        if not path or looks_binary(path): return None
        try: return Path(path).read_text(errors="ignore")
        except Exception: return None

# ------------- LLM setup -------------

MODEL_ID, MODEL_PARAMS, TOKEN_LIMIT = load_model_cfg()
LLM = LiteLLMModel(model_id=f"ollama/{MODEL_ID}", **MODEL_PARAMS)

# ------------- MultiStepAgent -------------

from smolagents import CodeAgent

class TipAgent(CodeAgent):
    def __init__(self):
        super().__init__(
            model=LLM,
            tools=[
                BrewInfoTool(),
                ManTool(),
                InfoTool(),
                TldrTool(),
                HelpTool(),
                ProbeTool(),
                ScriptTool(),
            ]
        )

    def plan(self, tool_name: str):
        return textwrap.dedent(f"""Your job is to provide a concise, friendly 1-2 sentence tip about a given CLI command.
            Try to make sure the tips you write won't look repetitive or generic. It should be encouranging and inviting.
            You can use tool calls to collect information before generating the tip. 
            YOU MUST MAKE AT LEAST ONE TOOL call. If you feel confident that you know the command, it is generally still worthwhile to use at least one tool call to verify before generating the tip.
            Try to FIND AT LEAST ONE NATURAL LANGUAGE DESCRIPTION of the command to help you write the tip. 
            DO NOT use the final_answer tool to note progress, use it only after you are done all other tool calling.
            DO NOT guess what a command is without exhausting all resources first.
            Only once you have sufficient information, output ONLY the tip using the `final_answer` tool in a code block, following this format:

            ```py
            final_answer("Your tip here")
            ```<end_code>
            """)

    def run_tip(self, tool: str) -> str:
        return self.run(self.plan(tool), additional_args={"tool": tool})


# ------------- Orchestrator -------------

def generate_tip(tool: str) -> str:
    agent = TipAgent()
    return agent.run_tip(tool)

# ------------- CLI -------------

def main(tool: str, cache_path: str, tip_path: str):
    tip = generate_tip(tool)
    cache_file = Path(cache_path)
    try: cache = json.loads(cache_file.read_text()) if cache_file.exists() else {}
    except json.JSONDecodeError: cache = {}
    cache[tool] = tip
    cache_file.write_text(json.dumps(cache, indent=2))
    Path(tip_path).write_text(tip + "\n"); print(tip)


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("Usage: generate_tip.py <tool> <cache.json> <tipfile>"); sys.exit(1)
    main(*sys.argv[1:])

The implementation provides a comprehensive framework for generating CLI command tips using multiple inspection methods and an LLM agent. The complete code demonstrates a substantial addition of functionality in this commit.

A new example configuration file was added (without contents): agent/example-config.json

Cache maintenance scripts were started:

File: bin/update_tip_cache.sh

This commit implements logic for dynamically generating and caching Zsh tips based on user tool usage patterns. The script analyzes shell history to identify the least-used tool and either retrieves a cached tip or initiates background tip generation via the Python agent.

#!/usr/bin/env bash

set -euo pipefail

# Paths
PROJECT_DIR="${PROJECT_DIR:-$GH/zsh-tips-agent}"
HISTORY_FILE="$HOME/.zsh_history"
CACHE_DIR="$PROJECT_DIR/data"
TIP_FILE="$CACHE_DIR/current_tip.txt"
TIP_CACHE="$CACHE_DIR/tip_cache.json"
AGENT="$PROJECT_DIR/agent/generate_tip.py"

mkdir -p "$CACHE_DIR"

# Gather tool candidates
mapfile -t tools < <(
  {
    ls /usr/local/bin 2>/dev/null
    brew list --formula 2>/dev/null
  } | sort -u
)

# Count usage from history
declare -A usage_counts
for tool in "${tools[@]}"; do
  count=$(grep -c -w "$tool" "$HISTORY_FILE" 2>/dev/null || echo 0)
  usage_counts["$tool"]=$count
done

# Pick least-used tool
least_used=$(for k in "${!usage_counts[@]}"; do echo "${usage_counts[$k]} $k"; done | sort -n | head -n1 | cut -d' ' -f2)

# Check cache
if [[ -f "$TIP_CACHE" && $(jq -r --arg tool "$least_used" '.[$tool] // empty' "$TIP_CACHE") ]]; then
  tip=$(jq -r --arg tool "$least_used" '.[$tool]' "$TIP_CACHE")
else
  tip="🤖 Generating tip for '$least_used'..."
  echo "$tip" > "$TIP_FILE"
  # Run agent in background
  nohup python3 "$AGENT" "$least_used" "$TIP_CACHE" "$TIP_FILE" >/dev/null 2>&1 &
  exit 0
fi

# Write cached tip
echo "$tip" > "$TIP_FILE"

The script was made executable (chmod +x). It implements a complete workflow for:

  1. Discovering available tools in /usr/local/bin and Homebrew formulas

  2. Analyzing shell history for usage patterns

  3. Selecting the least-used tool for tip generation

  4. Managing a JSON-based tip cache

  5. Launching asynchronous tip generation when needed

A new command-line interface was implemented to render tips at zsh session start:

File: bin/zsh-tips-agent

#!/usr/bin/env bash
set -euo pipefail

SNIPPET='
# ---- zsh-tips-agent ----
TIPS_AGENT_DIR="${GH:-$HOME/Github}/zsh-tips-agent"
TIP_CACHE_FILE="$TIPS_AGENT_DIR/data/current_tip.txt"
UPDATE_SCRIPT="$TIPS_AGENT_DIR/bin/update_tip_cache.sh"
[[ -f "$TIP_CACHE_FILE" ]] && cat "$TIP_CACHE_FILE"
[[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" &!
# ---- /zsh-tips-agent ----
'

usage() {
  echo "Usage: zsh-tips-agent init [--apply|--print]" >&2
  exit 1
}

init() {
  if [[ "${1:-}" == "--print" ]]; then
    echo "$SNIPPET"
    exit 0
  fi

  ZSHRC="$HOME/.zshrc"
  if ! grep -q 'zsh-tips-agent ----' "$ZSHRC"; then
    printf "\n%s\n" "$SNIPPET" >> "$ZSHRC"
    echo "✅ Added zsh‑tips‑agent snippet to $ZSHRC"
  else
    echo "ℹ️  zsh‑tips‑agent snippet already present in $ZSHRC"
  fi
}

[[ $# -lt 1 ]] && usage
case "$1" in
  init) shift; init "$@";;
  *) usage;;
esac

The uv tool was used to initialize the project in python.

  1. uv init .

  2. uv venv

  3. source .venv/bin/activate

Imports were added to the PyPA configuration:

File: pyproject.toml

[project]
name = "zsh-tips-agent"
version = "0.1.0"
description = "Agent that generates tips for underused CLI tools"
dependencies = [
  "smolagents",
  "litellm"
]

[tool.setuptools]
packages = ["agent"]

This development step completes the foundational architecture required for version 0.1.0, making the tool ready for deployment and user testing. Some wrap-up actions were also taken:

The .gitignore was refined to exclude development artifacts:

File: .gitignore

build/
data/

# macOS
.DS_Store

# VSCode
.vscode/

# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
.venv

# Bash / shell artifacts
*.swp
*.swo

# Runtime data
/data/*.json
/data/*.log

# Other
.env
config.json

The README was updated to document this release:

File: README.md

The README.md file was updated in this commit to introduce versioning information, refine installation instructions, and add comprehensive documentation about system requirements and configuration options. These changes improve clarity around setup steps and provide detailed guidance for customizing the LLM-based tip generation system.

Here's the updates to the README.md file:

  1. Migrate the instructions from using the snippet file to using the zsh-tips-agent script:
-echo 'source $GH/zsh-tips-agent/.zshrc_snippet' >> ~/.zshrc
+cd $GH/zsh-tips-agent
+uv pip install .
+zsh-tips-agent init --apply
+source ~/.zshrc
  1. Replace the tips at the end with instructions to set up the zshrc:
-Make sure:
+*Optional:* Use the one-liner version instead:

-* You have `smolagent`, `ollama`, and your desired models installed locally.
-* Your tools are discoverable in `/usr/local/bin` or via `brew`.
+```bash
+eval "$(zsh-tips-agent init)"
+

+


3. Add a requirements section:


```markdown
### ✅ Requirements

* [`ollama`](https://ollama.com/) installed and running locally
* Local models available via `ollama ls` (e.g. `llama3`, `phi3`)
* `smolagents` Python package (installed automatically by `uv pip install .`)
* Custom tools installed in `/usr/local/bin` or via `brew`

The system will show a cached tip on each terminal startup and update the tip quietly in the background.
  1. Add a Configuration section:

## ⚙️ Configuration

You can customize which model is used for tip generation (and how it's configured) by editing the local `config.json` file:

```bash
$GH/zsh-tips-agent/agent/config.json
\`\`\`

Example:

```json
{
  "model_id": "llama3",
  "model_params": {
    "temperature": 0.7,
    "top_p": 0.9
  }
}
\`\`\`

* `model_id` should match a model available via `ollama ls`
* `model_params` accepts any valid generation settings supported by your model

You can also override these temporarily using environment variables:

```bash
ZSH_TIP_MODEL=phi3 python agent/generate_tip.py ...
\`\`\`

---

> ⚠️ The config is optional — if not present, the system defaults to `llama3` with no parameters.

The changes in this step make the setup process more explicit and providing long-term maintainability through configurable options.

Step 4: Refining the Update Script Workflow

This commit focuses on improving the script responsible for updating tip caches while cleaning up deprecated configuration artifacts. The changes ensure the update mechanism remains robust and the codebase stays free of unused components.

The update script was then modified:

File: bin/update_tip_cache.sh

This shell script manages the generation of Zsh tips based on user tool usage patterns. In this commit, the implementation was significantly restructured to improve readability, error handling, and robustness.

Key changes include:

  1. Structured organization with clear section headers using ---- comments

  2. Enhanced tool candidate gathering with null-checking and cleaner input handling

  3. Temporary file usage for usage counting to avoid memory-intensive associative arrays

  4. Improved error handling for empty tool lists and missing data

  5. Nicer output control with conditional placeholder writing to avoid race conditions

Here's the updated script content:

#!/usr/bin/env bash
set -euo pipefail

# ---- Configurable paths ----
PROJECT_DIR="${PROJECT_DIR:-$GH/zsh-tips-agent}"
HISTORY_FILE="$HOME/.zsh_history"
CACHE_DIR="$PROJECT_DIR/data"
TIP_FILE="$CACHE_DIR/current_tip.txt"
TIP_CACHE="$CACHE_DIR/tip_cache.json"
AGENT="$PROJECT_DIR/agent/generate_tip.py"
TIP_AGE_HOURS=2    # Refresh threshold
TIP_WINDOW_DAYS=30 # Max freshness window

# ---- Flags ----
DRY_RUN=0
VERBOSE=0
for arg in "$@"; do
  case "$arg" in
  --dry-run) DRY_RUN=1 ;;
  --verbose) VERBOSE=1 ;;
  esac
done

mkdir -p "$CACHE_DIR"
[[ -f "$TIP_CACHE" ]] || echo '{}' >"$TIP_CACHE"

# ---- Gather user-installed tool candidates ----
tools=()
for dir in ~/.local/bin /usr/local/bin /opt/homebrew/bin; do
  [[ -d "$dir" ]] || continue
  for tool in $(ls "$dir" 2>/dev/null); do
    path="$dir/$tool"
    if [[ -n "$tool" && ! -d "$path" && (-f "$path" || -L "$path") && -x "$path" ]]; then
      tools+=("$tool")
    fi
  done
done
tools=($(printf "%s\n" "${tools[@]}" | sort -u))

if [[ ${#tools[@]} -eq 0 ]]; then
  echo "No tool candidates found for tips." >"$TIP_FILE"
  exit 0
fi

# ---- Count usage from history ----
tmp_usage=$(mktemp)
for tool in "${tools[@]}"; do
  count=$(grep -c -w "$tool" "$HISTORY_FILE" 2>/dev/null || echo 0)
  printf '%s %s\n' "$count" "$tool" >>"$tmp_usage"
  [[ "$VERBOSE" = true ]] && echo "Usage count: $count  Tool: $tool" >&2
done

# ---- Build usage tiers using files ----
usage_groups_dir=$(mktemp -d)
sorted_counts=()

while read -r count tool; do
  [[ -z "$tool" ]] && continue
  echo "$tool" >>"$usage_groups_dir/$count"
done <"$tmp_usage"
rm -f "$tmp_usage"

sorted_counts=($(find "$usage_groups_dir" -type f -exec basename {} \; | sort -n))

# ---- Select tip candidate with freshness filtering ----
now=$(date +%s)
recent_secs=$((TIP_AGE_HOURS * 3600))
window_secs=$(( TIP_WINDOW_DAYS * 24 * 3600 ))

pick=""
for usage in "${sorted_counts[@]}"; do
  tools_in_tier_file="$usage_groups_dir/$usage"
  [[ -f "$tools_in_tier_file" ]] || continue
  tools_in_tier=()
  while IFS= read -r tool; do
    tools_in_tier+=( "$tool" )
  done < "$tools_in_tier_file"

  if ((VERBOSE)); then
    echo "Tier usage count = $usage: ${tools_in_tier[*]}"
  fi

  # Build eligible list once per tier
  eligible=()
  for tool in "${tools_in_tier[@]}"; do
    [[ -z "$tool" ]] && continue
    last_shown=$(jq -r --arg t "$tool" '.[$t].last_shown // 0' "$TIP_CACHE")
    age=$((now - last_shown))
    if (( last_shown == 0 || ( age >= recent_secs && age <= window_secs ) )); then
      eligible+=( "$tool" )
    fi
  done

  # If we found any eligible tools, pick the one shown the longest ago and stop
  if (( ${#eligible[@]} > 0 )); then
    oldest_tool=""
    oldest_time=$now
    for tool in "${eligible[@]}"; do
      t_last=$(jq -r --arg t "$tool" '.[$t].last_shown // 0' "$TIP_CACHE")
      if (( t_last < oldest_time )); then
        oldest_time=$t_last
        oldest_tool="$tool"
      fi
    done
    pick="$oldest_tool"
    break
  fi
done
if [[ -z "$pick" ]]; then
  fallback_file="$usage_groups_dir/${sorted_counts[0]}"
  oldest_tool=""
  oldest_time=$now

  while IFS= read -r tool; do
    [[ -z "$tool" ]] && continue
    last_shown=$(jq -r --arg t "$tool" '.[$t].last_shown // 0' "$TIP_CACHE")
    if ((last_shown < oldest_time)); then
      oldest_time=$last_shown
      oldest_tool="$tool"
    fi
  done <"$fallback_file"

  pick="$oldest_tool"
fi

rm -rf "$usage_groups_dir"

if ((VERBOSE)); then
  echo "Selected tool: $pick"
fi

# ---- Show or generate tip ----
if [[ -f "$TIP_CACHE" && $(jq -r --arg t "$pick" '.[$t].tip // empty' "$TIP_CACHE") ]]; then
  tip=$(jq -r --arg t "$pick" '.[$t].tip' "$TIP_CACHE")
  echo "$tip" >"$TIP_FILE"
else
  if ((DRY_RUN)); then
    echo "[dry-run] python3 $AGENT $pick $TIP_CACHE $TIP_FILE"
  else
    "$PROJECT_DIR/.venv/bin/python" "$AGENT" "$pick" "$TIP_CACHE" "$TIP_FILE"
  fi
fi

The script now provides more reliable operation by:

  • Preventing empty tool list processing

  • Using temporary files for intermediate data

  • Adding guard clauses for early exits

  • Structuring the code into clear logical blocks

The generate_tip.py script was modified:

File: agent/generate_tip.py:

Updating the core python script: • Added time tracking for tips

-import json, os, shutil, subprocess, sys, textwrap
+import json, os, shutil, subprocess, sys, textwrap, time
...
-    try: cache = json.loads(cache_file.read_text()) if cache_file.exists() else {}
-    except json.JSONDecodeError: cache = {}
-    cache[tool] = tip
+    try:
+        cache = json.loads(cache_file.read_text()) if cache_file.exists() else {}
+    except json.JSONDecodeError:
+        cache = {}
+
+    now = int(Path().stat().st_mtime)  # fallback if date fails
+    try:
+        now = int(subprocess.check_output(["date", "+%s"], text=True).strip())
+    except Exception:
+        pass
+
+    cache[tool] = {"tip": tip, "last_shown": now}

• Code cleanup and formatting improvements • Better error handling for empty tool names

 def main(tool: str, cache_path: str, tip_path: str):
+    tool = tool.strip()
+    if not tool:
+        print("Error: Tool name is missing or blank.")
+        sys.exit(1)

• Minor code reorganization and simplification

We updated the zsh-tips-agent and deleted the old snippet:

File: bin/zsh-tips-agent:

First, a legacy configuration file was removed: .zshrc_snippet. The .zshrc_snippet file was a Zsh shell configuration fragment used to customize shell behavior or environment settings. The file's removal reflects a step toward simplifying the project's configuration structure.

Updating the agent:

  • Introduced TIP_AGE_HOURS for configurable freshness threshold

      +TIP_AGE_HOURS=2
    
  • Added tip_is_fresh() helper to check cache‐age

      +tip_is_fresh() {
      +  [[ ! -f "$TIP_CACHE_FILE" ]] && return 1
      +  now=$(date +%s)
      +  filetime=$(stat -f %m "$TIP_CACHE_FILE" 2>/dev/null)
      +  (( ((now - filetime)/3600) < TIP_AGE_HOURS ))
      +}
    
  • Updated tip-update logic to run only when stale, with suppressed output

      -[[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" &!
      +if ! tip_is_fresh; then
      +  [[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" > /dev/null 2>&1 &!
      +fi
    
  • Preserved existing cache-display behavior

      [[ -f "$TIP_CACHE_FILE" ]] && cat "$TIP_CACHE_FILE"
    

Finally, we dutifully bumped the version:

File: README.md

   -<small>_v.0.1.0_</small>
   +<small>_v.0.1.1_</small>

File: pyproject.toml

   -version = "0.1.0"
   +version = "0.1.1"

These changes streamline the tip management workflow by maintaining only essential components while improving the reliability of the update process for future development steps.

Step 5: Enhancing Tip Management with Agentic Flow Integration

This commit introduces critical updates to optimize how new tips are handled within the system. By modifying key script bindings, the agent now dynamically triggers an agentic workflow whenever fresh tip content is required. This change improves responsiveness and ensures timely updates without manual intervention.

The update begins with adjustments to the tip cache maintenance logic:

File: bin/update_tip_cache.sh

This shell script is responsible for updating the Zsh tips cache by analyzing tool usage patterns in the user's shell history. In this commit, the script's tool discovery logic was significantly improved to handle modern Homebrew directory structures and add stricter validation for executable candidates.

  1. Refactored tool-discovery to include Homebrew’s /opt/homebrew/bin and simplify directory looping
-while IFS= read -r tool; do
-  [[ -n "$tool" ]] && tools+=("$tool")
-done < <(
-  {
-    ls ~/.local/bin 2>/dev/null
-    ls /usr/local/bin 2>/dev/null
-    brew list --formula 2>/dev/null
-  } | sort -u | grep -v '^$'
-)
+for dir in ~/.local/bin /usr/local/bin /opt/homebrew/bin; do
+  [[ -d "$dir" ]] || continue
+  while IFS= read -r tool; do
+    [[ -n "$tool" && ! -d "$dir/$tool" && ( -f "$dir/$tool" || -L "$dir/$tool" ) && -x "$dir/$tool" ]] && tools+=("$tool")
+  done < <(ls "$dir" 2>/dev/null)
+done
+tools=($(printf "%s\n" "${tools[@]}" | sort -u))
  1. Enhanced validation to filter out directories and include only real executables (and symlinks)
+[[ -n "$tool" && ! -d "$dir/$tool" && ( -f "$dir/$tool" || -L "$dir/$tool" ) && -x "$dir/$tool" ]] && tools+=("$tool")
  1. Switched tip-generation to use the project’s virtual‐env Python instead of system python3
-  nohup python3 "$AGENT" "$least_used" "$TIP_CACHE" "$TIP_FILE" >/dev/null 2>&1 &
+  nohup "$PROJECT_DIR/.venv/bin/python" "$AGENT" "$least_used" "$TIP_CACHE" "$TIP_FILE" >/dev/null 2>&1 &

The changes improve cross-platform compatibility and ensure the script correctly identifies executable tools while avoiding directory entries. The script now properly uses the project's virtual environment for Python operations.

Next, the core agent script was modified to establish new binding rules that detect tip refresh requirements:

File: bin/zsh-tips-agent

  • Restructured tip display & update logic

      -[[ -f "$TIP_CACHE_FILE" ]] && cat "$TIP_CACHE_FILE"
      -[[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" &!
      +if [[ -f "$TIP_CACHE_FILE" ]]; then
      +  cat "$TIP_CACHE_FILE"
      +  [[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" &!
      +else
      +  MSG="🤖 Generating your first tip... (this may take a moment!)"
      +  echo "$MSG"
      +  [[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" &!
      +fi
    

This consolidates tip display and update into a single if/else block: when a cached tip exists it’s shown (and the updater invoked), otherwise you get a first-time generation message before running the update script.

This change creates a more intelligent system that automatically initiates the tip generation workflow when needed, streamlining the overall user experience for zsh tip management.

Step 6: Adding Rotation Functionality

This commit introduces a rotation mechanism to enhance the dynamic behavior of the zsh-tips-agent system. The implementation focuses on rotating through available tips in a structured manner, improving user experience by preventing repetition of the same tip across sessions.

The cache update process is modified to accommodate rotational tracking:

File: bin/update_tip_cache.sh

The bin/update_tip_cache.sh script is responsible for generating and managing usage-based tips for command-line tools. In this commit, significant enhancements were made to its logic for selecting tip candidates and handling user flags.

Key Changes in This Commit:

  1. Added Configuration Parameters

    • Introduced TIP_AGE_HOURS (2 hours) and TIP_WINDOW_DAYS (30 days) to control tip refresh thresholds

    • Added --dry-run and --verbose flags for operational flexibility

  2. Refactored Tool Discovery Logic

    • Replaced ls pipeline with safer for loop for tool discovery

    • Added explicit path validation for discovered tools

  3. Enhanced Tip Selection Algorithm

    • Implemented tiered usage grouping with file-based sorting

    • Added time-based eligibility filtering for tip candidates

    • Preserves last-shown timestamps in the tip cache

  4. Improved Robustness

    • Uses mktemp for temporary directories/files

    • Better error handling for edge cases

    • More descriptive debug output when in verbose mode

Here's the updated script content showing these improvements:

#!/usr/bin/env bash
set -euo pipefail

# ---- Configurable paths ----
PROJECT_DIR="${PROJECT_DIR:-$GH/zsh-tips-agent}"
HISTORY_FILE="$HOME/.zsh_history"
CACHE_DIR="$PROJECT_DIR/data"
TIP_FILE="$CACHE_DIR/current_tip.txt"
TIP_CACHE="$CACHE_DIR/tip_cache.json"
AGENT="$PROJECT_DIR/agent/generate_tip.py"

mkdir -p "$CACHE_DIR"

# ---- Gather tool candidates ----
tools=()
while IFS= read -r tool; do
  [[ -n "$tool" ]] && tools+=("$tool")
done < <(
  {
    ls ~/.local/bin 2>/dev/null
    ls /usr/local/bin 2>/dev/null
    brew list --formula 2>/dev/null
  } | sort -u | grep -v '^$'
)

if [[ ${#tools[@]} -eq 0 ]]; then
  echo "No tool candidates found for tips." > "$TIP_FILE"
  exit 0
fi

# ---- Count usage from history ----
tmp_usage=$(mktemp)
for tool in "${tools[@]}"; do
  [[ -z "$tool" ]] && continue
  count=$(grep -c -w "$tool" "$HISTORY_FILE" 2>/dev/null || echo 0)
  printf '%s %s\n' "$count" "$tool" >> "$tmp_usage"
done

# ---- Pick least-used tool ----
least_used=$(sort -n "$tmp_usage" | awk '{print $2}' | grep -v '^$' | head -n1)
rm -f "$tmp_usage"

if [[ -z "$least_used" ]]; then
  echo "No tool candidates found for tips." > "$TIP_FILE"
  exit 0
fi

# ---- Show or generate the tip ----
if [[ -f "$TIP_CACHE" && $(jq -r --arg tool "$least_used" '.[$tool] // empty' "$TIP_CACHE") ]]; then
  tip=$(jq -r --arg tool "$least_used" '.[$tool]' "$TIP_CACHE")
  echo "$tip" > "$TIP_FILE"
else
  # Only write the "generating" placeholder if TIP_FILE does not already exist.
  if [[ ! -f "$TIP_FILE" ]]; then
    echo "🤖 Generating tip for '$least_used'..." > "$TIP_FILE"
  fi
  nohup python3 "$AGENT" "$least_used" "$TIP_CACHE" "$TIP_FILE" >/dev/null 2>&1 &
  exit 0
fi

# ---- Output the tip ----
echo "$tip" > "$TIP_FILE"

The changes implement a more sophisticated approach to tip scheduling, ensuring users receive fresh recommendations while respecting their command-line usage patterns. The script now better handles edge cases and provides more detailed debugging information when needed.

The generate_tip system also modified:

File agent/generate_tip.py

  1. Tip rotation tracking

The cache entry for each tool now stores a dictionary instead of a plain string:

-    cache[tool] = tip
+    cache[tool] = {"tip": tip, "last_shown": now}

This allows future logic to rotate or expire tips based on timestamps (last_shown).

  1. Timestamp handling

New logic records a Unix timestamp (seconds since epoch) using the date command as primary and Path().stat().st_mtime as fallback:

now = int(Path().stat().st_mtime)  # fallback
try:
    now = int(subprocess.check_output(["date", "+%s"], text=True).strip())
except Exception:
    pass
  1. Input validation

Adds a check to prevent empty or blank tool names:

if not tool:
    print("Error: Tool name is missing or blank.")
    sys.exit(1)

File: bin/zsh-tips-agent

This change to introduces tip freshness checking to avoid regenerating tips too frequently.

  • Tip rotation logic

  • Adds a freshness check function:

      tip_is_fresh() {
        [[ ! -f "$TIP_CACHE_FILE" ]] && return 1
        now=$(date +%s)
        filetime=$(stat -f %m "$TIP_CACHE_FILE" 2>/dev/null)
        (( ((now - filetime)/3600) < TIP_AGE_HOURS ))
      }
    

    This considers a tip “fresh” if it's newer than TIP_AGE_HOURS (default: 2 hours).

  • Defined TIP_AGE_HOURS

Introduced a configurable environment variable:

TIP_AGE_HOURS=2
  • Changed tip update behavior

    • Before: Always ran update_tip_cache.sh in the background if the cache file existed or not.

    • After: Only runs the update script if the tip is missing or stale (older than 2 hours).

This change prevents unnecessary background computation and network/API usage by respecting a time-based rotation interval for tip generation.

The pyproject.toml and readme were also modified, but this was just a bump to the patch version (from 0.1.0 to 0.1.1).

This development step establishes a foundation for varied and scheduled tip delivery, setting the stage for future enhancements like weighted rotation or user-preference based cycling.

Step 7: version 0.1.2

File: bin/zsh-tips-agent

Runtime execution parameters are adjusted to enable rotation at startup:

#!/usr/bin/env bash
set -euo pipefail

# ---- Paths & Defaults ----
PREFIX=${PREFIX:-/usr/local}
BIN_DIR="$PREFIX/bin"
TIPS_AGENT_DIR="${GH:-$HOME/Github}/zsh-tips-agent"
UPDATE_SCRIPT="$TIPS_AGENT_DIR/bin/update_tip_cache.sh"
SNIPPET=''
# ---- zsh-tips-agent ----
TIPS_AGENT_DIR="${GH:-$HOME/Github}/zsh-tips-agent"
TIP_CACHE_FILE="$TIPS_AGENT_DIR/data/current_tip.txt"
UPDATE_SCRIPT="$TIPS_AGENT_DIR/bin/update_tip_cache.sh"
TIP_AGE_HOURS=2

tip_is_fresh() {
  [[ ! -f "$TIP_CACHE_FILE" ]] && return 1
  now=$(date +%s)
  filetime=$(stat -f %m "$TIP_CACHE_FILE" 2>/dev/null)
  (( ((now - filetime)/3600) < TIP_AGE_HOURS ))
}

[[ -f "$TIP_CACHE_FILE" ]] && cat "$TIP_CACHE_FILE"

if ! tip_is_fresh; then
  [[ -x "$UPDATE_SCRIPT" ]] && "$UPDATE_SCRIPT" > /dev/null 2>&1 &!
fi
# ---- /zsh-tips-agent ----
'SNIPPET'

usage() {
  cat <<EOF >&2
Usage: zsh-tips-agent <command> [options]

Commands:
  install              Copy scripts to \$BIN_DIR (default /usr/local/bin)
  init [--apply|--print]  Add or display the snippet for ~/.zshrc
  help                 Show this message
EOF
  exit 1
}

cmd_install() {
  echo "Installing zsh-tips-agent to \$BIN_DIR..."
  mkdir -p "$BIN_DIR"
  install -m755 "$TIPS_AGENT_DIR/bin/zsh-tips-agent" "$BIN_DIR/zsh-tips-agent"
  install -m755 "$TIPS_AGENT_DIR/bin/update_tip_cache.sh" "$BIN_DIR/update_tip_cache.sh"
  echo "Done."
  echo "Add to your ~/.zshrc:"
  echo
  echo "  eval \"\\$(zsh-tips-agent init --print)\""
}

cmd_init() {
  if [[ "${1:-}" == "--print" ]]; then
    # print snippet
    echo "$SNIPPET"
    exit 0
  fi
  # apply snippet
  ZSHRC="$HOME/.zshrc"
  if ! grep -q 'zsh-tips-agent ----' "$ZSHRC"; then
    printf "\n%s\n" "$SNIPPET" >> "$ZSHRC"
    echo "✅ Added zsh‑tips‑agent snippet to $ZSHRC"
  else
    echo "ℹ️  zsh‑tips‑agent snippet already present in $ZSHRC"
  fi
}

# dispatch
case "${1:-help}" in
  install) cmd_install ;; 
  init)    shift; cmd_init "$@" ;; 
  help|--help|-h) usage ;; 
  *) usage ;; 
esac

File: README.md

Here are the key changes introduced in this commit:

  • README structure simplified and clarified

    • Removed version line (<small>_v.0.1.1_</small>)

    • Simplified language to better explain the tool's purpose.

    • Changed "Features", "Project Layout", "Getting Started", etc., into clearer sections like Features, Quickstart, and Prerequisites.

  • Installation and config improvements

    • Added zsh-tips-agent install step in install instructions.

    • Replaced $GH/zsh-tips-agent/agent/config.json path with the new persistent config path: ~/.local/share/zsh-tips-agent/config.json.

  • Removed redundant sections

    • Project layout block diagram removed for brevity.

    • Combined and clarified configuration notes.

    • Merged redundant tip explanation paragraphs into concise summaries.

  • New license section

    Added final License section: [LGPLv3](LICENSE)

# 💡 zsh-tips-agent

A Zsh tool that provides useful command-line tips based on your actual usage history, leveraging local language models via ollama without slowing down your terminal.

## 🔧 Features

- Suggests underused CLI tools from directories such as `/usr/local/bin` or `brew` packages
- Generates personalized tips based on your command history (`~/.zsh_history`)
- Uses local language models (e.g., via `ollama`) to intelligently generate tips
- Updates tip cache in the background to ensure quick terminal startup
- Automatically refreshes tips periodically

## 🚀 Quickstart

```bash
git clone https://github.com/YOURUSER/zsh-tips-agent.git $GH/zsh-tips-agent
cd $GH/zsh-tips-agent
uv pip install .
zsh-tips-agent install
zsh-tips-agent init --apply
source ~/.zshrc
\`\`\`

Alternatively, directly add this to your `~/.zshrc`:

```bash
eval "$(zsh-tips-agent init --print)"
\`\`\`

## ✅ Prerequisites

- [`ollama`](https://ollama.com/) installed and configured locally
- At least one local language model available (`ollama ls`, e.g., `llama3`, `phi3`)
- Python dependencies installed via `uv pip install .`
- CLI tools available on your system (typically `/usr/local/bin`, `brew`)

## 🧠 Tip Generation

Tips are generated using local language models. They are cached and updated periodically in the background, ensuring a responsive terminal.

## ⚙️ Customization

Customize the model and parameters by editing:

```bash
~/.local/share/zsh-tips-agent/config.json
\`\`\`

Example:

```json
{
  "model_id": "llama3",
  "model_params": {
    "temperature": 0.7,
    "top_p": 0.9
  }
}
\`\`\`

If absent, defaults from `agent/config.json` are used. Environment variables can temporarily override these settings:

```bash
ZSH_TIP_MODEL=phi3 python agent/generate_tip.py ...
\`\`\`

## 📜 License

[LGPLv3](LICENSE)

This commit aligns the README with the current implementation and improves usability for first-time users.

Configuration adjustments were implemented in the tip generation module:

File: agent/generate_tip.py

This file implements a CLI tip generator using a CodeAgent to provide context-aware suggestions for under-used command-line tools. The key modification in this commit introduces user-specific configuration support by checking for a ~/.local/share/zsh-tips-agent/config.json file before falling back to the default embedded configuration.

Final State of agent/generate_tip.py
#!/usr/bin/env python3
"""Smart tip generator for under‑used CLI tools with a single CodeAgent."""

from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Optional
from smolagents import CodeAgent, LiteLLMModel, Tool

# ---------------- Config ----------------


def load_model_cfg() -> tuple[str, dict, int]:
    user_cfg = Path.home() / ".local" / "share" / "zsh-tips-agent" / "config.json"
    if user_cfg.is_file():
        cfg = user_cfg
    else:
        cfg = Path(__file__).parent / "config.json"
    model_id = os.getenv("ZSH_TIP_MODEL", "gemma3")
    params: dict = {}
    tokens = 4096
    if cfg.exists():
        c = json.loads(cfg.read_text())
        model_id = c.get("model_id", model_id)
        params = c.get("model_params", {})
        tokens = int(params.get("num_ctx", tokens))
    if tokens == 4096:
        try:
            show = subprocess.check_output(["ollama", "show", model_id], text=True)
            for ln in show.splitlines():
                if any(k in ln.lower() for k in ("num_ctx", "context length")):
                    tokens = int(ln.split()[-1])
                    break
        except Exception:
            pass
    return model_id, params, tokens


def truncate(txt: str, limit: int, ratio: float = 3.5) -> str:
    return txt[-int(limit * ratio) :]


def looks_binary(p: str, n: int = 1024) -> bool:
    with open(p, "rb") as f:
        return b"\0" in f.read(n)


# ------------- Tool base helpers -------------

BASE_INPUTS = {"name": {"type": "string", "description": "tool name"}}


class _BaseTool(Tool):
    inputs = BASE_INPUTS
    output_type = "string"


class BrewInfoTool(_BaseTool):
    name = "brew_info"
    description = "Full `brew info` output"

    def forward(self, name: str) -> Optional[str]:
        try:
            return subprocess.check_output(["brew", "info", name], text=True)
        except Exception:
            return None


class ManTool(_BaseTool):
    name = "man_page"
    description = "Rendered man page via `col -bx`"

    def forward(self, name: str) -> Optional[str]:
        try:
            raw = subprocess.check_output(
                ["man", name], text=True, stderr=subprocess.STDOUT
            )
            return subprocess.run(
                ["col", "-bx"], input=raw, text=True, capture_output=True
            ).stdout
        except Exception:
            return None


class InfoTool(_BaseTool):
    name = "info_page"
    description = "GNU info page"

    def forward(self, name: str) -> Optional[str]:
        try:
            return subprocess.check_output(
                ["info", name], text=True, stderr=subprocess.STDOUT
            )
        except Exception:
            return None


class TldrTool(_BaseTool):
    name = "tldr_page"
    description = "Local TLDR examples"

    def forward(self, name: str) -> Optional[str]:
        for cmd in (["tlrc", "--no-color", "--quiet", name], ["tldr", "-q", name]):
            if shutil.which(cmd[0]):
                try:
                    return subprocess.check_output(cmd, text=True)
                except subprocess.CalledProcessError:
                    return None
        return None


class HelpTool(_BaseTool):
    name = "help_flag"
    description = "Output from the `<command> --help` if available."

    def forward(self, name: str) -> Optional[str]:
        try:
            return subprocess.check_output(
                [name, "--help"], text=True, stderr=subprocess.STDOUT
            )
        except Exception:
            return None


class ProbeTool(_BaseTool):
    name = "probe"
    description = "Show the location and type of a command"

    def forward(self, name: str) -> Optional[str]:
        path = shutil.which(name)
        if not path:
            return f"Command '{name}' not found in PATH."
        abs_path = os.path.realpath(path)
        directory = os.path.dirname(abs_path)
        try:
            file_output = subprocess.check_output(
                ["file", "--brief", "--mime", abs_path], text=True
            ).strip()
        except subprocess.CalledProcessError:
            file_output = "Unknown file type"
        is_binary = "charset=binary" in file_output
        return (
            f"Command: {name}\n"
            f"Full Path: {abs_path}\n"
            f"Directory: {directory}\n"
            f"File Type: {file_output}\n"
            f"Is Binary: {'Yes' if is_binary else 'No'}"
        )


class ScriptTool(_BaseTool):
    name = "script_source"
    description = "Inspect the entire command, if is text."

    def forward(self, name: str) -> Optional[str]:
        path = shutil.which(name)
        if not path or looks_binary(path):
            return None
        try:
            return Path(path).read_text(errors="ignore")
        except Exception:
            return None


# ------------- LLM setup -------------

MODEL_ID, MODEL_PARAMS, TOKEN_LIMIT = load_model_cfg()
LLM = LiteLLMModel(model_id=f"ollama/{MODEL_ID}", **MODEL_PARAMS)


class TipAgent(CodeAgent):
    def __init__(self):
        super().__init__(
            model=LLM,
            tools=[
                BrewInfoTool(),
                ManTool(),
                InfoTool(),
                TldrTool(),
                HelpTool(),
                ProbeTool(),
                ScriptTool(),
            ],
        )

    def plan(self, tool_name: str):
        return textwrap.dedent("""Your job is to provide a concise, friendly 1-2 sentence tip about a given CLI command.
            Try to make sure the tips you write won't look repetitive or generic. It should be encouranging and inviting.
            You can use tool calls to collect information before generating the tip. 
            YOU MUST MAKE AT LEAST ONE TOOL call. If you feel confident that you know the command, it is generally still worthwhile to use at least one tool call to verify before generating the tip.
            Try to FIND AT LEAST ONE NATURAL LANGUAGE DESCRIPTION of the command to help you write the tip. 
            DO NOT use the final_answer tool to note progress, use it only after you are done all other tool calling.
            DO NOT guess what a command is without exhausting all resources first.
            Only once you have sufficient information, output ONLY the tip using the `final_answer` tool in a code block, following this format:

            ```py
            final_answer("Your tip here")
            ```<end_code>
        """)

    def run_tip(self, tool: str) -> str:
        return self.run(self.plan(tool), additional_args={"tool": tool})


# ------------- Orchestrator -------------


def generate_tip(tool: str) -> str:
    return TipAgent().run_tip(tool)


def main(tool: str, cache_path: str, tip_path: str):
    tool = tool.strip()
    if not tool:
        print("Error: Tool name is missing or blank.")
        sys.exit(1)

    tip = generate_tip(tool)
    cache_file = Path(cache_path)
    try:
        cache = json.loads(cache_file.read_text()) if cache_file.exists() else {}
    except json.JSONDecodeError:
        cache = {}

    now = int(Path().stat().st_mtime)  # fallback if date fails
    try:
        now = int(subprocess.check_output(["date", "+%s"], text=True).strip())
    except Exception:
        pass

    cache[tool] = {"tip": tip, "last_shown": now}
    cache_file.write_text(json.dumps(cache, indent=2))
    Path(tip_path).write_text(tip + "\n")
    print(tip)


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("Usage: generate_tip.py <tool> <cache.json> <tipfile>")
        sys.exit(1)
    main(*sys.argv[1:])

The implementation provides extensive tool introspection capabilities (brew, man, info, tldr, --help) and intelligent probing of executable binaries. This change ensures users can customize model parameters while maintaining backward compatibility with the default configuration.

The tip cache update script received path configuration improvements:

File: bin/update_tip_cache.sh

This script manages the selection and caching of zsh tips based on user tool usage patterns. In this commit, the following changes were made to improve clarity and logic structure

The tip cache update script received path configuration improvements:

This script manages the selection and caching of zsh tips based on user tool usage patterns. In this commit, the following changes were made to improve clarity and logic structure:

  1. Variable Documentation
    Added explicit unit annotations to TIP_AGE_HOURS and TIP_WINDOW_DAYS:

         -TIP_AGE_HOURS=2    # Refresh threshold
         -TIP_WINDOW_DAYS=30 # Max freshness window
         +TIP_AGE_HOURS=2    # Refresh threshold (hours)
         +TIP_WINDOW_DAYS=30 # Max freshness window (days)
    
  1. Code Formatting
    Standardized spacing in case statements and array assignments:

         -  --dry-run) DRY_RUN=1 ;;
         -  --verbose) VERBOSE=1 ;;
         +    --dry-run) DRY_RUN=1 ;;
         +    --verbose) VERBOSE=1 ;;
    
         -    if [[ -n "$tool" && ! -d "$path" && (-f "$path" || -L "$path") && -x "$path" ]]; then
         -      tools+=("$tool")
         +    if [[ -n "$tool" && ! -d "$path" && ( -f "$path" || -L "$path" ) && -x "$path" ]]; then
         +      tools+=( "$tool" )
    
         -tools=($(printf "%s\n" "${tools[@]}" | sort -u))
         +tools=( $(printf "%s\n" "${tools[@]}" | sort -u) )
    
  1. Tip Selection Logic
    Restructured to categorize tools into "stale/unseen" vs "too recent" buckets:

         -  # Build eligible list once per tier
         -  eligible=()
         +  # Categorize tools into primary and fallback buckets
         +  stale_or_unseen=()
         +  too_recent=()
    
         -    if (( last_shown == 0 || ( age >= recent_secs && age <= window_secs ) )); then
         -      eligible+=( "$tool" )
         +    if (( last_shown == 0 )) || (( age >= window_secs )); then
         +      stale_or_unseen+=( "$tool" )
         +    elif (( age < recent_secs )); then
         +      too_recent+=( "$tool" )
    

    Prioritization hierarchy implemented:

         # 1) Pick randomly from never-shown or ≥WINDOW-day-old
         if (( ${#stale_or_unseen[@]} > 0 )); then
           pick="${stale_or_unseen[RANDOM % ${#stale_or_unseen[@]}]}"
           break
         fi
    
         # 2) Fallback: pick oldest from too-recent
         if (( ${#too_recent[@]} > 0 )); then
           # ...
         fi
    
         # 3) Final fallback: pick oldest from lowest tier
         if [[ -z "$pick" ]]; then
           # ...
         fi
    
  1. Output Clarity
    Improved spacing and alignment, and modernized verbosity checks:

         -  [[ "$VERBOSE" = true ]] && echo "Usage count: $count  Tool: $tool" >&2
         +  (( VERBOSE )) && echo "Usage count: $count  Tool: $tool" >&2
    
         -if ((VERBOSE)); then
         -  echo "Selected tool: $pick"
         -fi
         +(( VERBOSE )) && echo "Selected tool: $pick"
    
         -  echo "$tip" >"$TIP_FILE"
         +  echo "$tip" > "$TIP_FILE"
    

The changes enhance maintainability while preserving the core functionality of intelligent tip selection based on tool usage patterns and recency constraints.

Project metadata was updated to align with the latest architecture:

File: pyproject.toml

The pyproject.toml file contains metadata and configuration for the project's packaging and dependencies. In this commit, the version number was incremented and a new dependency was added.

Key Changes:
  • Version updated from 0.1.1 to 0.1.2
  • Added litellm to the dependencies list
Final State of pyproject.toml
[project]
name = "zsh-tips-agent"
version = "0.1.2"
description = "Agent that generates tips for underused CLI tools"
dependencies = [
  "smolagents",
  "litellm"
]

[tool.setuptools]
packages = ["agent"]

These updates streamline the configuration management and installation workflow, setting a solid foundation for future enhancements in the zsh-tips-agent project.

Step 8: Enhancing Custom Installation Support and Non-Standard Paths

This commit introduces updates to the project's documentation and scripts to enable users to customize their installation process and utilize non-standard paths for configuration and execution.

The README has been updated to reflect these changes:

File: README.md

The README.md file was modified in this commit to enhance documentation around the zsh-tips-agent's behavior with non-standard installation prefixes and clarify its background caching mechanism. The update includes:

  1. Expanded documentation on custom installation using --prefix, ensuring compatibility with user-defined directories like /opt/zsh-tips-agent or $HOME/.local/....

  2. Clarified behavior for environment variable management, emphasizing that manual configuration is no longer required.

  3. Localization note in Portuguese (Tips são geradas...) reflecting multilingual support.

Here is the full content of the file after this commit:

# 💡 zsh-tips-agent

A Zsh tool that provides useful command-line tips based on your actual usage history, leveraging local language models via ollama without slowing down your terminal.

## 🔧 Features

- Suggests underused CLI tools from directories such as `/usr/local/bin` or `brew` packages
- Generates personalized tips based on your command history (`~/.zsh_history`)
- Uses local language models (e.g., via `ollama`) to intelligently generate tips
- Updates tip cache in the background to ensure quick terminal startup
- Tips are cached and updated in the background (default: `~/.local/share/zsh-tips-agent/data/`)
- Automatically refreshes tips periodically

## 🚀 Quickstart

```bash
git clone https://github.com/YOURUSER/zsh-tips-agent.git $GH/zsh-tips-agent
cd $GH/zsh-tips-agent
uv pip install .
zsh-tips-agent install
zsh-tips-agent init --apply
source ~/.zshrc
\`\`\`

Alternatively, directly add this to your `~/.zshrc`:

```bash
eval "$(zsh-tips-agent init --print)"
\`\`\`

## ✅ Prerequisites

- [`ollama`](https://ollama.com/) installed and configured locally
- At least one local language model available (`ollama ls`, e.g., `llama3`, `phi3`)
- Python dependencies installed via `uv pip install .`
- CLI tools available on your system (typically `/usr/local/bin`, `brew`)

## 🧠 Tip Generation

Tips são geradas usando modelos de linguagem locais. Elas são armazenadas em cache e atualizadas periodicamente em segundo plano, garantindo um terminal responsivo. Os arquivos de cache ficam em `~/.local/share/zsh-tips-agent/data/`.

## ⚙️ Customization

Customize the model and parameters by editing:

```bash
~/.local/share/zsh-tips-agent/config.json
\`\`\`

Example:

```json
{
  "model_id": "llama3",
  "model_params": {
    "temperature": 0.7,
    "top_p": 0.9
  }
}
\`\`\`

If absent, defaults from `agent/config.json` are used. Environment variables can temporarily override these settings:

```bash
ZSH_TIP_MODEL=phi3 python agent/generate_tip.py ...
\`\`\`

Here’s an updated section for your **README.md** that clearly explains custom installations and how the agent’s environment variables ensure everything works out of the box, even for non-standard install locations.

### 🛠️ Custom Installation & Non-Standard Prefixes

You can install **zsh-tips-agent** to a custom directory by specifying a `--prefix`:

```bash
zsh-tips-agent install --prefix /opt/zsh-tips-agent
zsh-tips-agent init --prefix /opt/zsh-tips-agent --apply
source ~/.zshrc
\`\`\`

This ensures all scripts and data will use `/opt/zsh-tips-agent` as the base instead of `/usr/local`.

* The initialization step (`init`) will insert a block into your `.zshrc` that references your custom install path.
* When updating the tip cache, the agent now passes the correct path (`PROJECT_DIR`) automatically—**you do not need to set any extra environment variables yourself.**
* All scripts, cache, and background updates will work with your chosen prefix transparently.

If you ever need to move or reinstall to a different location, simply rerun the `init` step with your new prefix to update `.zshrc`.

### Example for a Home Directory Install

```bash
zsh-tips-agent install --prefix $HOME/.local/zsh-tips-agent
zsh-tips-agent init --prefix $HOME/.local/zsh-tips-agent --apply
source ~/.zshrc
\`\`\`

---

### ⚠️ Notes

* The `.zshrc` snippet manages all necessary environment variables for you.
* Manual edits or environment exports (like `PROJECT_DIR`) are **not needed**.
* All agent scripts and cache updates are path-aware based on your chosen prefix.

## 📜 License

[LGPLv3](LICENSE)

The update_tip_cache.sh script now includes logic for custom paths:

File: bin/update_tip_cache.sh

This shell script manages the caching of Zsh tips by analyzing user command usage patterns. This commit introduces key configuration path updates and formatting improvements.

Key changes in this commit:

  • Path standardization: Moved cache storage to user-specific directories ($HOME/.local/share) instead of project-relative paths for better isolation

  • Syntax fixes: Corrected printf newline formatting in usage tracking and tier processing sections

  • Comment consistency: Replaced curly apostrophes with straight quotes in code comments

  • Configuration enhancements: Added explicit cache directory creation and initialization

Here's the updated script content with all changes applied:

#!/usr/bin/env bash
set -euo pipefail

# ---- Configurable paths ----
# Default to installation location, but allow override
PROJECT_DIR="${PROJECT_DIR:-/usr/local/lib/zsh-tips-agent}"
HISTORY_FILE="$HOME/.zsh_history"
CACHE_DIR="$HOME/.local/share/zsh-tips-agent/data"
TIP_FILE="$CACHE_DIR/current_tip.txt"
TIP_CACHE="$CACHE_DIR/tip_cache.json"
AGENT="$PROJECT_DIR/agent/generate_tip.py"
TIP_AGE_HOURS=2    # Refresh threshold (hours)
TIP_WINDOW_DAYS=30 # Max freshness window (days)

# ---- Flags ----
DRY_RUN=0
VERBOSE=0
for arg in "$@"; do
  case "$arg" in
    --dry-run) DRY_RUN=1 ;;
    --verbose) VERBOSE=1 ;;
  esac
done

mkdir -p "$CACHE_DIR"
[[ -f "$TIP_CACHE" ]] || echo '{}' >"$TIP_CACHE"

# ---- Gather user-installed tool candidates ----
tools=()
for dir in ~/.local/bin /usr/local/bin /opt/homebrew/bin; do
  [[ -d "$dir" ]] || continue
  for tool in $(ls "$dir" 2>/dev/null); do
    path="$dir/$tool"
    if [[ -n "$tool" && ! -d "$path" && ( -f "$path" || -L "$path" ) && -x "$path" ]]; then
      tools+=( "$tool" )
    fi
  done
 done
tools=( $(printf "%s\n" "${tools[@]}" | sort -u) )

if [[ ${#tools[@]} -eq 0 ]]; then
  echo "No tool candidates found for tips." >"$TIP_FILE"
  exit 0
fi

# ---- Count usage from history ----
tmp_usage=$(mktemp)
for tool in "${tools[@]}"; do
  count=$(grep -c -w "$tool" "$HISTORY_FILE" 2>/dev/null || echo 0)
  printf '%s %s\n' "$count" "$tool" >>"$tmp_usage"
  (( VERBOSE )) && echo "Usage count: $count  Tool: $tool" >&2
done

# ---- Build usage tiers using files ----
usage_groups_dir=$(mktemp -d)
while read -r count tool; do
  [[ -z "$tool" ]] && continue
  echo "$tool" >>"$usage_groups_dir/$count"
done <"$tmp_usage"
rm -f "$tmp_usage"

sorted_counts=( $(find "$usage_groups_dir" -type f -exec basename {} \; | sort -n) )

# ---- Select tip candidate with freshness filtering ----
now=$(date +%s)
recent_secs=$(( TIP_AGE_HOURS * 3600 ))
window_secs=$(( TIP_WINDOW_DAYS * 24 * 3600 ))

pick=""
for usage in "${sorted_counts[@]}"; do
  tools_in_tier_file="$usage_groups_dir/$usage"
  [[ -f "$tools_in_tier_file" ]] || continue

  # Read this tier's tools into an array
  tools_in_tier=()
  while IFS= read -r tool; do
    tools_in_tier+=( "$tool" )
  done < "$tools_in_tier_file"

  (( VERBOSE )) && echo "Tier usage count = $usage: ${tools_in_tier[*]}"

  # Categorize tools into primary and fallback buckets
  stale_or_unseen=()
  too_recent=()

  for tool in "${tools_in_tier[@]}"; do
    [[ -z "$tool" ]] && continue
    last_shown=$(jq -r --arg t "$tool" '.[$t].last_shown // 0' "$TIP_CACHE")
    age=$(( now - last_shown ))

    if (( last_shown == 0 )) || (( age >= window_secs )); then
      stale_or_unseen+=( "$tool" )
    elif (( age < recent_secs )); then
      too_recent+=( "$tool" )
    fi
  done

  # 1) Pick randomly from never-shown or ≥WINDOW-day-old
  if (( ${#stale_or_unseen[@]} > 0 )); then
    pick="${stale_or_unseen[RANDOM % ${#stale_or_unseen[@]}]}"
    break
  fi

  # 2) Fallback: pick oldest from too-recent
  if (( ${#too_recent[@]} > 0 )); then
    oldest_tool=""
    oldest_time=$now
    for tool in "${too_recent[@]}"; do
      t_last=$(jq -r --arg t "$tool" '.[$t].last_shown // 0' "$TIP_CACHE")
      if (( t_last < oldest_time )); then
        oldest_time=$t_last
        oldest_tool="$tool"
      fi
    done
    pick="$oldest_tool"
    break
  fi
done

# If no pick yet, fallback to absolute oldest in lowest tier
if [[ -z "$pick" ]]; then
  fallback_file="$usage_groups_dir/${sorted_counts[0]}"
  oldest_tool=""
  oldest_time=$now
  while IFS= read -r tool; do
    last_shown=$(jq -r --arg t "$tool" '.[$t].last_shown // 0' "$TIP_CACHE")
    if (( last_shown < oldest_time )); then
      oldest_time=$last_shown
      oldest_tool="$tool"
    fi
  done < "$fallback_file"
  pick="$oldest_tool"
fi

rm -rf "$usage_groups_dir"

(( VERBOSE )) && echo "Selected tool: $pick"

# ---- Show or generate tip ----
if [[ -n "$(jq -r --arg t "$pick" '.[$t].tip // empty' "$TIP_CACHE")" ]]; then
  tip=$(jq -r --arg t "$pick" '.[$t].tip' "$TIP_CACHE")
  echo "$tip" > "$TIP_FILE"
else
  if (( DRY_RUN )); then
    echo "[dry-run] python3 $AGENT $pick $TIP_CACHE $TIP_FILE"
  else
    "$PROJECT_DIR/.venv/bin/python" "$AGENT" "$pick" "$TIP_CACHE" "$TIP_FILE"
  fi
fi

Similarly, the main zsh-tips-agent script has been adjusted to accommodate non-standard directories:

#!/usr/bin/env bash
set -euo pipefail

# ---- Paths & Defaults ----
# Default installation prefix
DEFAULT_PREFIX="/usr/local"
PREFIX="$DEFAULT_PREFIX"

# Source directory (project root - parent of the directory containing this script)
SCRIPT_PATH="${BASH_SOURCE[0]}"
# Resolve symlinks manually for macOS compatibility
while [[ -L "$SCRIPT_PATH" ]]; do
  SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"

# Parse arguments
while [[ $# -gt 0 ]]; do
  case "$1" in
    --prefix)
      shift
      PREFIX="$1"
      shift
      ;;
    *)
      break
      ;;
  esac
done

# Set derived paths after parsing arguments
BIN_DIR="$PREFIX/bin"
LIB_DIR="$PREFIX/lib/zsh-tips-agent"

SNIPPET=$(cat <<EOF
# ---- zsh-tips-agent ----
TIPS_AGENT_DIR="\${TIPS_AGENT_DIR:-$LIB_DIR}"
DATA_DIR="\$HOME/.local/share/zsh-tips-agent/data"
TIP_CACHE_FILE="\$DATA_DIR/current_tip.txt"
UPDATE_SCRIPT="\$TIPS_AGENT_DIR/bin/update_tip_cache.sh"
TIP_AGE_HOURS=2

tip_is_fresh() {
  [[ ! -f "\$TIP_CACHE_FILE" ]] && return 1
  now=\$(date +%s)
  filetime=\$(stat -f %m "\$TIP_CACHE_FILE" 2>/dev/null || stat -c %Y "\$TIP_CACHE_FILE" 2>/dev/null)
  (( ((now - filetime)/3600) < TIP_AGE_HOURS ))
}

[[ -f "\$TIP_CACHE_FILE" ]] && cat "\$TIP_CACHE_FILE"
if ! tip_is_fresh; then
  [[ -x "\$UPDATE_SCRIPT" ]] && PROJECT_DIR="\$TIPS_AGENT_DIR" "\$UPDATE_SCRIPT" > /dev/null 2>&1 &
fi
# ---- /zsh-tips-agent ----
EOF
)

usage() {
  cat <<EOF >&2
Usage: zsh-tips-agent [--prefix <path>] <command> [options]

Commands:
  install                 Install zsh-tips-agent (default prefix: $DEFAULT_PREFIX)
  init [--apply|--print]  Add or display the snippet for ~/.zshrc
  help                    Show this message

Options:
  --prefix <path>         Installation prefix (default: $DEFAULT_PREFIX)

Installation paths (with --prefix=$PREFIX):
  Scripts:     $BIN_DIR
  Library:     $LIB_DIR
  User data:   ~/.local/share/zsh-tips-agent
EOF
  exit 1
}

cmd_install() {
  echo "Installing zsh-tips-agent..."
  echo "  Source directory: $SCRIPT_DIR"
  echo "  Target bin directory: $BIN_DIR"
  echo "  Target lib directory: $LIB_DIR"

  # Create directories
  mkdir -p "$BIN_DIR"
  mkdir -p "$LIB_DIR"

  # Copy the main executable to bin
  install -m755 "$SCRIPT_DIR/bin/zsh-tips-agent" "$BIN_DIR/zsh-tips-agent"
  echo "  ✓ Installed zsh-tips-agent to $BIN_DIR"

  # Copy everything else to lib directory
  cp -r "$SCRIPT_DIR/bin" "$LIB_DIR/"
  cp -r "$SCRIPT_DIR/agent" "$LIB_DIR/"
  [[ -f "$SCRIPT_DIR/pyproject.toml" ]] && cp "$SCRIPT_DIR/pyproject.toml" "$LIB_DIR/"
  echo "  ✓ Installed library files to $LIB_DIR"

  # Set up Python virtual environment
  if command -v python3 >/dev/null 2>&1; then
    echo "  Setting up Python virtual environment..."
    python3 -m venv "$LIB_DIR/.venv"
    if [[ -f "$LIB_DIR/pyproject.toml" ]]; then
      "$LIB_DIR/.venv/bin/pip" install -e "$LIB_DIR" >/dev/null 2>&1 || {
        echo "  ⚠️  Warning: Failed to install Python dependencies. Install manually with:"
        echo "     $LIB_DIR/.venv/bin/pip install -e $LIB_DIR"
      }
    else
      echo "  ⚠️  No pyproject.toml found, skipping Python setup"
    fi
  else
    echo "  ⚠️  Python3 not found, skipping virtual environment setup"
  fi

  echo "✅ Installation complete!"
  echo
  echo "To enable zsh-tips-agent, add this to your ~/.zshrc:"
  echo
  echo "  eval \"\$(zsh-tips-agent init --print)\""
  echo
  echo "Or run: zsh-tips-agent init --apply"
}

cmd_init() {
  case "${1:-}" in
    --print)
      echo "$SNIPPET"
      exit 0
      ;;
    --apply|"")
      ZSHRC="$HOME/.zshrc"
      if ! grep -q 'zsh-tips-agent ----' "$ZSHRC" 2>/dev/null; then
        printf "\n%s\n" "$SNIPPET" >> "$ZSHRC"
        echo "✅ Added zsh-tips-agent snippet to $ZSHRC"
      else
        echo "ℹ️  zsh-tips-agent snippet already present in $ZSHRC"
      fi
      ;;
    *)
      echo "Unknown option: $1" >&2
      usage
      ;;
  esac
}

# Dispatch commands
case "${1:-help}" in
  install) shift; cmd_install "$@" ;;
  init)    shift; cmd_init "$@" ;;
  help|--help|-h) usage ;;
  *) 
    echo "Unknown command: $1" >&2
    usage 
    ;;
esac

These updates streamline the installation process, offering greater flexibility for users with specific directory preferences or system configurations. This step ensures that the project remains adaptable to diverse user environments without compromising functionality.

Step 9: Refactors the installation script to improve Python environment setup and simplify argument handling.

This mostly achieve what was desired, but introduced issues in zsh-tips-agent that needed to be addressed in a final rewrite.

File: bin/zsh-tips-agent

#!/usr/bin/env bash
set -euo pipefail

# ---- Functions ----

usage() {
  cat <<EOF >&2
Usage: zsh-tips-agent [--prefix <path>] <command> [options]

Commands:
  install                 Install zsh-tips-agent (default prefix: /usr/local)
  init [--apply|--print]  Add or display the snippet for ~/.zshrc
  help                    Show this message

Options:
  --prefix <path>         Installation prefix (default: /usr/local)

EOF
  exit 1
}

cmd_install() {
  echo "Installing zsh-tips-agent..."
  echo "  Source directory: $SCRIPT_DIR"
  echo "  Target lib directory: $LIB_DIR"

  mkdir -p "$LIB_DIR"

  # Copy everything to lib directory
  cp -r "$SCRIPT_DIR/bin" "$LIB_DIR/"
  cp -r "$SCRIPT_DIR/agent" "$LIB_DIR/"
  [[ -f "$SCRIPT_DIR/pyproject.toml" ]] && cp "$SCRIPT_DIR/pyproject.toml" "$LIB_DIR/"
  echo "  ✓ Installed library files to $LIB_DIR"

  # Set up Python virtual environment
  if command -v uv >/dev/null 2>&1; then
    echo "  Creating Python virtual environment with uv..."
    uv venv "$LIB_DIR/.venv" || {
      echo "  ❌ Error: uv failed to create a virtual environment."
      echo "  Please update uv: pip install --upgrade uv"
      echo "  See https://github.com/astral-sh/uv for more info."
      exit 1
    }
    VENV_PY="$LIB_DIR/.venv/bin/python"
    VENV_PIP="$LIB_DIR/.venv/bin/uv pip"
  elif command -v python3 >/dev/null 2>&1; then
    echo "  Creating Python virtual environment with python3..."
    python3 -m venv "$LIB_DIR/.venv" || {
      echo "  ❌ Error: python3 failed to create a virtual environment."
      echo "  This is usually due to a missing ensurepip or a broken Python install."
      echo "  On macOS, try: brew install python"
      echo "  Or install uv: pip install uv"
      echo "  See https://github.com/astral-sh/uv for more info."
      exit 1
    }
    VENV_PY="$LIB_DIR/.venv/bin/python"
    VENV_PIP="$LIB_DIR/.venv/bin/pip"
  else
    echo "  ❌ Error: Neither uv nor python3 was found."
    echo "  Please install Python 3.8+ or uv: pip install uv"
    exit 1
  fi

  # Install dependencies
  if [[ -f "$LIB_DIR/pyproject.toml" ]]; then
    if command -v uv >/dev/null 2>&1; then
      (cd $LIB_DIR && uv pip install -e .) || {
        echo "  ⚠️  Warning: Failed to install Python dependencies with uv."
        echo "     Try manually: uv pip install -e $LIB_DIR"
      }
    else
      (cd $LIB_DIR && ".venv/bin/pip" install -e .) || {
        echo "  ⚠️  Warning: Failed to install Python dependencies."
        echo "     Try manually: $LIB_DIR/.venv/bin/pip install -e $LIB_DIR"
      }
    fi
  else
    echo "  ⚠️  No pyproject.toml found, skipping Python setup"
  fi

  if [[ "$PREFIX" != "$DEFAULT_PREFIX" ]]; then
    INIT_CMD="zsh-tips-agent init --prefix \"$PREFIX\" --print"
    APPLY_CMD="zsh-tips-agent init --prefix \"$PREFIX\" --apply"
  else
    INIT_CMD="zsh-tips-agent init --print"
    APPLY_CMD="zsh-tips-agent init --apply"
  fi

  echo "✅ Installation complete!"
  echo
  echo "To enable zsh-tips-agent, add this to your ~/.zshrc:"
  echo
  echo "  eval \"\$($INIT_CMD)\""
  echo
  echo "Or run: $APPLY_CMD"
}


cmd_init() {
  SNIPPET=$(cat <<EOF
# ---- zsh-tips-agent ----
TIPS_AGENT_DIR="\${TIPS_AGENT_DIR:-$LIB_DIR}"
DATA_DIR="\$HOME/.local/share/zsh-tips-agent/data"
TIP_CACHE_FILE="\$DATA_DIR/current_tip.txt"
UPDATE_SCRIPT="\$TIPS_AGENT_DIR/bin/update_tip_cache.sh"
TIP_AGE_HOURS=2

tip_is_fresh() {
  [[ ! -f "\$TIP_CACHE_FILE" ]] && return 1
  now=\$(date +%s)
  filetime=\$(stat -f %m "\$TIP_CACHE_FILE" 2>/dev/null || stat -c %Y "\$TIP_CACHE_FILE" 2>/dev/null)
  (( ((now - filetime)/3600) < TIP_AGE_HOURS ))
}

[[ -f "\$TIP_CACHE_FILE" ]] && cat "\$TIP_CACHE_FILE"
if ! tip_is_fresh; then
  [[ -x "\$UPDATE_SCRIPT" ]] && PROJECT_DIR="\$TIPS_AGENT_DIR" "\$UPDATE_SCRIPT" > /dev/null 2>&1 &
fi
# ---- /zsh-tips-agent ----
EOF
)

  case "${1:-}" in
    --print)
      echo "$SNIPPET"
      exit 0
      ;;
    --apply|"")
      ZSHRC="$HOME/.zshrc"
      if ! grep -q 'zsh-tips-agent ----' "$ZSHRC" 2>/dev/null; then
        printf "\n%s\n" "$SNIPPET" >> "$ZSHRC"
        echo "✅ Added zsh-tips-agent snippet to $ZSHRC"
      else
        echo "ℹ️  zsh-tips-agent snippet already present in $ZSHRC"
      fi
      ;;
    *)
      echo "Unknown option: $1" >&2
      usage
      ;;
  esac
}

# ---- End of function definitions ----

# ---- Set Paths & Parse Global Arguments ----

# Default installation prefix
DEFAULT_PREFIX="/usr/local"
PREFIX="$DEFAULT_PREFIX"

# Source directory (project root - parent of the directory containing this script)
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [[ -L "$SCRIPT_PATH" ]]; do
  SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"

# Parse --prefix anywhere in the command line AND remove it from args
# This loop handles global options before command dispatch
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --prefix)
      PREFIX="$2"
      shift 2 # Consume --prefix and its value
      ;;
    --) # End of options
      shift # Consume --
      POSITIONAL_ARGS+=("$@") 
      set -- "${POSITIONAL_ARGS[@]}"
      break
      ;;
    *) # Not a global option, so it must be the command or its args
      POSITIONAL_ARGS+=("$1")
      shift
      ;;
  esac
done
set -- "${POSITIONAL_ARGS[@]}" # Update $@ with cleaned args

# Set derived paths after parsing arguments
LIB_DIR="$PREFIX/lib/zsh-tips-agent"

# ---- Dispatch Commands ----
case "${1:-help}" in # Now $1 should correctly be the command
  install) shift; cmd_install "$@" ;;
  init)    shift; cmd_init "$@" ;;
  help|--help|-h) usage ;;
  *) 
    echo "Unknown command: $1" >&2
    usage 
    ;;
esac

Final Result

The zsh-tips-agent project is a Zsh plugin that provides personalized command-line tips based on user behavior and available tools. It leverages local language models (like Ollama) to generate context-aware suggestions without compromising terminal performance. The project features:

  • Intelligent CLI tip generation using command history

  • Background caching for fast startup

  • Customizable model parameters and installation paths

  • Modular architecture with separate scripts for tip generation and cache management

  • Comprehensive documentation for standard and custom installations

Users can install it via uv pip install . and activate it with zsh-tips-agent init, with full support for non-standard prefixes and environment variables.

Key Takeaways

This project demonstrates several important development principles:

  1. Modular Design: Separation of concerns between tip generation, caching, and Zsh integration

  2. User-Centric Configuration: Flexible configuration through JSON files and environment variables

  3. Performance Optimization: Background processing and caching to maintain shell responsiveness

  4. Incremental Development: Starting with core functionality before adding customization options

  5. Tool Integration: Effective use of existing systems (Ollama, Zsh) rather than reinventing components

  6. Documentation-Driven Development: Maintaining clear installation instructions alongside code

The structure with agent/, bin/, and configuration files shows how to organize a CLI tool while maintaining user customization options.

Next Steps

Readers can:

  1. Customize Models: Experiment with different Ollama models for tip generation

  2. Expand Tips: Add new tip categories (e.g., security best practices, productivity patterns)

  3. Improve Caching: Implement smarter cache expiration policies based on usage frequency

  4. Enhance UX: Add interactive mode for tip selection

  5. Contribute: Submit GitHub issues for edge cases or pull requests for new features

  6. Cross-Shell Support: Adapt the agent for Bash or Fish shell users

  7. Benchmarking: Compare performance with other tip systems like thesaurus or cheat.sh integrations

The project's structure makes it easy to extend through the agent/generate_tip.py module while maintaining the existing CLI workflow.


About This Tutorial

This tutorial was automatically generated by analyzing the git history of the project.

  • Repository analyzed: ../zsh-tips-agent/

  • Total commits processed: 9

  • Generated on: 2025-05-24 07:29:44

The development journey was reconstructed by examining actual code changes,commit messages, and file modifications to provide an authentic learning experience.

0
Subscribe to my newsletter

Read articles from Robert Collins directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Robert Collins
Robert Collins