chore: public workspace with GitHub submodules

This commit is contained in:
bvn13 2026-08-22 12:09:41 +03:00
commit 68548c0afe
11 changed files with 536 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Local run artefacts of scripts/dev-host.sh
.dev-host.log
.dev-host.pid

8
.gitmodules vendored Normal file
View File

@ -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

138
README.md Normal file
View File

@ -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 <submodule> to <sha>`.
- 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.

1
cloudcli Submodule

@ -0,0 +1 @@
Subproject commit dfc8ba98022e8e0827a3741b32dc71d832579a09

1
plugin-taskwork Submodule

@ -0,0 +1 @@
Subproject commit 3dbc55cce18a207a1d2eabd5f96c98c0dd58043c

59
scripts/bootstrap.sh Executable file
View File

@ -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

32
scripts/check-submodule-pins.sh Executable file
View File

@ -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

168
scripts/dev-host.sh Executable file
View File

@ -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 <<EOF
note: another CloudCLI is listening on :3001 (${other}).
This instance shares its database (~/.cloudcli/auth.db) and its plugin
directory. That is intentional here — set DATABASE_PATH to run against a
separate database instead.
EOF
}
start_host() {
local pid
if pid="$(running_pid)"; then
echo "already running (pid $pid) — http://localhost:$VITE_PORT"
exit 0
fi
require_environment
warn_about_other_instances
for port in "$SERVER_PORT" "$VITE_PORT"; do
if ss -ltn 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

49
scripts/export-patches.sh Executable file
View File

@ -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

55
scripts/link-plugin.sh Executable file
View File

@ -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

22
scripts/start-proxy.sh Executable file
View File

@ -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 "$@"