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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Completion obligations for those binaries are tracked in
| ---- | -------- | -------- |
| API testing | `api-rest`, `api-gql`, `api-grpc`, `api-websocket`, `api-test` | Run protocol-specific API checks or orchestrate a mixed API test suite. |
| Git tooling | `git-scope`, `git-cli`, `git-summary`, `git-lock` | Inspect changes, run Git helper flows, summarize commits, or manage repo-local commit locks. |
| Forge automation | `forge-cli` | Drive PR/MR + Issue lifecycle on GitHub (via `gh`) or GitLab (via `glab`) through a single provider-neutral surface; covers create / view / edit / comment / ready / merge / close, CI wait-checks, and the `pr deliver` macro. |
| Agent policy and evidence | `agent-docs`, `agent-out`, `agent-scope-lock`, `test-first-evidence`, `web-evidence`, `browser-session`, `canary-check`, `docs-impact`, `heuristic-inbox`, `model-cross-check`, `repo-retro`, `review-evidence`, `skill-usage` | Resolve agent policy docs, allocate artifact paths, enforce edit scope, inspect repo retrospectives, or persist deterministic workflow evidence. |
| Planning and delivery | `plan-tooling`, `plan-issue`, `plan-issue-local`, `semantic-commit` | Validate/split implementation plans, orchestrate issue delivery, rehearse local plan flows, or create semantic commits. |
| Provider lanes | `codex-cli`, `gemini-cli` | Run provider-specific diagnostics, auth checks, and workflow adapters. |
Expand Down Expand Up @@ -57,6 +58,9 @@ Each crate is either a standalone CLI binary, a multi-binary crate, or a shared
- [crates/git-cli](crates/git-cli): Git tools dispatcher (utils/reset/commit/branch/ci/open).
- [crates/git-summary](crates/git-summary): Per-author contribution summaries over a date range (adds/dels/net/commits).
- [crates/git-lock](crates/git-lock): Label-based commit locks per repo (lock/list/diff/unlock/tag).
- [crates/forge-cli](crates/forge-cli): Provider-neutral forge CLI wrapping `gh` / `glab` for
PR/MR + Issue lifecycle, CI wait-checks, and the `pr deliver` macro
(open draft → CI green → ready → merge).

### Desktop, media, and local utility CLIs

