From 088ea62731a637b166b7fb0c43708f9751db3cc8 Mon Sep 17 00:00:00 2001
From: thedancingdeveloper
<306930456+thedancingdeveloper@users.noreply.github.com>
Date: Mon, 26 Oct 2026 14:30:10 +0000
Subject: [PATCH 1/4] docs: index the user guide and lint internal names
Graft-Changeset: cab55bfc1ce3bb0a157e74f11447569777f9e67eec6b15aeb84c7a720f04d8bad
Graft-Session: ses_01M5W8RBSY2V06NCY2KQ87EKS7
Graft-Work-Item: WI-2549
Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Graft-Batch: 4
---
README.md | 23 +++++++++++++++++++++++
changes/WI-2549.md | 1 +
docs/guide/README.md | 23 +++++++++++++++++++++++
docs/guide/bench.svg | 4 ++--
docs/guide/benchmarks.md | 24 ++++++++++++------------
scripts/check-guide-names.sh | 12 ++++++++++++
6 files changed, 73 insertions(+), 14 deletions(-)
create mode 100644 changes/WI-2549.md
create mode 100644 docs/guide/README.md
create mode 100755 scripts/check-guide-names.sh
diff --git a/README.md b/README.md
index b0fed75..0962a49 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,29 @@ graft is early. The design is [docs/design.md](docs/design.md); work lands
on short-lived branches through the merge queue. See
[CONTRIBUTING.md](CONTRIBUTING.md).
+## Documentation
+
+User guide:
+
+- [Concepts](docs/guide/concepts.md): glossary and the git-to-graft table.
+- [MCP](docs/guide/mcp.md): every MCP tool, with its arguments and results.
+- [Server](docs/guide/server.md): config, tokens, repositories, lines, metrics and upgrades.
+- [Git migration](docs/guide/git-migration.md): import, follow GitHub, batch pull requests and export.
+- [FAQ](docs/guide/faq.md): git compatibility, offline use, lines, CI and the licence.
+- [Benchmarks](docs/guide/benchmarks.md): how the published timings were measured.
+- [Agents](docs/agents.md): how an agent session uses graft.
+
+[The same index](docs/guide/README.md) lives with the guide.
+
+## How it is built
+
+- [Design](docs/design.md)
+- [Plan](docs/plan.md)
+- [Decisions](docs/decisions.md)
+- [Status](docs/status.md)
+- [Trials](docs/trials)
+- [Notes](docs/notes)
+
## Licence
[AGPL-3.0-only](LICENSE).
diff --git a/changes/WI-2549.md b/changes/WI-2549.md
new file mode 100644
index 0000000..6609d23
--- /dev/null
+++ b/changes/WI-2549.md
@@ -0,0 +1 @@
+- User guide pages are indexed from the README and `docs/guide/README.md`, with the engineering record under "How it is built". `scripts/check-guide-names.sh` fails when the guide names an estate host, tracker, trial or internal id.
diff --git a/docs/guide/README.md b/docs/guide/README.md
new file mode 100644
index 0000000..e79f2e9
--- /dev/null
+++ b/docs/guide/README.md
@@ -0,0 +1,23 @@
+# User guide
+
+- [Concepts](concepts.md): glossary and the git-to-graft table.
+- [MCP](mcp.md): every MCP tool, with its arguments and results.
+- [Server](server.md): config, tokens, repositories, lines, metrics and upgrades.
+- [Git migration](git-migration.md): import, follow GitHub, batch pull requests and export.
+- [FAQ](faq.md): git compatibility, offline use, lines, CI and the licence.
+- [Benchmarks](benchmarks.md): how the published timings were measured.
+- [Bench chart](bench.svg): the rendered timings the methodology describes.
+- [Agents](../agents.md): how an agent session uses graft.
+
+The same list is in the repository [README](../../README.md#documentation).
+
+## How it is built
+
+Engineering record, not part of the user guide:
+
+- [Design](../design.md)
+- [Plan](../plan.md)
+- [Decisions](../decisions.md)
+- [Status](../status.md)
+- [Trials](../trials)
+- [Notes](../notes)
diff --git a/docs/guide/bench.svg b/docs/guide/bench.svg
index 12eedf3..982ce70 100644
--- a/docs/guide/bench.svg
+++ b/docs/guide/bench.svg
@@ -80,7 +80,7 @@
rdpapp-split: 9 PRs, clean 1=1, conflicts 8=8, rt 17/19
rdpapp-stack: 11 PRs, clean 0=0, conflicts 1=1, rt 2/2
fluent2-registration: 34 PRs, clean 18=18, conflicts 16=16, rt 50/64
-rusttorrent-qbit: 4 PRs, clean 0=0, conflicts 4=4, rt 8/8
+qbit-importer: 4 PRs, clean 0=0, conflicts 4=4, rt 8/8
graft-merge-back: 57 PRs, clean 39=39, conflicts 18=18, rt 75/153
Branching strategies
@@ -88,6 +88,6 @@
trunk: round trips 85 / 60, conflict blocks 4 / 5
gitflow: round trips 293 / 154, conflict blocks 29 / 18
release: round trips 173 / 106, conflict blocks 11 / 11
-Not on this chart: the WI-2341 twelve-run read-path medians (D31 floor). They are in docs/status.md and docs/decisions.md only, not in a results file.
+Not on this chart: the twelve-run read-path medians (the read-latency floor). They are in the engineering record only, not in a results file.
diff --git a/docs/guide/benchmarks.md b/docs/guide/benchmarks.md
index eb55d5c..0072dbb 100644
--- a/docs/guide/benchmarks.md
+++ b/docs/guide/benchmarks.md
@@ -25,9 +25,9 @@ as a speed change.
## What is not on the chart
-The twelve-run read-path medians recorded by WI-2341 on 2026-10-24 are
-not in any file under `bench/results/`. They are written in
-[docs/status.md](../status.md) and [docs/decisions.md](../decisions.md) (D31):
+The twelve-run read-path medians recorded on 2026-10-24 are
+not in any file under `bench/results/`. They are written in the engineering
+record listed under "How it is built":
| | 1k graft / git | 10k graft / git |
| --- | ---: | ---: |
@@ -48,17 +48,17 @@ graft is faster on full clone, sparse clone, `log --path` and blame at
both recorded scales, and faster on concurrent clean lands at 10k
(238 ms vs 365 ms). It is slower on `log -50`, status and, at 10k, sync.
-Read latency is a no-regression floor, not a phase-1 target (D31,
-2026-10-25). No read path may be slower than the WI-2341 medians above,
-and every operation stays O(change). Being 1.3× to 2.4× slower than git
-on log, status and sync in the single-run files is recorded, not a
-defect to close in phase 1. 2× faster than git is a later-phase target.
-WI-2400 is wont_do.
+Read latency is a no-regression floor, not a phase-1 target
+(recorded 2026-10-25). No read path may be slower than the twelve-run
+medians above, and every operation stays O(change). Being 1.3× to 2.4×
+slower than git on log, status and sync in the single-run files is
+recorded, not a defect to close in phase 1. 2× faster than git is a
+later-phase target. Closing that gap is out of scope for phase 1.
## Real-PR replay
`bench/results/2026-10-24-real-prs/results.json`, 2026-10-24, 115 PRs in
-five cases (rdpapp split and stack, fluent2 registration, rustTorrent
+five cases (rdpapp split and stack, fluent2 registration, the qBittorrent importer
qBittorrent, graft merge-back). Clean merges and conflicts match git on
every case: 58 clean, 47 conflicts, graft-only and git-only clean both
zero. One case (`rdpapp-stack`) has no `agreement` object; its graft and
@@ -73,8 +73,8 @@ The chart uses the agreement block.
`bench/results/2026-10-24-branching/results.json`, seed 2362. The chart
shows round trips and conflict blocks for trunk, git flow and release
-branches, graft-tracked against git. This file predates merge points
-(WI-2372). [docs/status.md](../status.md) records the later hand-off: with merge points,
+branches, graft-tracked against git. This file predates merge points.
+The engineering record lists the later hand-off: with merge points,
graft reports the same conflicts as git (18 of 18 blocks) and merges
`release/1.1` into `main` clean. That later run is not in this file, so
it is not on the chart. The file will be regenerated; until then the
diff --git a/scripts/check-guide-names.sh b/scripts/check-guide-names.sh
new file mode 100755
index 0000000..3f0fbc4
--- /dev/null
+++ b/scripts/check-guide-names.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Fail if the user guide names estate hosts, trackers, or internal ids.
+# Repository CI only. Not part of graft's land path.
+set -euo pipefail
+root=$(cd "$(dirname "$0")/.." && pwd)
+cd "$root"
+shopt -s nullglob
+files=(docs/guide/* docs/agents.md)
+pattern='graft-dev|vogt|trial-[0-9]|rusttorrent|\bWI-[0-9]+|\bD[0-9]{2}\b|\bG[0-9]\.[0-9]+'
+if grep -nEiH "$pattern" "${files[@]}"; then
+ exit 1
+fi
From 505f9b5a898784f1159b64bd1a50c8bfd8e05e5b Mon Sep 17 00:00:00 2001
From: thedancingdeveloper
<306930456+thedancingdeveloper@users.noreply.github.com>
Date: Mon, 26 Oct 2026 14:30:28 +0000
Subject: [PATCH 2/4] Add graft doctor checks for config, server, endpoint,
protocol and token.
Graft-Changeset: c93554ed219410aedea619ece9769330ca8df4fa8538ba1bd96949c837291916f
Graft-Session: ses_01M5W86AP9XQ8NTA7PGQWN5EZD
Graft-Work-Item: WI-2538
Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Graft-Batch: 4
---
Cargo.lock | 1 +
changes/WI-2538.md | 3 +
crates/graft-cli/Cargo.toml | 1 +
crates/graft-cli/src/cli.rs | 2 +
crates/graft-cli/src/cmd/doctor.rs | 360 ++++++++++++++++++
crates/graft-cli/src/cmd/mod.rs | 1 +
crates/graft-cli/src/conn.rs | 48 ++-
crates/graft-cli/src/main.rs | 1 +
crates/graft-cli/tests/cli.rs | 1 +
crates/graft-cli/tests/completions.rs | 8 +
crates/graft-cli/tests/doctor.rs | 156 ++++++++
.../tests/snapshots/cli__top_level_help.snap | 3 +-
12 files changed, 568 insertions(+), 17 deletions(-)
create mode 100644 changes/WI-2538.md
create mode 100644 crates/graft-cli/src/cmd/doctor.rs
create mode 100644 crates/graft-cli/tests/doctor.rs
diff --git a/Cargo.lock b/Cargo.lock
index aff5c80..bcccaab 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1891,6 +1891,7 @@ dependencies = [
"clap",
"clap_complete",
"clap_mangen",
+ "graft-auth",
"graft-client",
"graft-engine",
"graft-lsp",
diff --git a/changes/WI-2538.md b/changes/WI-2538.md
new file mode 100644
index 0000000..f8b3825
--- /dev/null
+++ b/changes/WI-2538.md
@@ -0,0 +1,3 @@
+# WI-2538: graft doctor checks config, server, endpoint, protocol and token
+
+`graft doctor` prints `ok`, `fail` or `skip` for the user config, the resolved server, one unauthenticated default-line read, protocol compatibility, and a peeked token (expiry and scopes only). `--json` prints `{checks: [{name, ok, detail}]}`. The exit is 0 only when every check passes. The token is never printed.
diff --git a/crates/graft-cli/Cargo.toml b/crates/graft-cli/Cargo.toml
index 33abbc0..74a99c7 100644
--- a/crates/graft-cli/Cargo.toml
+++ b/crates/graft-cli/Cargo.toml
@@ -21,6 +21,7 @@ base64.workspace = true
clap.workspace = true
clap_complete.workspace = true
clap_mangen.workspace = true
+graft-auth.workspace = true
graft-client.workspace = true
graft-mirror = { path = "../graft-mirror" }
graft-node = { path = "../graft-node" }
diff --git a/crates/graft-cli/src/cli.rs b/crates/graft-cli/src/cli.rs
index d8d5620..b63598a 100644
--- a/crates/graft-cli/src/cli.rs
+++ b/crates/graft-cli/src/cli.rs
@@ -62,6 +62,8 @@ pub(crate) enum Command {
Logout(LogoutArgs),
/// Inspect user configuration
Config(ConfigArgs),
+ /// Check config, server, endpoint, protocol and token
+ Doctor,
/// Create a local repository and working copy
Init(InitArgs),
/// Open a workspace and lay out a sparse working copy
diff --git a/crates/graft-cli/src/cmd/doctor.rs b/crates/graft-cli/src/cmd/doctor.rs
new file mode 100644
index 0000000..35fdcd9
--- /dev/null
+++ b/crates/graft-cli/src/cmd/doctor.rs
@@ -0,0 +1,360 @@
+//! `graft doctor`: config, server, endpoint, protocol and token checks.
+//!
+//! The endpoint probe calls `resolve_ref` with no token, so an expired
+//! token does not hide a server that is down. Claims are peeked only to
+//! show expiry and scopes. The token itself is never printed.
+
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use graft_auth::{Claims, Token};
+use graft_client::{Endpoint, Remote};
+use graft_proto::PROTOCOL_VERSION;
+use graft_proto::api::RepoApi;
+use graft_proto::context::{CallContext, Principal, PrincipalKind};
+use graft_proto::types::ResolveRef;
+use serde_json::{Value, json};
+
+use crate::cli::{DEFAULT_LINE, Global};
+use crate::config::{self, UserConfig};
+use crate::conn::{self, WcView, resolve_connection_from_sources};
+use crate::error::Failure;
+use crate::render;
+
+struct Check {
+ name: &'static str,
+ status: Status,
+ detail: String,
+}
+
+enum Status {
+ Ok,
+ Fail,
+ Skip,
+}
+
+impl Status {
+ fn label(&self) -> &'static str {
+ match self {
+ Self::Ok => "ok",
+ Self::Fail => "fail",
+ Self::Skip => "skip",
+ }
+ }
+
+ fn passed(&self) -> bool {
+ matches!(self, Self::Ok)
+ }
+}
+
+/// Runs the checks. Exit 0 only when every check passes.
+pub(crate) fn run(g: &Global) -> Result<(), Failure> {
+ let mut checks = Vec::new();
+ let Some(config) = config_check(&mut checks) else {
+ return finish(g, checks);
+ };
+ let wc = conn::working_copy_info()?;
+ let Some(resolved) = server_check(g, wc.as_ref(), &config, &mut checks) else {
+ skip(&mut checks, "endpoint", "no server");
+ skip(&mut checks, "protocol", "no server");
+ skip(&mut checks, "token", "no server");
+ return finish(g, checks);
+ };
+ if !endpoint_check(&resolved, &mut checks) {
+ skip(&mut checks, "protocol", "endpoint unreachable");
+ skip(&mut checks, "token", "endpoint unreachable");
+ return finish(g, checks);
+ }
+ token_check(g, wc.as_ref(), &config, &resolved.server, &mut checks)?;
+ finish(g, checks)
+}
+
+fn config_check(checks: &mut Vec) -> Option {
+ let path = match config::config_path() {
+ Ok(path) => path,
+ Err(failure) => {
+ push(checks, "config", Status::Fail, failure.to_string());
+ return None;
+ }
+ };
+ if !path.is_file() {
+ push(
+ checks,
+ "config",
+ Status::Fail,
+ format!("user config is missing at {}", path.display()),
+ );
+ return None;
+ }
+ match config::load() {
+ Ok(config) => {
+ push(checks, "config", Status::Ok, path.display().to_string());
+ Some(config)
+ }
+ Err(failure) => {
+ push(checks, "config", Status::Fail, failure.to_string());
+ None
+ }
+ }
+}
+
+struct Resolved {
+ endpoint: Endpoint,
+ server: Option,
+}
+
+fn server_check(
+ g: &Global,
+ wc: Option<&impl WcView>,
+ user: &UserConfig,
+ checks: &mut Vec,
+) -> Option {
+ let endpoint_override = endpoint_override(g);
+ let selected = g.server.as_deref().or_else(|| {
+ wc.and_then(WcView::server)
+ .or(user.default_server.as_deref())
+ });
+ let named = selected.and_then(|name| user.servers.get(name));
+ let legacy = wc
+ .filter(|view| view.server().is_none())
+ .and_then(|view| view.config().endpoint.as_deref())
+ .map(Endpoint::parse);
+ if endpoint_override.is_none() && legacy.is_none() && selected.is_some() && named.is_none() {
+ let name = selected.unwrap_or_default();
+ push(
+ checks,
+ "server",
+ Status::Fail,
+ format!("server {name} is not configured; run `graft login {name} --endpoint URL`"),
+ );
+ return None;
+ }
+ let named_endpoint = named.map(|server| Endpoint::parse(&server.endpoint));
+ match resolve_connection_from_sources(
+ wc.map(|view| (view.config(), view.server())),
+ selected,
+ named_endpoint.as_ref(),
+ legacy,
+ endpoint_override,
+ ) {
+ Ok((endpoint, server)) => {
+ let detail = match &server {
+ Some(name) => format!("{name} at {endpoint}"),
+ None => endpoint.to_string(),
+ };
+ push(checks, "server", Status::Ok, detail);
+ Some(Resolved { endpoint, server })
+ }
+ Err(failure) => {
+ push(checks, "server", Status::Fail, failure.to_string());
+ None
+ }
+ }
+}
+
+fn endpoint_override(g: &Global) -> Option {
+ g.endpoint.as_deref().map(Endpoint::parse).or_else(|| {
+ std::env::var(graft_client::ENDPOINT_ENV)
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .map(|value| Endpoint::parse(&value))
+ })
+}
+
+fn endpoint_check(resolved: &Resolved, checks: &mut Vec) -> bool {
+ let endpoint = resolved.endpoint.clone();
+ let shown = endpoint.to_string();
+ let probed = conn::runtime().and_then(|rt| {
+ rt.block_on(async move {
+ let remote = Remote::connect(endpoint).await?;
+ remote
+ .resolve_ref(&probe_context(), probe_request())
+ .await
+ .map(|_| ())
+ .map_err(graft_client::Error::from)
+ })
+ .map_err(Failure::from)
+ });
+ match probed {
+ Ok(()) => {
+ push(
+ checks,
+ "endpoint",
+ Status::Ok,
+ format!("reachable at {shown}"),
+ );
+ push(checks, "protocol", Status::Ok, protocol_ok());
+ true
+ }
+ Err(failure) => classify_probe(&failure, &shown, checks),
+ }
+}
+
+fn probe_context() -> CallContext {
+ CallContext {
+ principal: Principal {
+ subject: String::new(),
+ kind: PrincipalKind::Human,
+ session: None,
+ work_item: None,
+ scopes: Vec::new(),
+ },
+ request_id: "doctor".to_owned(),
+ deadline_ms: None,
+ }
+}
+
+fn probe_request() -> ResolveRef {
+ ResolveRef {
+ repo: String::new(),
+ name: DEFAULT_LINE.to_owned(),
+ }
+}
+
+fn protocol_ok() -> String {
+ format!(
+ "client {}.{} binds",
+ PROTOCOL_VERSION.major, PROTOCOL_VERSION.minor
+ )
+}
+
+fn classify_probe(failure: &Failure, endpoint: &str, checks: &mut Vec) -> bool {
+ if failure.kind() == "unavailable" {
+ push(
+ checks,
+ "endpoint",
+ Status::Fail,
+ format!("server down or unreachable at {endpoint}"),
+ );
+ return false;
+ }
+ if failure.kind() == "incompatible_protocol" {
+ push(
+ checks,
+ "endpoint",
+ Status::Ok,
+ format!("reachable at {endpoint}"),
+ );
+ let server = failure
+ .to_string()
+ .split_whitespace()
+ .find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
+ .unwrap_or("unknown")
+ .to_owned();
+ push(
+ checks,
+ "protocol",
+ Status::Fail,
+ format!(
+ "client {}.{} server {server}; upgrade graft",
+ PROTOCOL_VERSION.major, PROTOCOL_VERSION.minor
+ ),
+ );
+ return true;
+ }
+ // Any other typed answer means the server spoke this protocol.
+ push(
+ checks,
+ "endpoint",
+ Status::Ok,
+ format!("reachable at {endpoint}"),
+ );
+ push(checks, "protocol", Status::Ok, protocol_ok());
+ true
+}
+
+fn token_check(
+ g: &Global,
+ wc: Option<&impl WcView>,
+ user: &UserConfig,
+ server: &Option,
+ checks: &mut Vec,
+) -> Result<(), Failure> {
+ let settings = conn::resolve_settings(g, wc, user)?;
+ let Some(raw) = settings.token.filter(|token| !token.is_empty()) else {
+ let where_ = server.as_deref().unwrap_or("the resolved server");
+ push(
+ checks,
+ "token",
+ Status::Fail,
+ format!("no token for {where_}; run `graft login` again"),
+ );
+ return Ok(());
+ };
+ let claims = match Claims::peek_unverified(&Token::from_encoded(raw)) {
+ Ok(claims) => claims,
+ Err(_) => {
+ push(
+ checks,
+ "token",
+ Status::Fail,
+ "token is malformed; run `graft login` again".to_owned(),
+ );
+ return Ok(());
+ }
+ };
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
+ .unwrap_or(0);
+ if claims.exp <= now {
+ push(
+ checks,
+ "token",
+ Status::Fail,
+ "token expired; run `graft login` again".to_owned(),
+ );
+ return Ok(());
+ }
+ let scopes = if claims.principal.scopes.is_empty() {
+ "none".to_owned()
+ } else {
+ claims.principal.scopes.join(",")
+ };
+ push(
+ checks,
+ "token",
+ Status::Ok,
+ format!("expires {} scopes {scopes}", claims.exp),
+ );
+ Ok(())
+}
+
+fn skip(checks: &mut Vec, name: &'static str, detail: &str) {
+ push(checks, name, Status::Skip, detail.to_owned());
+}
+
+fn push(checks: &mut Vec, name: &'static str, status: Status, detail: String) {
+ checks.push(Check {
+ name,
+ status,
+ detail,
+ });
+}
+
+fn finish(g: &Global, checks: Vec) -> Result<(), Failure> {
+ let ok = checks.iter().all(|check| check.status.passed());
+ if g.json {
+ let rows: Vec = checks
+ .iter()
+ .map(|check| {
+ json!({
+ "name": check.name,
+ "ok": check.status.passed(),
+ "detail": check.detail,
+ })
+ })
+ .collect();
+ render::print_json(&json!({ "checks": rows }))?;
+ } else {
+ for check in &checks {
+ outln!("{} {} {}", check.status.label(), check.name, check.detail);
+ }
+ }
+ if ok {
+ Ok(())
+ } else {
+ // The report is already printed. A second error line would mix
+ // shapes, and under --json it would not be one JSON document.
+ Err(Failure::silent(crate::Exit::Failure))
+ }
+}
diff --git a/crates/graft-cli/src/cmd/mod.rs b/crates/graft-cli/src/cmd/mod.rs
index 937b1b9..6289ad7 100644
--- a/crates/graft-cli/src/cmd/mod.rs
+++ b/crates/graft-cli/src/cmd/mod.rs
@@ -10,6 +10,7 @@ pub(crate) mod commit;
pub(crate) mod completions;
pub(crate) mod config;
pub(crate) mod diff;
+pub(crate) mod doctor;
pub(crate) mod init;
pub(crate) mod land;
pub(crate) mod line;
diff --git a/crates/graft-cli/src/conn.rs b/crates/graft-cli/src/conn.rs
index 4c1c05e..8e9a781 100644
--- a/crates/graft-cli/src/conn.rs
+++ b/crates/graft-cli/src/conn.rs
@@ -299,41 +299,57 @@ fn resolve_settings_from_sources(
));
}
let named_endpoint = named.map(|server| Endpoint::parse(&server.endpoint));
+ let (endpoint, server) = resolve_connection_from_sources(
+ wc,
+ selected_server,
+ named_endpoint.as_ref(),
+ legacy_endpoint,
+ endpoint_override,
+ )?;
+ let token = match token_override {
+ Some(token) => Some(token),
+ None => server
+ .as_ref()
+ .and(named)
+ .map(|server| token_from_file(&server.token_file))
+ .transpose()?,
+ };
+ Ok(ResolvedSettings {
+ server,
+ endpoint,
+ token,
+ })
+}
+
+pub(crate) fn resolve_connection_from_sources(
+ wc: Option<(&WcConfig, Option<&str>)>,
+ selected_server: Option<&str>,
+ named_endpoint: Option<&Endpoint>,
+ legacy_endpoint: Option,
+ endpoint_override: Option,
+) -> Result<(Endpoint, Option), Failure> {
let endpoint = endpoint_override
.clone()
.or_else(|| {
wc.and_then(|(_, server)| server)
- .and(named_endpoint.clone())
+ .and(named_endpoint.cloned())
})
.or_else(|| {
let socket = Path::new(NODE_SOCKET);
socket.exists().then(|| Endpoint::NodeSocket(socket.to_owned()))
})
.or(legacy_endpoint)
- .or(named_endpoint.clone())
+ .or(named_endpoint.cloned())
.ok_or_else(|| {
Failure::usage(
"no endpoint: pass --endpoint, set GRAFT_ENDPOINT, run `graft login`, or use a configured working copy",
)
})?;
let server = named_endpoint
- .as_ref()
.filter(|named_endpoint| **named_endpoint == endpoint)
.and(selected_server)
.map(str::to_owned);
- let token = match token_override {
- Some(token) => Some(token),
- None => server
- .as_ref()
- .and(named)
- .map(|server| token_from_file(&server.token_file))
- .transpose()?,
- };
- Ok(ResolvedSettings {
- server,
- endpoint,
- token,
- })
+ Ok((endpoint, server))
}
/// The bearer token from `--token-file`, else `GRAFT_TOKEN`. Surrounding
diff --git a/crates/graft-cli/src/main.rs b/crates/graft-cli/src/main.rs
index 842256c..3a7454e 100644
--- a/crates/graft-cli/src/main.rs
+++ b/crates/graft-cli/src/main.rs
@@ -267,6 +267,7 @@ fn dispatch(cli: &Cli) -> Result<(), Failure> {
Command::Login(args) => cmd::login::run(g, args),
Command::Logout(args) => cmd::login::logout(g, args),
Command::Config(args) => cmd::config::run(g, args),
+ Command::Doctor => cmd::doctor::run(g),
Command::Init(args) => cmd::init::run(g, args),
Command::Clone(args) => cmd::clone::run(g, args),
Command::Status(args) => cmd::status::run(g, args),
diff --git a/crates/graft-cli/tests/cli.rs b/crates/graft-cli/tests/cli.rs
index 564d47a..b08c1bd 100644
--- a/crates/graft-cli/tests/cli.rs
+++ b/crates/graft-cli/tests/cli.rs
@@ -118,6 +118,7 @@ fn help_is_public_facing_for_every_command() {
&["login"],
&["logout"],
&["config"],
+ &["doctor"],
&["init"],
&["mcp"],
&["lsp"],
diff --git a/crates/graft-cli/tests/completions.rs b/crates/graft-cli/tests/completions.rs
index d42ca66..41a6619 100644
--- a/crates/graft-cli/tests/completions.rs
+++ b/crates/graft-cli/tests/completions.rs
@@ -45,6 +45,14 @@ fn completions_cover_public_commands_and_options() {
"{shell} completion exposed hidden command {hidden}"
);
}
+ if shell == "bash" {
+ for hidden in HIDDEN_COMMANDS {
+ assert!(
+ !script.split_whitespace().any(|word| word == *hidden),
+ "bash completion exposed hidden command {hidden}"
+ );
+ }
+ }
assert_no_internal_references(&["completions", shell], &script);
}
}
diff --git a/crates/graft-cli/tests/doctor.rs b/crates/graft-cli/tests/doctor.rs
new file mode 100644
index 0000000..860c74d
--- /dev/null
+++ b/crates/graft-cli/tests/doctor.rs
@@ -0,0 +1,156 @@
+//! `graft doctor` against the testkit server.
+
+mod support;
+
+use std::fs;
+use std::os::unix::fs::PermissionsExt;
+
+use graft_auth::{Claims, SigningKey, Token, mint, new_jti};
+use graft_proto::context::{Principal, PrincipalKind};
+use serde_json::Value;
+use support::{graft, json_out, scratch, serve};
+
+const TOKEN: &str = "doctor-token-must-stay-hidden";
+
+fn write_token(path: &std::path::Path, raw: &str) {
+ fs::write(path, raw).unwrap();
+ let mut perms = fs::metadata(path).unwrap().permissions();
+ perms.set_mode(0o600);
+ fs::set_permissions(path, perms).unwrap();
+}
+
+fn minted(exp: i64) -> String {
+ let key = SigningKey::generate().expect("key");
+ let claims = Claims {
+ principal: Principal {
+ subject: "doctor".into(),
+ kind: PrincipalKind::Human,
+ session: None,
+ work_item: None,
+ scopes: vec!["repo:demo:read".into()],
+ },
+ exp,
+ jti: new_jti().expect("jti"),
+ };
+ mint(&key, &claims).expect("mint").expose().to_owned()
+}
+
+fn login(home: &std::path::Path, config: &std::path::Path, url: &str, token: &str) {
+ graft(home)
+ .env("GRAFT_CONFIG", config)
+ .args(["login", "dev", "--endpoint", url])
+ .write_stdin(format!("{token}\n"))
+ .assert()
+ .success();
+}
+
+fn assert_redacted(text: &str) {
+ assert!(!text.contains(TOKEN), "doctor output contained the token");
+}
+
+#[test]
+fn healthy_server_every_check_ok() {
+ let served = serve();
+ let home = scratch();
+ let config = home.path().join("config.toml");
+ let raw = minted(4_102_444_800);
+ login(home.path(), &config, &served.url, &raw);
+ let out = graft(home.path())
+ .env("GRAFT_CONFIG", &config)
+ .args(["doctor"])
+ .assert()
+ .success();
+ let text = String::from_utf8(out.get_output().stdout.clone()).unwrap();
+ for name in ["config", "server", "endpoint", "protocol", "token"] {
+ assert!(
+ text.lines()
+ .any(|line| line.starts_with(&format!("ok {name} "))),
+ "{text}"
+ );
+ }
+ assert!(text.contains("scopes repo:demo:read"), "{text}");
+ assert_redacted(&text);
+ assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr));
+ let _ = Token::from_encoded(raw);
+}
+
+#[test]
+fn stopped_server_names_the_endpoint() {
+ let url = "http://127.0.0.1:1".to_owned();
+ let home = scratch();
+ let config = home.path().join("config.toml");
+ let token_path = home.path().join("tokens").join("dev");
+ fs::create_dir_all(token_path.parent().unwrap()).unwrap();
+ write_token(&token_path, TOKEN);
+ let body = format!(
+ "default_server = \"dev\"\n\n[servers.dev]\nendpoint = \"{url}\"\ntoken_file = \"{}\"\n",
+ token_path.display()
+ );
+ fs::write(&config, body).unwrap();
+ let out = graft(home.path())
+ .env("GRAFT_CONFIG", &config)
+ .args(["doctor"])
+ .assert()
+ .code(1);
+ let text = String::from_utf8(out.get_output().stdout.clone()).unwrap();
+ assert!(
+ text.contains(&format!(
+ "fail endpoint server down or unreachable at {url}"
+ )),
+ "{text}"
+ );
+ assert!(text.contains("skip protocol"), "{text}");
+ assert!(text.contains("skip token"), "{text}");
+ assert_redacted(&text);
+ assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr));
+}
+
+#[test]
+fn expired_token_asks_to_log_in_again() {
+ let served = serve();
+ let home = scratch();
+ let config = home.path().join("config.toml");
+ login(home.path(), &config, &served.url, TOKEN);
+ let parsed: toml::Value = toml::from_str(&fs::read_to_string(&config).unwrap()).unwrap();
+ let token_path = std::path::PathBuf::from(
+ parsed["servers"]["dev"]["token_file"]
+ .as_str()
+ .expect("token file"),
+ );
+ write_token(&token_path, &minted(1));
+ let out = graft(home.path())
+ .env("GRAFT_CONFIG", &config)
+ .args(["doctor"])
+ .assert()
+ .code(1);
+ let text = String::from_utf8(out.get_output().stdout.clone()).unwrap();
+ assert!(
+ text.contains("fail token token expired; run `graft login` again"),
+ "{text}"
+ );
+ assert!(text.contains("ok endpoint"), "{text}");
+ assert_redacted(&text);
+ assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr));
+}
+
+#[test]
+fn json_lists_checks_and_redacts_the_token() {
+ let served = serve();
+ let home = scratch();
+ let config = home.path().join("config.toml");
+ let raw = minted(4_102_444_800);
+ login(home.path(), &config, &served.url, &raw);
+ let out = graft(home.path())
+ .env("GRAFT_CONFIG", &config)
+ .args(["--json", "doctor"])
+ .assert()
+ .success();
+ let value: Value = json_out(out.get_output());
+ let checks = value["checks"].as_array().expect("checks");
+ assert_eq!(checks.len(), 5);
+ assert!(checks.iter().all(|check| check["ok"] == true));
+ let rendered = value.to_string();
+ assert_redacted(&rendered);
+ assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr));
+ assert!(!rendered.contains(&raw));
+}
diff --git a/crates/graft-cli/tests/snapshots/cli__top_level_help.snap b/crates/graft-cli/tests/snapshots/cli__top_level_help.snap
index 9ab3352..f49ab36 100644
--- a/crates/graft-cli/tests/snapshots/cli__top_level_help.snap
+++ b/crates/graft-cli/tests/snapshots/cli__top_level_help.snap
@@ -1,6 +1,6 @@
---
source: crates/graft-cli/tests/cli.rs
-assertion_line: 154
+assertion_line: 155
expression: text(out.get_output())
---
The graft command-line client.
@@ -11,6 +11,7 @@ Commands:
login Store credentials for a named server, or list configured servers
logout Remove a named server and its stored token
config Inspect user configuration
+ doctor Check config, server, endpoint, protocol and token
init Create a local repository and working copy
clone Open a workspace and lay out a sparse working copy
status Working-copy changes against the head draft, else the base (no server needed)
From 42721479dac2ff5d60c706edcae2ebd5e70af8d8 Mon Sep 17 00:00:00 2001
From: thedancingdeveloper
<306930456+thedancingdeveloper@users.noreply.github.com>
Date: Mon, 26 Oct 2026 14:36:00 +0000
Subject: [PATCH 3/4] docs: widen the guide name lint and drop the CI claim
Graft-Changeset: c46e285679e46060cb3c2547dab1e34d7d87c035e82a332ed082c6cf908949fa6
Graft-Session: ses_01M5W8RBSY2V06NCY2KQ87EKS7
Graft-Work-Item: WI-2549
Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Graft-Batch: 4
---
scripts/check-guide-names.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/check-guide-names.sh b/scripts/check-guide-names.sh
index 3f0fbc4..f7a73ac 100755
--- a/scripts/check-guide-names.sh
+++ b/scripts/check-guide-names.sh
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# Fail if the user guide names estate hosts, trackers, or internal ids.
-# Repository CI only. Not part of graft's land path.
+# Run by hand. Not part of CI, and not part of graft's land path.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
cd "$root"
shopt -s nullglob
files=(docs/guide/* docs/agents.md)
-pattern='graft-dev|vogt|trial-[0-9]|rusttorrent|\bWI-[0-9]+|\bD[0-9]{2}\b|\bG[0-9]\.[0-9]+'
+pattern='graft-dev|node-b|hetzner|hertz|proxmox|edgerouter|komodo|cadastre|infisical|winrarhost|vogt|trial-[0-9]|rusttorrent|\bWI-[0-9]+|\bD[0-9]{2}\b|\bG[0-9]\.[0-9]+'
if grep -nEiH "$pattern" "${files[@]}"; then
exit 1
fi
From 6053f485d57a6c818df22f420eca1227f69d95a4 Mon Sep 17 00:00:00 2001
From: thedancingdeveloper
<306930456+thedancingdeveloper@users.noreply.github.com>
Date: Mon, 26 Oct 2026 14:38:13 +0000
Subject: [PATCH 4/4] Document graft doctor exit codes and JSON check status.
Graft-Changeset: c5986c1951706774a6d30967c134e559406104bb17c7664e778115419f05a63d3
Graft-Session: ses_01M5W86AP9XQ8NTA7PGQWN5EZD
Graft-Work-Item: WI-2538
Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Graft-Batch: 4
---
changes/WI-2538.md | 2 +-
crates/graft-cli/src/cli.rs | 6 +++
crates/graft-cli/src/cmd/doctor.rs | 9 +++++
crates/graft-cli/tests/doctor.rs | 61 ++++++++++++++++++++++++++++++
4 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/changes/WI-2538.md b/changes/WI-2538.md
index f8b3825..a0fc9b5 100644
--- a/changes/WI-2538.md
+++ b/changes/WI-2538.md
@@ -1,3 +1,3 @@
# WI-2538: graft doctor checks config, server, endpoint, protocol and token
-`graft doctor` prints `ok`, `fail` or `skip` for the user config, the resolved server, one unauthenticated default-line read, protocol compatibility, and a peeked token (expiry and scopes only). `--json` prints `{checks: [{name, ok, detail}]}`. The exit is 0 only when every check passes. The token is never printed.
+`graft doctor` prints `ok`, `fail` or `skip` for the user config, the resolved server, one unauthenticated default-line read, protocol compatibility, and a peeked token (expiry and scopes only). `--json` prints `{checks: [{name, ok, status, detail}]}` with `status` of `pass`, `fail` or `skip`. Exit 0 when every check passes, 1 when any fails or is skipped, and 2 for a usage error or an unreadable working copy before the checks run. The token is never printed.
diff --git a/crates/graft-cli/src/cli.rs b/crates/graft-cli/src/cli.rs
index b63598a..4a7eb98 100644
--- a/crates/graft-cli/src/cli.rs
+++ b/crates/graft-cli/src/cli.rs
@@ -63,6 +63,12 @@ pub(crate) enum Command {
/// Inspect user configuration
Config(ConfigArgs),
/// Check config, server, endpoint, protocol and token
+ ///
+ /// Exit status:
+ ///
+ /// * 0 every check passes
+ /// * 1 any check fails or is skipped
+ /// * 2 a usage error, or the working copy cannot be read, before the checks run
Doctor,
/// Create a local repository and working copy
Init(InitArgs),
diff --git a/crates/graft-cli/src/cmd/doctor.rs b/crates/graft-cli/src/cmd/doctor.rs
index 35fdcd9..f20932f 100644
--- a/crates/graft-cli/src/cmd/doctor.rs
+++ b/crates/graft-cli/src/cmd/doctor.rs
@@ -41,6 +41,14 @@ impl Status {
}
}
+ fn json_status(&self) -> &'static str {
+ match self {
+ Self::Ok => "pass",
+ Self::Fail => "fail",
+ Self::Skip => "skip",
+ }
+ }
+
fn passed(&self) -> bool {
matches!(self, Self::Ok)
}
@@ -340,6 +348,7 @@ fn finish(g: &Global, checks: Vec) -> Result<(), Failure> {
json!({
"name": check.name,
"ok": check.status.passed(),
+ "status": check.status.json_status(),
"detail": check.detail,
})
})
diff --git a/crates/graft-cli/tests/doctor.rs b/crates/graft-cli/tests/doctor.rs
index 860c74d..0a390e3 100644
--- a/crates/graft-cli/tests/doctor.rs
+++ b/crates/graft-cli/tests/doctor.rs
@@ -154,3 +154,64 @@ fn json_lists_checks_and_redacts_the_token() {
assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr));
assert!(!rendered.contains(&raw));
}
+
+#[test]
+fn help_lists_exit_status_and_json_status() {
+ let out = graft(scratch().path())
+ .args(["doctor", "--help"])
+ .assert()
+ .success();
+ let text = String::from_utf8(out.get_output().stdout.clone()).unwrap();
+ assert!(text.contains("Exit status"), "{text}");
+ assert!(text.contains("0 every check passes"), "{text}");
+ assert!(text.contains("1 any check fails or is skipped"), "{text}");
+ assert!(
+ text.contains("2 a usage error, or the working copy cannot be read"),
+ "{text}"
+ );
+
+ let served = serve();
+ let home = scratch();
+ let config = home.path().join("config.toml");
+ login(home.path(), &config, &served.url, &minted(4_102_444_800));
+ let out = graft(home.path())
+ .env("GRAFT_CONFIG", &config)
+ .args(["--json", "doctor"])
+ .assert()
+ .success();
+ let value: Value = json_out(out.get_output());
+ let checks = value["checks"].as_array().expect("checks");
+ assert!(
+ checks.iter().all(|check| check["status"] == "pass"),
+ "{value}"
+ );
+
+ let url = "http://127.0.0.1:1".to_owned();
+ let home = scratch();
+ let config = home.path().join("config.toml");
+ let token_path = home.path().join("tokens").join("dev");
+ fs::create_dir_all(token_path.parent().unwrap()).unwrap();
+ write_token(&token_path, TOKEN);
+ let body = format!(
+ "default_server = \"dev\"\n\n[servers.dev]\nendpoint = \"{url}\"\ntoken_file = \"{}\"\n",
+ token_path.display()
+ );
+ fs::write(&config, body).unwrap();
+ let out = graft(home.path())
+ .env("GRAFT_CONFIG", &config)
+ .args(["--json", "doctor"])
+ .assert()
+ .code(1);
+ let value: Value = json_out(out.get_output());
+ let by_name: std::collections::BTreeMap<_, _> = value["checks"]
+ .as_array()
+ .expect("checks")
+ .iter()
+ .map(|check| (check["name"].as_str().unwrap().to_owned(), check.clone()))
+ .collect();
+ assert_eq!(by_name["endpoint"]["status"], "fail");
+ assert_eq!(by_name["endpoint"]["ok"], false);
+ assert_eq!(by_name["protocol"]["status"], "skip");
+ assert_eq!(by_name["token"]["status"], "skip");
+ assert_eq!(by_name["config"]["status"], "pass");
+}