mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
b8d7c954b5
The skill is a separate product (backnotprop/plannotator-guide) and must not install with Plannotator; the CLI --guide/--patch export form it drives stays.
2122 lines
90 KiB
Bash
2122 lines
90 KiB
Bash
#!/bin/bash
|
|
set -e
|
|
|
|
REPO="backnotprop/plannotator"
|
|
SEM_REPO="Ataraxy-Labs/sem"
|
|
SEM_VERSION="v0.8.0"
|
|
INSTALL_DIR="$HOME/.local/bin"
|
|
|
|
# First plannotator release that carries SLSA build-provenance attestations.
|
|
# Releases before this tag were cut before release.yml added the
|
|
# `actions/attest-build-provenance` step, so `gh attestation verify` will
|
|
# fail with "no attestations found" for them regardless of authenticity.
|
|
# When provenance verification is enabled (via flag, env var, or
|
|
# ~/.plannotator/config.json), the installer compares the resolved tag
|
|
# against this constant and fails fast with a clear message instead of
|
|
# downloading a binary, running SHA256, and then hitting a cryptic gh
|
|
# failure. Bumped once at the first attested release via the release skill.
|
|
MIN_ATTESTED_VERSION="v0.17.2"
|
|
|
|
# Compare two vMAJOR.MINOR.PATCH tags. Returns 0 (success) if $1 >= $2.
|
|
# Uses `sort -V` (version sort) which handles minor/patch width correctly
|
|
# unlike plain lexicographic comparison (e.g. v0.9.0 vs v0.10.0).
|
|
version_ge() {
|
|
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ]
|
|
}
|
|
|
|
VERSION="latest"
|
|
# Tracks whether a version was explicitly set via --version or positional.
|
|
# Used to reject mixing --version <tag> with a stray positional token,
|
|
# which would otherwise silently overwrite the earlier value and 404.
|
|
VERSION_EXPLICIT=0
|
|
# Three-layer opt-in for SLSA build-provenance verification.
|
|
# Precedence: CLI flag > env var > ~/.plannotator/config.json > default (off).
|
|
# -1 = flag not set yet (fall through to lower layers); 0 = disable; 1 = enable.
|
|
VERIFY_ATTESTATION_FLAG=-1
|
|
# Three-layer opt-in for the CallDiff call-flow runtime (large on-disk
|
|
# footprint, default off). The normal path installs it in-app from the review
|
|
# UI; this flag exists for scripted installs.
|
|
# Precedence: --with-call-flow > PLANNOTATOR_INSTALL_CALLDIFF > config.json
|
|
# installCallFlow > default (off).
|
|
WITH_CALL_FLOW_FLAG=-1
|
|
# Guided-install answers. Precedence: CLI flags > wizard (terminal, first run
|
|
# or --reconfigure) > saved prefs from a previous run > defaults (no extras,
|
|
# nothing model-invocable). Empty string = not set by a flag.
|
|
EXTRAS_FLAG=""
|
|
MODEL_INVOCABLE_FLAG=""
|
|
NON_INTERACTIVE=0
|
|
RECONFIGURE=0
|
|
# Binary-only mode. Installs just the plannotator binary (to $INSTALL_DIR) and
|
|
# no persistent state elsewhere — no sem sidecar, no CallDiff or agent-terminal runtime, no
|
|
# skills, hooks, slash commands, or per-agent config (Claude, Codex, OpenCode,
|
|
# Gemini, Kiro). Set by --minimal (1) / --no-minimal (0); -1 = neither flag
|
|
# given (fall through to the PLANNOTATOR_MINIMAL env var). Resolved after arg
|
|
# parsing so a flag overrides the env var in either direction.
|
|
MINIMAL_FLAG=-1
|
|
# Per-agent integration opt-outs (#1178). Skip means do-not-write: when a
|
|
# skipped agent is detected, the installer reports "detected, skipped" and
|
|
# writes nothing to that agent's home; it never removes an integration a
|
|
# previous install already wired. 1 = flag passed. Resolution (flag > env >
|
|
# config skipInstall.<agent> > default off) happens after _config_dir is known.
|
|
SKIP_CODEX_FLAG=0
|
|
SKIP_GEMINI_FLAG=0
|
|
SKIP_KIRO_FLAG=0
|
|
SKIP_OPENCODE_FLAG=0
|
|
# Same shape, but scoped to the skills/slash-command sparse checkout rather
|
|
# than one agent's home: --skip-skills turns the whole fetch into a no-op for
|
|
# every scope it writes (Claude, ~/.agents, OpenCode, Gemini, Kiro), including
|
|
# the extras and the skill-scope cleanup sweeps. Needed by any environment that
|
|
# cannot reach github.com for the tag being installed — the release smoke test
|
|
# installs a synthetic v9.9.9 whose tag has no GitHub counterpart.
|
|
SKIP_SKILLS_FLAG=0
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
Usage: install.sh [--version <tag>] [--verify-attestation | --skip-attestation]
|
|
[--extras | --no-extras] [--model-invocable <list>|none]
|
|
[--minimal | --no-minimal] [--skip-codex] [--skip-gemini]
|
|
[--skip-kiro] [--skip-opencode] [--skip-skills]
|
|
[--non-interactive] [--reconfigure] [--help]
|
|
install.sh <tag>
|
|
|
|
Options:
|
|
--version <tag> Install a specific version (e.g. vX.Y.Z or X.Y.Z;
|
|
see https://github.com/backnotprop/plannotator/releases).
|
|
Defaults to the latest GitHub release.
|
|
--verify-attestation Require SLSA build-provenance verification via
|
|
`gh attestation verify`. Fails the install if gh is
|
|
not available or the check does not pass.
|
|
--skip-attestation Force-skip provenance verification even if enabled
|
|
via env var or ~/.plannotator/config.json.
|
|
--with-call-flow Also install the optional pruned CallDiff core
|
|
(about 5 MB on macOS arm64, needs Node.js 22+).
|
|
By default it is NOT installed; the review UI offers
|
|
a one-click install when Call flow is enabled. Also
|
|
enabled by PLANNOTATOR_INSTALL_CALLDIFF=1 or
|
|
{ "installCallFlow": true } in config.json.
|
|
--extras Install the extra skills (compound, setup-goal,
|
|
visual-explainer) via `npx skills add` without asking.
|
|
--no-extras Skip the extras without asking.
|
|
--model-invocable <l> Comma-separated skill names to make model-invocable
|
|
(e.g. plannotator-review,plannotator-compound), or
|
|
"none". Skills are user-invoked-only by default.
|
|
--minimal Install only the plannotator binary (aliased
|
|
--binary-only). Skips the sem semantic-diff sidecar,
|
|
the CallDiff runtime, the agent-terminal runtime, and every per-agent
|
|
integration (skills, hooks, slash commands, and config
|
|
for Claude, Codex, OpenCode, Gemini, and Kiro). No
|
|
persistent state is written outside $HOME/.local/bin
|
|
(a temp download file is still used and removed). Also
|
|
enabled by exporting PLANNOTATOR_MINIMAL=1.
|
|
--no-minimal Force a full install even when PLANNOTATOR_MINIMAL is
|
|
set in the environment.
|
|
--skip-codex Do not write the Codex integration (hooks.json /
|
|
config.toml under CODEX_HOME) even when Codex is
|
|
detected. Never removes an existing integration.
|
|
Also enabled by PLANNOTATOR_SKIP_CODEX_INSTALL=1 or
|
|
{ "skipInstall": { "codex": true } } in
|
|
~/.plannotator/config.json (flag > env var > config).
|
|
--skip-gemini Same opt-out for the Gemini CLI integration
|
|
(~/.gemini policy, settings hook, commands). Env var:
|
|
PLANNOTATOR_SKIP_GEMINI_INSTALL; config key:
|
|
skipInstall.gemini.
|
|
--skip-kiro Same opt-out for the Kiro CLI integration
|
|
(~/.kiro skills and agent). Env var:
|
|
PLANNOTATOR_SKIP_KIRO_INSTALL; config key:
|
|
skipInstall.kiro.
|
|
--skip-opencode Do not write the OpenCode integration (command stubs
|
|
under ~/.config/opencode/commands and the OpenCode
|
|
plugin cache clear). OpenCode has no detection leg,
|
|
so this is a plain do-not-write switch. Env var:
|
|
PLANNOTATOR_SKIP_OPENCODE_INSTALL; config key:
|
|
skipInstall.opencode.
|
|
--skip-skills Do not fetch or write the /plannotator-* skills and
|
|
slash commands (the sparse checkout that feeds Claude
|
|
Code, ~/.agents, OpenCode, Gemini, and Kiro), the
|
|
extras, or the skill-scope cleanup sweeps. Nothing
|
|
already installed is removed. The binary, hooks, and
|
|
per-agent config still install. Use it where
|
|
github.com cannot serve the tag being installed. Env
|
|
var: PLANNOTATOR_SKIP_SKILLS_INSTALL; config key:
|
|
skipInstall.skills.
|
|
--non-interactive Never prompt, even in a terminal. Uses flags, then
|
|
saved answers from a previous run, then the defaults
|
|
(no extras, nothing model-invocable).
|
|
--reconfigure Re-open the guided questions even if answers were
|
|
saved by a previous run.
|
|
-h, --help Show this help and exit.
|
|
|
|
Guided install: when run in a terminal for the first time (or with
|
|
--reconfigure), the installer asks whether to install the extra skills and
|
|
whether any skills should be callable by the model. Answers are saved to
|
|
<data dir>/install-prefs and
|
|
reused silently on re-runs. Piped/CI runs (no terminal) never prompt and
|
|
keep the defaults.
|
|
|
|
Provenance verification is off by default. Enable it by any of:
|
|
- passing --verify-attestation
|
|
- exporting PLANNOTATOR_VERIFY_ATTESTATION=1
|
|
- setting { "verifyAttestation": true } in ~/.plannotator/config.json
|
|
When enabled, the attestation bundle is fetched from GitHub's public
|
|
attestations API and verified with `gh attestation verify --bundle`, so no
|
|
gh login is required. The credential-free path needs one JSON tool on PATH
|
|
(node, python3, or jq) to extract the bundle; without one, and whenever the
|
|
public bundle fetch or bundle verification does not complete, gh's
|
|
authenticated fetch runs as the fallback.
|
|
|
|
The optional semantic-diff sidecar (the 'sem' binary, used by code review) is
|
|
installed after Plannotator itself. Skip it by exporting
|
|
PLANNOTATOR_SKIP_SEM_INSTALL=1. Its download is time-bounded, so a slow network
|
|
never blocks an otherwise-complete install.
|
|
|
|
The optional annotate agent terminal runtime is installed after Plannotator
|
|
itself. Skip it by exporting PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL=1. If
|
|
Node/npm is unavailable, Plannotator still installs and annotate mode works
|
|
without the integrated terminal.
|
|
|
|
Examples:
|
|
curl -fsSL https://plannotator.ai/install.sh | bash
|
|
curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z
|
|
curl -fsSL https://plannotator.ai/install.sh | bash -s -- --no-extras --model-invocable none
|
|
bash install.sh vX.Y.Z
|
|
USAGE
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--version)
|
|
if [ -z "${2:-}" ]; then
|
|
echo "--version requires an argument" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
case "$2" in
|
|
-*)
|
|
echo "--version requires a tag value, got flag: $2" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
VERSION="$2"
|
|
VERSION_EXPLICIT=1
|
|
shift 2
|
|
;;
|
|
--version=*)
|
|
value="${1#--version=}"
|
|
if [ -z "$value" ]; then
|
|
echo "--version requires an argument" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
case "$value" in
|
|
-*)
|
|
echo "--version requires a tag value, got flag: $value" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
VERSION="$value"
|
|
VERSION_EXPLICIT=1
|
|
shift
|
|
;;
|
|
--verify-attestation)
|
|
if [ "$VERIFY_ATTESTATION_FLAG" = "0" ]; then
|
|
echo "--verify-attestation and --skip-attestation are mutually exclusive" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
VERIFY_ATTESTATION_FLAG=1
|
|
shift
|
|
;;
|
|
--with-call-flow)
|
|
WITH_CALL_FLOW_FLAG=1
|
|
shift
|
|
;;
|
|
--skip-attestation)
|
|
if [ "$VERIFY_ATTESTATION_FLAG" = "1" ]; then
|
|
echo "--skip-attestation and --verify-attestation are mutually exclusive" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
VERIFY_ATTESTATION_FLAG=0
|
|
shift
|
|
;;
|
|
--extras)
|
|
EXTRAS_FLAG="yes"
|
|
shift
|
|
;;
|
|
--no-extras)
|
|
EXTRAS_FLAG="no"
|
|
shift
|
|
;;
|
|
--model-invocable)
|
|
if [ -z "${2:-}" ]; then
|
|
echo "--model-invocable requires a comma-separated skill list or 'none'" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
MODEL_INVOCABLE_FLAG="$2"
|
|
shift 2
|
|
;;
|
|
--model-invocable=*)
|
|
MODEL_INVOCABLE_FLAG="${1#--model-invocable=}"
|
|
if [ -z "$MODEL_INVOCABLE_FLAG" ]; then
|
|
echo "--model-invocable requires a comma-separated skill list or 'none'" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
shift
|
|
;;
|
|
--non-interactive|--yes)
|
|
NON_INTERACTIVE=1
|
|
shift
|
|
;;
|
|
--reconfigure)
|
|
RECONFIGURE=1
|
|
shift
|
|
;;
|
|
--minimal|--binary-only)
|
|
if [ "$MINIMAL_FLAG" = "0" ]; then
|
|
echo "--minimal and --no-minimal are mutually exclusive" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
MINIMAL_FLAG=1
|
|
shift
|
|
;;
|
|
--no-minimal)
|
|
if [ "$MINIMAL_FLAG" = "1" ]; then
|
|
echo "--no-minimal and --minimal are mutually exclusive" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
MINIMAL_FLAG=0
|
|
shift
|
|
;;
|
|
--skip-codex)
|
|
SKIP_CODEX_FLAG=1
|
|
shift
|
|
;;
|
|
--skip-gemini)
|
|
SKIP_GEMINI_FLAG=1
|
|
shift
|
|
;;
|
|
--skip-kiro)
|
|
SKIP_KIRO_FLAG=1
|
|
shift
|
|
;;
|
|
--skip-opencode)
|
|
SKIP_OPENCODE_FLAG=1
|
|
shift
|
|
;;
|
|
--skip-skills)
|
|
SKIP_SKILLS_FLAG=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
-*)
|
|
echo "Unknown option: $1" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
*)
|
|
# Positional form: install.sh vX.Y.Z (matches install.cmd interface).
|
|
# Reject if --version was already passed — silent overwrite is worse
|
|
# than a clean usage error.
|
|
if [ "$VERSION_EXPLICIT" -eq 1 ]; then
|
|
echo "Unexpected positional argument: $1 (version already set)" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
VERSION="$1"
|
|
VERSION_EXPLICIT=1
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Resolve binary-only mode. Precedence: --minimal / --no-minimal flag >
|
|
# PLANNOTATOR_MINIMAL env var > default (off). The env var lets `curl ... | bash`
|
|
# runs opt in without a flag, matching how PLANNOTATOR_SKIP_SEM_INSTALL et al.
|
|
# work; --no-minimal lets a flag override an env var that enables it.
|
|
minimal=0
|
|
case "${PLANNOTATOR_MINIMAL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes) minimal=1 ;;
|
|
esac
|
|
if [ "$MINIMAL_FLAG" -ne -1 ]; then
|
|
minimal="$MINIMAL_FLAG"
|
|
fi
|
|
|
|
case "$(uname -s)" in
|
|
Darwin) os="darwin" ;;
|
|
Linux) os="linux" ;;
|
|
*) echo "Unsupported OS. For Windows, run: irm https://plannotator.ai/install.ps1 | iex" >&2; exit 1 ;;
|
|
esac
|
|
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) arch="x64" ;;
|
|
arm64|aarch64) arch="arm64" ;;
|
|
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
|
esac
|
|
|
|
platform="${os}-${arch}"
|
|
binary_name="plannotator-${platform}"
|
|
|
|
# Clean up old Windows install locations (for users running bash on Windows)
|
|
if [ -n "$USERPROFILE" ]; then
|
|
# Running on Windows (Git Bash, MSYS, etc.) - clean up old locations
|
|
rm -f "$USERPROFILE/.local/bin/plannotator" "$USERPROFILE/.local/bin/plannotator.exe" 2>/dev/null || true
|
|
rm -f "$LOCALAPPDATA/plannotator/plannotator.exe" 2>/dev/null || true
|
|
echo "Cleaned up old Windows install locations"
|
|
fi
|
|
|
|
if [ "$VERSION" = "latest" ]; then
|
|
echo "Fetching latest version..."
|
|
|
|
# api.github.com caps unauthenticated requests at 60/hour per source IP,
|
|
# which fails installs behind shared egress IPs (NAT/CGNAT/corporate
|
|
# proxies) and during repeated/debug runs within an hour. Attach an
|
|
# Authorization header when a token is available (raises the limit to
|
|
# 5000/hour); when none is found, fall back to anonymous (unchanged
|
|
# behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
|
|
GH_AUTH_HEADER=()
|
|
if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
|
|
GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN:-${GH_TOKEN}}")
|
|
elif command -v gh >/dev/null 2>&1; then
|
|
# --hostname github.com scopes the fallback to github.com credentials,
|
|
# so a gh setup whose default host is a GitHub Enterprise server never
|
|
# leaks a GHES token to api.github.com. On an ancient gh without the
|
|
# flag, stderr is swallowed and we fall back to anonymous.
|
|
if _gh_token="$(gh auth token --hostname github.com 2>/dev/null)" && [ -n "$_gh_token" ]; then
|
|
GH_AUTH_HEADER=(-H "Authorization: Bearer ${_gh_token}")
|
|
fi
|
|
fi
|
|
# A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
|
|
# dotfiles, direnv) gets a 401 here and would break an install that
|
|
# works fine anonymously today. Retry anonymously ONLY on HTTP 401:
|
|
# requests carrying invalid credentials count against the anonymous
|
|
# 60/hour per-IP pool, so a blind retry on any failure would double the
|
|
# burn, and network failures gain nothing from a second attempt.
|
|
# Note: no -f here, so a 401 body doesn't abort curl before -w prints
|
|
# the status code. See backnotprop/plannotator#1157.
|
|
_api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
|
_api_body=$(curl -sSL -w '\n%{http_code}' "${GH_AUTH_HEADER[@]}" "$_api_url" 2>/dev/null) || true
|
|
_api_code="${_api_body##*$'\n'}"
|
|
if [ "$_api_code" = "401" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then
|
|
_api_body=$(curl -sSL -w '\n%{http_code}' "$_api_url" 2>/dev/null) || true
|
|
_api_code="${_api_body##*$'\n'}"
|
|
fi
|
|
if [ "$_api_code" = "200" ]; then
|
|
latest_tag=$(printf '%s' "$_api_body" | grep '"tag_name"' | cut -d'"' -f4)
|
|
else
|
|
latest_tag=""
|
|
fi
|
|
# Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain
|
|
# in the environment exactly as the user set them.
|
|
unset _gh_token GH_AUTH_HEADER _api_url _api_body _api_code
|
|
|
|
if [ -z "$latest_tag" ]; then
|
|
echo "Failed to fetch latest version" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
# Normalize: auto-prefix v if missing (matches install.cmd behaviour)
|
|
case "$VERSION" in
|
|
v*) latest_tag="$VERSION" ;;
|
|
*) latest_tag="v$VERSION" ;;
|
|
esac
|
|
fi
|
|
|
|
echo "Installing plannotator ${latest_tag}..."
|
|
|
|
# Resolve SLSA build-provenance verification opt-in BEFORE the download so we
|
|
# can fail fast without wasting bandwidth if the requested tag predates
|
|
# provenance support. The three layers (config file, env var, CLI flag) are
|
|
# all cheap to check — no reason to defer this past the arg parse.
|
|
#
|
|
# Precedence: CLI flag > env var > ~/.plannotator/config.json > default (off).
|
|
verify_attestation=0
|
|
|
|
# Layer 3: config file (lowest precedence of the opt-in sources).
|
|
# Crude grep against a flat boolean — PlannotatorConfig has no nested
|
|
# verifyAttestation, so false positives are not a concern.
|
|
# Resolve the data directory, expanding ~ the same way the runtime does.
|
|
# Unset: an existing ~/.plannotator (legacy default) always wins; otherwise
|
|
# an explicitly-set absolute XDG_DATA_HOME places it at
|
|
# $XDG_DATA_HOME/plannotator; otherwise ~/.plannotator.
|
|
_raw_dir="${PLANNOTATOR_DATA_DIR:-}"
|
|
case "$_raw_dir" in
|
|
"")
|
|
_config_dir="$HOME/.plannotator"
|
|
if [ ! -d "$_config_dir" ]; then
|
|
case "${XDG_DATA_HOME:-}" in
|
|
/*) _config_dir="$XDG_DATA_HOME/plannotator" ;;
|
|
esac
|
|
fi
|
|
;;
|
|
"~") _config_dir="$HOME" ;;
|
|
"~/"*) _config_dir="$HOME/${_raw_dir#\~/}" ;;
|
|
*) _config_dir="$_raw_dir" ;;
|
|
esac
|
|
if [ -f "$_config_dir/config.json" ]; then
|
|
if grep -q '"verifyAttestation"[[:space:]]*:[[:space:]]*true' "$_config_dir/config.json" 2>/dev/null; then
|
|
verify_attestation=1
|
|
fi
|
|
fi
|
|
|
|
# Layer 2: env var (overrides config file).
|
|
case "${PLANNOTATOR_VERIFY_ATTESTATION:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes) verify_attestation=1 ;;
|
|
0|false|no|FALSE|NO|False|No) verify_attestation=0 ;;
|
|
esac
|
|
|
|
# Layer 1: CLI flag (overrides everything).
|
|
if [ "$VERIFY_ATTESTATION_FLAG" -ne -1 ]; then
|
|
verify_attestation="$VERIFY_ATTESTATION_FLAG"
|
|
fi
|
|
|
|
# Resolve the CallDiff call-flow runtime opt-in. Same three-layer shape as
|
|
# verifyAttestation: CLI flag > env var > config.json > default (off). The
|
|
# config grep targets a flat top-level boolean, matching verifyAttestation.
|
|
install_call_flow=0
|
|
if [ -f "$_config_dir/config.json" ]; then
|
|
if grep -q '"installCallFlow"[[:space:]]*:[[:space:]]*true' "$_config_dir/config.json" 2>/dev/null; then
|
|
install_call_flow=1
|
|
fi
|
|
fi
|
|
case "${PLANNOTATOR_INSTALL_CALLDIFF:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes) install_call_flow=1 ;;
|
|
0|false|no|FALSE|NO|False|No) install_call_flow=0 ;;
|
|
esac
|
|
if [ "$WITH_CALL_FLOW_FLAG" -ne -1 ]; then
|
|
install_call_flow="$WITH_CALL_FLOW_FLAG"
|
|
fi
|
|
|
|
# Resolve the per-agent integration opt-outs (#1178). Same three-layer shape
|
|
# as verifyAttestation: CLI flag > env var > config.json > default (off).
|
|
# The config layer first extracts JUST the skipInstall object (from the
|
|
# first "{" after the "skipInstall" key to its first "}" — the object is a
|
|
# flat map of booleans, so the first closing brace ends it) and matches
|
|
# per-agent keys only inside that region. This keeps a "codex": true under
|
|
# some OTHER key from opting anyone out (M2), works whether the JSON is
|
|
# pretty-printed or single-line, and an explicit `"codex": false` inside
|
|
# skipInstall is honored as a veto rather than being ignored. Each resolved
|
|
# skip remembers its source so the detected-but-skipped report can name
|
|
# what the user set.
|
|
skip_codex=0
|
|
skip_codex_source=""
|
|
skip_gemini=0
|
|
skip_gemini_source=""
|
|
skip_kiro=0
|
|
skip_kiro_source=""
|
|
skip_opencode=0
|
|
skip_opencode_source=""
|
|
# skipInstall.skills is not an agent — it opts out of the skills/slash-command
|
|
# checkout for every scope at once — but it shares the same three layers and
|
|
# the same key region, so it rides along in the loop below.
|
|
skip_skills=0
|
|
skip_skills_source=""
|
|
_skip_install_block=""
|
|
if [ -f "$_config_dir/config.json" ]; then
|
|
_skip_install_block=$(awk '
|
|
{ buf = buf $0 "\n" }
|
|
END {
|
|
# Token check: the key must be followed by optional whitespace,
|
|
# a colon, and the object brace - so a STRING VALUE that merely
|
|
# contains "skipInstall" cannot anchor the extraction. Non-token
|
|
# occurrences are skipped and the scan continues.
|
|
pos = 1
|
|
while (1) {
|
|
i = index(substr(buf, pos), "\"skipInstall\"")
|
|
if (i == 0) exit
|
|
i = pos + i - 1
|
|
rest = substr(buf, i + 13)
|
|
if (match(rest, /^[ \t\r\n]*:[ \t\r\n]*\{/) != 0) {
|
|
rest = substr(rest, RLENGTH)
|
|
k = index(rest, "}")
|
|
if (k == 0) exit
|
|
print substr(rest, 1, k)
|
|
exit
|
|
}
|
|
pos = i + 13
|
|
}
|
|
}' "$_config_dir/config.json" 2>/dev/null) || _skip_install_block=""
|
|
fi
|
|
if [ -n "$_skip_install_block" ]; then
|
|
for _agent in codex gemini kiro opencode skills; do
|
|
if printf '%s' "$_skip_install_block" | grep -q "\"$_agent\"[[:space:]]*:[[:space:]]*false"; then
|
|
continue # explicit false is a veto, never a skip
|
|
fi
|
|
if printf '%s' "$_skip_install_block" | grep -q "\"$_agent\"[[:space:]]*:[[:space:]]*true"; then
|
|
case "$_agent" in
|
|
codex)
|
|
skip_codex=1
|
|
skip_codex_source="config skipInstall.codex"
|
|
;;
|
|
gemini)
|
|
skip_gemini=1
|
|
skip_gemini_source="config skipInstall.gemini"
|
|
;;
|
|
kiro)
|
|
skip_kiro=1
|
|
skip_kiro_source="config skipInstall.kiro"
|
|
;;
|
|
opencode)
|
|
skip_opencode=1
|
|
skip_opencode_source="config skipInstall.opencode"
|
|
;;
|
|
skills)
|
|
skip_skills=1
|
|
skip_skills_source="config skipInstall.skills"
|
|
;;
|
|
esac
|
|
fi
|
|
done
|
|
unset _agent
|
|
fi
|
|
unset _skip_install_block
|
|
case "${PLANNOTATOR_SKIP_CODEX_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
skip_codex=1
|
|
skip_codex_source="PLANNOTATOR_SKIP_CODEX_INSTALL"
|
|
;;
|
|
0|false|no|FALSE|NO|False|No)
|
|
skip_codex=0
|
|
skip_codex_source=""
|
|
;;
|
|
esac
|
|
case "${PLANNOTATOR_SKIP_GEMINI_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
skip_gemini=1
|
|
skip_gemini_source="PLANNOTATOR_SKIP_GEMINI_INSTALL"
|
|
;;
|
|
0|false|no|FALSE|NO|False|No)
|
|
skip_gemini=0
|
|
skip_gemini_source=""
|
|
;;
|
|
esac
|
|
case "${PLANNOTATOR_SKIP_KIRO_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
skip_kiro=1
|
|
skip_kiro_source="PLANNOTATOR_SKIP_KIRO_INSTALL"
|
|
;;
|
|
0|false|no|FALSE|NO|False|No)
|
|
skip_kiro=0
|
|
skip_kiro_source=""
|
|
;;
|
|
esac
|
|
case "${PLANNOTATOR_SKIP_OPENCODE_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
skip_opencode=1
|
|
skip_opencode_source="PLANNOTATOR_SKIP_OPENCODE_INSTALL"
|
|
;;
|
|
0|false|no|FALSE|NO|False|No)
|
|
skip_opencode=0
|
|
skip_opencode_source=""
|
|
;;
|
|
esac
|
|
case "${PLANNOTATOR_SKIP_SKILLS_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
skip_skills=1
|
|
skip_skills_source="PLANNOTATOR_SKIP_SKILLS_INSTALL"
|
|
;;
|
|
0|false|no|FALSE|NO|False|No)
|
|
skip_skills=0
|
|
skip_skills_source=""
|
|
;;
|
|
esac
|
|
if [ "$SKIP_CODEX_FLAG" -eq 1 ]; then
|
|
skip_codex=1
|
|
skip_codex_source="--skip-codex"
|
|
fi
|
|
if [ "$SKIP_GEMINI_FLAG" -eq 1 ]; then
|
|
skip_gemini=1
|
|
skip_gemini_source="--skip-gemini"
|
|
fi
|
|
if [ "$SKIP_KIRO_FLAG" -eq 1 ]; then
|
|
skip_kiro=1
|
|
skip_kiro_source="--skip-kiro"
|
|
fi
|
|
if [ "$SKIP_OPENCODE_FLAG" -eq 1 ]; then
|
|
skip_opencode=1
|
|
skip_opencode_source="--skip-opencode"
|
|
fi
|
|
if [ "$SKIP_SKILLS_FLAG" -eq 1 ]; then
|
|
skip_skills=1
|
|
skip_skills_source="--skip-skills"
|
|
fi
|
|
|
|
# Pre-flight: if verification is requested, reject tags older than the first
|
|
# attested release before we download anything. This catches both explicit
|
|
# `--version <old-tag>` and implicit `latest`-resolves-to-old-tag cases with
|
|
# a clean, actionable error — no cryptic `gh: no attestations found` after
|
|
# a wasted download.
|
|
if [ "$verify_attestation" -eq 1 ]; then
|
|
if ! version_ge "$latest_tag" "$MIN_ATTESTED_VERSION"; then
|
|
echo "Provenance verification was requested, but ${latest_tag} predates" >&2
|
|
echo "plannotator's attestation support. The first release carrying signed" >&2
|
|
echo "build provenance is ${MIN_ATTESTED_VERSION}. Options:" >&2
|
|
echo " - Pin to ${MIN_ATTESTED_VERSION} or later: --version ${MIN_ATTESTED_VERSION}" >&2
|
|
echo " - Install without provenance verification: --skip-attestation" >&2
|
|
echo " - Or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation" >&2
|
|
echo " from ~/.plannotator/config.json" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
binary_url="https://github.com/${REPO}/releases/download/${latest_tag}/${binary_name}"
|
|
checksum_url="${binary_url}.sha256"
|
|
|
|
mkdir -p "$INSTALL_DIR"
|
|
|
|
tmp_file=$(mktemp)
|
|
curl -fsSL -o "$tmp_file" "$binary_url"
|
|
|
|
expected_checksum=$(curl -fsSL "$checksum_url" | cut -d' ' -f1)
|
|
|
|
if [ "$(uname -s)" = "Darwin" ]; then
|
|
actual_checksum=$(shasum -a 256 "$tmp_file" | cut -d' ' -f1)
|
|
else
|
|
actual_checksum=$(sha256sum "$tmp_file" | cut -d' ' -f1)
|
|
fi
|
|
|
|
if [ "$actual_checksum" != "$expected_checksum" ]; then
|
|
echo "Checksum verification failed!" >&2
|
|
rm -f "$tmp_file"
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$verify_attestation" -eq 1 ]; then
|
|
# $verify_attestation was resolved before the download; MIN_ATTESTED_VERSION
|
|
# pre-flight already ran and rejected old tags. At this point we know
|
|
# the tag is attested and gh should find a bundle.
|
|
if command -v gh >/dev/null 2>&1; then
|
|
# Credential-free path first (#1178): the attestations endpoint on
|
|
# api.github.com is world-readable for public repos, so fetch the
|
|
# Sigstore bundle anonymously and hand it to gh via --bundle. The
|
|
# authenticated path fetches the SAME endpoint with an Authorization
|
|
# header it does not need; dropping the login requirement avoids
|
|
# forcing a broadly-scoped plaintext token onto headless machines
|
|
# just to read public data. Single fetch attempt, deliberately never
|
|
# retried: the unauthenticated API allows 60 requests/hour per IP.
|
|
# Any failure on this path (no JSON extractor, network, rate limit,
|
|
# extraction failure, or a gh that cannot handle --bundle) falls
|
|
# back to gh's own authenticated fetch below — verification itself
|
|
# is never skipped.
|
|
#
|
|
# The response is { "attestations": [ { "bundle": {...}, ... } ] }.
|
|
# gh --bundle expects the bundle values themselves, one JSON document
|
|
# per line (the same JSONL format `gh attestation download` writes).
|
|
# The extraction needs a JSON tool: node, then python3, then jq —
|
|
# whichever is present. With none of the three, the credential-free
|
|
# path is unavailable and the fallback runs (M3: the message names
|
|
# that cause instead of blaming a fetch that never happened).
|
|
attestation_bundle=""
|
|
attestation_bundle_dir=""
|
|
bundle_fallback_reason=""
|
|
if ! command -v node >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1 && ! command -v jq >/dev/null 2>&1; then
|
|
bundle_fallback_reason="No JSON extractor found (the credential-free bundle path needs node, python3, or jq)"
|
|
else
|
|
_att_json=$(curl -fsSL --connect-timeout 10 --max-time 30 \
|
|
"https://api.github.com/repos/${REPO}/attestations/sha256:${actual_checksum}" 2>/dev/null) || _att_json=""
|
|
if [ -z "$_att_json" ]; then
|
|
bundle_fallback_reason="Could not fetch the attestation bundle from the public API"
|
|
else
|
|
# Private temp dir with the bundle inside (M5): no
|
|
# rename-into-place of a predictable sibling path, and one
|
|
# rm -rf covers every exit. gh requires a .json/.jsonl
|
|
# extension on --bundle files. A mktemp failure degrades to
|
|
# the authenticated fallback, never aborts under set -e.
|
|
attestation_bundle_dir=$(mktemp -d 2>/dev/null) || attestation_bundle_dir=""
|
|
if [ -z "$attestation_bundle_dir" ]; then
|
|
bundle_fallback_reason="Could not create a temporary directory for the attestation bundle"
|
|
else
|
|
_att_bundle_file="$attestation_bundle_dir/bundle.jsonl"
|
|
_att_extract_ok=0
|
|
if command -v node >/dev/null 2>&1; then
|
|
if printf '%s' "$_att_json" | node -e '
|
|
let d = "";
|
|
process.stdin.on("data", (c) => (d += c));
|
|
process.stdin.on("end", () => {
|
|
let p;
|
|
try { p = JSON.parse(d); } catch { process.exit(1); }
|
|
const atts = Array.isArray(p.attestations) ? p.attestations : [];
|
|
const lines = atts.map((a) => a && a.bundle).filter(Boolean).map((b) => JSON.stringify(b));
|
|
if (!lines.length) process.exit(1);
|
|
process.stdout.write(lines.join("\n") + "\n");
|
|
});
|
|
' > "$_att_bundle_file" 2>/dev/null && [ -s "$_att_bundle_file" ]; then
|
|
_att_extract_ok=1
|
|
fi
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
if printf '%s' "$_att_json" | python3 -c '
|
|
import json, sys
|
|
try:
|
|
p = json.load(sys.stdin)
|
|
except Exception:
|
|
sys.exit(1)
|
|
atts = p.get("attestations") or []
|
|
lines = [json.dumps(a["bundle"], separators=(",", ":")) for a in atts if isinstance(a, dict) and a.get("bundle")]
|
|
if not lines:
|
|
sys.exit(1)
|
|
sys.stdout.write("\n".join(lines) + "\n")
|
|
' > "$_att_bundle_file" 2>/dev/null && [ -s "$_att_bundle_file" ]; then
|
|
_att_extract_ok=1
|
|
fi
|
|
else
|
|
if printf '%s' "$_att_json" | jq -c '.attestations[]?.bundle | select(. != null)' > "$_att_bundle_file" 2>/dev/null && [ -s "$_att_bundle_file" ]; then
|
|
_att_extract_ok=1
|
|
fi
|
|
fi
|
|
if [ "$_att_extract_ok" -eq 1 ]; then
|
|
attestation_bundle="$_att_bundle_file"
|
|
else
|
|
bundle_fallback_reason="Could not extract a bundle from the attestations API response"
|
|
rm -rf "$attestation_bundle_dir"
|
|
attestation_bundle_dir=""
|
|
fi
|
|
unset _att_bundle_file _att_extract_ok
|
|
fi
|
|
fi
|
|
unset _att_json
|
|
fi
|
|
# Capture combined output so we can surface gh's actual error message
|
|
# (auth, network, missing attestation, etc.) on failure instead of a
|
|
# generic "verification failed" with no diagnostic detail.
|
|
# Constrain verification to the exact tag + signing workflow — not
|
|
# just "built by somewhere in this repo". --source-ref pins the
|
|
# git ref the attestation was produced from; --signer-workflow pins
|
|
# the workflow file that signed it. Together they prevent accepting
|
|
# a misattached asset or an attestation from an unrelated workflow.
|
|
gh_status=0
|
|
used_bundle=0
|
|
if [ -n "$attestation_bundle" ]; then
|
|
used_bundle=1
|
|
gh_output=$(gh attestation verify "$tmp_file" \
|
|
--bundle "$attestation_bundle" \
|
|
--repo "$REPO" \
|
|
--source-ref "refs/tags/${latest_tag}" \
|
|
--signer-workflow "backnotprop/plannotator/.github/workflows/release.yml" 2>&1) || gh_status=$?
|
|
if [ "$gh_status" -ne 0 ]; then
|
|
# H1: a --bundle failure is not necessarily a provenance
|
|
# failure (an older gh rejects the flag outright, a corrupted
|
|
# bundle write fails parsing, etc.). Retry once through the
|
|
# exact authenticated path before classifying anything; only
|
|
# the retry's verdict is reported. A real provenance failure
|
|
# fails again here, so nothing bad ever slips through.
|
|
echo "Bundle-based verification did not complete; retrying via gh's authenticated fetch."
|
|
used_bundle=0
|
|
gh_status=0
|
|
gh_output=$(gh attestation verify "$tmp_file" \
|
|
--repo "$REPO" \
|
|
--source-ref "refs/tags/${latest_tag}" \
|
|
--signer-workflow "backnotprop/plannotator/.github/workflows/release.yml" 2>&1) || gh_status=$?
|
|
fi
|
|
else
|
|
echo "${bundle_fallback_reason}; falling back to gh's authenticated fetch."
|
|
gh_output=$(gh attestation verify "$tmp_file" \
|
|
--repo "$REPO" \
|
|
--source-ref "refs/tags/${latest_tag}" \
|
|
--signer-workflow "backnotprop/plannotator/.github/workflows/release.yml" 2>&1) || gh_status=$?
|
|
fi
|
|
if [ -n "$attestation_bundle_dir" ]; then rm -rf "$attestation_bundle_dir"; fi
|
|
if [ "$gh_status" -eq 0 ]; then
|
|
if [ "$used_bundle" -eq 1 ]; then
|
|
echo "✓ verified build provenance (SLSA, credential-free via the public attestations API)"
|
|
else
|
|
echo "✓ verified build provenance (SLSA)"
|
|
fi
|
|
else
|
|
echo "$gh_output" >&2
|
|
# Classification precedence: TUF connectivity first, then auth,
|
|
# then real provenance failure (matches install.cmd).
|
|
case "$gh_output" in
|
|
*"Sigstore verifiers"*)
|
|
# gh could not initialize the Sigstore trusted root. The
|
|
# TUF root is fetched on EVERY run (not embedded, not
|
|
# cached), so this is a connectivity failure, not a
|
|
# provenance failure — the two mean very different things.
|
|
echo "Could not initialize the Sigstore trust root (TUF)." >&2
|
|
echo "Provenance verification needs network access on every run; the trusted" >&2
|
|
echo "root is fetched per-run, never cached. This is a connectivity failure," >&2
|
|
echo "NOT evidence of a bad binary. Refusing to install unverified; retry" >&2
|
|
echo "with network access or pass --skip-attestation." >&2
|
|
;;
|
|
*"gh auth login"*)
|
|
# Only reachable on the authenticated path: the bundle
|
|
# path was unavailable or did not complete AND gh has no
|
|
# login to fetch the attestation itself. Environment
|
|
# problem, not a provenance failure.
|
|
echo "The credential-free bundle path did not complete and gh is not logged" >&2
|
|
echo "in, so the authenticated fallback could not run. Retry with network" >&2
|
|
echo "access to api.github.com, run 'gh auth login', or pass --skip-attestation." >&2
|
|
;;
|
|
*)
|
|
echo "Attestation verification failed!" >&2
|
|
echo "The binary's SHA256 matched, but no valid signed provenance was found" >&2
|
|
echo "for ${REPO}. Refusing to install." >&2
|
|
;;
|
|
esac
|
|
rm -f "$tmp_file"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "verifyAttestation is enabled but gh CLI was not found." >&2
|
|
echo "Install https://cli.github.com (no login is needed when the public" >&2
|
|
echo "attestation bundle fetch succeeds), or unset" >&2
|
|
echo "PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation from" >&2
|
|
echo "~/.plannotator/config.json / pass --skip-attestation." >&2
|
|
rm -f "$tmp_file"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "SHA256 verified. For build provenance verification, see"
|
|
echo "https://docs.plannotator.ai/open-source/start/installation#pin-or-verify-a-release"
|
|
fi
|
|
|
|
# Remove old binary first (handles Windows .exe and locked file issues)
|
|
rm -f "$INSTALL_DIR/plannotator" "$INSTALL_DIR/plannotator.exe" 2>/dev/null || true
|
|
|
|
mv "$tmp_file" "$INSTALL_DIR/plannotator"
|
|
chmod +x "$INSTALL_DIR/plannotator"
|
|
|
|
echo ""
|
|
echo "plannotator ${latest_tag} installed to ${INSTALL_DIR}/plannotator"
|
|
|
|
# Print the PATH-setup hint if $INSTALL_DIR isn't already on PATH. Extracted so
|
|
# both the normal flow and the --minimal early exit below can reuse it.
|
|
print_path_advice() {
|
|
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
|
|
echo ""
|
|
echo "${INSTALL_DIR} is not in your PATH. Add it with:"
|
|
echo ""
|
|
|
|
case "$SHELL" in
|
|
*/zsh) shell_config="~/.zshrc" ;;
|
|
*/bash) shell_config="~/.bashrc" ;;
|
|
*) shell_config="your shell config" ;;
|
|
esac
|
|
|
|
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ${shell_config}"
|
|
echo " source ${shell_config}"
|
|
fi
|
|
echo ""
|
|
echo "To uninstall later: plannotator uninstall"
|
|
}
|
|
|
|
# Binary-only mode stops here: the binary is installed, so print PATH advice and
|
|
# exit before any sidecar download, agent integration, skill checkout, config
|
|
# write, cache clear, or cleanup migration runs. No persistent state is written
|
|
# outside $INSTALL_DIR (the temp download file was already cleaned up above; the
|
|
# config dir may have been read, never written). See the MINIMAL_FLAG /
|
|
# PLANNOTATOR_MINIMAL resolution near the top.
|
|
if [ "$minimal" -eq 1 ]; then
|
|
print_path_advice
|
|
echo ""
|
|
echo "Minimal install complete — only the plannotator binary was installed."
|
|
echo "No skills, hooks, agent integrations, or config files were written."
|
|
exit 0
|
|
fi
|
|
|
|
sem_asset_for_platform() {
|
|
case "$platform" in
|
|
darwin-arm64) echo "sem-darwin-arm64.tar.gz" ;;
|
|
linux-arm64) echo "sem-linux-arm64.tar.gz" ;;
|
|
linux-x64) echo "sem-linux-x86_64.tar.gz" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
install_sem_sidecar() {
|
|
case "${PLANNOTATOR_SKIP_SEM_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
echo "Skipping semantic diff sidecar install (PLANNOTATOR_SKIP_SEM_INSTALL is set)"
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
sem_asset="$(sem_asset_for_platform 2>/dev/null || true)"
|
|
if [ -z "$sem_asset" ]; then
|
|
echo "Skipping semantic diff sidecar install (sem does not publish ${platform})"
|
|
return 0
|
|
fi
|
|
|
|
sem_dir="${_config_dir}/vendor/sem/${SEM_VERSION}"
|
|
sem_bin="${sem_dir}/sem"
|
|
if [ -x "$sem_bin" ] && "$sem_bin" --version 2>/dev/null | grep -q '^sem '; then
|
|
echo "Semantic diff sidecar already installed at ${sem_bin}"
|
|
return 0
|
|
fi
|
|
|
|
tmp_sem_dir="$(mktemp -d)"
|
|
sem_archive="${tmp_sem_dir}/${sem_asset}"
|
|
sem_checksums="${tmp_sem_dir}/checksums.txt"
|
|
sem_base_url="https://github.com/${SEM_REPO}/releases/download/${SEM_VERSION}"
|
|
|
|
# Bounded so a slow/hung download of this optional sidecar can't wedge an
|
|
# install where plannotator itself already landed. On timeout curl fails and
|
|
# we skip gracefully. Opt out entirely with PLANNOTATOR_SKIP_SEM_INSTALL=1.
|
|
if ! curl -fsSL --connect-timeout 10 --max-time 120 -o "$sem_archive" "${sem_base_url}/${sem_asset}"; then
|
|
echo "Skipping semantic diff sidecar install (download failed)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
if ! curl -fsSL --connect-timeout 10 --max-time 60 -o "$sem_checksums" "${sem_base_url}/checksums.txt"; then
|
|
echo "Skipping semantic diff sidecar install (checksum download failed)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
|
|
expected_sem_checksum="$(awk -v name="$sem_asset" '$2 == name { print $1 }' "$sem_checksums")"
|
|
if [ -z "$expected_sem_checksum" ]; then
|
|
echo "Skipping semantic diff sidecar install (checksum missing for ${sem_asset})"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$(uname -s)" = "Darwin" ]; then
|
|
actual_sem_checksum="$(shasum -a 256 "$sem_archive" | cut -d' ' -f1)"
|
|
else
|
|
actual_sem_checksum="$(sha256sum "$sem_archive" | cut -d' ' -f1)"
|
|
fi
|
|
|
|
if [ "$actual_sem_checksum" != "$expected_sem_checksum" ]; then
|
|
echo "Skipping semantic diff sidecar install (checksum mismatch)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
|
|
if ! tar -xzf "$sem_archive" -C "$tmp_sem_dir"; then
|
|
echo "Skipping semantic diff sidecar install (extract failed)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
|
|
extracted_sem="$(find "$tmp_sem_dir" -type f -name sem -print -quit)"
|
|
if [ -z "$extracted_sem" ]; then
|
|
echo "Skipping semantic diff sidecar install (binary missing from archive)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
|
|
if ! mkdir -p "$sem_dir"; then
|
|
echo "Skipping semantic diff sidecar install (directory creation failed)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
if ! cp "$extracted_sem" "$sem_bin"; then
|
|
echo "Skipping semantic diff sidecar install (copy failed)"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
if ! chmod +x "$sem_bin"; then
|
|
echo "Skipping semantic diff sidecar install (chmod failed)"
|
|
rm -f "$sem_bin"
|
|
rm -rf "$tmp_sem_dir"
|
|
return 0
|
|
fi
|
|
rm -rf "$tmp_sem_dir"
|
|
echo "Semantic diff sidecar installed to ${sem_bin}"
|
|
}
|
|
|
|
install_agent_terminal_runtime() {
|
|
case "${PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL:-}" in
|
|
1|true|yes|TRUE|YES|True|Yes)
|
|
echo "Skipping agent terminal runtime install (PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL is set)"
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
if ! "$INSTALL_DIR/plannotator" install-runtime agent-terminal; then
|
|
echo "Skipping agent terminal runtime install (plannotator install-runtime failed)"
|
|
fi
|
|
}
|
|
|
|
# Strictly opt-in: Call flow is off by default, so a default install never
|
|
# downloads even its pruned core. Review-specific packs install in-app.
|
|
install_call_flow_runtime() {
|
|
if [ "$install_call_flow" -ne 1 ]; then
|
|
echo "Call-flow analysis: available as an in-app opt-in install (enable Call flow in review Settings), or run: plannotator install-runtime call-flow"
|
|
return 0
|
|
fi
|
|
|
|
if ! "$INSTALL_DIR/plannotator" install-runtime call-flow; then
|
|
echo "Call-flow runtime install failed; it remains available as an in-app opt-in install"
|
|
fi
|
|
}
|
|
|
|
install_sem_sidecar
|
|
install_agent_terminal_runtime
|
|
install_call_flow_runtime
|
|
|
|
print_path_advice
|
|
|
|
# --- Codex CLI / Desktop app support (only if Codex is installed or configured) ---
|
|
# Codex stores config and state under $CODEX_HOME when set, falling back to
|
|
# ~/.codex (https://developers.openai.com/codex/config-advanced).
|
|
CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
|
|
|
|
codex_home_has_user_config() {
|
|
[ -d "$CODEX_DIR" ] || return 1
|
|
[ -n "$(find "$CODEX_DIR" -mindepth 1 -maxdepth 1 ! -name skills ! -name .DS_Store -print -quit 2>/dev/null)" ]
|
|
}
|
|
|
|
codex_available=0
|
|
if command -v codex >/dev/null 2>&1 || codex_home_has_user_config; then
|
|
codex_available=1
|
|
fi
|
|
|
|
kiro_available=0
|
|
if command -v kiro-cli >/dev/null 2>&1 || [ -d "$HOME/.kiro" ]; then
|
|
kiro_available=1
|
|
fi
|
|
|
|
if [ "$codex_available" -eq 1 ] && [ "$skip_codex" -eq 1 ]; then
|
|
# HONEST three-state reporting (#1178): detected-but-skipped is its own
|
|
# state, never conflated with "not detected". Skip is do-not-write only:
|
|
# nothing under $CODEX_DIR is created, updated, or removed on this run.
|
|
echo ""
|
|
echo "Codex: detected, skipped (${skip_codex_source})."
|
|
if [ -f "$CODEX_DIR/hooks.json" ] && grep -q "plannotator" "$CODEX_DIR/hooks.json" 2>/dev/null; then
|
|
echo "An existing Codex integration at ${CODEX_DIR}/hooks.json was left untouched."
|
|
fi
|
|
echo "Note: the shared agent skills in ~/.agents/skills serve multiple agents"
|
|
echo "(Codex among them) and are still installed."
|
|
elif [ "$codex_available" -eq 1 ]; then
|
|
CODEX_CONFIG="$CODEX_DIR/config.toml"
|
|
CODEX_HOOKS="$CODEX_DIR/hooks.json"
|
|
PLANNOTATOR_BIN="${INSTALL_DIR}/plannotator"
|
|
codex_hook_configured=0
|
|
|
|
mkdir -p "$CODEX_DIR"
|
|
|
|
enable_codex_hooks_config() {
|
|
if [ ! -f "$CODEX_CONFIG" ]; then
|
|
cat > "$CODEX_CONFIG" << 'CODEX_CONFIG_EOF'
|
|
[features]
|
|
hooks = true
|
|
CODEX_CONFIG_EOF
|
|
echo "Created Codex config at ${CODEX_CONFIG}"
|
|
return 0
|
|
fi
|
|
|
|
if grep -Eq '^[[:space:]]*features[[:space:]]*=' "$CODEX_CONFIG"; then
|
|
echo ""
|
|
echo "Codex config uses inline features in ${CODEX_CONFIG}; leaving it unchanged."
|
|
echo "Add this manually to enable Plannotator plan review:"
|
|
echo ""
|
|
echo " [features]"
|
|
echo " hooks = true"
|
|
return 1
|
|
fi
|
|
|
|
tmp_config="$(mktemp)"
|
|
if awk '
|
|
function is_table(line) {
|
|
return line ~ /^[[:space:]]*\[[^]]+\][[:space:]]*$/
|
|
}
|
|
BEGIN {
|
|
in_features = 0
|
|
saw_features = 0
|
|
saw_hook = 0
|
|
}
|
|
{
|
|
if (is_table($0)) {
|
|
if (in_features && !saw_hook) {
|
|
print "hooks = true"
|
|
saw_hook = 1
|
|
}
|
|
in_features = ($0 ~ /^[[:space:]]*\[features\][[:space:]]*$/)
|
|
if (in_features) saw_features = 1
|
|
}
|
|
|
|
if (in_features && $0 ~ /^[[:space:]]*(codex_hooks|hooks)[[:space:]]*=/) {
|
|
print "hooks = true"
|
|
saw_hook = 1
|
|
next
|
|
}
|
|
|
|
print
|
|
}
|
|
END {
|
|
if (saw_features && in_features && !saw_hook) {
|
|
print "hooks = true"
|
|
} else if (!saw_features) {
|
|
print ""
|
|
print "[features]"
|
|
print "hooks = true"
|
|
}
|
|
}
|
|
' "$CODEX_CONFIG" > "$tmp_config"; then
|
|
mv "$tmp_config" "$CODEX_CONFIG"
|
|
echo "Enabled Codex hooks in ${CODEX_CONFIG}"
|
|
return 0
|
|
fi
|
|
|
|
rm -f "$tmp_config"
|
|
echo "Could not update ${CODEX_CONFIG}; add hooks manually." >&2
|
|
return 1
|
|
}
|
|
|
|
if [ ! -f "$CODEX_HOOKS" ]; then
|
|
cat > "$CODEX_HOOKS" << CODEX_HOOKS_EOF
|
|
{
|
|
"hooks": {
|
|
"Stop": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "${PLANNOTATOR_BIN}",
|
|
"timeout": 345600
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
CODEX_HOOKS_EOF
|
|
echo "Created Codex hooks at ${CODEX_HOOKS}"
|
|
codex_hook_configured=1
|
|
elif command -v node >/dev/null 2>&1; then
|
|
if codex_merge_result=$(node - "$CODEX_HOOKS" "$PLANNOTATOR_BIN" <<'NODE'
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const [hooksPath, command] = process.argv.slice(2);
|
|
const config = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
|
|
config.hooks ||= {};
|
|
const stopHooks = Array.isArray(config.hooks.Stop) ? config.hooks.Stop : [];
|
|
let updated = false;
|
|
let foundCustomPlannotatorHook = false;
|
|
|
|
function isManagedPlannotatorCommand(value) {
|
|
const current = value.trim();
|
|
if (current === "plannotator" || current === command) return true;
|
|
return current.startsWith("/") && path.posix.basename(current) === "plannotator";
|
|
}
|
|
|
|
for (const entry of stopHooks) {
|
|
const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
for (const hook of hooks) {
|
|
if (hook?.type !== "command" || typeof hook.command !== "string") continue;
|
|
|
|
if (isManagedPlannotatorCommand(hook.command)) {
|
|
hook.command = command;
|
|
hook.timeout = 345600;
|
|
updated = true;
|
|
} else if (hook.command.includes("plannotator")) {
|
|
foundCustomPlannotatorHook = true;
|
|
}
|
|
}
|
|
}
|
|
if (!updated && !foundCustomPlannotatorHook) {
|
|
stopHooks.push({
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command,
|
|
timeout: 345600,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
config.hooks.Stop = stopHooks;
|
|
if (updated || !foundCustomPlannotatorHook) {
|
|
fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + "\n");
|
|
}
|
|
process.stdout.write(updated ? "updated" : foundCustomPlannotatorHook ? "custom" : "added");
|
|
NODE
|
|
); then
|
|
case "$codex_merge_result" in
|
|
custom)
|
|
echo "Existing custom Codex Plannotator hook found at ${CODEX_HOOKS}; left it unchanged."
|
|
;;
|
|
added)
|
|
echo "Added Codex hooks at ${CODEX_HOOKS}"
|
|
;;
|
|
*)
|
|
echo "Updated Codex hooks at ${CODEX_HOOKS}"
|
|
;;
|
|
esac
|
|
codex_hook_configured=1
|
|
else
|
|
echo ""
|
|
echo "Codex hooks file already exists at ${CODEX_HOOKS}, but it could not be merged automatically."
|
|
echo "Leaving Codex hook support unchanged. Add or update this Stop hook manually:"
|
|
echo ""
|
|
echo " command: ${PLANNOTATOR_BIN}"
|
|
echo " timeout: 345600"
|
|
fi
|
|
else
|
|
echo ""
|
|
echo "Codex hooks file already exists at ${CODEX_HOOKS}, but node was not found to merge it safely."
|
|
echo "Leaving Codex hook support unchanged. Add or update this Stop hook manually:"
|
|
echo ""
|
|
echo " command: ${PLANNOTATOR_BIN}"
|
|
echo " timeout: 345600"
|
|
fi
|
|
|
|
if [ "$codex_hook_configured" -eq 1 ]; then
|
|
enable_codex_hooks_config || true
|
|
fi
|
|
fi
|
|
|
|
# Validate plugin hooks.json if plugin is already installed
|
|
PLUGIN_HOOKS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/marketplaces/plannotator/apps/hook/hooks/hooks.json"
|
|
if [ -f "$PLUGIN_HOOKS" ]; then
|
|
cat > "$PLUGIN_HOOKS" << 'HOOKS_EOF'
|
|
{
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "EnterPlanMode",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "plannotator improve-context",
|
|
"timeout": 5
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PermissionRequest": [
|
|
{
|
|
"matcher": "ExitPlanMode",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "plannotator",
|
|
"timeout": 345600
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
HOOKS_EOF
|
|
echo "Updated plugin hooks at ${PLUGIN_HOOKS}"
|
|
fi
|
|
|
|
# Clear any cached OpenCode plugin to force fresh download on next run.
|
|
# An OpenCode opt-out (#1178) leaves OpenCode's own cache directory alone;
|
|
# the Bun package cache is a shared cache, not OpenCode's home, and is
|
|
# always cleared.
|
|
if [ "$skip_opencode" -eq 0 ]; then
|
|
rm -rf "$HOME/.cache/opencode/node_modules/@plannotator" "$HOME/.cache/opencode/packages/@plannotator" 2>/dev/null || true
|
|
fi
|
|
rm -rf "$HOME/.bun/install/cache/@plannotator" 2>/dev/null || true
|
|
|
|
# Clear Pi jiti cache to force fresh download on next run
|
|
rm -rf /tmp/jiti 2>/dev/null || true
|
|
|
|
update_pi_extension_if_present() {
|
|
if ! command -v pi &>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
echo "Updating Pi extension..."
|
|
if pi install npm:@plannotator/pi-extension; then
|
|
echo "Pi extension updated."
|
|
else
|
|
echo "Skipping Pi extension update (pi install failed)"
|
|
fi
|
|
}
|
|
|
|
# --- Aggressive cleanup of skills/commands we no longer manage ---
|
|
# Echo each removal; ignore missing entries.
|
|
|
|
# NOTE: legacy Claude command cleanup happens AFTER the skill install below —
|
|
# a command file is only removed once its replacement skill is on disk, so a
|
|
# failed or skipped skill install never leaves users with neither.
|
|
|
|
# NOTE: Codex stale-skill cleanup happens AFTER the skill install below —
|
|
# the core skills are only removed from the Codex home once their replacement
|
|
# exists in ~/.agents/skills, so an old pinned tag never strips Codex users
|
|
# of working skills without a successor.
|
|
STALE_CODEX_SKILLS_DIR="$CODEX_DIR/skills"
|
|
|
|
# Old installers (pre core/extra split) ran `cp -r apps/skills/*` against a
|
|
# new-layout tag and could leave junk `core`/`extra` directory copies in the
|
|
# Claude skills scope. Never valid skill names — always safe to remove.
|
|
for junk in core extra; do
|
|
if [ -d "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/$junk" ]; then
|
|
rm -rf "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/$junk"
|
|
echo "Removed stale layout directory ~/.claude/skills/$junk (left by an older installer)"
|
|
fi
|
|
done
|
|
|
|
# Extras are no longer installed by this script anywhere except Kiro. Remove
|
|
# previously default-installed copies ONCE per machine — recorded in the
|
|
# migrations ledger under the Plannotator data dir — because copies the user
|
|
# reinstalls via `npx skills add` are byte-identical to ours and can only be
|
|
# told apart by remembering that this cleanup already ran.
|
|
CLAUDE_SKILLS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"
|
|
AGENTS_SKILLS_DIR="$HOME/.agents/skills"
|
|
MIGRATIONS_DIR="$_config_dir/migrations"
|
|
EXTRAS_MIGRATION="$MIGRATIONS_DIR/2026-06-extras-default-install-removed"
|
|
if [ ! -f "$EXTRAS_MIGRATION" ]; then
|
|
for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR"; do
|
|
for skill in plannotator-compound plannotator-setup-goal plannotator-visual-explainer; do
|
|
if [ -d "$scope/$skill" ]; then
|
|
rm -rf "$scope/$skill"
|
|
echo "Removed extra Plannotator skill from ${scope}/$skill (reinstall via npx skills add)"
|
|
fi
|
|
done
|
|
done
|
|
mkdir -p "$MIGRATIONS_DIR"
|
|
: > "$EXTRAS_MIGRATION"
|
|
fi
|
|
|
|
# --- Guided install (interactive terminals only) ---
|
|
# Two questions: install the extra skills? make any skills callable by the
|
|
# model? Answers persist to $PREFS_FILE and are reused silently on re-runs.
|
|
# --reconfigure re-opens the wizard; --non-interactive forces silence; piped
|
|
# CI runs without a terminal never prompt. CLI flags win over everything.
|
|
PREFS_FILE="$_config_dir/install-prefs"
|
|
CORE_SKILL_NAMES="plannotator-review plannotator-annotate plannotator-last"
|
|
EXTRA_SKILL_NAMES="plannotator-compound plannotator-setup-goal plannotator-visual-explainer"
|
|
|
|
saved_extras=""
|
|
saved_invocable=""
|
|
if [ -f "$PREFS_FILE" ]; then
|
|
saved_extras=$(sed -n 's/^extras=//p' "$PREFS_FILE" | head -1)
|
|
saved_invocable=$(sed -n 's/^model_invocable=//p' "$PREFS_FILE" | head -1)
|
|
fi
|
|
|
|
# Extras already on disk (pre-existing or previously npx-installed)? Then the
|
|
# extras question is moot — they still count toward the checkbox list, and we
|
|
# never launch the npx flow over them.
|
|
extras_present=0
|
|
for skill in $EXTRA_SKILL_NAMES; do
|
|
if [ -d "$CLAUDE_SKILLS_DIR/$skill" ] || [ -d "$AGENTS_SKILLS_DIR/$skill" ]; then
|
|
extras_present=1
|
|
break
|
|
fi
|
|
done
|
|
|
|
# A wizard needs a real human at the keyboard. Piped installs (curl | bash)
|
|
# still have a terminal at /dev/tty even though stdin is the pipe; CI and
|
|
# scripts do not. Some automated contexts (docker run -t, devcontainer and
|
|
# provisioner shells) DO expose an openable /dev/tty with nobody behind it —
|
|
# opening /dev/tty succeeds, yet a read would block forever. The per-prompt
|
|
# timeout below (see PROMPT_TIMEOUT / ask_yes_no) handles that: a mis-detected
|
|
# terminal falls through to the safe non-interactive defaults (extras=no,
|
|
# model-invocable=none) instead of wedging. We deliberately do NOT
|
|
# gate on $CI here — an exported CI var must not silently suppress an explicit
|
|
# --reconfigure or --extras in an otherwise interactive shell.
|
|
can_prompt=0
|
|
if [ "$NON_INTERACTIVE" -eq 0 ] && { : < /dev/tty; } 2>/dev/null; then
|
|
can_prompt=1
|
|
fi
|
|
|
|
# Bound every interactive read so an unattended-but-open /dev/tty auto-takes
|
|
# the default rather than hanging. Set PLANNOTATOR_PROMPT_TIMEOUT=0 to wait
|
|
# indefinitely (restores the old unbounded behavior); non-numeric falls to 30.
|
|
PROMPT_TIMEOUT="${PLANNOTATOR_PROMPT_TIMEOUT:-30}"
|
|
case "$PROMPT_TIMEOUT" in
|
|
''|*[!0-9]*) PROMPT_TIMEOUT=30 ;;
|
|
esac
|
|
|
|
run_wizard=0
|
|
if [ "$can_prompt" -eq 1 ]; then
|
|
if [ "$RECONFIGURE" -eq 1 ] || [ ! -f "$PREFS_FILE" ]; then
|
|
run_wizard=1
|
|
fi
|
|
fi
|
|
|
|
# Ask a y/n question on the terminal. $1 prompt, $2 default (yes/no).
|
|
ask_yes_no() {
|
|
local prompt="$1" default="$2" answer suffix rc
|
|
suffix="[y/N]"
|
|
[ "$default" = "yes" ] && suffix="[Y/n]"
|
|
printf '%s %s ' "$prompt" "$suffix" > /dev/tty
|
|
# Bounded read so an unattended-but-open /dev/tty (e.g. docker run -t with
|
|
# no human) can't hang the install. Distinguish a human pressing Enter
|
|
# (read succeeds with an empty answer -> use the prompt's $default) from a
|
|
# timeout/EOF with nobody there (read fails -> use the SAFE "no", never the
|
|
# default). Otherwise a prompt whose default is "yes" could
|
|
# silently install software on an unattended terminal.
|
|
# Keep the read in a tested context (`|| rc=$?`) so the read itself never
|
|
# trips `set -e` (active at the top of this script), without relying on the
|
|
# subtle rule that -e is suppressed inside a function called in a tested
|
|
# context. ask_yes_no still returns non-zero on timeout/EOF to signal "no
|
|
# human", so every caller consumes it with `|| wizard_timed_out=1`.
|
|
rc=0
|
|
if [ "$PROMPT_TIMEOUT" -gt 0 ]; then
|
|
IFS= read -r -t "$PROMPT_TIMEOUT" answer < /dev/tty || rc=$?
|
|
else
|
|
IFS= read -r answer < /dev/tty || rc=$?
|
|
fi
|
|
if [ "$rc" -ne 0 ]; then
|
|
printf '\n' > /dev/tty
|
|
echo "no"
|
|
return 1
|
|
fi
|
|
case "$answer" in
|
|
y|Y|yes|YES|Yes) echo "yes" ;;
|
|
n|N|no|NO|No) echo "no" ;;
|
|
*) echo "$default" ;;
|
|
esac
|
|
}
|
|
|
|
# Space-toggle checkbox over the skill names in $1 (space-separated), with
|
|
# the names in $2 (comma-separated) preselected. Echoes the chosen names as
|
|
# a comma list, or "none". Up/down (or j/k) moves, space toggles, enter
|
|
# confirms. All I/O goes to /dev/tty so piped stdout is unaffected.
|
|
select_skills_checkbox() {
|
|
local names=($1) pre=",$2," idx=0 count key seq i mark cursor
|
|
count=${#names[@]}
|
|
local sel=()
|
|
for ((i = 0; i < count; i++)); do
|
|
case "$pre" in
|
|
*",${names[$i]},"*) sel[i]=1 ;;
|
|
*) sel[i]=0 ;;
|
|
esac
|
|
done
|
|
printf 'Space toggles, enter confirms, up/down or j/k moves:\n' > /dev/tty
|
|
while true; do
|
|
for ((i = 0; i < count; i++)); do
|
|
mark=" "; [ "${sel[$i]}" -eq 1 ] && mark="x"
|
|
cursor=" "; [ "$i" -eq "$idx" ] && cursor="> "
|
|
printf '%s[%s] %s\033[K\n' "$cursor" "$mark" "${names[$i]}" > /dev/tty
|
|
done
|
|
IFS= read -rsn1 key < /dev/tty || key=""
|
|
if [ -z "$key" ]; then
|
|
break # enter
|
|
fi
|
|
case "$key" in
|
|
" ") sel[idx]=$((1 - sel[idx])) ;;
|
|
j) [ "$idx" -lt $((count - 1)) ] && idx=$((idx + 1)) ;;
|
|
k) [ "$idx" -gt 0 ] && idx=$((idx - 1)) ;;
|
|
$'\x1b')
|
|
seq=""
|
|
IFS= read -rsn2 -t 1 seq < /dev/tty || seq=""
|
|
case "$seq" in
|
|
'[A') [ "$idx" -gt 0 ] && idx=$((idx - 1)) ;;
|
|
'[B') [ "$idx" -lt $((count - 1)) ] && idx=$((idx + 1)) ;;
|
|
esac
|
|
;;
|
|
esac
|
|
printf '\033[%dA' "$count" > /dev/tty
|
|
done
|
|
local out=""
|
|
for ((i = 0; i < count; i++)); do
|
|
if [ "${sel[$i]}" -eq 1 ]; then
|
|
[ -n "$out" ] && out="$out,"
|
|
out="$out${names[$i]}"
|
|
fi
|
|
done
|
|
echo "${out:-none}"
|
|
}
|
|
|
|
extras_choice=""
|
|
invocable_choice=""
|
|
# Set if any wizard prompt times out / hits EOF (no human answered). A run whose
|
|
# answers are synthetic timeout fallbacks must not be persisted as install-prefs.
|
|
wizard_timed_out=0
|
|
|
|
if [ "$run_wizard" -eq 1 ]; then
|
|
{
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " PLANNOTATOR GUIDED INSTALL"
|
|
echo "=========================================="
|
|
echo ""
|
|
} > /dev/tty
|
|
if [ "$extras_present" -eq 1 ]; then
|
|
echo "Extra skills already installed — keeping them." > /dev/tty
|
|
extras_choice="yes"
|
|
elif [ -n "$EXTRAS_FLAG" ]; then
|
|
# Flag already answered this question — don't ask and then ignore.
|
|
extras_choice="$EXTRAS_FLAG"
|
|
else
|
|
extras_choice=$(ask_yes_no "Install the extra skills (compound planning, setup-goal, visual explainer)?" "${saved_extras:-no}") || wizard_timed_out=1
|
|
fi
|
|
invocable_list="$CORE_SKILL_NAMES"
|
|
if [ "$extras_choice" = "yes" ]; then
|
|
invocable_list="$CORE_SKILL_NAMES $EXTRA_SKILL_NAMES"
|
|
fi
|
|
if [ -n "$MODEL_INVOCABLE_FLAG" ]; then
|
|
# Flag already answered this question — don't ask and then ignore.
|
|
invocable_choice="$MODEL_INVOCABLE_FLAG"
|
|
else
|
|
want_invocable=$(ask_yes_no "Make any skills callable by the model (instead of user-invoked only)?" "no") || wizard_timed_out=1
|
|
if [ "$want_invocable" = "yes" ]; then
|
|
invocable_choice=$(select_skills_checkbox "$invocable_list" "$saved_invocable")
|
|
else
|
|
invocable_choice="none"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Flags override the wizard and saved answers; otherwise saved, then defaults.
|
|
[ -n "$EXTRAS_FLAG" ] && extras_choice="$EXTRAS_FLAG"
|
|
[ -n "$MODEL_INVOCABLE_FLAG" ] && invocable_choice="$MODEL_INVOCABLE_FLAG"
|
|
[ -z "$extras_choice" ] && extras_choice="${saved_extras:-no}"
|
|
[ -z "$invocable_choice" ] && invocable_choice="${saved_invocable:-none}"
|
|
|
|
# Persist only when the wizard ran with real answers, or a flag set something.
|
|
# Silent re-runs must not clobber saved answers with defaults, and a wizard that
|
|
# timed out to synthetic fallbacks (unattended /dev/tty) must not become sticky
|
|
# prefs that suppress the wizard on a later genuine interactive install.
|
|
if [ "$wizard_timed_out" -eq 0 ] && { [ "$run_wizard" -eq 1 ] || [ -n "$EXTRAS_FLAG" ] || [ -n "$MODEL_INVOCABLE_FLAG" ]; }; then
|
|
mkdir -p "$_config_dir"
|
|
{
|
|
echo "extras=$extras_choice"
|
|
echo "model_invocable=$invocable_choice"
|
|
} > "$PREFS_FILE"
|
|
fi
|
|
|
|
# Extras install is delegated to the skills CLI (its UI picks the agents).
|
|
# Interactive only — the CLI needs the keyboard, so silent runs and CI get
|
|
# the printed command instead. Never runs when the extras already exist.
|
|
# The extras ARE skills, so --skip-skills suppresses them too — a saved
|
|
# extras=yes preference must not smuggle a skill install past the opt-out.
|
|
if [ "$skip_skills" -eq 0 ] && [ "$extras_choice" = "yes" ] && [ "$extras_present" -eq 0 ]; then
|
|
if [ "$can_prompt" -eq 1 ] && command -v npx >/dev/null 2>&1; then
|
|
echo "Launching the skills CLI for the extras (pick your agents in its UI)..."
|
|
npx skills add backnotprop/plannotator/apps/skills/extra --global < /dev/tty || \
|
|
echo "skills CLI did not complete — install later with: npx skills add backnotprop/plannotator/apps/skills/extra --global"
|
|
else
|
|
echo "Install the extras with: npx skills add backnotprop/plannotator/apps/skills/extra --global"
|
|
fi
|
|
fi
|
|
|
|
# Skills/commands opt-out. HONEST reporting like the per-agent family: the
|
|
# skipped state is announced, and skip means do-not-write — nothing already on
|
|
# disk in any skill or command scope is fetched, replaced, or removed on this
|
|
# run. Announced here, before the checkout, so the reason precedes the silence.
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
echo ""
|
|
echo "Skills: skipped (${skip_skills_source})."
|
|
echo "No skills or slash commands were fetched, and none already installed"
|
|
echo "were changed or removed. The /plannotator-* commands are NOT installed"
|
|
echo "by this run — re-run without the opt-out to install them."
|
|
fi
|
|
|
|
# Install skills and slash commands from a sparse checkout (requires git).
|
|
# Hard requirement: without git we cannot install the /plannotator-* skills,
|
|
# so fail loudly instead of leaving a partial install. Hook/config writing
|
|
# above has already run by this point; the Pi update and Gemini config below
|
|
# are skipped on failure and complete when the user re-runs the installer.
|
|
# Nothing is fetched under --skip-skills, so git stops being a requirement
|
|
# there — a git-less machine must still get the binary, hooks, and config.
|
|
if [ "$skip_skills" -eq 0 ] && ! command -v git &>/dev/null; then
|
|
echo "Error: git is required to install Plannotator's skills and slash commands." >&2
|
|
echo "Install git, then run this installer again." >&2
|
|
echo "To install without them, re-run with --skip-skills." >&2
|
|
exit 1
|
|
fi
|
|
|
|
KIRO_SKILLS_DIR="$HOME/.kiro/skills"
|
|
OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands"
|
|
GEMINI_COMMANDS_DIR="$HOME/.gemini/commands"
|
|
skills_tmp=$(mktemp -d)
|
|
|
|
copy_skill_if_present() {
|
|
local source_dir="$1"
|
|
local target_dir="$2"
|
|
|
|
if [ -d "$source_dir" ]; then
|
|
# Remove any existing copy first so re-runs replace rather than
|
|
# nest (cp -r dir dest/dir would otherwise create dest/dir/dir).
|
|
rm -rf "$target_dir/$(basename "$source_dir")"
|
|
cp -r "$source_dir" "$target_dir/"
|
|
fi
|
|
}
|
|
|
|
# Copy every command file in a directory if the source dir exists.
|
|
# Used for OpenCode (.md stubs) and Gemini (.toml) commands, both of
|
|
# which are checked out from the repo rather than generated by heredocs.
|
|
copy_commands_if_present() {
|
|
local source_dir="$1"
|
|
local target_dir="$2"
|
|
|
|
if [ -d "$source_dir" ] && [ -n "$(ls -A "$source_dir" 2>/dev/null)" ]; then
|
|
mkdir -p "$target_dir"
|
|
cp "$source_dir"/* "$target_dir/"
|
|
fi
|
|
}
|
|
|
|
# Wrap the cd-bearing block in a subshell so any `cd` is scoped to
|
|
# the subshell and can't leave the parent script with a dangling CWD.
|
|
# Previous version chained `cd` inside an `&&` condition, and if
|
|
# sparse-checkout failed the else branch ran without restoring the
|
|
# directory — then `rm -rf "$skills_tmp"` below executed while the
|
|
# shell's CWD was still inside the directory being deleted. No
|
|
# production failure (subsequent code uses absolute paths) but
|
|
# structurally incorrect. install.ps1 and install.cmd use
|
|
# Push-Location/pushd for the same logic; a subshell is bash's
|
|
# equivalent — the parent shell's CWD is inherited in, and any
|
|
# cd inside the subshell disappears when the subshell exits.
|
|
#
|
|
# Do NOT rely on `set -e` in here. POSIX says -e is ignored for every
|
|
# command of an AND-OR list except the last, and every shell we tested
|
|
# (bash 3.2.57, which is what `curl | bash` gets on macOS, plus bash 5.3,
|
|
# dash, zsh, and ksh) carries that suppression into the subshell. Writing
|
|
# it as `if ! ( ... ); then` suppresses -e the same way. So the fetch
|
|
# steps below carry an explicit `|| exit 1`: without them a failed clone
|
|
# ran the whole block anyway, the subshell exited 0 on its trailing `if`,
|
|
# and the installer printed "YOU'RE ALL SET!" with no skills installed.
|
|
# Everything after the checkout stays best-effort on purpose, matching
|
|
# install.cmd, which only checks git clone and lets every xcopy run
|
|
# unchecked. A local cp/mkdir/rm hiccup must not be reported through the
|
|
# "network or git error" message below.
|
|
checkout_failed=0
|
|
(
|
|
# --skip-skills / PLANNOTATOR_SKIP_SKILLS_INSTALL / skipInstall.skills.
|
|
# Exit 0 BEFORE the clone so no network call is made and checkout_failed
|
|
# stays 0 — an opt-out is not a fetch failure and must not trip the guard
|
|
# below. The report was already printed above the git check.
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
exit 0
|
|
fi
|
|
|
|
cd "$skills_tmp" || exit 1
|
|
# Capture git's stderr instead of discarding it (#1238): on failure the
|
|
# real error is surfaced below so incompatibilities self-diagnose instead
|
|
# of hiding behind the generic "network or git error" message.
|
|
git_err="$skills_tmp/git-stderr"
|
|
surface_git_error() {
|
|
echo "git reported:" >&2
|
|
tail -n 5 "$git_err" >&2
|
|
}
|
|
sparse_clone=1
|
|
# LC_ALL=C pins git's error strings to English: the capability probe below
|
|
# matches the literal "unknown option ... sparse" text, and a localized
|
|
# git (standard Linux NLS builds) would otherwise emit a translated
|
|
# message the match misses, sending old-git non-English users to a hard
|
|
# failure instead of the fallback.
|
|
if ! LC_ALL=C LANGUAGE=C git clone --depth 1 --filter=blob:none --sparse \
|
|
"https://github.com/${REPO}.git" --branch "$latest_tag" repo 2>"$git_err"; then
|
|
# Capability probe, not a version parse (same philosophy as the
|
|
# GitButler flag probing in packages/shared/gitbutler-core.ts):
|
|
# `git clone --sparse` needs git >= 2.25, and an older git (macOS
|
|
# with stale Xcode CLT ships 2.23) rejects the flag instantly with
|
|
# "error: unknown option `sparse'" before any network call (#1238).
|
|
# Fall back to a plain shallow clone — it costs download size, not
|
|
# correctness: every path the copy steps below read is present in
|
|
# the full checkout, and `git sparse-checkout set` (equally missing
|
|
# on that git) is skipped because there is nothing to narrow.
|
|
if grep -qi "unknown option" "$git_err" && grep -qi "sparse" "$git_err"; then
|
|
echo "This git does not support 'git clone --sparse' (needs git >= 2.25) — falling back to a plain shallow clone."
|
|
sparse_clone=0
|
|
rm -rf repo
|
|
if ! git clone --depth 1 \
|
|
"https://github.com/${REPO}.git" --branch "$latest_tag" repo 2>"$git_err"; then
|
|
surface_git_error
|
|
exit 1
|
|
fi
|
|
else
|
|
surface_git_error
|
|
exit 1
|
|
fi
|
|
fi
|
|
cd repo || exit 1
|
|
if [ "$sparse_clone" -eq 1 ]; then
|
|
if ! git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>"$git_err"; then
|
|
surface_git_error
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Core skills -> Claude Code (also serve as /plannotator-* slash commands)
|
|
# and the official OpenAI shared-agent path. SOFT guard: a tag pinned
|
|
# via --version may predate the core/extra layout — skip core skills
|
|
# but keep installing the command files below (matches install.ps1 and
|
|
# install.cmd, which guard each block independently).
|
|
# Claude Code and Codex consume different skill bodies. Claude Code reads
|
|
# the apps/skills/claude/* copies, which use dynamic-context injection
|
|
# (`!`plannotator … $ARGUMENTS``) + allowed-tools so /plannotator-* run the
|
|
# binary directly with no permission prompt — matching the old slash
|
|
# commands. Codex (the OpenAI shared-agent path) reads apps/skills/core/*,
|
|
# whose prose bodies the model follows via its own shell; the `!`…``
|
|
# injection is a Claude-Code-only extension, so the two are sourced
|
|
# separately rather than sharing one body.
|
|
if [ -d "apps/skills/claude" ] && [ -n "$(ls -A apps/skills/claude 2>/dev/null)" ]; then
|
|
mkdir -p "$CLAUDE_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/claude/plannotator-review "$CLAUDE_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/claude/plannotator-annotate "$CLAUDE_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/claude/plannotator-last "$CLAUDE_SKILLS_DIR"
|
|
echo "Installed Claude Code skills to ${CLAUDE_SKILLS_DIR}/"
|
|
else
|
|
echo "Tag ${latest_tag} predates the per-agent skill layout — skipping Claude Code skill install"
|
|
fi
|
|
if [ -d "apps/skills/core" ] && [ -n "$(ls -A apps/skills/core 2>/dev/null)" ]; then
|
|
mkdir -p "$AGENTS_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/core/plannotator-review "$AGENTS_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/core/plannotator-annotate "$AGENTS_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/core/plannotator-last "$AGENTS_SKILLS_DIR"
|
|
echo "Installed shared agent skills to ${AGENTS_SKILLS_DIR}/"
|
|
else
|
|
echo "Tag ${latest_tag} predates the core/extra skill layout — skipping shared agent skill install"
|
|
fi
|
|
|
|
# OpenCode slash command stubs (the plugin intercepts execution) —
|
|
# always installed when the checkout provides them. Guard the echo on
|
|
# the same condition as the copy so old pinned tags don't report a
|
|
# success that never happened (ps1/cmd already gate this way).
|
|
if [ "$skip_opencode" -eq 0 ] && [ -d "apps/opencode-plugin/commands" ] && [ -n "$(ls -A apps/opencode-plugin/commands 2>/dev/null)" ]; then
|
|
copy_commands_if_present apps/opencode-plugin/commands "$OPENCODE_COMMANDS_DIR"
|
|
echo "Installed OpenCode commands to ${OPENCODE_COMMANDS_DIR}/"
|
|
fi
|
|
|
|
# Gemini native TOML commands — only when Gemini is present and not
|
|
# opted out (#1178; skip_gemini is inherited by this subshell).
|
|
if [ -d "$HOME/.gemini" ] && [ "$skip_gemini" -eq 0 ] && [ -d "apps/gemini/commands" ] && [ -n "$(ls -A apps/gemini/commands 2>/dev/null)" ]; then
|
|
copy_commands_if_present apps/gemini/commands "$GEMINI_COMMANDS_DIR"
|
|
echo "Installed Gemini commands to ${GEMINI_COMMANDS_DIR}/"
|
|
fi
|
|
|
|
if [ "$kiro_available" -eq 1 ] && [ "$skip_kiro" -eq 0 ] && [ -d "apps/kiro-cli/skills" ] && [ -n "$(ls -A apps/kiro-cli/skills 2>/dev/null)" ]; then
|
|
mkdir -p "$KIRO_SKILLS_DIR"
|
|
# Kiro-specific skills (origin baked in) come from apps/kiro-cli/skills.
|
|
copy_skill_if_present apps/kiro-cli/skills/plannotator-review "$KIRO_SKILLS_DIR"
|
|
copy_skill_if_present apps/kiro-cli/skills/plannotator-annotate "$KIRO_SKILLS_DIR"
|
|
# Extras come from apps/skills/extra (not duplicated into apps/kiro-cli/skills).
|
|
copy_skill_if_present apps/skills/extra/plannotator-setup-goal "$KIRO_SKILLS_DIR"
|
|
copy_skill_if_present apps/skills/extra/plannotator-visual-explainer "$KIRO_SKILLS_DIR"
|
|
# Plannotator custom agent — don't clobber a user's existing one.
|
|
if [ ! -f "$HOME/.kiro/agents/plannotator.json" ] && [ -f "apps/kiro-cli/agents/plannotator.json" ]; then
|
|
mkdir -p "$HOME/.kiro/agents"
|
|
cp apps/kiro-cli/agents/plannotator.json "$HOME/.kiro/agents/plannotator.json"
|
|
fi
|
|
echo "Installed Kiro skills to ${KIRO_SKILLS_DIR}/ and agent to ~/.kiro/agents/plannotator.json"
|
|
fi
|
|
) || checkout_failed=1
|
|
|
|
rm -rf "$skills_tmp"
|
|
|
|
if [ "${checkout_failed:-0}" -eq 1 ]; then
|
|
echo "Error: unable to fetch ${REPO} at ${latest_tag} (network or git error)." >&2
|
|
echo "Something went wrong — run the installer again." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Claude Code commands are deprecated in favor of skills. Remove a legacy
|
|
# command file only once its replacement skill is actually on disk — running
|
|
# AFTER the install above guarantees a failed or skipped skill install never
|
|
# leaves users with neither the command nor the skill.
|
|
CLAUDE_COMMANDS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/commands"
|
|
for cmd in plannotator-review plannotator-annotate plannotator-last; do
|
|
# A skills opt-out installed no replacement this run, so it removes
|
|
# nothing either — skip means do-not-write, never remove.
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
continue
|
|
fi
|
|
if [ -d "$CLAUDE_SKILLS_DIR/$cmd" ] && [ -f "$CLAUDE_COMMANDS_DIR/$cmd.md" ]; then
|
|
rm -f "$CLAUDE_COMMANDS_DIR/$cmd.md"
|
|
echo "Removed legacy Claude command ${CLAUDE_COMMANDS_DIR}/$cmd.md (replaced by the $cmd skill)"
|
|
fi
|
|
done
|
|
|
|
# plannotator-archive no longer ships as a skill. Remove any stale installed
|
|
# copy from every skill scope so upgraders don't keep a dead skill around.
|
|
for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" "$KIRO_SKILLS_DIR"; do
|
|
# A skills opt-out leaves every skill scope untouched, sweep included.
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
continue
|
|
fi
|
|
# A Kiro opt-out leaves ~/.kiro entirely untouched — including this sweep.
|
|
if [ "$scope" = "$KIRO_SKILLS_DIR" ] && [ "$skip_kiro" -eq 1 ]; then
|
|
continue
|
|
fi
|
|
if [ -d "$scope/plannotator-archive" ]; then
|
|
rm -rf "$scope/plannotator-archive"
|
|
echo "Removed stale plannotator-archive skill from ${scope}/plannotator-archive"
|
|
fi
|
|
done
|
|
# The /plannotator-archive OpenCode command was removed too — sweep the stub
|
|
# (only npm-plugin-postinstall users ever had it written here). An OpenCode
|
|
# opt-out suspends the sweep: skip means do-not-write, never remove.
|
|
# A skills opt-out suspends it for the same reason.
|
|
if [ "$skip_opencode" -eq 0 ] && [ "$skip_skills" -eq 0 ] && [ -f "$OPENCODE_COMMANDS_DIR/plannotator-archive.md" ]; then
|
|
rm -f "$OPENCODE_COMMANDS_DIR/plannotator-archive.md"
|
|
echo "Removed stale plannotator-archive command from ${OPENCODE_COMMANDS_DIR}/"
|
|
fi
|
|
|
|
# Codex no longer hosts core skills (they now live in ~/.agents/skills).
|
|
# Core skills are removed only once their replacement exists; the stale
|
|
# shared-agent extras were never Codex's and are removed unconditionally.
|
|
for skill in plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal; do
|
|
# A Codex opt-out leaves $CODEX_DIR entirely untouched — including this
|
|
# stale-skill cleanup. Skip means do-not-write, never remove. A skills
|
|
# opt-out installed no replacement, so it suspends the sweep as well.
|
|
if [ "$skip_codex" -eq 1 ] || [ "$skip_skills" -eq 1 ]; then
|
|
continue
|
|
fi
|
|
if [ -d "$STALE_CODEX_SKILLS_DIR/$skill" ]; then
|
|
case "$skill" in
|
|
plannotator-review|plannotator-annotate|plannotator-last)
|
|
[ -d "$AGENTS_SKILLS_DIR/$skill" ] || continue
|
|
;;
|
|
esac
|
|
rm -rf "$STALE_CODEX_SKILLS_DIR/$skill"
|
|
echo "Removed Plannotator skill from ${STALE_CODEX_SKILLS_DIR}/$skill"
|
|
fi
|
|
done
|
|
|
|
# Apply the saved model-invocation choices. Installed skill copies always
|
|
# arrive locked (disable-model-invocation: true in SKILL.md); for each chosen
|
|
# skill we unlock the INSTALLED copy by removing that line, and flip the Codex
|
|
# sidecar's allow_implicit_invocation to match. Re-applied on every run
|
|
# because installs replace the skill folders wholesale. Source files in the
|
|
# repo never change.
|
|
# A skills opt-out installed no skill copies this run, so there is nothing to
|
|
# unlock — and rewriting a PREVIOUS run's SKILL.md would be a write the opt-out
|
|
# promised not to make.
|
|
if [ "$skip_skills" -eq 0 ] && [ -n "$invocable_choice" ] && [ "$invocable_choice" != "none" ]; then
|
|
for skill in $(echo "$invocable_choice" | tr ',' ' '); do
|
|
for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR"; do
|
|
skill_md="$scope/$skill/SKILL.md"
|
|
if [ -f "$skill_md" ] && grep -q '^disable-model-invocation: true$' "$skill_md"; then
|
|
grep -v '^disable-model-invocation: true$' "$skill_md" > "$skill_md.tmp" && mv "$skill_md.tmp" "$skill_md"
|
|
echo "Enabled model invocation: ${scope}/${skill}"
|
|
fi
|
|
sidecar="$scope/$skill/agents/openai.yaml"
|
|
if [ -f "$sidecar" ] && grep -q 'allow_implicit_invocation: false' "$sidecar"; then
|
|
sed 's/allow_implicit_invocation: false/allow_implicit_invocation: true/' "$sidecar" > "$sidecar.tmp" && mv "$sidecar.tmp" "$sidecar"
|
|
fi
|
|
done
|
|
done
|
|
fi
|
|
|
|
# Update Pi extension if pi is installed. The pi-extension no longer bundles
|
|
# skills; Pi keeps its extension commands and the plannotator_submit_plan tool.
|
|
update_pi_extension_if_present
|
|
|
|
# --- Gemini CLI support (only if Gemini is installed) ---
|
|
if [ -d "$HOME/.gemini" ] && [ "$skip_gemini" -eq 1 ]; then
|
|
# HONEST three-state reporting (#1178): detected-but-skipped is its own
|
|
# state. Nothing under ~/.gemini is created, updated, or removed.
|
|
echo ""
|
|
echo "Gemini: detected, skipped (${skip_gemini_source})."
|
|
if [ -f "$HOME/.gemini/settings.json" ] && grep -q '"plannotator"' "$HOME/.gemini/settings.json" 2>/dev/null; then
|
|
echo "An existing Gemini integration at ~/.gemini/settings.json was left untouched."
|
|
fi
|
|
elif [ -d "$HOME/.gemini" ]; then
|
|
# Install policy file
|
|
GEMINI_POLICIES_DIR="$HOME/.gemini/policies"
|
|
mkdir -p "$GEMINI_POLICIES_DIR"
|
|
cat > "$GEMINI_POLICIES_DIR/plannotator.toml" << 'GEMINI_POLICY_EOF'
|
|
# Plannotator policy for Gemini CLI
|
|
# Allows exit_plan_mode without TUI confirmation so the browser UI is the sole gate.
|
|
[[rule]]
|
|
toolName = "exit_plan_mode"
|
|
decision = "allow"
|
|
priority = 100
|
|
GEMINI_POLICY_EOF
|
|
echo "Installed Gemini policy to ${GEMINI_POLICIES_DIR}/plannotator.toml"
|
|
|
|
# Configure hook in settings.json
|
|
GEMINI_SETTINGS="$HOME/.gemini/settings.json"
|
|
PLANNOTATOR_HOOK='{"matcher":"exit_plan_mode","hooks":[{"type":"command","command":"plannotator","timeout":345600}]}'
|
|
|
|
if [ -f "$GEMINI_SETTINGS" ]; then
|
|
if ! grep -q '"plannotator"' "$GEMINI_SETTINGS" 2>/dev/null; then
|
|
# Merge hook into existing settings.json using node (ships with Gemini CLI)
|
|
if command -v node &>/dev/null; then
|
|
node -e "
|
|
const fs = require('fs');
|
|
const settings = JSON.parse(fs.readFileSync('$GEMINI_SETTINGS', 'utf8'));
|
|
if (!settings.hooks) settings.hooks = {};
|
|
if (!settings.hooks.BeforeTool) settings.hooks.BeforeTool = [];
|
|
settings.hooks.BeforeTool.push($PLANNOTATOR_HOOK);
|
|
fs.writeFileSync('$GEMINI_SETTINGS', JSON.stringify(settings, null, 2) + '\n');
|
|
"
|
|
echo "Added plannotator hook to ${GEMINI_SETTINGS}"
|
|
else
|
|
echo ""
|
|
echo "Add the following to your ~/.gemini/settings.json hooks:"
|
|
echo ""
|
|
echo ' "hooks": {'
|
|
echo ' "BeforeTool": [{'
|
|
echo ' "matcher": "exit_plan_mode",'
|
|
echo ' "hooks": [{"type": "command", "command": "plannotator", "timeout": 345600}]'
|
|
echo ' }]'
|
|
echo ' }'
|
|
fi
|
|
fi
|
|
else
|
|
cat > "$GEMINI_SETTINGS" << 'GEMINI_SETTINGS_EOF'
|
|
{
|
|
"hooks": {
|
|
"BeforeTool": [
|
|
{
|
|
"matcher": "exit_plan_mode",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "plannotator",
|
|
"timeout": 345600
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
"experimental": {
|
|
"plan": true
|
|
}
|
|
}
|
|
GEMINI_SETTINGS_EOF
|
|
echo "Created Gemini settings at ${GEMINI_SETTINGS}"
|
|
fi
|
|
|
|
# Gemini slash commands (.toml) are installed from the sparse checkout in
|
|
# the skills/commands install block above (apps/gemini/commands).
|
|
fi
|
|
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " OPENCODE USERS"
|
|
echo "=========================================="
|
|
echo ""
|
|
if [ "$skip_opencode" -eq 1 ]; then
|
|
echo "OpenCode: integration skipped (${skip_opencode_source})."
|
|
echo "No command stubs were written and OpenCode's plugin cache was left alone."
|
|
echo "Re-run without the opt-out to install the command stubs."
|
|
elif [ "$skip_skills" -eq 1 ]; then
|
|
# The stubs ship in the skills checkout, so this run installed none.
|
|
echo "Add the plugin to your opencode.json:"
|
|
echo ""
|
|
echo ' "plugin": ["@plannotator/opencode@latest"]'
|
|
echo ""
|
|
echo "Skills were skipped (${skip_skills_source}), so no /plannotator-* command"
|
|
echo "stubs were installed. Re-run without the opt-out to add them."
|
|
else
|
|
echo "Add the plugin to your opencode.json:"
|
|
echo ""
|
|
echo ' "plugin": ["@plannotator/opencode@latest"]'
|
|
echo ""
|
|
echo "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready!"
|
|
fi
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " PI USERS"
|
|
echo "=========================================="
|
|
echo ""
|
|
echo "Install or update the extension:"
|
|
echo ""
|
|
echo " pi install npm:@plannotator/pi-extension"
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " GEMINI CLI USERS"
|
|
echo "=========================================="
|
|
echo ""
|
|
if [ -d "$HOME/.gemini" ] && [ "$skip_gemini" -eq 1 ]; then
|
|
echo "Gemini was detected, but the integration was skipped (${skip_gemini_source})."
|
|
echo "No files under ~/.gemini were written or removed. Re-run without the"
|
|
echo "opt-out to configure plan mode."
|
|
elif [ -d "$HOME/.gemini" ]; then
|
|
echo "Enable plan mode in Gemini settings, then run:"
|
|
echo ""
|
|
echo " gemini"
|
|
echo " /plan"
|
|
echo ""
|
|
echo "Plans will open in your browser for review."
|
|
echo "If settings.json was not auto-configured, see:"
|
|
echo " ~/.gemini/settings.json (add BeforeTool hook)"
|
|
else
|
|
echo "Gemini was not detected. After installing the Gemini CLI, rerun this"
|
|
echo "installer to configure plan mode."
|
|
fi
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " CODEX USERS"
|
|
echo "=========================================="
|
|
echo ""
|
|
if [ "$codex_available" -eq 1 ] && [ "$skip_codex" -eq 1 ]; then
|
|
echo "Codex was detected, but the integration was skipped (${skip_codex_source})."
|
|
echo "No files under ${CODEX_DIR} were written or removed. The shared agent"
|
|
echo "skills in ~/.agents/skills serve multiple agents and are still installed."
|
|
echo "Re-run without the opt-out to add the Stop hook."
|
|
elif [ "$codex_available" -eq 1 ]; then
|
|
echo "Restart Codex Desktop or CLI after installing."
|
|
echo "Plan review is configured through the Codex Stop hook."
|
|
echo ""
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
echo "Skills were skipped (${skip_skills_source}), so no core skills were"
|
|
echo "installed to ~/.agents/skills/. The Stop hook works without them;"
|
|
echo "re-run without the opt-out to add \$plannotator-review and friends."
|
|
else
|
|
echo "Core skills are installed to ~/.agents/skills/:"
|
|
echo " \$plannotator-review"
|
|
echo " \$plannotator-annotate <file|url|folder>"
|
|
echo " \$plannotator-last"
|
|
fi
|
|
else
|
|
echo "Codex was not detected. After installing Codex, rerun this installer to add"
|
|
echo "the Stop hook."
|
|
fi
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " KIRO CLI USERS"
|
|
echo "=========================================="
|
|
echo ""
|
|
if [ "$kiro_available" -eq 1 ] && [ "$skip_kiro" -eq 1 ]; then
|
|
echo "Kiro was detected, but the integration was skipped (${skip_kiro_source})."
|
|
echo "No files under ~/.kiro were written or removed. Re-run without the"
|
|
echo "opt-out to add Kiro skills."
|
|
elif [ "$kiro_available" -eq 1 ] && [ "$skip_skills" -eq 1 ]; then
|
|
echo "Kiro was detected, but skills were skipped (${skip_skills_source}), so no"
|
|
echo "Kiro skills or agent were installed. Re-run without the opt-out to add them."
|
|
elif [ "$kiro_available" -eq 1 ]; then
|
|
echo "Kiro skills are installed to ~/.kiro/skills/"
|
|
echo "The Plannotator agent is installed to ~/.kiro/agents/plannotator.json"
|
|
echo "Launch it: kiro-cli chat --agent plannotator"
|
|
else
|
|
echo "Kiro was not detected. After installing Kiro, rerun this installer to add Kiro skills."
|
|
fi
|
|
echo ""
|
|
echo "=========================================="
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
# Never claim the /plannotator-* commands are ready when nothing was
|
|
# installed — that false banner is exactly what the skills-checkout guard
|
|
# exists to prevent.
|
|
echo " CLAUDE CODE USERS: BINARY INSTALLED"
|
|
else
|
|
echo " CLAUDE CODE USERS: YOU'RE ALL SET!"
|
|
fi
|
|
echo "=========================================="
|
|
echo ""
|
|
echo "Install the Claude Code plugin:"
|
|
echo " /plugin marketplace add backnotprop/plannotator"
|
|
echo " /plugin install plannotator@plannotator"
|
|
echo ""
|
|
echo "Upgrading from an older version? Also run /plugin marketplace update"
|
|
echo "so the plugin drops its old plannotator:* command entries."
|
|
echo ""
|
|
if [ "$skip_skills" -eq 1 ]; then
|
|
echo "Skills were skipped (${skip_skills_source}), so the /plannotator-review,"
|
|
echo "/plannotator-annotate, and /plannotator-last commands are NOT installed."
|
|
echo "Re-run the installer without the opt-out to add them."
|
|
else
|
|
echo "The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use after you restart Claude Code!"
|
|
fi
|
|
|
|
if [ "$skip_skills" -eq 0 ] && [ "$extras_choice" != "yes" ]; then
|
|
echo ""
|
|
echo "Optional skills (compound planning, setup-goal, visual explainer):"
|
|
echo " npx skills add backnotprop/plannotator/apps/skills/extra --global"
|
|
fi
|
|
|
|
# Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution)
|
|
# Only warn when the plugin is installed — manual-only users won't have overlap
|
|
CLAUDE_SETTINGS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"
|
|
if [ -f "$PLUGIN_HOOKS" ] && [ -f "$CLAUDE_SETTINGS" ] && grep -q '"command".*plannotator' "$CLAUDE_SETTINGS" 2>/dev/null; then
|
|
echo ""
|
|
echo "⚠️ ⚠️ ⚠️ WARNING: DUPLICATE HOOK DETECTED ⚠️ ⚠️ ⚠️"
|
|
echo ""
|
|
echo " plannotator was found in your settings.json hooks:"
|
|
echo " $CLAUDE_SETTINGS"
|
|
echo ""
|
|
echo " This will cause plannotator to run TWICE on each plan review."
|
|
echo " Remove the plannotator hook from settings.json and rely on the"
|
|
echo " plugin instead (installed automatically via marketplace)."
|
|
echo ""
|
|
echo "⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️"
|
|
fi
|