Expand Down
1 change: 1 addition & 0 deletions crates/forge-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
mod integration {
mod auth_status;
mod cli;
mod completion_sync;
mod exit_codes;
mod exit_codes_full;
mod fixture_lint;
Expand Down
98 changes: 98 additions & 0 deletions crates/forge-cli/tests/integration/completion_sync.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! Sprint 8 Task 8.1 — completion script generation and sync test.
//!
//! Asserts:
//! 1. `forge-cli completion bash|zsh` succeeds and emits a non-empty script
//! whose preamble matches the shell-specific marker (`_forge-cli` for
//! bash, `#compdef forge-cli` for zsh).
//! 2. The checked-in completion files at `completions/{bash,zsh}/` stay in
//! byte-equality with the live binary output, so a future flag addition
//! that forgets to regenerate the snapshots fails CI rather than landing
//! silently.

use std::path::PathBuf;
use std::process::Command;

use pretty_assertions::assert_eq;

use super::support::forge_cli_bin;

fn run_completion(shell: &str) -> (i32, String, String) {
let output = Command::new(forge_cli_bin())
.args(["completion", shell])
.output()
.expect("spawn forge-cli");
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}

fn workspace_root() -> PathBuf {
// tests live under crates/forge-cli/tests; the workspace root is two
// ancestors above the crate dir. Walk up until we find `completions/`.
std::env::current_dir()
.expect("cwd")
.ancestors()
.find(|p| p.join("completions").is_dir() && p.join("Cargo.toml").is_file())
.expect("locate workspace root")
.to_path_buf()
}

#[test]
fn completion_bash_emits_non_empty_script_with_forge_cli_marker() {
let (code, stdout, stderr) = run_completion("bash");
assert_eq!(code, 0, "stderr={stderr}");
assert!(
!stdout.is_empty(),
"bash completion script must be non-empty"
);
assert!(
stdout.contains("_forge-cli"),
"bash completion must define _forge-cli, got first 200 bytes: {}",
&stdout[..stdout.len().min(200)]
);
}

#[test]
fn completion_zsh_emits_non_empty_script_with_compdef_marker() {
let (code, stdout, stderr) = run_completion("zsh");
assert_eq!(code, 0, "stderr={stderr}");
assert!(
!stdout.is_empty(),
"zsh completion script must be non-empty"
);
assert!(
stdout.contains("#compdef forge-cli"),
"zsh completion must start with #compdef forge-cli, got first 200 bytes: {}",
&stdout[..stdout.len().min(200)]
);
}

#[test]
fn checked_in_bash_completion_matches_live_binary_output() {
let (code, stdout, _) = run_completion("bash");
assert_eq!(code, 0);
let path = workspace_root().join("completions/bash/forge-cli");
let on_disk =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
assert_eq!(
on_disk.trim_end(),
stdout.trim_end(),
"completions/bash/forge-cli is out of sync with the binary; regenerate via\n forge-cli completion bash > completions/bash/forge-cli"
);
}

#[test]
fn checked_in_zsh_completion_matches_live_binary_output() {
let (code, stdout, _) = run_completion("zsh");
assert_eq!(code, 0);
let path = workspace_root().join("completions/zsh/_forge-cli");
let on_disk =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
assert_eq!(
on_disk.trim_end(),
stdout.trim_end(),
"completions/zsh/_forge-cli is out of sync with the binary; regenerate via\n forge-cli completion zsh > completions/zsh/_forge-cli"
);
}
30 changes: 22 additions & 8 deletions docs/plans/forge-cli/forge-cli-execution-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
- Execution window: 2026-05-19 → ongoing
- Staged execution confirmation: not applicable (default-continue
authorization: "默認就一直做下去")
- Current task: Sprint 7 review (PR pending)
- Next task: Task 8.1
- Current task: Sprint 8 review (PR pending)
- Next task: Task 8.3 (release flow — runs after PR merges via
`nils-cli-bump-version-tag-release`, not in this PR)
- Last updated: 2026-05-20
- Branch/commit: `feat/forge-cli-v1-sprint7-parity` cut from
`origin/main@15fcc73` (Sprint 6 PR #397 merge); parity harness,
exit-code matrix, and fixture redaction audit complete locally,
- Branch/commit: `feat/forge-cli-v1-sprint8-wrapper` cut from
`origin/main@3474555` (Sprint 7 PR #398 merge); Task 8.1 wrapper +
completion sync test and Task 8.2 README cross-link complete locally,
PR pending push and CI
- Source document: docs/plans/forge-cli/forge-cli-plan.md
- Direct source-doc execution waiver: not applicable
Expand Down Expand Up @@ -45,9 +46,9 @@
| Task 7.1 | completed | Parity harness | branch `feat/forge-cli-v1-sprint7-parity` | Sprint 7 — 11-row table + 5 cross-provider envelope assertions |
| Task 7.2 | completed | Exit-code matrix completion | branch `feat/forge-cli-v1-sprint7-parity` | Sprint 7 — 12 tests covering every documented (exit, kind) |
| Task 7.3 | completed | Fixture redaction audit | branch `feat/forge-cli-v1-sprint7-parity` | Sprint 7 — lint script + planted-token regression test |
| Task 8.1 | pending | `wrappers/forge-cli` + shell completions | n/a | Sprint 8 |
| Task 8.2 | pending | Homebrew tap formula update | n/a | Sprint 8 |
| Task 8.3 | pending | `nils-cli` minor bump + tag + tap formula bump | n/a | Sprint 8 |
| Task 8.1 | completed | `wrappers/forge-cli` + shell completions | branch `feat/forge-cli-v1-sprint8-wrapper` | Sprint 8 — wrapper mirrors git-cli, 4 completion-sync tests |
| Task 8.2 | completed | Homebrew tap formula update | branch `feat/forge-cli-v1-sprint8-wrapper` | Sprint 8 — workspace README cross-link + wrapper ready for tap |
| Task 8.3 | pending | `nils-cli` minor bump + tag + tap formula bump | post-merge release flow | Runs via nils-cli-bump-version-tag-release after PR merges |

## Validation

Expand Down Expand Up @@ -126,3 +127,16 @@
(`scripts/ci/forge-cli-fixture-lint.sh` + planted-token regression
in `fixture_lint.rs`, wired into the docs-only entrypoint). Test
surface grows to 219 lib + 89 integration.
- 2026-05-20 — Sprint 7 merged via PR #398 (`3474555`); cut Sprint 8
branch `feat/forge-cli-v1-sprint8-wrapper` from updated `origin/main`.
Task 8.1 added `wrappers/forge-cli` (bash wrapper mirroring
`wrappers/git-cli` — auto/debug/installed modes with cargo-fallback)
and four completion-sync tests
(`crates/forge-cli/tests/integration/completion_sync.rs`) that pin
byte-equality between the binary's `forge-cli completion bash|zsh`
output and the checked-in `completions/{bash,zsh}/` snapshots.
Task 8.2 added the workspace README cross-link (CLI surface map +
Git-tooling section). Task 8.3 (`nils-cli` minor bump + tap formula
bump) runs post-merge via the `nils-cli-bump-version-tag-release`
skill and is not part of this PR. Test surface grows to 219 lib +
93 integration.
102 changes: 102 additions & 0 deletions wrappers/forge-cli
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail

bin_name="forge-cli"
crate_name="nils-forge-cli"
args=("$@")
if [[ ${#args[@]} -ge 2 && ${args[0]} == "--" && ${args[1]} == "help" ]]; then
args=("help" "${args[@]:2}")
fi

self_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
self_path="$self_dir/$(basename -- "${BASH_SOURCE[0]}")"

mode="${NILS_WRAPPER_MODE:-auto}"
install_prefix="${NILS_WRAPPER_INSTALL_PREFIX:-$HOME/.local/nils-cli}"

if [[ "$install_prefix" == "~" ]]; then
install_prefix="$HOME"
elif [[ "$install_prefix" == "~/"* ]]; then
install_prefix="$HOME/${install_prefix#~/}"
fi

if [[ "$mode" != "auto" && "$mode" != "debug" && "$mode" != "installed" ]]; then
echo "${bin_name}: invalid NILS_WRAPPER_MODE='${mode}' (expected: auto|debug|installed)" >&2
exit 64
fi

emit_status() {
if [[ "${NILS_WRAPPER_STATUS_HINTS:-1}" == "0" ]]; then
return 0
fi

echo "$*" >&2
}

is_self_candidate() {
local candidate="$1"
[[ -n "$candidate" && -e "$candidate" && "$candidate" -ef "$self_path" ]]
}

resolve_installed() {
local candidate=""

candidate="${install_prefix}/${bin_name}"
if [[ -x "$candidate" ]] && ! is_self_candidate "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi

candidate="$(command -v "$bin_name" 2>/dev/null || true)"
if [[ -n "$candidate" && -x "$candidate" ]] && ! is_self_candidate "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi

return 1
}

run_debug() {
if command -v cargo >/dev/null 2>&1; then
emit_status "${bin_name}: exec=cargo mode=${mode} crate=${crate_name} (cargo -q: build may stay silent while compiling)"
exec cargo run -q -p "$crate_name" -- "${args[@]}"
fi

echo "${bin_name}: cargo not found (required when NILS_WRAPPER_MODE=debug)" >&2
exit 1
}

run_installed() {
local installed=""
if installed="$(resolve_installed)"; then
emit_status "${bin_name}: exec=installed mode=${mode} path=${installed}"
exec "$installed" "${args[@]}"
fi

echo "${bin_name}: installed binary not found (NILS_WRAPPER_MODE=installed)" >&2
echo "${bin_name}: expected ${install_prefix}/${bin_name} or a PATH entry" >&2
exit 1
}

case "$mode" in
debug)
run_debug
;;
installed)
run_installed
;;
esac

installed=""
if installed="$(resolve_installed)"; then
emit_status "${bin_name}: exec=installed mode=${mode} path=${installed}"
exec "$installed" "${args[@]}"
fi

if command -v cargo >/dev/null 2>&1; then
emit_status "${bin_name}: exec=cargo mode=${mode} crate=${crate_name} (cargo -q: build may stay silent while compiling)"
exec cargo run -q -p "$crate_name" -- "${args[@]}"
fi

echo "forge-cli: binary not found (install via cargo install or build the workspace)" >&2
exit 1