commit 68548c0afe5f355230b63abb3c6844aa8ad51b90 Author: bvn13 Date: Sat Aug 22 12:09:41 2026 +0300 chore: public workspace with GitHub submodules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d171a12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Local run artefacts of scripts/dev-host.sh +.dev-host.log +.dev-host.pid diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..367aa85 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "cloudcli"] + path = cloudcli + url = https://github.com/bvn13/claudecodeui.git + branch = dev/taskwork +[submodule "plugin-taskwork"] + path = plugin-taskwork + url = https://github.com/bvn13/cloudcli-plugin-taskwork.git + branch = main diff --git a/README.md b/README.md new file mode 100644 index 0000000..5888f64 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# cloudcli-taskwork — workspace + +Meta-repository for the **Task Work** plugin for CloudCLI UI (`siteboon/claudecodeui`). +It holds the specification, the developer scripts and two submodules. It contains +**no code of its own** — no `package.json`, no dependencies. + +Specification: [`spec/SPEC-v3.md`](spec/SPEC-v3.md) (current), design input: +[`spec/DESIGN-DOC.md`](spec/DESIGN-DOC.md). + +## Repository map + +| Path | Repository | Role | +|---|---|---| +| *(this repo)* | `gitea:ai-first/cloudcli-taskwork` | Workspace: spec, scripts, submodule pins | +| `plugin-taskwork/` | `gitea:ai-first/cloudcli-plugin-taskwork` | **The product**, developed here; `manifest.json` lives in its root | +| — | `github:bvn13/cloudcli-plugin-taskwork` | **Where users install the plugin from** — public, anonymous HTTPS clone | +| `cloudcli/` | `github:bvn13/claudecodeui` | Fork of the host; source of the three upstream PRs | +| — | `github:siteboon/claudecodeui` | Upstream: source of updates, target of the PRs | +| — | `gitea:ai-first/cloudcli` | Pull mirror of the fork, **read-only** | + +The plugin lives in two places on purpose: development and the submodule pin stay +on Gitea, while the host installs it over anonymous HTTPS, which a private Gitea +repository cannot serve. The GitHub copy is a **push mirror** — pushing to Gitea +is enough, it syncs on its own. Do not add a GitHub remote to `plugin-taskwork/` +and do not push there by hand. + +> ⚠️ Never push to `gitea:ai-first/cloudcli`. It is a pull mirror — anything pushed +> there is silently overwritten on the next sync. The `cloudcli/` submodule points +> at the GitHub fork, and the mirror is deliberately not wired up as a remote. + +## Getting started + +```bash +git clone --recurse-submodules ssh://git@gitea.bvn13.me:2221/ai-first/cloudcli-taskwork.git +cd cloudcli-taskwork +./scripts/bootstrap.sh +``` + +Then: + +```bash +./scripts/dev-host.sh start # installs the built plugin and starts the host +``` + +It prints the URL — `http://localhost:5183` by default. `stop`, `restart`, +`status` and `logs` do what they say; `SERVER_PORT`/`VITE_PORT` override the +ports. + +The host runs against your **real** environment: the same `~/.claude` projects, +the same `~/.cloudcli/auth.db`, the same `~/.claude-code-ui/plugins`. Nothing is +sandboxed, so you see what a user would see. Ports default to 3010/5183 rather +than 3001/5173 so an already installed CloudCLI keeps running — but note that +both instances then share one database. Set `DATABASE_PATH` to separate them. + +After changing the plugin: + +```bash +cd plugin-taskwork && npm run build +cd .. && ./scripts/link-plugin.sh # the host reads a copy, not a symlink +``` + +Client changes need only the plugin surface reopened; server changes need the host +restarted (`./scripts/dev-host.sh restart`), because the plugin's backend process +is spawned at host startup. + +The plugin stores its data **outside** its own directory, in +`~/.claude-code-ui/taskwork/tasks.json` — the plugin directory is wiped on *Update*. + +### Access + +All submodule URLs are SSH, so both submodules are writable with the same key +setup: `ssh://git@gitea.bvn13.me:2221/…` for the plugin (the Gitea repositories +are private) and `git@github.com:bvn13/claudecodeui.git` for the fork. The +`upstream` remote stays on HTTPS — it is fetch-only and public. + +Cloning therefore needs an SSH key registered with both hosts; check with +`ssh -T git@github.com` and `ssh -T -p 2221 git@gitea.bvn13.me`. + +Distribution goes through the public GitHub copy +(`github:bvn13/cloudcli-plugin-taskwork`), because the host clones a plugin +anonymously over HTTPS and the Gitea repository is private. The submodule keeps +pointing at Gitea: that is where the plugin is developed and pushed first. + +## Scripts + +| Script | Purpose | +|---|---| +| `scripts/bootstrap.sh` | Init submodules, wire the `upstream` remote, install deps, build the host's native modules and the plugin | +| `scripts/dev-host.sh` | Run the patched host locally: `start`, `stop`, `restart`, `status`, `logs` | +| `scripts/export-patches.sh` | Regenerate `plugin-taskwork/patches/*.patch` from the fork's feature branches (§12.0) | +| `scripts/link-plugin.sh` | Symlink the local plugin into the host's plugin directory (`--unlink` to remove) | +| `scripts/check-submodule-pins.sh` | Verify every pinned submodule commit is reachable from its origin | + +## Branches in `cloudcli/` + +Each feature branch is cut **from the upstream tag `v1.37.2`**, never from another +feature branch — that is what keeps the three pull requests independent. + +| Branch | Base | Purpose | +|---|---|---| +| `main` | tracks `upstream/main` | Upstream sync. Carries no commits of ours | +| `feat/resizable-sidebar` | `v1.37.2` | PR-1 — resizable sidebar | +| `feat/plugin-host-api` | `v1.37.2` | PR-2 — authenticated host API for plugins | +| `feat/plugin-sidebar-surface` | `v1.37.2` | PR-3 — sidebar surface for plugins | +| `dev/taskwork` | merge of the three | Local run and debugging; the submodule is pinned here | + +`dev/taskwork` never takes part in a pull request and may be recreated at any time. + +## Working with the submodules + +- Commit code **inside** the submodule, push it, and only then record the new pin + here with a separate commit `chore: bump to `. +- Update with `git submodule update --remote --merge`. +- Run `./scripts/check-submodule-pins.sh` before pushing a pin bump. + +## Status + +| Piece | State | +|---|---| +| Plugin | `v1.0.0`, pushed to Gitea; installs, builds and runs (`npm install --ignore-scripts` + `npm run build` in ~3 s) | +| `feat/resizable-sidebar` | Pushed to the fork. PR-1 not opened yet; no issue needed | +| `feat/plugin-host-api` | Pushed to the fork. Issue text in `spec/upstream/issue-1-plugin-host-api.md`, PR text in `plugin-taskwork/docs/upstream-pr-2.md` | +| `feat/plugin-sidebar-surface` | Pushed to the fork. Issue text in `spec/upstream/issue-2-plugin-sidebar-surface.md`, PR text in `plugin-taskwork/docs/upstream-pr-3.md` | +| `dev/taskwork` | Merge of the three plus one integration commit (host API in the sidebar surface) that never goes upstream; the `cloudcli` pin points here | + +Remaining before upstream contact (§19 of the spec): open issue #1, then PR-2; +issue #2, then PR-3; PR-1 can go on its own. Also outstanding: the manual +acceptance checklist (§15.4), which needs a running host in a browser. + +## Release + +See §13.2 of the spec. In short: export patches → bump the plugin version in +`package.json` and `manifest.json` → build, typecheck, test → commit `build/`, +`patches/`, `CHANGELOG.md` → tag `vX.Y.Z` in the plugin repo → bump the pins here. + +## Licence + +The plugin and the fork are AGPL-3.0-or-later, following the upstream host. diff --git a/cloudcli b/cloudcli new file mode 160000 index 0000000..dfc8ba9 --- /dev/null +++ b/cloudcli @@ -0,0 +1 @@ +Subproject commit dfc8ba98022e8e0827a3741b32dc71d832579a09 diff --git a/plugin-taskwork b/plugin-taskwork new file mode 160000 index 0000000..3dbc55c --- /dev/null +++ b/plugin-taskwork @@ -0,0 +1 @@ +Subproject commit 3dbc55cce18a207a1d2eabd5f96c98c0dd58043c diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..d60de00 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Brings the workspace up: initialises both submodules and installs their deps. +# Usage: ./scripts/bootstrap.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> Initialising submodules" +git submodule update --init --recursive + +echo "==> Configuring the host fork remotes" +if ! git -C cloudcli remote | grep -qx upstream; then + git -C cloudcli remote add upstream https://github.com/siteboon/claudecodeui.git +fi +# upstream is fetch-only: pull requests are opened from origin (the fork). +git -C cloudcli remote set-url --push upstream DISABLED_fetch_only +git -C cloudcli fetch upstream --tags --quiet + +echo "==> Installing host dependencies (cloudcli)" +# Not `npm ci`: the upstream lockfile pins a few tarballs on registry.npmmirror.com, +# and npm >= 12 refuses remote tarball URLs (EALLOWREMOTE) unless allowed explicitly. +(cd cloudcli && npm install --allow-remote=all --no-audit --no-fund) + +echo "==> Building the host's native modules" +# npm >= 12 also blocks install scripts, so prebuilt binaries are never fetched +# and the server cannot start. Build only what the server actually needs, and +# only when it is missing (electron and friends stay unbuilt on purpose). +build_native() { + local module="$1" builder="$2" + if (cd "cloudcli" && node -e "require('$module')") >/dev/null 2>&1; then + echo " $module: ok" + return + fi + + echo " $module: building" + (cd "cloudcli/node_modules/$module" && eval "$builder") >/dev/null 2>&1 || true + + if (cd "cloudcli" && node -e "require('$module')") >/dev/null 2>&1; then + echo " $module: built" + else + echo " $module: FAILED — the host server will not start" >&2 + fi +} + +build_native better-sqlite3 "npx --no-install prebuild-install || npx --no-install node-gyp rebuild --release" +build_native bcrypt "npx --no-install node-gyp-build" +build_native node-pty "node scripts/prebuild.js || npx --no-install node-gyp rebuild" + +echo "==> Building the plugin (plugin-taskwork)" +(cd plugin-taskwork && npm ci && npm run build) + +cat <<'EOF' + +Done. + + Run the host: ./scripts/dev-host.sh start (http://localhost:5183) + Install the build: ./scripts/link-plugin.sh (dev-host.sh start does this too) +EOF diff --git a/scripts/check-submodule-pins.sh b/scripts/check-submodule-pins.sh new file mode 100755 index 0000000..e3903b4 --- /dev/null +++ b/scripts/check-submodule-pins.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Verifies that every pinned submodule commit is reachable from its origin. +# Catches the classic "pinned to a local commit nobody else can fetch" mistake. +# +# Usage: ./scripts/check-submodule-pins.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +fail=0 + +while read -r _mode _type sha path; do + [ -n "${path:-}" ] || continue + + if [ ! -d "$path/.git" ] && [ ! -f "$path/.git" ]; then + echo "FAIL $path: submodule is not initialised (git submodule update --init)" + fail=1 + continue + fi + + git -C "$path" fetch origin --quiet --prune 2>/dev/null || true + + if git -C "$path" branch -r --contains "$sha" 2>/dev/null | grep -q .; then + echo "ok $path: $sha is reachable from origin" + else + echo "FAIL $path: $sha is NOT pushed to origin — push the submodule first" + fail=1 + fi +done < <(git ls-tree HEAD | grep '^160000') + +exit $fail diff --git a/scripts/dev-host.sh b/scripts/dev-host.sh new file mode 100755 index 0000000..7d37777 --- /dev/null +++ b/scripts/dev-host.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Runs the patched host (cloudcli/, branch dev/taskwork) against your real +# environment — the same ~/.claude projects, the same ~/.cloudcli/auth.db, the +# same ~/.claude-code-ui/plugins as an installed CloudCLI. Nothing is sandboxed, +# so what you see is what a user would see. +# +# ./scripts/dev-host.sh start install the built plugin and start the host +# ./scripts/dev-host.sh stop stop it +# ./scripts/dev-host.sh status ports, pid, plugin version +# ./scripts/dev-host.sh logs follow the log +# +# Ports default to 3010/5183, not 3001/5173, so an already installed CloudCLI +# keeps running untouched. Override with SERVER_PORT / VITE_PORT. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOST_DIR="$ROOT/cloudcli" +LOG_FILE="$ROOT/.dev-host.log" +PID_FILE="$ROOT/.dev-host.pid" + +SERVER_PORT="${SERVER_PORT:-3010}" +VITE_PORT="${VITE_PORT:-5183}" +READY_TIMEOUT_SECONDS=60 + +running_pid() { + [ -f "$PID_FILE" ] || return 1 + local pid + pid="$(cat "$PID_FILE")" + [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid" +} + +port_owner() { + ss -ltnp 2>/dev/null | awk -v port=":$1" '$4 ~ port' | grep -oE 'pid=[0-9]+' | head -1 +} + +require_environment() { + [ -d "$HOST_DIR/.git" ] || { echo "cloudcli/ is not initialised — run ./scripts/bootstrap.sh" >&2; exit 1; } + [ -d "$HOST_DIR/node_modules" ] || { echo "cloudcli/node_modules is missing — run ./scripts/bootstrap.sh" >&2; exit 1; } + + # The server needs these three compiled; npm >= 12 blocks install scripts by + # default, which leaves them unbuilt and the server dead on arrival. + local missing=() + for module in better-sqlite3 bcrypt node-pty; do + (cd "$HOST_DIR" && node -e "require('$module')") >/dev/null 2>&1 || missing+=("$module") + done + + if [ ${#missing[@]} -gt 0 ]; then + echo "native modules not built: ${missing[*]}" >&2 + echo "run ./scripts/bootstrap.sh, which builds them" >&2 + exit 1 + fi +} + +warn_about_other_instances() { + local other + other="$(port_owner 3001 || true)" + [ -n "$other" ] || return 0 + + cat >&2 </dev/null | grep -q ":$port "; then + echo "port $port is already in use — pick another with SERVER_PORT/VITE_PORT" >&2 + exit 1 + fi + done + + if [ -d "$ROOT/plugin-taskwork/build" ]; then + # Captured rather than piped into `head -1`: head closes the pipe after the + # first line, link-plugin.sh dies of SIGPIPE on its next write, and pipefail + # turns that into a silent exit here. + local plugin_output + plugin_output="$("$ROOT/scripts/link-plugin.sh")" + printf '%s\n' "${plugin_output%%$'\n'*}" + else + echo "note: plugin-taskwork/build is missing, the host will start without the plugin" + fi + + # setsid detaches from this shell's process group, so the host survives the + # terminal (or the agent turn) that started it. + ( + cd "$HOST_DIR" + SERVER_PORT="$SERVER_PORT" VITE_PORT="$VITE_PORT" \ + setsid nohup npm run dev > "$LOG_FILE" 2>&1 < /dev/null & + echo $! > "$PID_FILE" + ) + + printf 'starting' + for _ in $(seq "$READY_TIMEOUT_SECONDS"); do + if curl -sf "http://localhost:$SERVER_PORT/api/auth/status" >/dev/null 2>&1; then + echo + echo "host http://localhost:$VITE_PORT (api :$SERVER_PORT, pid $(cat "$PID_FILE"))" + grep -m1 '\[Plugins\] Server started' "$LOG_FILE" 2>/dev/null || echo "note: no plugin server in the log yet" + echo "log $LOG_FILE" + exit 0 + fi + printf '.' + sleep 1 + done + + echo + echo "the host did not answer within ${READY_TIMEOUT_SECONDS}s — see $LOG_FILE" >&2 + tail -5 "$LOG_FILE" >&2 || true + exit 1 +} + +stop_host() { + local pid + if ! pid="$(running_pid)"; then + echo "not running" + rm -f "$PID_FILE" + exit 0 + fi + + # npm run dev spawns the server and vite; killing the group takes all of them. + kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + for _ in $(seq 10); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + kill -9 -- "-$pid" 2>/dev/null || true + + rm -f "$PID_FILE" + echo "stopped" +} + +show_status() { + local pid + if pid="$(running_pid)"; then + echo "running pid $pid, http://localhost:$VITE_PORT (api :$SERVER_PORT)" + else + echo "stopped" + fi + + echo "branch $(git -C "$HOST_DIR" branch --show-current 2>/dev/null || echo '?')" + + local installed="$HOME/.claude-code-ui/plugins/cloudcli-plugin-taskwork/manifest.json" + if [ -f "$installed" ]; then + echo "plugin $(node -p "require('$installed').version") installed in ~/.claude-code-ui/plugins" + else + echo "plugin not installed — ./scripts/link-plugin.sh" + fi +} + +case "${1:-start}" in + start) start_host ;; + stop) stop_host ;; + restart) stop_host; start_host ;; + status) show_status ;; + logs) tail -f "$LOG_FILE" ;; + *) echo "usage: $0 [start|stop|restart|status|logs]" >&2; exit 1 ;; +esac diff --git a/scripts/export-patches.sh b/scripts/export-patches.sh new file mode 100755 index 0000000..270bc86 --- /dev/null +++ b/scripts/export-patches.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Regenerates plugin-taskwork/patches/*.patch from the fork's feature branches. +# +# The branches in cloudcli/ are the single source of truth (SPEC-v3 §12.0); +# the .patch files are a derived artefact and are never edited by hand. +# +# Usage: ./scripts/export-patches.sh [base-tag] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_TAG="${1:-v1.37.2}" +SRC="$ROOT/cloudcli" +OUT="$ROOT/plugin-taskwork/patches" + +# branch:output-file — the numeric prefix mirrors the publication order (§12.4), +# not a dependency: every patch applies to the base tag on its own. +BRANCHES=( + "feat/resizable-sidebar:0001-feat-resizable-sidebar.patch" + "feat/plugin-host-api:0002-feat-plugin-host-api.patch" + "feat/plugin-sidebar-surface:0003-feat-plugin-sidebar-surface.patch" +) + +[ -d "$SRC/.git" ] || { echo "cloudcli/ submodule is not initialised — run ./scripts/bootstrap.sh" >&2; exit 1; } +[ -d "$OUT" ] || { echo "plugin-taskwork/patches/ is missing — run ./scripts/bootstrap.sh" >&2; exit 1; } + +git -C "$SRC" rev-parse --verify --quiet "$BASE_TAG^{commit}" >/dev/null \ + || { echo "base tag $BASE_TAG not found in cloudcli/ (git fetch upstream --tags)" >&2; exit 1; } + +for entry in "${BRANCHES[@]}"; do + branch="${entry%%:*}" + file="${entry##*:}" + + if ! git -C "$SRC" rev-parse --verify --quiet "$branch" >/dev/null; then + echo "skip $branch (branch does not exist yet)" + continue + fi + + # Independence check: a feature branch must sit directly on the base tag, + # otherwise its patch would silently carry another PR's commits (§4.4). + base="$(git -C "$SRC" merge-base "$BASE_TAG" "$branch")" + if [ "$base" != "$(git -C "$SRC" rev-parse "$BASE_TAG^{commit}")" ]; then + echo "ERROR: $branch is not based on $BASE_TAG" >&2 + exit 1 + fi + + git -C "$SRC" format-patch "$BASE_TAG..$branch" --stdout > "$OUT/$file" + commits="$(git -C "$SRC" rev-list --count "$BASE_TAG..$branch")" + echo "wrote patches/$file ($commits commit(s))" +done diff --git a/scripts/link-plugin.sh b/scripts/link-plugin.sh new file mode 100755 index 0000000..9101174 --- /dev/null +++ b/scripts/link-plugin.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Installs the locally built plugin into the host's plugin directory, so the +# host picks up `npm run build` output without a reinstall from its URL. +# +# It copies rather than symlinks, and that is not a matter of taste: +# `scanPlugins()` walks the plugins directory with +# `readdirSync(dir, { withFileTypes: true })` and skips every entry whose +# `entry.isDirectory()` is false — which is exactly what a symlink reports. A +# symlinked plugin is silently invisible to the host. +# Symlinking the files *inside* a real directory fails too: the asset route +# compares the `realpath` of each asset against the `realpath` of the plugin +# directory and 404s anything pointing outside it. +# +# Usage: ./scripts/link-plugin.sh [--remove] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIR_NAME="cloudcli-plugin-taskwork" # last URL segment — how the host names it +TARGET="${HOME}/.claude-code-ui/plugins/${DIR_NAME}" +SOURCE="$ROOT/plugin-taskwork" + +# Everything the host needs at runtime: manifest, entry points, icon, and the +# package.json it reads to decide whether to run a build. +CONTENTS=(manifest.json package.json icon.svg build) + +if [ "${1:-}" = "--remove" ]; then + [ -e "$TARGET" ] && rm -rf "$TARGET" && echo "removed $TARGET" || echo "nothing to remove" + exit 0 +fi + +[ -f "$SOURCE/manifest.json" ] || { echo "plugin-taskwork/manifest.json not found — run ./scripts/bootstrap.sh" >&2; exit 1; } +[ -d "$SOURCE/build" ] || { echo "plugin-taskwork/build missing — run npm run build in plugin-taskwork/" >&2; exit 1; } + +if [ -L "$TARGET" ]; then + echo "removing a stale symlink at $TARGET (the host cannot see symlinked plugins)" + rm "$TARGET" +fi + +mkdir -p "$TARGET" +rm -rf "${TARGET:?}/build" +for item in "${CONTENTS[@]}"; do + cp -r "$SOURCE/$item" "$TARGET/" +done +# Intermediate tsc output is a build artefact, not part of the plugin. +rm -rf "$TARGET/build/.tsc-client" + +echo "installed $TARGET (copy of $SOURCE)" + +cat <<'EOF' + +Re-run this script after every `npm run build`; a copy does not track the source. + +Client changes need the plugin surface reopened, server changes need the host +restarted — the plugin's backend process is spawned once at host startup. +EOF diff --git a/scripts/start-proxy.sh b/scripts/start-proxy.sh new file mode 100755 index 0000000..e47b19d --- /dev/null +++ b/scripts/start-proxy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +HTTP_PROXY_PORT=${HTTP_PROXY_PORT:-25345} + +# Proxy settings (v2rayN / local HTTP proxy) +export HTTP_PROXY="${HTTP_PROXY:-http://127.0.0.1:${HTTP_PROXY_PORT}}" +export HTTPS_PROXY="${HTTPS_PROXY:-http://127.0.0.1:${HTTP_PROXY_PORT}}" +export NO_PROXY="${NO_PROXY:-localhost,127.0.0.1,::1,.corp.local,ahcode-01.digital.achrf.ru}" + +# Some libs read lowercase variants +export http_proxy="$HTTP_PROXY" +export https_proxy="$HTTPS_PROXY" +export no_proxy="$NO_PROXY" + +echo "-------" +echo "HTTP_PROXY=${HTTP_PROXY}" +echo "-------" + + +# Run claude with args +bash dev-host.sh "$@"