init: document-haness 설계
This commit is contained in:
Executable
+553
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install technical-doc-flow for Claude Code, Codex, and Gemini CLI.
|
||||
set -euo pipefail
|
||||
|
||||
PRODUCT="technical-doc-flow"
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_SOURCE="$REPO_DIR/skills/$PRODUCT"
|
||||
AGENTS_SOURCE="$REPO_DIR/agents"
|
||||
CLAUDE_ROOT="${TECH_DOC_FLOW_CLAUDE_HOME:-${CLAUDE_HOME:-$HOME/.claude}}"
|
||||
CODEX_ROOT="${TECH_DOC_FLOW_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}"
|
||||
PYTHON_BIN="${TECH_DOC_FLOW_PYTHON:-python3}"
|
||||
|
||||
MODE="symlink"
|
||||
CLAUDE_MODE="auto"
|
||||
CODEX_MODE="auto"
|
||||
GEMINI_MODE="auto"
|
||||
ONLY_PLATFORM=""
|
||||
FORCE=0
|
||||
DRY_RUN=0
|
||||
BACKUP_STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./install.sh [options]
|
||||
|
||||
With no platform option, installed CLIs or their configuration directories
|
||||
are detected automatically. Claude and Codex install the exact same source
|
||||
directory: skills/technical-doc-flow.
|
||||
|
||||
Options:
|
||||
--copy Copy files instead of creating symlinks (Claude/Codex).
|
||||
--claude-only Install Claude skill and root agents only.
|
||||
--codex-only Install Codex skill only.
|
||||
--gemini-only Link the Gemini extension only.
|
||||
--all Select all three platforms explicitly.
|
||||
--no-claude Skip Claude.
|
||||
--no-codex Skip Codex.
|
||||
--no-gemini Skip Gemini.
|
||||
--force Back up a conflicting target as .bak.<timestamp> first.
|
||||
--dry-run Print operations without changing files.
|
||||
-h, --help Show this help.
|
||||
|
||||
Optional location overrides:
|
||||
TECH_DOC_FLOW_CLAUDE_HOME, TECH_DOC_FLOW_CODEX_HOME
|
||||
|
||||
Gemini always uses `gemini extensions link`; --copy never changes it.
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "error: $*" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
select_only() {
|
||||
local platform="$1"
|
||||
if [[ -n "$ONLY_PLATFORM" && "$ONLY_PLATFORM" != "$platform" ]]; then
|
||||
die "only one of --claude-only, --codex-only, --gemini-only may be used"
|
||||
fi
|
||||
ONLY_PLATFORM="$platform"
|
||||
CLAUDE_MODE="no"
|
||||
CODEX_MODE="no"
|
||||
GEMINI_MODE="no"
|
||||
case "$platform" in
|
||||
claude) CLAUDE_MODE="yes" ;;
|
||||
codex) CODEX_MODE="yes" ;;
|
||||
gemini) GEMINI_MODE="yes" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--copy) MODE="copy" ;;
|
||||
--claude-only) select_only claude ;;
|
||||
--codex-only) select_only codex ;;
|
||||
--gemini-only) select_only gemini ;;
|
||||
--all)
|
||||
[[ -z "$ONLY_PLATFORM" ]] || die "--all cannot be combined with --*-only"
|
||||
CLAUDE_MODE="yes"
|
||||
CODEX_MODE="yes"
|
||||
GEMINI_MODE="yes"
|
||||
;;
|
||||
--no-claude) CLAUDE_MODE="no" ;;
|
||||
--no-codex) CODEX_MODE="no" ;;
|
||||
--no-gemini) GEMINI_MODE="no" ;;
|
||||
--force) FORCE=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -f "$SKILL_SOURCE/SKILL.md" ]] \
|
||||
|| die "canonical skill not found: $SKILL_SOURCE/SKILL.md"
|
||||
|
||||
shopt -s nullglob
|
||||
AGENT_FILES=("$AGENTS_SOURCE"/*.md)
|
||||
OBSOLETE_CLAUDE_AGENT_NAMES=("doc-visual-planner.md")
|
||||
|
||||
if [[ "$MODE" == "copy" && "$CLAUDE_MODE" == "no" \
|
||||
&& "$CODEX_MODE" == "no" && "$GEMINI_MODE" != "no" ]]; then
|
||||
echo "note: --copy does not apply to Gemini; using extension link"
|
||||
fi
|
||||
|
||||
run() {
|
||||
printf '+'
|
||||
printf ' %q' "$@"
|
||||
printf '\n'
|
||||
if ((DRY_RUN == 0)); then
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
next_backup_path() {
|
||||
local destination="$1"
|
||||
local candidate="$destination.bak.$BACKUP_STAMP"
|
||||
local suffix=1
|
||||
while [[ -e "$candidate" || -L "$candidate" ]]; do
|
||||
candidate="$destination.bak.$BACKUP_STAMP.$suffix"
|
||||
((suffix += 1))
|
||||
done
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
# Rename a staged or existing entry only when the destination name is still
|
||||
# unused. A preflight path check cannot provide that guarantee because another
|
||||
# process may create the destination before the rename.
|
||||
#
|
||||
# Return codes:
|
||||
# 0: renamed
|
||||
# 3: destination is occupied
|
||||
# 4: this platform/filesystem cannot provide atomic no-clobber rename
|
||||
# 5: another rename error occurred
|
||||
atomic_rename_noreplace() {
|
||||
local source="$1" destination="$2" rc=0
|
||||
printf '+ atomic-mv-noreplace %q %q\n' "$source" "$destination"
|
||||
if ((DRY_RUN)); then
|
||||
return 0
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" - "$source" "$destination" <<'PY' || rc=$?
|
||||
import ctypes
|
||||
import errno
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
source = os.fsencode(sys.argv[1])
|
||||
destination = os.fsencode(sys.argv[2])
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
try:
|
||||
renameat2 = libc.renameat2
|
||||
except AttributeError:
|
||||
raise SystemExit(4)
|
||||
renameat2.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
renameat2.restype = ctypes.c_int
|
||||
result = renameat2(-100, source, -100, destination, 1) # RENAME_NOREPLACE
|
||||
elif sys.platform == "darwin":
|
||||
try:
|
||||
renamex_np = libc.renamex_np
|
||||
except AttributeError:
|
||||
raise SystemExit(4)
|
||||
renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
|
||||
renamex_np.restype = ctypes.c_int
|
||||
result = renamex_np(source, destination, 0x00000004) # RENAME_EXCL
|
||||
else:
|
||||
raise SystemExit(4)
|
||||
|
||||
if result == 0:
|
||||
raise SystemExit(0)
|
||||
|
||||
error_number = ctypes.get_errno()
|
||||
if error_number in (errno.EEXIST, errno.ENOTEMPTY):
|
||||
raise SystemExit(3)
|
||||
if error_number in {
|
||||
errno.EINVAL,
|
||||
errno.ENOSYS,
|
||||
getattr(errno, "ENOTSUP", errno.EINVAL),
|
||||
getattr(errno, "EOPNOTSUPP", errno.EINVAL),
|
||||
}:
|
||||
raise SystemExit(4)
|
||||
|
||||
print(
|
||||
f"atomic no-clobber rename failed: {os.strerror(error_number)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(5)
|
||||
PY
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
backup_destination_noreplace() {
|
||||
local destination="$1" backup="$2" rc
|
||||
if atomic_rename_noreplace "$destination" "$backup"; then
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
case "$rc" in
|
||||
3)
|
||||
die "backup target became occupied after preflight: $backup; $destination was not moved"
|
||||
;;
|
||||
4)
|
||||
die "atomic no-clobber backup is unsupported on this platform/filesystem; $destination was not moved"
|
||||
;;
|
||||
*)
|
||||
die "could not back up $destination without replacing $backup"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
publish_copy_noreplace() {
|
||||
local source="$1" destination="$2" parent base stage_root stage rc
|
||||
parent="$(dirname "$destination")"
|
||||
base="$(basename "$destination")"
|
||||
|
||||
if ((DRY_RUN)); then
|
||||
printf '+ cp -RL %q %q\n' "$source" "<staged beside $destination>"
|
||||
printf '+ atomic-mv-noreplace %q %q\n' \
|
||||
"<staged beside $destination>" "$destination"
|
||||
return 0
|
||||
fi
|
||||
|
||||
stage_root="$(mktemp -d "$parent/.${base}.install.XXXXXXXX")" \
|
||||
|| die "could not create a staging directory beside $destination"
|
||||
stage="$stage_root/payload"
|
||||
|
||||
printf '+ cp -RL %q %q\n' "$source" "$stage"
|
||||
if ! cp -RL -- "$source" "$stage"; then
|
||||
rm -rf -- "$stage_root"
|
||||
die "could not stage copy for $destination"
|
||||
fi
|
||||
|
||||
if atomic_rename_noreplace "$stage" "$destination"; then
|
||||
rmdir -- "$stage_root"
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
rm -rf -- "$stage_root"
|
||||
case "$rc" in
|
||||
3)
|
||||
die "target appeared after preflight: $destination; the competing entry was preserved"
|
||||
;;
|
||||
4)
|
||||
die "atomic no-clobber copy publish is unsupported on this platform/filesystem"
|
||||
;;
|
||||
*)
|
||||
die "could not publish staged copy without replacing $destination"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
publish_symlink_noreplace() {
|
||||
local source="$1" destination="$2" parent base stage_root stage rc
|
||||
parent="$(dirname "$destination")"
|
||||
base="$(basename "$destination")"
|
||||
|
||||
if ((DRY_RUN)); then
|
||||
printf '+ ln -s -- %q %q\n' "$source" "<staged beside $destination>"
|
||||
printf '+ atomic-mv-noreplace %q %q\n' \
|
||||
"<staged beside $destination>" "$destination"
|
||||
return 0
|
||||
fi
|
||||
|
||||
stage_root="$(mktemp -d "$parent/.${base}.install.XXXXXXXX")" \
|
||||
|| die "could not create a staging directory beside $destination"
|
||||
stage="$stage_root/payload"
|
||||
|
||||
printf '+ ln -s -- %q %q\n' "$source" "$stage"
|
||||
if ! ln -s -- "$source" "$stage"; then
|
||||
rmdir -- "$stage_root"
|
||||
die "could not stage symlink for $destination"
|
||||
fi
|
||||
|
||||
if atomic_rename_noreplace "$stage" "$destination"; then
|
||||
rmdir -- "$stage_root"
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
rm -- "$stage"
|
||||
rmdir -- "$stage_root"
|
||||
case "$rc" in
|
||||
3)
|
||||
die "target appeared after preflight: $destination; the competing entry was preserved"
|
||||
;;
|
||||
4)
|
||||
die "atomic no-clobber symlink publish is unsupported on this platform/filesystem"
|
||||
;;
|
||||
*)
|
||||
die "could not publish staged symlink without replacing $destination"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Installation is planned in memory first. No mkdir, backup, symlink, or copy
|
||||
# may happen until every selected local target and required command passes this
|
||||
# preflight.
|
||||
PLAN_PLATFORMS=()
|
||||
PLAN_SOURCES=()
|
||||
PLAN_DESTINATIONS=()
|
||||
PLAN_ACTIONS=()
|
||||
PLAN_BACKUPS=()
|
||||
MIGRATION_PLAN_ACTIONS=()
|
||||
MIGRATION_PLAN_DESTINATIONS=()
|
||||
MIGRATION_PLAN_SOURCES=()
|
||||
MIGRATION_PLAN_MESSAGES=()
|
||||
|
||||
preflight_parent() {
|
||||
local destination="$1" cursor
|
||||
cursor="$(dirname "$destination")"
|
||||
while [[ "$cursor" != "/" && ! -e "$cursor" && ! -L "$cursor" ]]; do
|
||||
cursor="$(dirname "$cursor")"
|
||||
done
|
||||
[[ -d "$cursor" ]] \
|
||||
|| die "destination parent is not a directory: $cursor (for $destination)"
|
||||
}
|
||||
|
||||
plan_target() {
|
||||
local platform="$1" source="$2" destination="$3"
|
||||
local action="install" backup="" existing
|
||||
|
||||
[[ -e "$source" || -L "$source" ]] \
|
||||
|| die "installation source not found: $source"
|
||||
preflight_parent "$destination"
|
||||
|
||||
for existing in "${PLAN_DESTINATIONS[@]}"; do
|
||||
[[ "$existing" != "$destination" ]] \
|
||||
|| die "duplicate installation destination: $destination"
|
||||
done
|
||||
|
||||
if [[ -L "$destination" && "$(readlink "$destination")" == "$source" ]]; then
|
||||
if [[ "$MODE" == "symlink" ]]; then
|
||||
action="skip"
|
||||
elif ((FORCE == 0)); then
|
||||
die "$destination is currently symlinked; use --copy --force to convert it"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$action" != "skip" && ( -e "$destination" || -L "$destination" ) ]]; then
|
||||
if ((FORCE == 0)); then
|
||||
die "target already exists: $destination (use --force to back it up)"
|
||||
fi
|
||||
backup="$(next_backup_path "$destination")"
|
||||
fi
|
||||
|
||||
PLAN_PLATFORMS+=("$platform")
|
||||
PLAN_SOURCES+=("$source")
|
||||
PLAN_DESTINATIONS+=("$destination")
|
||||
PLAN_ACTIONS+=("$action")
|
||||
PLAN_BACKUPS+=("$backup")
|
||||
}
|
||||
|
||||
append_migration_plan() {
|
||||
MIGRATION_PLAN_ACTIONS+=("$1")
|
||||
MIGRATION_PLAN_DESTINATIONS+=("$2")
|
||||
MIGRATION_PLAN_SOURCES+=("$3")
|
||||
MIGRATION_PLAN_MESSAGES+=("$4")
|
||||
}
|
||||
|
||||
plan_obsolete_claude_agent() {
|
||||
local name="$1" destination source target
|
||||
destination="$CLAUDE_ROOT/agents/$name"
|
||||
source="$AGENTS_SOURCE/$name"
|
||||
|
||||
# If a future release restores this source name, it is no longer obsolete
|
||||
# and the normal installation plan owns it.
|
||||
if [[ -e "$source" || -L "$source" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -L "$destination" ]]; then
|
||||
if ! target="$(readlink "$destination")"; then
|
||||
die "could not inspect obsolete Claude agent candidate: $destination"
|
||||
fi
|
||||
if [[ "$target" == "$source" ]]; then
|
||||
append_migration_plan remove "$destination" "$source" ""
|
||||
else
|
||||
append_migration_plan preserve "$destination" "" \
|
||||
"preserve (obsolete agent path has a different symlink target): $destination -> $target"
|
||||
fi
|
||||
elif [[ -e "$destination" ]]; then
|
||||
append_migration_plan preserve "$destination" "" \
|
||||
"preserve (obsolete agent copy or user-managed entry): $destination"
|
||||
fi
|
||||
}
|
||||
|
||||
revalidate_migration_removal() {
|
||||
local destination="$1" source="$2" target
|
||||
if [[ ! -L "$destination" ]]; then
|
||||
die "obsolete managed agent changed type or disappeared: $destination; nothing at this path was removed"
|
||||
fi
|
||||
if ! target="$(readlink "$destination")"; then
|
||||
die "could not re-read obsolete managed agent symlink: $destination; nothing at this path was removed"
|
||||
fi
|
||||
if [[ "$target" != "$source" ]]; then
|
||||
die "obsolete managed agent changed target: $destination -> $target; expected $source"
|
||||
fi
|
||||
}
|
||||
|
||||
revalidate_migration_plan() {
|
||||
local index
|
||||
for index in "${!MIGRATION_PLAN_DESTINATIONS[@]}"; do
|
||||
[[ "${MIGRATION_PLAN_ACTIONS[$index]}" == "remove" ]] || continue
|
||||
revalidate_migration_removal \
|
||||
"${MIGRATION_PLAN_DESTINATIONS[$index]}" \
|
||||
"${MIGRATION_PLAN_SOURCES[$index]}"
|
||||
done
|
||||
}
|
||||
|
||||
execute_migration_plan() {
|
||||
local index destination source
|
||||
for index in "${!MIGRATION_PLAN_DESTINATIONS[@]}"; do
|
||||
if [[ "${MIGRATION_PLAN_ACTIONS[$index]}" == "preserve" ]]; then
|
||||
echo "${MIGRATION_PLAN_MESSAGES[$index]}"
|
||||
continue
|
||||
fi
|
||||
|
||||
destination="${MIGRATION_PLAN_DESTINATIONS[$index]}"
|
||||
source="${MIGRATION_PLAN_SOURCES[$index]}"
|
||||
printf '+ rm -- %q\n' "$destination"
|
||||
if ((DRY_RUN == 0)); then
|
||||
revalidate_migration_removal "$destination" "$source"
|
||||
rm -- "$destination"
|
||||
echo "removed obsolete managed agent: $destination"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
install_planned_platform() {
|
||||
local platform="$1" index source destination action backup
|
||||
for index in "${!PLAN_DESTINATIONS[@]}"; do
|
||||
[[ "${PLAN_PLATFORMS[$index]}" == "$platform" ]] || continue
|
||||
source="${PLAN_SOURCES[$index]}"
|
||||
destination="${PLAN_DESTINATIONS[$index]}"
|
||||
action="${PLAN_ACTIONS[$index]}"
|
||||
backup="${PLAN_BACKUPS[$index]}"
|
||||
|
||||
if [[ "$action" == "skip" ]]; then
|
||||
echo "ok (already linked): $destination"
|
||||
continue
|
||||
fi
|
||||
|
||||
run mkdir -p -- "$(dirname "$destination")"
|
||||
if [[ -n "$backup" ]]; then
|
||||
backup_destination_noreplace "$destination" "$backup"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "symlink" ]]; then
|
||||
publish_symlink_noreplace "$source" "$destination"
|
||||
else
|
||||
publish_copy_noreplace "$source" "$destination"
|
||||
fi
|
||||
echo "installed: $destination"
|
||||
done
|
||||
}
|
||||
|
||||
is_selected() {
|
||||
local mode="$1" command_name="$2" config_root="$3"
|
||||
case "$mode" in
|
||||
yes) return 0 ;;
|
||||
no) return 1 ;;
|
||||
auto) command -v "$command_name" >/dev/null 2>&1 || [[ -d "$config_root" ]] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
CLAUDE_SELECTED=0
|
||||
CODEX_SELECTED=0
|
||||
GEMINI_SELECTED=0
|
||||
is_selected "$CLAUDE_MODE" claude "$CLAUDE_ROOT" && CLAUDE_SELECTED=1
|
||||
is_selected "$CODEX_MODE" codex "$CODEX_ROOT" && CODEX_SELECTED=1
|
||||
is_selected "$GEMINI_MODE" gemini "${TECH_DOC_FLOW_GEMINI_HOME:-$HOME/.gemini}" \
|
||||
&& GEMINI_SELECTED=1
|
||||
|
||||
if ((CLAUDE_SELECTED)); then
|
||||
((${#AGENT_FILES[@]} > 0)) \
|
||||
|| die "Claude agent definitions not found: $AGENTS_SOURCE/*.md"
|
||||
plan_target claude "$SKILL_SOURCE" "$CLAUDE_ROOT/skills/$PRODUCT"
|
||||
for agent in "${AGENT_FILES[@]}"; do
|
||||
plan_target claude "$agent" "$CLAUDE_ROOT/agents/$(basename "$agent")"
|
||||
done
|
||||
for obsolete_name in "${OBSOLETE_CLAUDE_AGENT_NAMES[@]}"; do
|
||||
plan_obsolete_claude_agent "$obsolete_name"
|
||||
done
|
||||
fi
|
||||
|
||||
if ((CODEX_SELECTED)); then
|
||||
plan_target codex "$SKILL_SOURCE" "$CODEX_ROOT/skills/$PRODUCT"
|
||||
fi
|
||||
|
||||
if ((GEMINI_SELECTED && DRY_RUN == 0)) && ! command -v gemini >/dev/null 2>&1; then
|
||||
die "gemini command not found; install Gemini CLI or skip with --no-gemini"
|
||||
fi
|
||||
|
||||
ATOMIC_RENAME_REQUIRED=0
|
||||
for index in "${!PLAN_DESTINATIONS[@]}"; do
|
||||
[[ "${PLAN_ACTIONS[$index]}" == "skip" ]] && continue
|
||||
ATOMIC_RENAME_REQUIRED=1
|
||||
break
|
||||
done
|
||||
if ((ATOMIC_RENAME_REQUIRED && DRY_RUN == 0)) \
|
||||
&& ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
|
||||
die "$PYTHON_BIN is required for atomic no-clobber install operations"
|
||||
fi
|
||||
|
||||
# Revalidate every proven obsolete symlink after all selected targets and
|
||||
# command prerequisites pass, but before the first installation mutation.
|
||||
revalidate_migration_plan
|
||||
|
||||
# All predictable collisions and command prerequisites have passed. Only now
|
||||
# may the installer mutate any destination.
|
||||
if ((CLAUDE_SELECTED)); then
|
||||
echo "== Claude Code =="
|
||||
execute_migration_plan
|
||||
install_planned_platform claude
|
||||
else
|
||||
echo "== Claude Code: skipped =="
|
||||
fi
|
||||
|
||||
if ((CODEX_SELECTED)); then
|
||||
echo "== Codex =="
|
||||
install_planned_platform codex
|
||||
else
|
||||
echo "== Codex: skipped =="
|
||||
fi
|
||||
|
||||
if ((GEMINI_SELECTED)); then
|
||||
echo "== Gemini CLI =="
|
||||
if ((DRY_RUN)); then
|
||||
printf '+ gemini extensions link %q\n' "$REPO_DIR"
|
||||
elif ! printf 'Y\n' | gemini extensions link "$REPO_DIR"; then
|
||||
die "Gemini extension link failed"
|
||||
else
|
||||
echo "installed: Gemini extension ($PRODUCT)"
|
||||
fi
|
||||
else
|
||||
echo "== Gemini CLI: skipped =="
|
||||
fi
|
||||
|
||||
echo "installation complete (claude_codex_mode=$MODE, gemini_mode=extension-link, dry_run=$DRY_RUN)"
|
||||
Reference in New Issue
Block a user