disinto/bin/disinto
openhands 1265fa2d3b fix: preflight API check uses /user endpoint which requires read:user scope (#569)
Replace /api/v1/user with /api/v1/repos/{owner}/{repo} in three places:
- preflight_check() auth verification
- setup_codeberg_auth() --token flag verification
- setup_codeberg_auth() interactive flow verification

The repo endpoint only requires repo-level access, which matches the
scopes disinto actually needs (write:issue, write:repository). Tokens
without read:user scope now pass verification correctly.

Also use generic "token" as netrc login since the username is no longer
retrieved from the API (git operations authenticate via the token, not
the login field).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:07:20 +00:00

617 lines
19 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# disinto — CLI entry point for the disinto code factory
#
# Commands:
# disinto init <repo-url> [options] Bootstrap a new project
# disinto status Show factory status
#
# Usage:
# disinto init https://codeberg.org/user/repo
# disinto init https://codeberg.org/user/repo --branch main --ci-id 3
# disinto status
# =============================================================================
set -euo pipefail
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
source "${FACTORY_ROOT}/lib/env.sh"
# ── Helpers ──────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
disinto — autonomous code factory CLI
Usage:
disinto init <repo-url> [options] Bootstrap a new project
disinto status Show factory status
Init options:
--branch <name> Primary branch (default: auto-detect)
--repo-root <path> Local clone path (default: ~/name)
--ci-id <n> Woodpecker CI repo ID (default: 0 = no CI)
--token <token> Codeberg API token (saved to ~/.netrc)
--yes Skip confirmation prompts
EOF
exit 1
}
# Extract org/repo slug from various URL formats.
# Accepts: https://codeberg.org/user/repo, codeberg.org/user/repo,
# user/repo, https://codeberg.org/user/repo.git
parse_repo_slug() {
local url="$1"
url="${url#https://}"
url="${url#http://}"
url="${url#codeberg.org/}"
url="${url%.git}"
url="${url%/}"
if [[ ! "$url" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then
echo "Error: invalid repo URL — expected https://codeberg.org/org/repo or org/repo" >&2
exit 1
fi
printf '%s' "$url"
}
# Build a clone-able URL from a slug.
clone_url_from_slug() {
printf 'https://codeberg.org/%s.git' "$1"
}
# Write (or update) Codeberg credentials in ~/.netrc.
write_netrc() {
local login="$1" token="$2"
local netrc="${HOME}/.netrc"
# Remove existing codeberg.org entry if present
if [ -f "$netrc" ]; then
local tmp
tmp=$(mktemp)
awk '
/^machine codeberg\.org/ { skip=1; next }
/^machine / { skip=0 }
!skip
' "$netrc" > "$tmp"
mv "$tmp" "$netrc"
fi
# Append new entry
printf 'machine codeberg.org\nlogin %s\npassword %s\n' "$login" "$token" >> "$netrc"
chmod 600 "$netrc"
}
# Interactively set up Codeberg auth if missing.
# Args: [token_from_flag]
setup_codeberg_auth() {
local token_flag="${1:-}"
local repo_slug="${2:-}"
# --token flag takes priority: verify and save
if [ -n "$token_flag" ]; then
local verify_url="https://codeberg.org/api/v1/repos/${repo_slug}"
if ! curl -sf --max-time 10 \
-H "Authorization: token ${token_flag}" \
"$verify_url" >/dev/null 2>&1; then
echo "Error: provided token failed verification" >&2
exit 1
fi
write_netrc "token" "$token_flag"
echo "Saving to ~/.netrc... done."
echo "Verified: token accepted ✓"
export CODEBERG_TOKEN="$token_flag"
return
fi
# Existing auth — skip
if [ -n "${CODEBERG_TOKEN:-}" ]; then
return
fi
if grep -q 'codeberg\.org' ~/.netrc 2>/dev/null; then
CODEBERG_TOKEN="$(awk '/codeberg.org/{getline;getline;print $2}' ~/.netrc 2>/dev/null || true)"
export CODEBERG_TOKEN
return
fi
# Non-interactive — fail with guidance
if [ ! -t 0 ]; then
echo "Error: no Codeberg auth found" >&2
echo " Set CODEBERG_TOKEN, configure ~/.netrc, or use --token <token>" >&2
exit 1
fi
# Interactive guided flow
echo ""
echo "No Codeberg authentication found."
echo ""
echo "1. Open https://codeberg.org/user/settings/applications"
echo "2. Create a token with these scopes:"
echo " - write:issue (create issues, add labels, post comments, close issues)"
echo " - write:repository (push branches, create PRs, merge PRs)"
echo "3. Paste the token below."
echo ""
while true; do
read -rsp "Codeberg token: " token_input
echo ""
if [ -z "$token_input" ]; then
echo "Token cannot be empty. Try again." >&2
continue
fi
local verify_url="https://codeberg.org/api/v1/repos/${repo_slug}"
if ! curl -sf --max-time 10 \
-H "Authorization: token ${token_input}" \
"$verify_url" >/dev/null 2>&1; then
echo "Token verification failed. Check your token and try again." >&2
read -rp "Retry? [Y/n] " retry
if [[ "$retry" =~ ^[Nn] ]]; then
echo "Aborted." >&2
exit 1
fi
continue
fi
write_netrc "token" "$token_input"
echo "Saving to ~/.netrc... done."
echo "Verified: token accepted ✓"
export CODEBERG_TOKEN="$token_input"
return
done
}
# Preflight check — verify all factory requirements before proceeding.
preflight_check() {
local repo_slug="${1:-}"
local errors=0
# ── Required commands ──
local -A hints=(
[claude]="Install: https://docs.anthropic.com/en/docs/claude-code/overview"
[tmux]="Install: apt install tmux / brew install tmux"
[git]="Install: apt install git / brew install git"
[jq]="Install: apt install jq / brew install jq"
[python3]="Install: apt install python3 / brew install python3"
[curl]="Install: apt install curl / brew install curl"
)
local cmd
for cmd in claude tmux git jq python3 curl; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: ${cmd} not found" >&2
echo " ${hints[$cmd]}" >&2
errors=$((errors + 1))
fi
done
# ── Claude Code authentication ──
if command -v claude &>/dev/null && command -v jq &>/dev/null; then
local auth_json auth_stderr auth_rc=0
auth_stderr=$(claude auth status 2>&1 >/dev/null) || auth_rc=$?
auth_json=$(claude auth status 2>/dev/null) || auth_json=""
# Only skip check if subcommand is unrecognized (old claude version)
if printf '%s' "$auth_stderr" | grep -qi "unknown command"; then
: # claude version doesn't support auth status — skip
elif [ -z "$auth_json" ] || [ "$auth_rc" -ne 0 ]; then
echo "Error: Claude Code is not authenticated (auth check failed)" >&2
echo " Run: claude auth login" >&2
errors=$((errors + 1))
else
local logged_in
logged_in=$(printf '%s' "$auth_json" | jq -r '.loggedIn // false' 2>/dev/null) || logged_in="false"
if [ "$logged_in" != "true" ]; then
echo "Error: Claude Code is not authenticated" >&2
echo " Run: claude auth login" >&2
errors=$((errors + 1))
fi
fi
fi
# ── Codeberg auth (setup_codeberg_auth handles interactive setup;
# this verifies the API actually works) ──
if [ -n "${CODEBERG_TOKEN:-}" ] && command -v curl &>/dev/null; then
local curl_args=(-sf --max-time 10)
if [ -n "${CODEBERG_TOKEN:-}" ]; then
curl_args+=(-H "Authorization: token ${CODEBERG_TOKEN}")
else
curl_args+=(--netrc)
fi
if ! curl "${curl_args[@]}" "https://codeberg.org/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then
echo "Error: Codeberg API auth failed" >&2
echo " Verify your CODEBERG_TOKEN or ~/.netrc credentials" >&2
errors=$((errors + 1))
fi
fi
# ── Codeberg SSH access ──
if command -v ssh &>/dev/null; then
local ssh_output
ssh_output=$(ssh -T -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
git@codeberg.org 2>&1) || true
if ! printf '%s' "$ssh_output" | grep -qi "successfully authenticated"; then
echo "Error: Codeberg SSH access failed (agents push via SSH)" >&2
echo " Add your SSH key: https://codeberg.org/user/settings/keys" >&2
errors=$((errors + 1))
fi
fi
# ── Optional tools (warn only) ──
if ! command -v docker &>/dev/null; then
echo "Warning: docker not found (some projects may need it)" >&2
fi
if [ "$errors" -gt 0 ]; then
echo "" >&2
echo "${errors} preflight error(s) — fix the above before running disinto init" >&2
exit 1
fi
}
# Clone the repo if the target directory doesn't exist; validate if it does.
clone_or_validate() {
local slug="$1" target="$2"
if [ -d "${target}/.git" ]; then
echo "Repo: ${target} (existing clone)"
return
fi
local url
url=$(clone_url_from_slug "$slug")
echo "Cloning: ${url} -> ${target}"
git clone "$url" "$target"
}
# Detect the primary branch from the remote HEAD or fallback to main/master.
detect_branch() {
local repo_root="$1"
local branch
branch=$(git -C "$repo_root" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \
| sed 's|refs/remotes/origin/||') || true
if [ -z "$branch" ]; then
if git -C "$repo_root" show-ref --verify --quiet refs/remotes/origin/main 2>/dev/null; then
branch="main"
else
branch="master"
fi
fi
printf '%s' "$branch"
}
# Generate projects/<name>.toml config file.
generate_toml() {
local path="$1" name="$2" repo="$3" root="$4" branch="$5" ci_id="$6"
cat > "$path" <<EOF
# projects/${name}.toml — Project config for ${repo}
#
# Generated by disinto init
name = "${name}"
repo = "${repo}"
repo_root = "${root}"
primary_branch = "${branch}"
[ci]
woodpecker_repo_id = ${ci_id}
stale_minutes = 60
[services]
containers = []
[monitoring]
check_prs = true
check_dev_agent = true
check_pipeline_stall = false
EOF
}
# Create standard labels on the Codeberg repo.
create_labels() {
local repo="$1"
local api="https://codeberg.org/api/v1/repos/${repo}"
local -A labels=(
["backlog"]="#0075ca"
["in-progress"]="#e4e669"
["blocked"]="#d73a4a"
["tech-debt"]="#cfd3d7"
["underspecified"]="#fbca04"
["vision"]="#0e8a16"
["action"]="#1d76db"
)
echo "Creating labels on ${repo}..."
local name color
for name in backlog in-progress blocked tech-debt underspecified vision action; do
color="${labels[$name]}"
if curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \
-H "Content-Type: application/json" \
"${api}/labels" \
-d "{\"name\":\"${name}\",\"color\":\"${color}\"}" >/dev/null 2>&1; then
echo " + ${name}"
else
echo " . ${name} (already exists)"
fi
done
}
# Generate a minimal VISION.md template in the target project.
generate_vision() {
local repo_root="$1" name="$2"
local vision_path="${repo_root}/VISION.md"
if [ -f "$vision_path" ]; then
echo "VISION: ${vision_path} (already exists, skipping)"
return
fi
cat > "$vision_path" <<EOF
# Vision
## What ${name} does
<!-- Describe the purpose of this project in one paragraph -->
## Who it's for
<!-- Describe the target audience -->
## Design principles
- <!-- Principle 1 -->
- <!-- Principle 2 -->
- <!-- Principle 3 -->
## Milestones
### Current
- <!-- What you're working on now -->
### Next
- <!-- What comes after -->
EOF
echo "Created: ${vision_path}"
echo " Commit this to your repo when ready"
}
# Generate and optionally install cron entries for the project agents.
install_cron() {
local name="$1" toml="$2" auto_yes="$3"
# Use absolute path for the TOML in cron entries
local abs_toml
abs_toml="$(cd "$(dirname "$toml")" && pwd)/$(basename "$toml")"
local cron_block
cron_block="# disinto: ${name}
2,7,12,17,22,27,32,37,42,47,52,57 * * * * ${FACTORY_ROOT}/review/review-poll.sh ${abs_toml} >/dev/null 2>&1
4,9,14,19,24,29,34,39,44,49,54,59 * * * * ${FACTORY_ROOT}/dev/dev-poll.sh ${abs_toml} >/dev/null 2>&1
0 0,6,12,18 * * * cd ${FACTORY_ROOT} && bash gardener/gardener-run.sh ${abs_toml} >/dev/null 2>&1"
echo ""
echo "Cron entries to install:"
echo "$cron_block"
echo ""
if [ "$auto_yes" = false ] && [ -t 0 ]; then
read -rp "Install these cron entries? [y/N] " confirm
if [[ ! "$confirm" =~ ^[Yy] ]]; then
echo "Skipped cron install. Add manually with: crontab -e"
return
fi
fi
# Append to existing crontab
{ crontab -l 2>/dev/null || true; printf '%s\n' "$cron_block"; } | crontab -
echo "Cron entries installed"
}
# ── init command ─────────────────────────────────────────────────────────────
disinto_init() {
local repo_url="${1:-}"
if [ -z "$repo_url" ]; then
echo "Error: repo URL required" >&2
echo "Usage: disinto init <repo-url>" >&2
exit 1
fi
shift
# Parse flags
local branch="" repo_root="" ci_id="0" auto_yes=false token_flag=""
while [ $# -gt 0 ]; do
case "$1" in
--branch) branch="$2"; shift 2 ;;
--repo-root) repo_root="$2"; shift 2 ;;
--ci-id) ci_id="$2"; shift 2 ;;
--token) token_flag="$2"; shift 2 ;;
--yes) auto_yes=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Extract org/repo slug
local codeberg_repo
codeberg_repo=$(parse_repo_slug "$repo_url")
local project_name="${codeberg_repo##*/}"
local toml_path="${FACTORY_ROOT}/projects/${project_name}.toml"
echo "=== disinto init ==="
echo "Project: ${codeberg_repo}"
echo "Name: ${project_name}"
# Check for existing config
local toml_exists=false
if [ -f "$toml_path" ]; then
toml_exists=true
echo "Config: ${toml_path} (already exists, reusing)"
# Read repo_root and branch from existing TOML
local existing_root existing_branch
existing_root=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
cfg = tomllib.load(f)
print(cfg.get('repo_root', ''))
" "$toml_path" 2>/dev/null) || existing_root=""
existing_branch=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
cfg = tomllib.load(f)
print(cfg.get('primary_branch', ''))
" "$toml_path" 2>/dev/null) || existing_branch=""
# Use existing values as defaults
if [ -n "$existing_branch" ] && [ -z "$branch" ]; then
branch="$existing_branch"
fi
# Handle repo_root: flag overrides TOML, prompt if they differ
if [ -z "$repo_root" ]; then
repo_root="${existing_root:-/home/${USER}/${project_name}}"
elif [ -n "$existing_root" ] && [ "$repo_root" != "$existing_root" ]; then
echo "Note: --repo-root (${repo_root}) differs from TOML (${existing_root})"
local update_toml=false
if [ "$auto_yes" = true ]; then
update_toml=true
elif [ -t 0 ]; then
read -rp "Update repo_root in TOML to ${repo_root}? [y/N] " confirm
if [[ "$confirm" =~ ^[Yy] ]]; then
update_toml=true
else
repo_root="$existing_root"
fi
fi
if [ "$update_toml" = true ]; then
python3 -c "
import sys, re, pathlib
p = pathlib.Path(sys.argv[1])
text = p.read_text()
text = re.sub(r'^repo_root\s*=\s*.*$', 'repo_root = \"' + sys.argv[2] + '\"', text, flags=re.MULTILINE)
p.write_text(text)
" "$toml_path" "$repo_root"
echo "Updated: repo_root in ${toml_path}"
fi
fi
fi
# Set up Codeberg auth (interactive if needed, before preflight)
setup_codeberg_auth "$token_flag" "$codeberg_repo"
# Preflight: verify factory requirements
preflight_check "$codeberg_repo"
# Determine repo root (for new projects)
repo_root="${repo_root:-/home/${USER}/${project_name}}"
# Clone or validate
clone_or_validate "$codeberg_repo" "$repo_root"
# Detect primary branch
if [ -z "$branch" ]; then
branch=$(detect_branch "$repo_root")
fi
echo "Branch: ${branch}"
# Generate project TOML (skip if already exists)
if [ "$toml_exists" = false ]; then
# Prompt for CI ID if interactive and not already set via flag
if [ "$ci_id" = "0" ] && [ "$auto_yes" = false ] && [ -t 0 ]; then
read -rp "Woodpecker CI repo ID (0 to skip CI): " user_ci_id
ci_id="${user_ci_id:-0}"
fi
generate_toml "$toml_path" "$project_name" "$codeberg_repo" "$repo_root" "$branch" "$ci_id"
echo "Created: ${toml_path}"
fi
# Create labels on remote
create_labels "$codeberg_repo"
# Generate VISION.md template
generate_vision "$repo_root" "$project_name"
# Install cron jobs
install_cron "$project_name" "$toml_path" "$auto_yes"
echo ""
echo "Done. Project ${project_name} is ready."
echo " Config: ${toml_path}"
echo " Clone: ${repo_root}"
echo " Run 'disinto status' to verify."
}
# ── status command ───────────────────────────────────────────────────────────
disinto_status() {
local toml_dir="${FACTORY_ROOT}/projects"
local found=false
for toml in "${toml_dir}"/*.toml; do
[ -f "$toml" ] || continue
found=true
# Parse name and repo from TOML
local pname prepo
pname=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
print(tomllib.load(f)['name'])
" "$toml" 2>/dev/null) || continue
prepo=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
print(tomllib.load(f)['repo'])
" "$toml" 2>/dev/null) || continue
echo "== ${pname} (${prepo}) =="
# Active dev sessions
local has_sessions=false
for pf in /tmp/dev-session-"${pname}"-*.phase; do
[ -f "$pf" ] || continue
has_sessions=true
local issue phase_line
issue=$(basename "$pf" | sed "s/dev-session-${pname}-//;s/\.phase//")
phase_line=$(head -1 "$pf" 2>/dev/null || echo "unknown")
echo " Session #${issue}: ${phase_line}"
done
if [ "$has_sessions" = false ]; then
echo " Sessions: none"
fi
# Backlog depth via API
if [ -n "${CODEBERG_TOKEN:-}" ]; then
local api="https://codeberg.org/api/v1/repos/${prepo}"
local backlog_count pr_count
backlog_count=$(curl -sf -I \
-H "Authorization: token ${CODEBERG_TOKEN}" \
"${api}/issues?state=open&labels=backlog&limit=1" 2>/dev/null \
| grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}') || backlog_count="?"
echo " Backlog: ${backlog_count:-0} issues"
pr_count=$(curl -sf -I \
-H "Authorization: token ${CODEBERG_TOKEN}" \
"${api}/pulls?state=open&limit=1" 2>/dev/null \
| grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}') || pr_count="?"
echo " Open PRs: ${pr_count:-0}"
else
echo " Backlog: (no CODEBERG_TOKEN)"
echo " Open PRs: (no CODEBERG_TOKEN)"
fi
echo ""
done
if [ "$found" = false ]; then
echo "No projects configured."
echo "Run 'disinto init <repo-url>' to get started."
fi
}
# ── Main dispatch ────────────────────────────────────────────────────────────
case "${1:-}" in
init) shift; disinto_init "$@" ;;
status) shift; disinto_status "$@" ;;
-h|--help) usage ;;
*) usage ;;
esac