From 237277023dd3dc0c44df33e84923efe8b81da95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 19:48:45 +0100 Subject: [PATCH 1/5] feat(cli): task-setup.sh + per-worktree context registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/task-setup.sh and extends `lobu context add` so paired worktrees register themselves as managed contexts the menubar can spawn. Fixes the "we keep losing changes" submodule pain: 1. Detached-HEAD trap — script switches packages/owletto onto a real feat/ branch before any commits can happen. 2. bun.lock prune — submodule init runs before `bun install`. 3. Missing .env in fresh worktrees — copies from the main repo. 4. Port collisions — picks next free PORT / WORKER_PROXY_PORT. CLI changes: - LobuServerConfig.cwd: directory the lifecycle owner cd's into before spawning `lobu run`. No reader yet; menubar follow-up in owletto. - `lobu context add` now accepts --port, --host, --database-url, --data-dir, --cwd, --lifecycle. - addContext() takes an optional server config. Companion shell functions documented in the script header; not auto-installed (user adds to ~/.zshrc). --- packages/cli/src/commands/context.ts | 21 ++- packages/cli/src/index.ts | 58 +++++- .../src/internal/__tests__/context.test.ts | 34 ++++ packages/cli/src/internal/context.ts | 18 +- scripts/task-setup.sh | 171 ++++++++++++++++++ 5 files changed, 295 insertions(+), 7 deletions(-) create mode 100755 scripts/task-setup.sh diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 63cec1a16..3e1a9905c 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -6,6 +6,7 @@ import { resolveContext, setCurrentContext, } from "../internal/index.js"; +import type { LobuServerConfig } from "../internal/context.js"; export async function contextListCommand(): Promise { const config = await loadContextConfig(); @@ -45,8 +46,26 @@ export async function contextCurrentCommand(): Promise { export async function contextAddCommand(options: { name: string; apiUrl: string; + port?: number; + host?: string; + databaseUrl?: string; + dataDir?: string; + cwd?: string; + lifecycle?: "managed" | "external"; }): Promise { - await addContext(options.name, options.apiUrl); + const server: LobuServerConfig = {}; + if (options.port !== undefined) server.port = options.port; + if (options.host) server.host = options.host; + if (options.databaseUrl) server.databaseUrl = options.databaseUrl; + if (options.dataDir) server.dataDir = options.dataDir; + if (options.cwd) server.cwd = options.cwd; + if (options.lifecycle) server.lifecycle = options.lifecycle; + + await addContext( + options.name, + options.apiUrl, + Object.keys(server).length === 0 ? undefined : server + ); console.log( chalk.green(`\n Saved context ${options.name} -> ${options.apiUrl}\n`) ); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0a9556385..77b779423 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -543,10 +543,60 @@ Memory: .command("add ") .description("Add a named context") .requiredOption("--api-url ", "API base URL for this context") - .action(async (name: string, options: { apiUrl: string }) => { - const { contextAddCommand } = await import("./commands/context.js"); - await contextAddCommand({ name, apiUrl: options.apiUrl }); - }); + .option( + "--port ", + "Server port (when this context owns a managed lobu server)", + (value) => Number.parseInt(value, 10) + ) + .option("--host ", "Server host (default: 127.0.0.1)") + .option( + "--database-url ", + "Postgres DATABASE_URL for the managed server" + ) + .option( + "--data-dir ", + "LOBU_DATA_DIR for the managed server (state, PGlite)" + ) + .option( + "--cwd ", + "Working directory the lifecycle owner cd's into before spawning `lobu run` (used by per-worktree contexts)" + ) + .option( + "--lifecycle ", + "managed | external — managed means the menubar spawns `lobu run`", + (value: string) => { + if (value !== "managed" && value !== "external") { + throw new Error(`--lifecycle must be 'managed' or 'external'`); + } + return value; + } + ) + .action( + async ( + name: string, + options: { + apiUrl: string; + port?: number; + host?: string; + databaseUrl?: string; + dataDir?: string; + cwd?: string; + lifecycle?: "managed" | "external"; + } + ) => { + const { contextAddCommand } = await import("./commands/context.js"); + await contextAddCommand({ + name, + apiUrl: options.apiUrl, + port: options.port, + host: options.host, + databaseUrl: options.databaseUrl, + dataDir: options.dataDir, + cwd: options.cwd, + lifecycle: options.lifecycle, + }); + } + ); context .command("use ") diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index 69693d99b..c7a0414d7 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -9,6 +9,7 @@ import { } from "bun:test"; import * as fs from "node:fs/promises"; import { + addContext, DEFAULT_CONTEXT_NAME, findContextByMemoryUrl, findContextByUrl, @@ -143,6 +144,39 @@ describe("context management", () => { }); }); + test("addContext stores optional server config (port + cwd + lifecycle)", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await addContext("verify-flow", "http://localhost:8788", { + port: 8788, + cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", + lifecycle: "managed", + }); + + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts["verify-flow"]).toEqual({ + apiUrl: "http://localhost:8788", + server: { + port: 8788, + cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", + lifecycle: "managed", + }, + }); + }); + + test("addContext without server keeps shape backwards-compatible", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await addContext("plain", "https://example.com/api/v1"); + + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts.plain).toEqual({ + apiUrl: "https://example.com/api/v1", + }); + }); + test("drops invalid server fields during normalization", async () => { const configData = { currentContext: "local", diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index 996e1e6b7..da0cf31f0 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -15,6 +15,11 @@ export interface LobuServerConfig { port?: number; host?: string; dataDir?: string; + // Directory the lifecycle owner should `cd` into before spawning + // `lobu run`. Used by per-worktree contexts so the menubar launches + // the server against the worktree's source (hot-reload on the right + // checkout). Absent → spawner uses its own cwd. + cwd?: string; // "managed" → the Mac menubar (or another lifecycle owner) spawns // `lobu run` for this context. "external" → just connect; never // spawn or kill. Absent → infer from apiUrl: loopback ⇒ managed, @@ -174,7 +179,8 @@ export async function resolveContext( export async function addContext( name: string, - apiUrl: string + apiUrl: string, + server?: LobuServerConfig ): Promise { const trimmedName = name.trim(); if (!trimmedName) { @@ -182,9 +188,14 @@ export async function addContext( } const config = await loadContextConfig(); - config.contexts[trimmedName] = { + const entry: LobuContextEntry = { apiUrl: normalizeAndValidateApiUrl(apiUrl), }; + const normalizedServer = server ? normalizeServerConfig(server) : undefined; + if (normalizedServer) { + entry.server = normalizedServer; + } + config.contexts[trimmedName] = entry; await saveContextConfig(config); return config; } @@ -264,6 +275,9 @@ function normalizeServerConfig(raw: unknown): LobuServerConfig | undefined { if (typeof src.dataDir === "string" && src.dataDir.trim()) { out.dataDir = src.dataDir.trim(); } + if (typeof src.cwd === "string" && src.cwd.trim()) { + out.cwd = src.cwd.trim(); + } if (src.lifecycle === "managed" || src.lifecycle === "external") { out.lifecycle = src.lifecycle; } diff --git a/scripts/task-setup.sh b/scripts/task-setup.sh new file mode 100755 index 000000000..d12001d3a --- /dev/null +++ b/scripts/task-setup.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# task-setup.sh — prepare a paired-branch worktree for a new task. +# +# Usage: +# scripts/task-setup.sh +# +# kebab-case task slug (e.g. fix-sse-leak) +# +# Behavior (idempotent — re-running on an existing worktree refreshes .env +# and .env.local only): +# 1. Creates a lobu worktree at .claude/worktrees/ on branch feat/. +# 2. Initializes packages/owletto submodule on a real named branch feat/ +# (never detached HEAD — fixes the "we keep losing changes" bug). +# 3. Runs `bun install` after submodule init (avoids the bun.lock prune bug). +# 4. Copies .env from the main repo (gitignored secrets don't auto-carry into +# a fresh worktree, so `make dev` / `lobu run` would otherwise fail at boot). +# 5. Writes .env.local with PORT / WORKER_PROXY_PORT picked to avoid collisions +# with other worktrees and the main repo (which defaults to 8787 / 8118). +# 6. Drops a .task marker file at the worktree root so `git worktree list` +# can distinguish human task-worktrees from agent-* isolation worktrees. +# +# Companion shell functions (add to ~/.zshrc — NOT auto-installed): +# +# task-start() { +# local name="$1" +# local repo="$HOME/Code/lobu" +# "$repo/scripts/task-setup.sh" "$name" || return $? +# cd "$repo/.claude/worktrees/$name" && exec claude +# } +# task-resume() { +# local name="$1" +# local repo="$HOME/Code/lobu" +# [[ -d "$repo/.claude/worktrees/$name" ]] \ +# || { echo "no such worktree: $name"; return 1; } +# cd "$repo/.claude/worktrees/$name" && exec claude +# } +# +# The cd + exec must live in the shell function (not this script) so that the +# parent terminal actually moves and Warp/iTerm detect the new working dir. + +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 + echo " kebab-case task slug (lowercase letters/digits, hyphens)" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage +name="$1" + +if ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "error: name must be kebab-case: '$name'" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +worktree_dir="$repo/.claude/worktrees/$name" +branch="feat/$name" + +refresh_only=0 +if [[ -d "$worktree_dir" ]]; then + echo "→ worktree exists; refreshing .env + .env.local only" + refresh_only=1 +fi + +if [[ $refresh_only -eq 0 ]]; then + echo "→ creating lobu worktree: $worktree_dir on $branch" + (cd "$repo" && git fetch origin --quiet) + if (cd "$repo" && git show-ref --verify --quiet "refs/heads/$branch"); then + (cd "$repo" && git worktree add "$worktree_dir" "$branch") + else + (cd "$repo" && git worktree add "$worktree_dir" -b "$branch" origin/main) + fi + + echo "→ preparing packages/owletto submodule on $branch (real branch, not detached)" + (cd "$worktree_dir" && git submodule update --init packages/owletto) + ( + cd "$worktree_dir/packages/owletto" + git fetch origin --quiet + if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + git switch -c "$branch" --track "origin/$branch" + elif git show-ref --verify --quiet "refs/heads/$branch"; then + git switch "$branch" + else + git switch -c "$branch" origin/main + fi + ) + + echo "→ bun install" + (cd "$worktree_dir" && bun install) +fi + +if [[ -f "$repo/.env" ]]; then + cp "$repo/.env" "$worktree_dir/.env" + echo "→ copied .env from main repo" +else + echo "warning: no .env in $repo — worktree will lack secrets" >&2 +fi + +highest_port=8787 +highest_proxy=8118 +shopt -s nullglob +for env_local in "$repo"/.claude/worktrees/*/.env.local; do + [[ "$env_local" == "$worktree_dir/.env.local" ]] && continue + p="$(awk -F= '/^PORT=/{print $2; exit}' "$env_local" | tr -d '[:space:]')" + q="$(awk -F= '/^WORKER_PROXY_PORT=/{print $2; exit}' "$env_local" | tr -d '[:space:]')" + [[ -n "$p" && "$p" =~ ^[0-9]+$ && "$p" -gt "$highest_port" ]] && highest_port="$p" + [[ -n "$q" && "$q" =~ ^[0-9]+$ && "$q" -gt "$highest_proxy" ]] && highest_proxy="$q" +done +shopt -u nullglob + +if [[ -f "$worktree_dir/.env.local" ]] \ + && existing_port="$(awk -F= '/^PORT=/{print $2; exit}' "$worktree_dir/.env.local" | tr -d '[:space:]')" \ + && [[ "$existing_port" =~ ^[0-9]+$ ]]; then + port="$existing_port" + proxy="$(awk -F= '/^WORKER_PROXY_PORT=/{print $2; exit}' "$worktree_dir/.env.local" | tr -d '[:space:]')" + [[ "$proxy" =~ ^[0-9]+$ ]] || proxy=$((highest_proxy + 1)) +else + port=$((highest_port + 1)) + proxy=$((highest_proxy + 1)) +fi + +cat > "$worktree_dir/.env.local" < "$worktree_dir/.task" + +# Register the worktree as a Lobu CLI context so the Mac menubar can spawn +# `lobu run` for it (lifecycle: managed) against the worktree's source. Safe to +# re-run — `lobu context add` overwrites the existing entry. Non-fatal: the +# worktree is still useful even if the lobu CLI isn't on PATH yet. +if command -v lobu >/dev/null 2>&1; then + if lobu context add "$name" \ + --api-url "http://localhost:$port" \ + --port "$port" \ + --cwd "$worktree_dir" \ + --lifecycle managed >/dev/null; then + echo "→ registered Lobu context '$name' (menubar can spawn its server)" + else + echo "warning: failed to register Lobu context '$name'" >&2 + fi +else + echo "warning: 'lobu' CLI not on PATH; skipping context registration" >&2 +fi + +cat < Date: Mon, 18 May 2026 20:00:32 +0100 Subject: [PATCH 2/5] feat(cli,make): task-setup/task-clean Makefile targets + context rm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit relied on a per-user zsh function. Make this team- friendly by exposing the workflow as Makefile targets the whole repo can use, and add a cleanup path that takes the worktree down safely: - `make task-setup NAME=` — creates the paired worktree. - `make task-clean NAME= [FORCE=1]` — removes the worktree, both feat/ branches, and the Lobu CLI context. Refuses by default when there's uncommitted work or unpushed commits; FORCE=1 overrides. - `lobu context rm ` — new CLI command + removeContext() helper. Idempotent on missing entries; refuses the default context. The optional `task-start` / `task-resume` shell functions are still documented in the script header for users who want one-command setup + exec claude — Make can't `cd` the parent shell, but a shell function can. --- Makefile | 17 ++- packages/cli/src/commands/context.ts | 6 + packages/cli/src/index.ts | 8 ++ .../src/internal/__tests__/context.test.ts | 40 +++++++ packages/cli/src/internal/context.ts | 25 ++++ packages/cli/src/internal/index.ts | 1 + scripts/task-clean.sh | 112 ++++++++++++++++++ scripts/task-setup.sh | 21 ++-- 8 files changed, 221 insertions(+), 9 deletions(-) create mode 100755 scripts/task-clean.sh diff --git a/Makefile b/Makefile index e0f583359..d2f8ec6f9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Development Makefile for Lobu -.PHONY: help setup build test eval clean dev build-packages ensure-submodule clean-workers test-unit test-integration test-e2e typecheck +.PHONY: help setup build test eval clean dev build-packages ensure-submodule clean-workers test-unit test-integration test-e2e typecheck task-setup task-clean # Default target help: @@ -15,6 +15,8 @@ help: @echo " make eval - Run agent evals" @echo " make clean-workers - Stop any running embedded worker subprocesses" @echo " make typecheck - Strict typecheck (same as Dockerfile) for server + owletto" + @echo " make task-setup NAME= - Create a paired worktree at .claude/worktrees/ (lobu + submodule on real branch, .env copied, ports auto-assigned, Lobu context registered)" + @echo " make task-clean NAME= [FORCE=1] - Remove the worktree, both branches, and the Lobu context (refuses if there's uncommitted/unpushed work unless FORCE=1)" # Strict typecheck — mirrors the Dockerfile so local matches CI. Catches # what `build-packages` (relaxed, bundler-only) misses. @@ -70,6 +72,19 @@ test: eval: @npx @lobu/cli@latest eval +# --- Task worktrees --------------------------------------------------------- +# Paired-branch worktrees for parallel work without losing changes to the +# packages/owletto submodule. See scripts/task-setup.sh header for details +# (the script also documents an optional `task-start` shell function alias). + +task-setup: + @: $${NAME?Usage: make task-setup NAME=} + @./scripts/task-setup.sh "$(NAME)" + +task-clean: + @: $${NAME?Usage: make task-clean NAME= [FORCE=1]} + @./scripts/task-clean.sh "$(NAME)" $$( [ -n "$(FORCE)" ] && echo --force ) + # --- Test pipelines --------------------------------------------------------- # These mirror what CI runs (.github/workflows/ci.yml) so a passing local run # is a strong signal CI will pass. diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 3e1a9905c..a650cebe5 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -3,6 +3,7 @@ import { addContext, getCurrentContextName, loadContextConfig, + removeContext, resolveContext, setCurrentContext, } from "../internal/index.js"; @@ -71,6 +72,11 @@ export async function contextAddCommand(options: { ); } +export async function contextRmCommand(name: string): Promise { + await removeContext(name); + console.log(chalk.dim(`\n Removed context ${name}\n`)); +} + export async function contextUseCommand(name: string): Promise { const trimmedName = name.trim(); const config = await setCurrentContext(trimmedName); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 77b779423..d8fbe1d61 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -606,6 +606,14 @@ Memory: await contextUseCommand(name); }); + context + .command("rm ") + .description("Remove a named context (idempotent)") + .action(async (name: string) => { + const { contextRmCommand } = await import("./commands/context.js"); + await contextRmCommand(name); + }); + // ─── status ───────────────────────────────────────────────────────── withCommonOpts( program diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index c7a0414d7..f66c5b3b3 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -16,6 +16,7 @@ import { getActiveOrg, getServerConfig, loadContextConfig, + removeContext, setActiveOrg, setServerConfig, } from "../context"; @@ -177,6 +178,45 @@ describe("context management", () => { }); }); + test("removeContext deletes the entry and resets currentContext if needed", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + currentContext: "verify-flow", + contexts: { + lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, + "verify-flow": { apiUrl: "http://localhost:8788" }, + }, + }) + ); + + await removeContext("verify-flow"); + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts["verify-flow"]).toBeUndefined(); + expect(saved.currentContext).toBe(DEFAULT_CONTEXT_NAME); + }); + + test("removeContext is idempotent for missing entries", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await removeContext("never-existed"); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + + test("removeContext refuses the default context", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + contexts: { + [DEFAULT_CONTEXT_NAME]: { apiUrl: "https://app.lobu.ai/api/v1" }, + }, + }) + ); + + await expect(removeContext(DEFAULT_CONTEXT_NAME)).rejects.toThrow( + /Cannot remove the default context/ + ); + }); + test("drops invalid server fields during normalization", async () => { const configData = { currentContext: "local", diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index da0cf31f0..bbdb87fef 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -200,6 +200,31 @@ export async function addContext( return config; } +export async function removeContext( + name: string +): Promise { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new Error("Context name cannot be empty."); + } + + const config = await loadContextConfig(); + if (!config.contexts[trimmedName]) { + // Idempotent: removing a non-existent context is a no-op. + return config; + } + if (trimmedName === DEFAULT_CONTEXT_NAME) { + throw new Error(`Cannot remove the default context "${trimmedName}".`); + } + + delete config.contexts[trimmedName]; + if (config.currentContext === trimmedName) { + config.currentContext = DEFAULT_CONTEXT_NAME; + } + await saveContextConfig(config); + return config; +} + export async function setCurrentContext( name: string ): Promise { diff --git a/packages/cli/src/internal/index.ts b/packages/cli/src/internal/index.ts index d40964224..b09d8b0c0 100644 --- a/packages/cli/src/internal/index.ts +++ b/packages/cli/src/internal/index.ts @@ -5,6 +5,7 @@ export { getCurrentContextName, getMemoryUrl, loadContextConfig, + removeContext, resolveContext, setActiveOrg, setCurrentContext, diff --git a/scripts/task-clean.sh b/scripts/task-clean.sh new file mode 100755 index 000000000..a430466b5 --- /dev/null +++ b/scripts/task-clean.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# task-clean.sh — remove a task worktree, its branches in both repos, +# and its Lobu CLI context entry. +# +# Usage: +# scripts/task-clean.sh [--force] +# +# Refuses by default when there is unfinished work: +# - uncommitted changes in the lobu worktree or in packages/owletto +# - commits on feat/ not pushed to origin +# +# Pass --force (or `make task-clean NAME= FORCE=1`) to override. + +set -euo pipefail + +usage() { + echo "usage: $0 [--force]" >&2 + exit 1 +} + +force=0 +name="" +for arg in "$@"; do + case "$arg" in + --force|-f) force=1 ;; + -*) echo "error: unknown flag '$arg'" >&2; usage ;; + *) [[ -z "$name" ]] && name="$arg" || usage ;; + esac +done +[[ -n "$name" ]] || usage + +if ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "error: name must be kebab-case: '$name'" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +worktree_dir="$repo/.claude/worktrees/$name" +branch="feat/$name" + +if [[ ! -d "$worktree_dir" ]]; then + echo "error: no worktree at $worktree_dir" >&2 + exit 1 +fi + +# Count commits on $branch that aren't reachable from $upstream. Echoes 0 when +# $upstream is missing (treats "no upstream" as "no proven publish"; caller +# may still bail elsewhere). Echoes a positive number if the branch is ahead. +ahead_of() { + local gitdir="$1" upstream="$2" + if ! git -C "$gitdir" rev-parse --verify "$upstream" >/dev/null 2>&1; then + echo 0 + return + fi + git -C "$gitdir" rev-list --count "$upstream..$branch" 2>/dev/null || echo 0 +} + +if [[ $force -eq 0 ]]; then + if [[ -n "$(git -C "$worktree_dir" status --porcelain)" ]]; then + echo "error: uncommitted changes in $worktree_dir (pass --force to discard)" >&2 + exit 1 + fi + if [[ -d "$worktree_dir/packages/owletto" ]] \ + && [[ -n "$(git -C "$worktree_dir/packages/owletto" status --porcelain)" ]]; then + echo "error: uncommitted changes in packages/owletto (pass --force to discard)" >&2 + exit 1 + fi + + ahead_lobu="$(ahead_of "$worktree_dir" "origin/$branch")" + # When the branch was never pushed, fall back to "ahead of origin/main". + if ! git -C "$worktree_dir" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + ahead_lobu="$(ahead_of "$worktree_dir" "origin/main")" + fi + if [[ "$ahead_lobu" != "0" ]]; then + echo "error: lobu $branch has $ahead_lobu unpushed commit(s) (pass --force to discard)" >&2 + exit 1 + fi + + if [[ -d "$worktree_dir/packages/owletto" ]]; then + ahead_owl="$(ahead_of "$worktree_dir/packages/owletto" "origin/$branch")" + if ! git -C "$worktree_dir/packages/owletto" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + ahead_owl="$(ahead_of "$worktree_dir/packages/owletto" "origin/main")" + fi + if [[ "$ahead_owl" != "0" ]]; then + echo "error: owletto $branch has $ahead_owl unpushed commit(s) (pass --force to discard)" >&2 + exit 1 + fi + fi +fi + +echo "→ removing worktree $worktree_dir" +git -C "$repo" worktree remove "$worktree_dir" --force + +if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then + echo "→ deleting lobu branch $branch" + git -C "$repo" branch -D "$branch" +fi + +if [[ -d "$repo/packages/owletto" ]] \ + && git -C "$repo/packages/owletto" show-ref --verify --quiet "refs/heads/$branch"; then + echo "→ deleting owletto branch $branch" + git -C "$repo/packages/owletto" branch -D "$branch" +fi + +if command -v lobu >/dev/null 2>&1; then + if lobu context rm "$name" >/dev/null 2>&1; then + echo "→ removed Lobu context '$name'" + fi +fi + +echo "✓ cleaned up task '$name'" diff --git a/scripts/task-setup.sh b/scripts/task-setup.sh index d12001d3a..0d58e1695 100755 --- a/scripts/task-setup.sh +++ b/scripts/task-setup.sh @@ -2,10 +2,14 @@ # task-setup.sh — prepare a paired-branch worktree for a new task. # # Usage: -# scripts/task-setup.sh +# make task-setup NAME= (recommended, team-friendly) +# scripts/task-setup.sh (direct invocation) # # kebab-case task slug (e.g. fix-sse-leak) # +# Companion: `make task-clean NAME= [FORCE=1]` removes the worktree, +# its branches in both repos, and the Lobu CLI context. +# # Behavior (idempotent — re-running on an existing worktree refreshes .env # and .env.local only): # 1. Creates a lobu worktree at .claude/worktrees/ on branch feat/. @@ -19,24 +23,25 @@ # 6. Drops a .task marker file at the worktree root so `git worktree list` # can distinguish human task-worktrees from agent-* isolation worktrees. # -# Companion shell functions (add to ~/.zshrc — NOT auto-installed): +# Optional shell-function sugar — `make task-setup` does the setup, then you +# still have to `cd && claude` by hand. If you want one command that +# also moves your shell and launches claude, add this to ~/.zshrc: # # task-start() { -# local name="$1" -# local repo="$HOME/Code/lobu" +# local name="$1"; local repo="$HOME/Code/lobu" # "$repo/scripts/task-setup.sh" "$name" || return $? # cd "$repo/.claude/worktrees/$name" && exec claude # } # task-resume() { -# local name="$1" -# local repo="$HOME/Code/lobu" +# local name="$1"; local repo="$HOME/Code/lobu" # [[ -d "$repo/.claude/worktrees/$name" ]] \ # || { echo "no such worktree: $name"; return 1; } # cd "$repo/.claude/worktrees/$name" && exec claude # } # -# The cd + exec must live in the shell function (not this script) so that the -# parent terminal actually moves and Warp/iTerm detect the new working dir. +# The cd + exec must live in the shell function (not a Makefile target or this +# script) so that the parent terminal actually moves and Warp/iTerm detect the +# new working directory. set -euo pipefail From c716f241867e21f3183e60e8be2fe12723553451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 20:18:25 +0100 Subject: [PATCH 3/5] fix(cli,scripts): address Pi review findings on task-setup PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi review of #891 surfaced 7 real issues (no cosmetic). All addressed: 1. .task untracked → task-clean always refuses without FORCE=1. Fix: add `.task` to repo .gitignore so worktrees inherit the rule. 2. Submodule branch was created from origin/main, immediately dirtying the worktree when the parent pin and owletto's origin/main differ (today's reality). Fix: branch from HEAD (the just-checked-out submodule pin) instead. 3. `make task-clean FORCE=0` was treated as truthy ([ -n ] on any non-empty string). Fix: explicit `[ "$(FORCE)" = "1" ]`. 4. task-clean trusted potentially-stale origin refs when checking "ahead of remote". Fix: `git fetch origin --prune` first in both the parent and submodule repos. 5. `task-setup NAME=lobu` would have overwritten the default Lobu CLI context (and `lobu context rm` refuses the default, so cleanup couldn't recover). Fix at two layers: - task-setup.sh reserves names matching the built-in contexts (lobu, dev, local) and refuses early. - addContext() in the CLI refuses to overwrite the default context — protects any caller, not just the script. 6. `lobu context add --port` silently accepted malformed values ("abc" → no port; "8788abc" → 8788; "70000" → out of TCP range but persisted). Fix: parser validates `^\d+$` and 1..65535 range. 7. Tests: cover addContext refusing the default name. 12/12 pass. --- .gitignore | 5 +++++ Makefile | 2 +- packages/cli/src/index.ts | 11 ++++++++++- .../cli/src/internal/__tests__/context.test.ts | 15 +++++++++++++++ packages/cli/src/internal/context.ts | 9 ++++++--- scripts/task-clean.sh | 8 ++++++++ scripts/task-setup.sh | 16 +++++++++++++++- 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 7195b6762..aba962644 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ node_modules **/.claude/settings.local.json .claude/worktrees/ +# Marker file written by scripts/task-setup.sh at each worktree root. Untracked +# inside the worktree's own checkout, so it must be ignored to keep `git status` +# clean (otherwise `make task-clean` would always refuse without FORCE=1). +.task + # Environment files .env .env.* diff --git a/Makefile b/Makefile index d2f8ec6f9..fb23e840d 100644 --- a/Makefile +++ b/Makefile @@ -83,7 +83,7 @@ task-setup: task-clean: @: $${NAME?Usage: make task-clean NAME= [FORCE=1]} - @./scripts/task-clean.sh "$(NAME)" $$( [ -n "$(FORCE)" ] && echo --force ) + @./scripts/task-clean.sh "$(NAME)" $$( [ "$(FORCE)" = "1" ] && echo --force ) # --- Test pipelines --------------------------------------------------------- # These mirror what CI runs (.github/workflows/ci.yml) so a passing local run diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d8fbe1d61..18e53567a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -546,7 +546,16 @@ Memory: .option( "--port ", "Server port (when this context owns a managed lobu server)", - (value) => Number.parseInt(value, 10) + (value: string) => { + if (!/^\d+$/.test(value)) { + throw new Error(`--port must be an integer, got "${value}"`); + } + const n = Number.parseInt(value, 10); + if (n < 1 || n > 65535) { + throw new Error(`--port must be in 1-65535, got ${n}`); + } + return n; + } ) .option("--host ", "Server host (default: 127.0.0.1)") .option( diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index f66c5b3b3..ca9385012 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -166,6 +166,21 @@ describe("context management", () => { }); }); + test("addContext refuses to overwrite the default context", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + contexts: { + [DEFAULT_CONTEXT_NAME]: { apiUrl: "https://app.lobu.ai/api/v1" }, + }, + }) + ); + + await expect( + addContext(DEFAULT_CONTEXT_NAME, "http://localhost:8788") + ).rejects.toThrow(/Cannot overwrite the default context/); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + test("addContext without server keeps shape backwards-compatible", async () => { readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index bbdb87fef..b41459051 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -186,6 +186,11 @@ export async function addContext( if (!trimmedName) { throw new Error("Context name cannot be empty."); } + if (trimmedName === DEFAULT_CONTEXT_NAME) { + throw new Error( + `Cannot overwrite the default context "${trimmedName}". Pick a different name.` + ); + } const config = await loadContextConfig(); const entry: LobuContextEntry = { @@ -200,9 +205,7 @@ export async function addContext( return config; } -export async function removeContext( - name: string -): Promise { +export async function removeContext(name: string): Promise { const trimmedName = name.trim(); if (!trimmedName) { throw new Error("Context name cannot be empty."); diff --git a/scripts/task-clean.sh b/scripts/task-clean.sh index a430466b5..38436c74e 100755 --- a/scripts/task-clean.sh +++ b/scripts/task-clean.sh @@ -57,6 +57,14 @@ ahead_of() { } if [[ $force -eq 0 ]]; then + # Refresh remote-tracking refs so the "ahead of origin/" check below + # doesn't trust a stale local ref (e.g. branch deleted on remote → we'd see + # origin/ at its old position and conclude 0 unpushed commits). + (cd "$worktree_dir" && git fetch origin --prune --quiet) || true + if [[ -d "$worktree_dir/packages/owletto" ]]; then + (cd "$worktree_dir/packages/owletto" && git fetch origin --prune --quiet) || true + fi + if [[ -n "$(git -C "$worktree_dir" status --porcelain)" ]]; then echo "error: uncommitted changes in $worktree_dir (pass --force to discard)" >&2 exit 1 diff --git a/scripts/task-setup.sh b/scripts/task-setup.sh index 0d58e1695..2009e52c7 100755 --- a/scripts/task-setup.sh +++ b/scripts/task-setup.sh @@ -59,6 +59,17 @@ if ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then exit 1 fi +# Reserve names that match the built-in Lobu CLI contexts so `task-setup` never +# clobbers a global context (it calls `lobu context add `, which would +# overwrite the entry — and `lobu context rm` refuses the default, so cleanup +# wouldn't recover). Keep this list in sync with the contexts most users have. +case "$name" in + lobu|dev|local) + echo "error: '$name' is a reserved CLI context name; pick a feature slug" >&2 + exit 1 + ;; +esac + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo="$(cd "$script_dir/.." && pwd)" worktree_dir="$repo/.claude/worktrees/$name" @@ -81,6 +92,9 @@ if [[ $refresh_only -eq 0 ]]; then echo "→ preparing packages/owletto submodule on $branch (real branch, not detached)" (cd "$worktree_dir" && git submodule update --init packages/owletto) + # Branch from the submodule HEAD (the SHA the parent pins), NOT origin/main — + # the pin and origin/main can differ, and using origin/main here would + # silently bump the submodule pointer in the new worktree. ( cd "$worktree_dir/packages/owletto" git fetch origin --quiet @@ -89,7 +103,7 @@ if [[ $refresh_only -eq 0 ]]; then elif git show-ref --verify --quiet "refs/heads/$branch"; then git switch "$branch" else - git switch -c "$branch" origin/main + git switch -c "$branch" HEAD fi ) From d94ea1d98c7261eda7bca5d794d3020cc3e92ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 21:04:34 +0100 Subject: [PATCH 4/5] =?UTF-8?q?feat(scripts,make):=20task-use=20=E2=80=94?= =?UTF-8?q?=20symlink=20swap=20for=20Chrome=20ext=20+=20Mac=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets external tools that hardcode a path (Chrome's "Load unpacked" / Xcode opening the Mac app) follow whichever worktree is currently active, without manually re-pointing them. - `scripts/task-use.sh ` retargets two fixed symlinks: ~/.config/lobu-dev/active/chrome → /packages/owletto/apps/chrome ~/.config/lobu-dev/active/mac → /packages/owletto/apps/mac Chrome's Load-unpacked / Xcode's Open are pointed at the symlinks permanently; `task-use` swaps which worktree they resolve to. - `task-setup` auto-activates each new worktree it creates. - `task-clean` of the *active* worktree falls back to `main` so the symlinks never dangle into a deleted directory. - Records the active name at ~/.config/lobu-dev/active/.active-name. - New Makefile target: `make task-use NAME=`. Scoped to two specific consumer dirs intentionally — no ~/.config/lobu/worktree-targets.json registry yet. When a third consumer appears, revisit the registry design. Verified end-to-end: setup auto-activates, swap to main, swap back, clean-of-active falls back to main with no dangling links. --- Makefile | 7 ++++- scripts/task-clean.sh | 8 ++++++ scripts/task-setup.sh | 4 +++ scripts/task-use.sh | 67 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 scripts/task-use.sh diff --git a/Makefile b/Makefile index fb23e840d..1adf9ad0b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Development Makefile for Lobu -.PHONY: help setup build test eval clean dev build-packages ensure-submodule clean-workers test-unit test-integration test-e2e typecheck task-setup task-clean +.PHONY: help setup build test eval clean dev build-packages ensure-submodule clean-workers test-unit test-integration test-e2e typecheck task-setup task-clean task-use # Default target help: @@ -17,6 +17,7 @@ help: @echo " make typecheck - Strict typecheck (same as Dockerfile) for server + owletto" @echo " make task-setup NAME= - Create a paired worktree at .claude/worktrees/ (lobu + submodule on real branch, .env copied, ports auto-assigned, Lobu context registered)" @echo " make task-clean NAME= [FORCE=1] - Remove the worktree, both branches, and the Lobu context (refuses if there's uncommitted/unpushed work unless FORCE=1)" + @echo " make task-use NAME= - Point Chrome ext / Mac app symlinks at this worktree (or 'main' for the canonical checkout)" # Strict typecheck — mirrors the Dockerfile so local matches CI. Catches # what `build-packages` (relaxed, bundler-only) misses. @@ -85,6 +86,10 @@ task-clean: @: $${NAME?Usage: make task-clean NAME= [FORCE=1]} @./scripts/task-clean.sh "$(NAME)" $$( [ "$(FORCE)" = "1" ] && echo --force ) +task-use: + @: $${NAME?Usage: make task-use NAME=} + @./scripts/task-use.sh "$(NAME)" + # --- Test pipelines --------------------------------------------------------- # These mirror what CI runs (.github/workflows/ci.yml) so a passing local run # is a strong signal CI will pass. diff --git a/scripts/task-clean.sh b/scripts/task-clean.sh index 38436c74e..d756998e0 100755 --- a/scripts/task-clean.sh +++ b/scripts/task-clean.sh @@ -117,4 +117,12 @@ if command -v lobu >/dev/null 2>&1; then fi fi +# If this worktree was the active target for the Chrome/Mac symlinks, fall +# back to 'main' so the symlinks don't dangle into a deleted directory. +active_name_file="$HOME/.config/lobu-dev/active/.active-name" +if [[ -f "$active_name_file" ]] && [[ "$(cat "$active_name_file")" == "$name" ]]; then + "$repo/scripts/task-use.sh" main >/dev/null && \ + echo "→ active worktree was '$name'; reset to 'main'" +fi + echo "✓ cleaned up task '$name'" diff --git a/scripts/task-setup.sh b/scripts/task-setup.sh index 2009e52c7..0e864eb66 100755 --- a/scripts/task-setup.sh +++ b/scripts/task-setup.sh @@ -150,6 +150,10 @@ echo "→ .env.local: PORT=$port WORKER_PROXY_PORT=$proxy" echo "$name" > "$worktree_dir/.task" +# Auto-activate this worktree for the Chrome extension / Mac app (symlink swap). +# Safe / idempotent — task-use.sh re-points existing symlinks each time. +"$repo/scripts/task-use.sh" "$name" || true + # Register the worktree as a Lobu CLI context so the Mac menubar can spawn # `lobu run` for it (lifecycle: managed) against the worktree's source. Safe to # re-run — `lobu context add` overwrites the existing entry. Non-fatal: the diff --git a/scripts/task-use.sh b/scripts/task-use.sh new file mode 100755 index 000000000..549bf21ab --- /dev/null +++ b/scripts/task-use.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# task-use.sh — point external tools (Chrome extension, Xcode/Mac app) at a +# specific worktree's source by retargeting fixed symlinks. +# +# Usage: +# scripts/task-use.sh # use the worktree at .claude/worktrees/ +# scripts/task-use.sh main # use the main checkout (no worktree) +# +# Symlinks (created/updated each run): +# ~/.config/lobu-dev/active/chrome → /packages/owletto/apps/chrome +# ~/.config/lobu-dev/active/mac → /packages/owletto/apps/mac +# +# Point Chrome's "Load unpacked" at ~/.config/lobu-dev/active/chrome ONCE. +# Open Xcode against ~/.config/lobu-dev/active/mac. Then `task-use ` +# swaps which worktree those resolve to; reload the extension / re-open the +# Xcode project to pick up the new source. + +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage +name="$1" + +if [[ "$name" != "main" ]] && ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "error: name must be kebab-case or 'main': '$name'" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" + +if [[ "$name" == "main" ]]; then + source_root="$repo" +else + source_root="$repo/.claude/worktrees/$name" + if [[ ! -d "$source_root" ]]; then + echo "error: no worktree at $source_root" >&2 + exit 1 + fi +fi + +active_dir="$HOME/.config/lobu-dev/active" +mkdir -p "$active_dir" + +set_link() { + local label="$1" target="$2" link="$3" + if [[ -d "$target" ]]; then + ln -sfn "$target" "$link" + echo "→ $label: $link → $target" + else + # Source path missing in this worktree (e.g. submodule not initialized). + # Leave the existing symlink alone rather than break it. + echo "(skip $label: source not present at $target)" + fi +} + +set_link "chrome" "$source_root/packages/owletto/apps/chrome" "$active_dir/chrome" +set_link "mac" "$source_root/packages/owletto/apps/mac" "$active_dir/mac" + +# Record the active task name for tooling that wants it (and for task-clean +# to know whether to reset to 'main' when cleaning the active worktree). +echo "$name" > "$active_dir/.active-name" +echo "✓ active worktree: $name" From 0f43709ac9ca82dee7aa56254493a4ea20572e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 21:23:52 +0100 Subject: [PATCH 5/5] fix(scripts): print Chrome reload reminder after task-use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi second-pass review noted that Chrome's MV3 extension does not auto- reload when the "Load unpacked" symlink retargets — the user must click Reload in chrome://extensions. Without the reminder it looks like the worktree swap didn't take effect. No code path change; only a multi-line note appended to task-use.sh's success output. Pi verdict (verbatim summary): - Option B (symlink swap, no registry) is sound for this stage. - Registry would be overengineering at 2 hardcoded entries; revisit when a third real consumer appears. - LobuServerConfig.cwd is the right layer for the dev server (no symlink needed); caveat: running servers must be restarted after cwd/config change. - Sharper-name suggestion (e.g. owletto-chrome-ext over bare chrome) is nice-to-have — deferred. --- scripts/task-use.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/task-use.sh b/scripts/task-use.sh index 549bf21ab..38e9adbde 100755 --- a/scripts/task-use.sh +++ b/scripts/task-use.sh @@ -65,3 +65,8 @@ set_link "mac" "$source_root/packages/owletto/apps/mac" "$active_dir/mac" # to know whether to reset to 'main' when cleaning the active worktree). echo "$name" > "$active_dir/.active-name" echo "✓ active worktree: $name" +echo "" +echo " ↳ Chrome will not auto-reload the extension when the symlink retargets." +echo " Click 'Reload' on the owletto extension in chrome://extensions to pick" +echo " up the new source. (MV3 service workers re-register on extension reload," +echo " not on symlink change.)"