diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 19:49:08 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 19:49:08 +0200 |
| commit | b6965ac87bfffb610bf099812562951cc154738e (patch) | |
| tree | 0431d2466731a9bc4084843298afb5268ca38985 | |
| parent | 6cb503a50fc49ec26af0f0624141a0c97e75cca4 (diff) | |
| download | desktop-assistant-b6965ac87bfffb610bf099812562951cc154738e.tar.gz desktop-assistant-b6965ac87bfffb610bf099812562951cc154738e.zip | |
clone tool: clone a public repo into the host's folder
A public URL is cloned into ~/Programming/GIT/<host-folder>/<repo>, the folder chosen by host from ~/.config/desktop-assistant/clone-hosts (github.com, gitlab.com, forge.slackware.nl seeded). An unknown host or an already-cloned repo stops and asks with a concrete action; approving a new host folder appends it to the map, so later clones of that host ask nothing. Update pulls with --ff-only, never clones over the folder.
test-clone.sh stubs git and uses a scratch tree and map.
| -rw-r--r-- | AGENTS.md | 24 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-19-clone-tool-design.md | 78 | ||||
| -rwxr-xr-x | examples/tools/clone.sh | 113 | ||||
| -rw-r--r-- | examples/tools/clone.toml | 15 | ||||
| -rwxr-xr-x | examples/tools/test-clone.sh | 95 |
5 files changed, 325 insertions, 0 deletions
@@ -236,6 +236,30 @@ mode is one bit and undone by asking again. Spec: - The game hold on presentation is covered by `test-mode.sh` only: faking `status.gaming` live would fight the shell's game detector. +### Clone tool, live check (2026-10-19) + +`clone` (`examples/tools/clone.sh`): a public repo goes to +`~/Programming/GIT/<host-folder>/<repo>`, and the two things the model +must not decide alone (unknown host, already cloned) are asked. Spec: +`docs/superpowers/specs/2026-10-19-clone-tool-design.md`. + +- Host→folder map in `~/.config/desktop-assistant/clone-hosts`, seeded + github.com→GITHUB, gitlab.com→GITLAB, forge.slackware.nl→FORGE. + Approving a new host appends its folder, so later clones of that host + ask nothing. +- Live: "Clone https://codeberg.org/example/repo" called `clone`, got + "no folder for host codeberg.org", and asked "Create CODEBERG/ and clone + repo there?"; no folder was created. "Clone + https://github.com/firefly-iii/firefly-iii.git" called `clone`, got + "already cloned at .../GITHUB/firefly-iii", and asked about updating; + nothing was written. +- Real clone, already-cloned and pull checked against a `file://localhost` + origin in a scratch `CLONE_ROOT`: cloned into `LOCAL/repo`, the second + run said already cloned, `update=1` ran `git pull --ff-only` ("Already + up to date"). +- A tool added to `~/.config/desktop-assistant/tools/` needs a daemon + restart: `TOOLS` is loaded once at import, not per request. + ### Text daemon, live check (2026-10-16) Model Gemma4-12B-qat-mtp, ctx 32768, preset reasoning-budget 4096. diff --git a/docs/superpowers/specs/2026-10-19-clone-tool-design.md b/docs/superpowers/specs/2026-10-19-clone-tool-design.md new file mode 100644 index 0000000..09d3be5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-clone-tool-design.md @@ -0,0 +1,78 @@ +# Clone tool: design + +Date: 2026-10-19. Status: implemented (2026-10-19). + +## Goal + +Ask the assistant to clone a public repository ("clone this repo: +https://github.com/owner/repo") and have it land in the right folder of +`~/Programming/GIT`: `GITHUB/`, `GITLAB/`, `FORGE/`, or a folder already +approved for that host. Public repositories only, plain `git clone`, no +SSH aliases or keys. + +The assistant must not decide two things alone: a host with no folder yet, +and a repository that is already cloned. Both are asked, with a concrete +action, never a bare "skip". + +## Tool + +`examples/tools/clone.toml`, installed to +`~/.config/desktop-assistant/tools/` like the others. No change to +`assistant.py`. + +```toml +command = ["clone.sh", "{url}", "{to}", "{update}"] +timeout = 300 +``` + +- `url`: the repository URL. +- `to`: only when the user approved a new host folder; the folder name. +- `update`: `1` to pull an existing clone. + +`examples/tools/clone.sh`: + +- Parses `https://host/owner/repo` and `git@host:owner/repo` alike; `repo` + is the last path segment with a trailing `/` and a `.git` suffix removed. + Both `host` and `repo` are validated as plain names, so neither can + escape the tree. +- Resolves `host -> folder` from the mapping file. Absent, it is seeded + with `github.com -> GITHUB`, `gitlab.com -> GITLAB`, + `forge.slackware.nl -> FORGE`. +- Target is `<root>/<folder>/<repo>` (`CLONE_ROOT`, default + `~/Programming/GIT`). +- Known host, target free: `git clone` and report it. No question. +- Unknown host: prints the folder to offer (hostname without its last + label, uppercased: `codeberg.org -> CODEBERG`) and does nothing. +- Target exists: prints "already cloned" and does nothing. With + `update=1`, runs `git pull --ff-only` and reports the result; a diverged + clone is an error, not a merge. +- A `to` given for an unknown host clones there and appends + `host<TAB>folder` to the mapping, so that host is known from then on. + An existing mapping is never duplicated or overwritten. + +## Mapping file + +`~/.config/desktop-assistant/clone-hosts` (`CLONE_MAP`), one +`host<TAB>folder` line per host, user-editable. It lives outside the tool +directory so reinstalling the tools does not lose it, and the file is the +single source of truth: nothing is hardcoded in the script beyond the +seed. + +## Check + +`test-clone.sh`: a scratch `CLONE_ROOT` and `CLONE_MAP`, and a `git` stub +on `PATH` that records its argv and creates the target, so no network and +no real tree. Covers seeding, clone argv, trailing slash and `.git`, the +unknown-host message, the approved folder being remembered and used on +the next clone without a question, no duplicate map line, the +already-cloned message, the update argv, and the refused inputs. + +Live, against the running model: ask for a known host and an unknown host, +accept the offered folder, ask for the same host again (must ask nothing), +then ask for an existing repo (must offer an update, never a second +clone). + +## Not included + +Private repositories (no credentials), clone depth or branches, and +opening the clone in an editor or agent after it lands. diff --git a/examples/tools/clone.sh b/examples/tools/clone.sh new file mode 100755 index 0000000..e4051fc --- /dev/null +++ b/examples/tools/clone.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# desktop-assistant tool: clone a public git repository into the project tree. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# clone.sh <url> [to] [update]: clone into <root>/<folder>/<repo>, the folder +# chosen by host from the clone-hosts map (github.com -> GITHUB, ...). An +# unknown host stops and says which folder to offer; passing that folder as +# <to> clones there and remembers the mapping. <update>=1 pulls an existing +# clone instead. Check: test-clone.sh. + +set -uo pipefail + +url=${1:-} +to=${2:-} +update=${3:-} +root=${CLONE_ROOT:-$HOME/Programming/GIT} +map=${CLONE_MAP:-${XDG_CONFIG_HOME:-$HOME/.config}/desktop-assistant/clone-hosts} + +[ -n "$url" ] || { echo "url is empty" >&2; exit 1; } +# The fragment and query are not part of a repository; a trailing slash is not +# either. The repo name is the last path segment with a .git suffix removed. +url=${url%%\#*} +url=${url%%\?*} +url=${url%/} + +case "$url" in + *://*) + rest=${url#*://} + rest=${rest#*@} + host=${rest%%/*} + path=${rest#*/} + ;; + *@*:*) + rest=${url#*@} + host=${rest%%:*} + path=${rest#*:} + ;; + *) + echo "not a git URL: $url" >&2; exit 1 + ;; +esac +host=${host%%:*} +path=${path%/} +repo=${path##*/} +repo=${repo%.git} + +# Both names land in a path, so neither may escape the tree. +[[ $host =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || + { echo "bad host: $host" >&2; exit 1; } +[[ $repo =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + { echo "bad repository name: $repo" >&2; exit 1; } + +# host -> folder, one tab-separated pair per line, seeded once. Appending the +# approved folder is how a new host is remembered, so the next clone of that +# host goes straight there. +if [ ! -e "$map" ]; then + mkdir -p "$(dirname "$map")" || exit 1 + printf '%s\t%s\n' github.com GITHUB gitlab.com GITLAB \ + forge.slackware.nl FORGE > "$map" || exit 1 +fi +folder=$(awk -v h="$host" '$1 == h { f = $2 } END { print f }' "$map") + +if [ -n "$to" ]; then + [[ $to =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + { echo "bad folder name: $to" >&2; exit 1; } + [ -n "$folder" ] || printf '%s\t%s\n' "$host" "$to" >> "$map" || exit 1 + folder=$to +fi + +if [ -z "$folder" ]; then + label=${host%.*} + label=$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]') + echo "no folder for host $host; ask the user: create $label/ and clone $repo there? if yes, call again with to=$label" + exit 0 +fi + +target=$root/$folder/$repo + +# Never clone over an existing folder, and never under another name: offer an +# update instead. ff-only so a diverged clone is reported, not merged. +if [ -e "$target" ]; then + if [ -n "$update" ]; then + out=$(git -C "$target" pull --ff-only 2>&1) && { + echo "updated $target: $(tail -1 <<< "$out")" + exit 0 + } + echo "could not update $target: $(tail -1 <<< "$out")" >&2 + exit 1 + fi + echo "already cloned at $target; ask the user: update it? if yes, call again with update=1" + exit 0 +fi + +mkdir -p "$root/$folder" || exit 1 +if out=$(git clone -- "$url" "$target" 2>&1); then + echo "cloned $url into $target" +else + echo "clone failed: $(tail -1 <<< "$out")" >&2 + exit 1 +fi diff --git a/examples/tools/clone.toml b/examples/tools/clone.toml new file mode 100644 index 0000000..0862165 --- /dev/null +++ b/examples/tools/clone.toml @@ -0,0 +1,15 @@ +description = "Clone a public git repository into the user's project tree. The folder comes from the host: github.com -> GITHUB, gitlab.com -> GITLAB, forge.slackware.nl -> FORGE, plus hosts remembered from earlier clones. Call it with url and nothing else; when the host is known and the repo is not there yet it clones straight away. If it answers that the host has no folder, or that the repo is already cloned, it did nothing: ask the user exactly as that answer says, then call again with the to or update it names. Never clone a repo under a different name when it is already there; offer to update it." +command = ["clone.sh", "{url}", "{to}", "{update}"] +timeout = 300 + +[params.url] +type = "string" +description = "Repository URL, e.g. https://github.com/owner/repo.git" + +[params.to] +type = "string" +description = "Only when the user approved a new host folder: the folder name (e.g. CODEBERG). Empty otherwise." + +[params.update] +type = "string" +description = "1 to pull an already-cloned repository. Empty otherwise." diff --git a/examples/tools/test-clone.sh b/examples/tools/test-clone.sh new file mode 100755 index 0000000..d84a157 --- /dev/null +++ b/examples/tools/test-clone.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# desktop-assistant tool check: clone.sh against a scratch tree and a stub git. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# test-clone.sh: CLONE_ROOT and CLONE_MAP point at a throwaway dir, and git is +# a stub that records its argv and creates the clone target, so nothing hits +# the network or the real tree. Prints "ok" or one FAIL line per broken case. + +set -u +here=$(dirname "$0") +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +export CLONE_ROOT=$tmp/git +export CLONE_MAP=$tmp/clone-hosts +export GIT_LOG=$tmp/git.log +: > "$GIT_LOG" +mkdir -p "$tmp/bin" + +cat > "$tmp/bin/git" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >> "$GIT_LOG" +[ "$1" = clone ] && mkdir -p "${@: -1}" +exit 0 +EOF +chmod +x "$tmp/bin/git" +PATH=$tmp/bin:$PATH +export PATH + +fail=0 +check() { [ "$2" = "$3" ] || { echo "FAIL $1: got '$2', want '$3'"; fail=1; }; } +contains() { case "$2" in *"$3"*) ;; *) echo "FAIL $1: '$2' lacks '$3'"; fail=1 ;; esac; } +count() { grep -cF -- "$1" "$GIT_LOG" || true; } +mapcount() { grep -cF -- "$1" "$CLONE_MAP" || true; } + +# Known host, clean copy: seeded map, straight clone, no question. +out=$("$here/clone.sh" https://github.com/a/b.git) +check "github clone" "$out" "cloned https://github.com/a/b.git into $CLONE_ROOT/GITHUB/b" +check "seed" "$(cat "$CLONE_MAP")" \ + "$(printf '%s\t%s\n' github.com GITHUB gitlab.com GITLAB forge.slackware.nl FORGE)" +check "clone argv" "$(count "clone -- https://github.com/a/b.git $CLONE_ROOT/GITHUB/b")" "1" + +# Trailing slash and .git are not part of the repo name. +out=$("$here/clone.sh" https://gitlab.com/a/c.git/) +check "gitlab repo" "$out" "cloned https://gitlab.com/a/c.git into $CLONE_ROOT/GITLAB/c" + +# Unknown host: ask, do nothing, name the folder to offer. +out=$("$here/clone.sh" https://codeberg.org/a/d) +contains "unknown asks" "$out" "no folder for host codeberg.org" +contains "unknown label" "$out" "to=CODEBERG" +check "unknown no clone" "$(count "$CLONE_ROOT/CODEBERG/d")" "0" + +# Approved folder: clone there and remember it. +out=$("$here/clone.sh" https://codeberg.org/a/d CODEBERG) +check "approved clone" "$out" "cloned https://codeberg.org/a/d into $CLONE_ROOT/CODEBERG/d" +check "remembered" "$(mapcount 'codeberg.org')" "1" +check "remembered pair" "$(grep -F 'codeberg.org' "$CLONE_MAP")" \ + "$(printf 'codeberg.org\tCODEBERG')" + +# Next clone of that host asks nothing. +out=$("$here/clone.sh" https://codeberg.org/a/e) +check "remembered clone" "$out" "cloned https://codeberg.org/a/e into $CLONE_ROOT/CODEBERG/e" + +# Re-approving the same folder does not duplicate the map line. +"$here/clone.sh" https://codeberg.org/a/f CODEBERG >/dev/null +check "no duplicate map line" "$(mapcount 'codeberg.org')" "1" + +# Already cloned: ask, do not clone again; update pulls. +out=$("$here/clone.sh" https://github.com/a/b.git) +contains "already asks" "$out" "already cloned at $CLONE_ROOT/GITHUB/b" +check "already no reclone" "$(count "clone -- https://github.com/a/b.git")" "1" +out=$("$here/clone.sh" https://github.com/a/b.git "" 1) +contains "update says" "$out" "updated $CLONE_ROOT/GITHUB/b" +check "update argv" "$(count "-C $CLONE_ROOT/GITHUB/b pull --ff-only")" "1" + +# Bad input is refused before anything is created. +"$here/clone.sh" "" >/dev/null 2>&1; check "empty url" "$?" "1" +"$here/clone.sh" not-a-url >/dev/null 2>&1; check "not a url" "$?" "1" +"$here/clone.sh" https://github.com/a/.. >/dev/null 2>&1; check "bad repo" "$?" "1" +"$here/clone.sh" https://codeberg.org/a/z ../escape >/dev/null 2>&1; check "bad to" "$?" "1" + +[ $fail = 0 ] && echo "ok" +exit $fail |
