Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/case-custom-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Create a new case in a folder of your choice. Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case (`CLAUDE.md`, `src/`, hooks), and listed alongside the rest. `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty (use Link Existing for a project that already has files), system folders, the home folder and credential folders are refused, and nothing is left behind if creation fails part-way. Admin only in multi-user mode, like Link Existing.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
Expand Down
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/case-custom-path.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
Expand Down
14 changes: 14 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,20 @@ normal `caseName`/`mode`/etc. body)
jarring than a full relaunch, and folding it into the one-shot path is
separate work — see `docs/custom-model-endpoints-plan.md`).

## Creating a case in a custom folder

`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.

The target is judged before anything is written:

- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.

Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.

## CLI management

Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Core-Concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:

| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Quick-Start.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three

| Tab | Use it when |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |

Expand Down
137 changes: 137 additions & 0 deletions src/web/case-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
* judged before anything is created:
*
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
* a case this accepts is one a session can actually start in;
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
* its symlink-resolved form, so a link into `/etc` is not a way around it;
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
* - it must not be a symlink.
*
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
* so it can be tested without a disk.
*
* @module web/case-path
*/

import { promises as fs } from 'node:fs';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { isValidWorkingDir } from './schemas.js';

/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
const BLOCKED_SYSTEM_ROOTS = [
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib32',
'/lib64',
'/proc',
'/run',
'/sbin',
'/sys',
'/usr',
];

/** Home-relative trees that hold credentials or other tools' own configuration. */
const BLOCKED_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.claude', '.codex', '.gemini'];

export interface NewCasePathContext {
home: string;
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
dataDir: string;
}

export type NewCasePathResult =
| { ok: true; path: string; existedEmpty: boolean }
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS'; reason: string };

const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);

/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
export function expandHome(raw: string, home: string): string {
if (raw === '~') return home;
if (raw.startsWith('~/')) return join(home, raw.slice(2));
return raw;
}

/** Why a case may not live at this (already absolute and normalised) path, or null. */
export function blockedReason(absPath: string, ctx: NewCasePathContext): string | null {
if (absPath === sep) return 'The filesystem root cannot be a case';
for (const root of BLOCKED_SYSTEM_ROOTS) {
if (isWithin(absPath, root)) return `${root} is a system directory`;
}
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
for (const dir of BLOCKED_HOME_DIRS) {
if (isWithin(absPath, join(ctx.home, dir))) return `~/${dir} holds credentials or another tool's configuration`;
}
if (isWithin(absPath, ctx.dataDir)) return "Codeman's own data folder cannot be a case";
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
// the one this process uses.
if (absPath.startsWith(ctx.home + sep)) {
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
}
return null;
}

/**
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
* Never creates anything.
*/
export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext): Promise<NewCasePathResult> {
const typed = raw.trim();
if (!typed) return { ok: false, code: 'INVALID', reason: 'Enter a folder path' };
const expanded = expandHome(typed, ctx.home);
if (!isValidWorkingDir(expanded)) {
return {
ok: false,
code: 'INVALID',
reason: 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)',
};
}

const target = resolve(expanded);
const typedBlock = blockedReason(target, ctx);
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };

// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
let realParent: string;
try {
realParent = await fs.realpath(dirname(target));
if (!(await fs.stat(realParent)).isDirectory()) {
return { ok: false, code: 'INVALID', reason: `${dirname(target)} is not a folder` };
}
} catch {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
const real = join(realParent, basename(target));
const realBlock = blockedReason(real, ctx);
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };

try {
const st = await fs.lstat(real);
if (st.isSymbolicLink()) return { ok: false, code: 'INVALID', reason: `${target} is a symbolic link` };
if (!st.isDirectory()) return { ok: false, code: 'INVALID', reason: `${target} exists and is not a folder` };
if ((await fs.readdir(real)).length > 0) {
return {
ok: false,
code: 'EXISTS',
reason: `${target} already has files in it. Use "Link Existing" for a project that already exists`,
};
}
return { ok: true, path: real, existedEmpty: true };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, path: real, existedEmpty: false };
return { ok: false, code: 'INVALID', reason: `Cannot read ${target}: ${(err as Error).message}` };
}
}
16 changes: 14 additions & 2 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2972,15 +2972,27 @@ <h2>Create New</h2>
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
</div>
<div class="form-row">
<label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row" id="newCaseCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="newCaseCustomPathToggle" onchange="app.toggleNewCaseCustomPath()"> 📁 Create in a custom folder</label>
<span class="form-hint">By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.</span>
</div>
<div class="form-row" id="newCaseCustomPathRow" style="display:none">
<label>Parent Folder</label>
<div class="path-input-group">
<input type="text" id="newCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openNewCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="newCasePathPreview">Pick the folder the new case folder should be created inside.</span>
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleNewCaseCustomPath()"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
</div>
Expand Down
Loading
Loading