diff --git a/bin/disinto b/bin/disinto new file mode 100755 index 0000000..6e7005a --- /dev/null +++ b/bin/disinto @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +# ============================================================================= +# disinto — CLI entry point for the disinto code factory +# +# Commands: +# disinto init [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 < [options] Bootstrap a new project + disinto status Show factory status + +Init options: + --branch Primary branch (default: auto-detect) + --repo-root Local clone path (default: ~/name) + --ci-id Woodpecker CI repo ID (default: 0 = no CI) + --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" +} + +# Validate that required tokens and tools are available. +validate_env() { + local errors=0 + if [ -z "${CODEBERG_TOKEN:-}" ]; then + echo "Error: CODEBERG_TOKEN is not set" >&2 + echo " Set it in ${FACTORY_ROOT}/.env or export it" >&2 + errors=1 + fi + if ! command -v claude &>/dev/null; then + echo "Warning: claude CLI not found in PATH" >&2 + fi + if [ "$errors" -gt 0 ]; then + 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/.toml config file. +generate_toml() { + local path="$1" name="$2" repo="$3" root="$4" branch="$5" ci_id="$6" + cat > "$path" </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" < + +## Who it's for + + + +## Design principles + +- +- +- + +## Milestones + +### Current +- + +### Next +- +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 " >&2 + exit 1 + fi + shift + + # Parse flags + local branch="" repo_root="" ci_id="0" auto_yes=false + 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 ;; + --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 + if [ -f "$toml_path" ]; then + echo "Error: ${toml_path} already exists" >&2 + exit 1 + fi + + # Validate tokens + validate_env + + # Determine repo root + 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}" + + # 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 project TOML + generate_toml "$toml_path" "$project_name" "$codeberg_repo" "$repo_root" "$branch" "$ci_id" + echo "Created: ${toml_path}" + + # 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 ' to get started." + fi +} + +# ── Main dispatch ──────────────────────────────────────────────────────────── + +case "${1:-}" in + init) shift; disinto_init "$@" ;; + status) shift; disinto_status "$@" ;; + -h|--help) usage ;; + *) usage ;; +esac