Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- The CLI prints one GitHub star line to stderr after the fifth successful interactive run, once ever (counter in the cache directory). It is silent for the MCP server, with `--json`, in CI, and when stderr is not a terminal; `LOCKDOCS_NO_STAR_HINT=1` turns it off.

## 0.3.0

- **Docs sites follow your major.** For packages whose docs live in a separate website repository, `lockdocs fetch` takes the docs for the pinned major: the default branch when your major is the latest, a `vN` / `N.x` branch when the site keeps one (Tailwind CSS v3, Prisma v6), or the last commit before the next major was released. Pages about a later major are skipped. React keeps the latest-only rule (react.dev documents APIs before they ship). tokio's website (tutorial and topics) is added, and docs sites now work for crates and PyPI packages too.
Expand Down
12 changes: 11 additions & 1 deletion crates/lockdocs/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod mcp;
mod setup;
mod star_hint;
mod tools;

use anyhow::{bail, Result};
Expand Down Expand Up @@ -151,7 +152,12 @@ fn run() -> Result<()> {
}
let json = args.on("json");
let engine = || Engine::new(&args.root(), args.opts());
match cmd.as_str() {
let counts_as_run = !json
&& !matches!(
cmd.as_str(),
"mcp" | "serve" | "help" | "--help" | "-h" | "version" | "--version" | "-V" | "setup" | "cache" | "fetch" | "pull"
);
let result = match cmd.as_str() {
"mcp" | "serve" => {
let offline = args.on("offline");
// Fetch the model in the background; queries are keyword-only until it lands.
Expand Down Expand Up @@ -207,7 +213,11 @@ fn run() -> Result<()> {
Err(err) => bail!("{err}"),
}
}
};
if result.is_ok() && counts_as_run {
star_hint::after_success();
}
result
}

fn ensure_model(offline: bool) {
Expand Down
134 changes: 134 additions & 0 deletions crates/lockdocs/src/star_hint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! A one-time star line on stderr after the fifth successful CLI run.
//!
//! The run counter lives in the lockdocs cache directory (`LOCKDOCS_CACHE`). The line is shown once
//! ever, and never for the MCP server, for a non-TTY stderr, in CI, or when
//! `LOCKDOCS_NO_STAR_HINT` is set.

use std::io::IsTerminal;
use std::path::{Path, PathBuf};

pub const OPT_OUT_ENV: &str = "LOCKDOCS_NO_STAR_HINT";
const FILE_NAME: &str = "star-hint";
const SHOWN: &str = "shown";
const SHOW_AFTER_RUNS: u32 = 5;
const MESSAGE: &str = "Enjoying lockdocs? A GitHub star helps others find it: https://github.com/SylphxAI/lockdocs";

/// What the environment says about where the run is happening.
pub struct Context {
pub stderr_is_tty: bool,
pub ci: bool,
pub opted_out: bool,
}

impl Context {
fn from_process() -> Self {
let set = |key: &str| std::env::var_os(key).is_some_and(|v| !v.is_empty());
Context {
stderr_is_tty: std::io::stderr().is_terminal(),
ci: set("CI"),
opted_out: set(OPT_OUT_ENV),
}
}

fn quiet(&self) -> bool {
!self.stderr_is_tty || self.ci || self.opted_out
}
}

/// Decide from the stored counter text. Returns the text to store next and
/// whether to print the line now.
pub fn step(stored: Option<&str>, context: &Context) -> (Option<String>, bool) {
if context.quiet() {
return (None, false);
}
let stored = stored.map(str::trim).unwrap_or("");
if stored == SHOWN {
return (None, false);
}
let runs = stored.parse::<u32>().unwrap_or(0).saturating_add(1);
if runs >= SHOW_AFTER_RUNS {
(Some(SHOWN.to_string()), true)
} else {
(Some(runs.to_string()), false)
}
}

fn state_path() -> PathBuf {
lockdocs_core::cache::dir().join(FILE_NAME)
}

fn apply(path: &Path, context: &Context) -> bool {
let stored = std::fs::read_to_string(path).ok();
let (next, show) = step(stored.as_deref(), context);
if let Some(next) = next {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(path, next).is_err() {
return false;
}
}
show
}

/// Call after a CLI run that succeeded. Best effort: any file error is silent.
pub fn after_success() {
let context = Context::from_process();
if context.quiet() {
return;
}
if apply(&state_path(), &context) {
eprintln!("{MESSAGE}");
}
}

#[cfg(test)]
mod tests {
use super::*;

fn context(stderr_is_tty: bool, ci: bool, opted_out: bool) -> Context {
Context { stderr_is_tty, ci, opted_out }
}

#[test]
fn counts_four_runs_then_shows_once() {
let interactive = context(true, false, false);
let mut stored: Option<String> = None;
for run in 1..=4 {
let (next, show) = step(stored.as_deref(), &interactive);
assert!(!show, "run {run}");
stored = next;
}
assert_eq!(stored.as_deref(), Some("4"));
let (next, show) = step(stored.as_deref(), &interactive);
assert!(show);
assert_eq!(next.as_deref(), Some("shown"));
let (next, show) = step(next.as_deref(), &interactive);
assert!(!show);
assert_eq!(next, None);
}

#[test]
fn stays_silent_and_uncounted_when_gated() {
for quiet in [context(false, false, false), context(true, true, false), context(true, false, true)] {
assert_eq!(step(Some("4"), &quiet), (None, false));
}
}

#[test]
fn garbage_counter_restarts() {
let interactive = context(true, false, false);
assert_eq!(step(Some("x"), &interactive), (Some("1".into()), false));
}

#[test]
fn persists_and_shows_on_the_fifth_run_only() {
let dir = std::env::temp_dir().join(format!("lockdocs-star-hint-{}", std::process::id()));
let path = dir.join("nested").join(FILE_NAME);
let interactive = context(true, false, false);
let shown: Vec<bool> = (0..7).map(|_| apply(&path, &interactive)).collect();
assert_eq!(shown, [false, false, false, false, true, false, false]);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "shown");
let _ = std::fs::remove_dir_all(&dir);
}
}
1 change: 1 addition & 0 deletions docs/guide/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@
| `LOCKDOCS_ROOT` | Project directory when the client sends no roots |
| `LOCKDOCS_FETCH=1` | Allow [fetching](./fetch) exact versions that are not installed |
| `LOCKDOCS_CACHE` | Cache directory (default: the OS cache dir + `/lockdocs`) |
| `LOCKDOCS_NO_STAR_HINT=1` | Turn off the one-time GitHub star line the CLI prints to stderr after the fifth successful interactive run (never shown for the MCP server, with `--json`, in CI, or when stderr is not a terminal) |
| `LOCKDOCS_NO_SYSTEM_PYTHON=1` | Do not ask the system interpreter for its site-packages |
Loading