diff --git a/README.md b/README.md index 2956888..485fbdd 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,27 @@ npm i -g codedash-app codedash run ``` +## Docker + +Docker files live under `docker/`. Use `./in-docker.sh` to select which host agent data sources should be mounted into the container. + +```bash +./in-docker.sh --claude --codex +./in-docker.sh --cursor -- up -d +./in-docker.sh --all -- up --build +``` + +Supported flags: +- `--claude` mounts `~/.claude` to `/root/.claude` +- `--codex` mounts `~/.codex` to `/root/.codex` +- `--cursor` mounts `~/.cursor` to `/root/.cursor` +- `--opencode` mounts `~/.local/share/opencode/opencode.db` +- `--kiro` mounts `~/Library/Application Support/kiro-cli/data.sqlite3` +- `--all` enables every supported mount + +The base container always listens on `0.0.0.0:3847`, so it can be reached via the host machine LAN IP when Docker publishes that port. +Mounted agent data sources are writable inside the container so CodeDash actions like delete, convert, and settings updates work the same way as a host run. + ## Supported Agents | Agent | Sessions | Preview | Search | Live Status | Convert | Handoff | Launch | @@ -72,6 +93,16 @@ codedash restart codedash stop ``` +Bind host can be configured with `--host=ADDR` or `CODEDASH_HOST`: + +```bash +codedash run +codedash run --host=0.0.0.0 +CODEDASH_HOST=0.0.0.0 codedash run +``` + +If both are set, `--host` takes precedence. + **Keyboard Shortcuts**: `/` search, `j/k` navigate, `Enter` open, `x` star, `d` delete, `s` select, `g` group, `r` refresh, `Esc` close ## Data Sources @@ -84,7 +115,7 @@ codedash stop ~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) ``` -Zero dependencies. Everything runs on `localhost`. +Zero dependencies. By default everything runs on `localhost`. Set `--host=0.0.0.0` or `CODEDASH_HOST=0.0.0.0` to listen on all interfaces, including your host machine LAN IP. ## Install Agents diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ab7b5b8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +RUN apk add --no-cache sqlite + +WORKDIR /app + +COPY package.json ./ +COPY bin ./bin +COPY src ./src + +EXPOSE 3847 + +CMD ["node", "bin/cli.js", "run", "--no-browser", "--port=3847"] diff --git a/docker/docker-compose.claude.yml b/docker/docker-compose.claude.yml new file mode 100644 index 0000000..f6ffe98 --- /dev/null +++ b/docker/docker-compose.claude.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_CLAUDE_DIR} + target: /root/.claude diff --git a/docker/docker-compose.codex.yml b/docker/docker-compose.codex.yml new file mode 100644 index 0000000..0158dd9 --- /dev/null +++ b/docker/docker-compose.codex.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_CODEX_DIR} + target: /root/.codex diff --git a/docker/docker-compose.cursor.yml b/docker/docker-compose.cursor.yml new file mode 100644 index 0000000..60aad96 --- /dev/null +++ b/docker/docker-compose.cursor.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_CURSOR_DIR} + target: /root/.cursor diff --git a/docker/docker-compose.kiro.yml b/docker/docker-compose.kiro.yml new file mode 100644 index 0000000..0e86d93 --- /dev/null +++ b/docker/docker-compose.kiro.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_KIRO_DB} + target: /root/Library/Application Support/kiro-cli/data.sqlite3 diff --git a/docker/docker-compose.opencode.yml b/docker/docker-compose.opencode.yml new file mode 100644 index 0000000..33b52a0 --- /dev/null +++ b/docker/docker-compose.opencode.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_OPENCODE_DB} + target: /root/.local/share/opencode/opencode.db diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..de94762 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,14 @@ +services: + codedash: + image: ghcr.io/vakovalskii/codedash + build: + context: .. + dockerfile: docker/Dockerfile + container_name: codedash + restart: unless-stopped + environment: + HOME: /root + CODEDASH_HOST: 0.0.0.0 + CODEDASH_LOG: "1" + ports: + - "3847:3847" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1343794..bcfce85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Overview -CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 6 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro. Single process serves a web UI at `localhost:3847`. +CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 6 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro. Single process serves a web UI at `localhost:3847` by default, or another bind host via `--host=ADDR` / `CODEDASH_HOST`. ``` Browser (localhost:3847) Node.js Server diff --git a/in-docker.sh b/in-docker.sh new file mode 100755 index 0000000..f016b6c --- /dev/null +++ b/in-docker.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DOCKER_DIR="$SCRIPT_DIR/docker" + +BASE_COMPOSE="$DOCKER_DIR/docker-compose.yml" + +USE_CLAUDE=0 +USE_CODEX=0 +USE_CURSOR=0 +USE_KIRO=0 +USE_OPENCODE=0 + +print_usage() { + cat <<'EOF' +Usage: ./in-docker.sh [options] [-- docker-compose-args...] + +Options: + --claude Mount ~/.claude + --codex Mount ~/.codex + --cursor Mount ~/.cursor + --kiro Mount ~/Library/Application Support/kiro-cli/data.sqlite3 + --opencode Mount ~/.local/share/opencode/opencode.db + --all Enable all supported mounts + -h, --help Show this help + +Examples: + ./in-docker.sh --claude --codex + ./in-docker.sh --all -- up --build + ./in-docker.sh --cursor -- up -d + +If no docker-compose args are provided, the script runs: + docker compose ... up --build +EOF +} + +require_path() { + label=$1 + path_value=$2 + if [ ! -e "$path_value" ]; then + printf '%s\n' "Missing $label path: $path_value" >&2 + exit 1 + fi +} + +while [ $# -gt 0 ]; do + case "$1" in + --claude) + USE_CLAUDE=1 + shift + ;; + --codex) + USE_CODEX=1 + shift + ;; + --cursor) + USE_CURSOR=1 + shift + ;; + --kiro) + USE_KIRO=1 + shift + ;; + --opencode) + USE_OPENCODE=1 + shift + ;; + --all) + USE_CLAUDE=1 + USE_CODEX=1 + USE_CURSOR=1 + USE_KIRO=1 + USE_OPENCODE=1 + shift + ;; + -h|--help) + print_usage + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +compose_files=(-f "$BASE_COMPOSE") +compose_args=("$@") + +if [ "${#compose_args[@]}" -eq 0 ]; then + compose_args=(up --build) +fi + +if [ "$USE_CLAUDE" -eq 1 ]; then + HOST_CLAUDE_DIR="${HOST_CLAUDE_DIR:-$HOME/.claude}" + require_path "Claude" "$HOST_CLAUDE_DIR" + export HOST_CLAUDE_DIR + compose_files+=(-f "$DOCKER_DIR/docker-compose.claude.yml") +fi + +if [ "$USE_CODEX" -eq 1 ]; then + HOST_CODEX_DIR="${HOST_CODEX_DIR:-$HOME/.codex}" + require_path "Codex" "$HOST_CODEX_DIR" + export HOST_CODEX_DIR + compose_files+=(-f "$DOCKER_DIR/docker-compose.codex.yml") +fi + +if [ "$USE_CURSOR" -eq 1 ]; then + HOST_CURSOR_DIR="${HOST_CURSOR_DIR:-$HOME/.cursor}" + require_path "Cursor" "$HOST_CURSOR_DIR" + export HOST_CURSOR_DIR + compose_files+=(-f "$DOCKER_DIR/docker-compose.cursor.yml") +fi + +if [ "$USE_KIRO" -eq 1 ]; then + HOST_KIRO_DB="${HOST_KIRO_DB:-$HOME/Library/Application Support/kiro-cli/data.sqlite3}" + require_path "Kiro" "$HOST_KIRO_DB" + export HOST_KIRO_DB + compose_files+=(-f "$DOCKER_DIR/docker-compose.kiro.yml") +fi + +if [ "$USE_OPENCODE" -eq 1 ]; then + HOST_OPENCODE_DB="${HOST_OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}" + require_path "OpenCode" "$HOST_OPENCODE_DB" + export HOST_OPENCODE_DB + compose_files+=(-f "$DOCKER_DIR/docker-compose.opencode.yml") +fi + +exec docker compose "${compose_files[@]}" "${compose_args[@]}" diff --git a/src/frontend/app.js b/src/frontend/app.js index 6fd757a..735709c 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -122,6 +122,43 @@ function showToast(msg) { setTimeout(() => el.classList.remove('show'), 2500); } +function fallbackCopyText(text) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch (e) { + return false; + } +} + +function copyText(text, successMsg) { + var done = function () { + showToast(successMsg || ('Copied: ' + text)); + return true; + }; + var fail = function () { + if (fallbackCopyText(text)) return done(); + prompt('Copy this command:', text); + showToast(window.isSecureContext ? 'Clipboard copy failed' : 'Clipboard unavailable on non-secure origin'); + return false; + }; + + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + return navigator.clipboard.writeText(text).then(done).catch(fail); + } + return Promise.resolve(fail()); +} + function formatBytes(bytes) { if (!bytes || bytes < 1024) return (bytes || 0) + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; @@ -137,28 +174,38 @@ function estimateCost(fileSize) { // ── Subscription service plans (pricing as of 2025) ───────────── var SERVICE_PLANS = { - 'Claude': { label: 'Claude (Anthropic)', plans: [ - { name: 'Pro', price: 20 }, - { name: 'Max 5×', price: 100 }, - { name: 'Max 20×', price: 200 } - ]}, - 'OpenAI': { label: 'OpenAI (ChatGPT)', plans: [ - { name: 'Plus', price: 20 }, - { name: 'Pro', price: 200 } - ]}, - 'Cursor': { label: 'Cursor', plans: [ - { name: 'Pro', price: 20 }, - { name: 'Pro+', price: 60 }, - { name: 'Ultra', price: 200 } - ]}, - 'Kiro': { label: 'Kiro', plans: [ - { name: 'Pro', price: 20 }, - { name: 'Pro+', price: 40 }, - { name: 'Power', price: 200 } - ]}, - 'OpenCode': { label: 'OpenCode', plans: [ - { name: 'Go', price: 10 } - ]} + 'Claude': { + label: 'Claude (Anthropic)', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Max 5×', price: 100 }, + { name: 'Max 20×', price: 200 } + ] + }, + 'OpenAI': { + label: 'OpenAI (ChatGPT)', plans: [ + { name: 'Plus', price: 20 }, + { name: 'Pro', price: 200 } + ] + }, + 'Cursor': { + label: 'Cursor', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 60 }, + { name: 'Ultra', price: 200 } + ] + }, + 'Kiro': { + label: 'Kiro', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 40 }, + { name: 'Power', price: 200 } + ] + }, + 'OpenCode': { + label: 'OpenCode', plans: [ + { name: 'Go', price: 10 } + ] + } }; function onSubServiceChange() { @@ -170,7 +217,7 @@ function onSubServiceChange() { planEl.innerHTML = ''; paidEl.value = ''; if (service && SERVICE_PLANS[service]) { - SERVICE_PLANS[service].plans.forEach(function(p) { + SERVICE_PLANS[service].plans.forEach(function (p) { var opt = document.createElement('option'); opt.value = p.name; opt.textContent = p.name + ' ($' + p.price + '/mo)'; @@ -186,7 +233,7 @@ function onSubPlanChange() { var service = serviceEl ? serviceEl.value : ''; var planName = planEl ? planEl.value : ''; if (service && planName && SERVICE_PLANS[service]) { - var found = SERVICE_PLANS[service].plans.find(function(p) { return p.name === planName; }); + var found = SERVICE_PLANS[service].plans.find(function (p) { return p.name === planName; }); if (found && paidEl) paidEl.value = found.price; } } @@ -200,7 +247,7 @@ function getSubscriptionConfig() { return raw; } function saveSubscriptionConfig(cfg) { localStorage.setItem('codedash-subscription', JSON.stringify(cfg)); } -function subTotalPaid(entries) { return entries.reduce(function(s,e){return s+(parseFloat(e.paid)||0);},0); } +function subTotalPaid(entries) { return entries.reduce(function (s, e) { return s + (parseFloat(e.paid) || 0); }, 0); } function addSubEntry() { var service = (document.getElementById('sub-new-service').value || '').trim(); var planEl = document.getElementById('sub-new-plan'); @@ -210,7 +257,7 @@ function addSubEntry() { if (!paid) return; var cfg = getSubscriptionConfig(); cfg.entries.push({ service: service || '', plan: plan || 'Subscription', paid: paid, from: from }); - cfg.entries.sort(function(a,b){return (a.from||'').localeCompare(b.from||'');}); + cfg.entries.sort(function (a, b) { return (a.from || '').localeCompare(b.from || ''); }); saveSubscriptionConfig(cfg); render(); } @@ -234,11 +281,11 @@ const TAG_OPTIONS = ['bug', 'feature', 'research', 'infra', 'deploy', 'review']; function showTagDropdown(event, sessionId) { event.stopPropagation(); - document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); }); + document.querySelectorAll('.tag-dropdown').forEach(function (el) { el.remove(); }); var dd = document.createElement('div'); dd.className = 'tag-dropdown'; var existingTags = tags[sessionId] || []; - dd.innerHTML = TAG_OPTIONS.map(function(t) { + dd.innerHTML = TAG_OPTIONS.map(function (t) { var has = existingTags.indexOf(t) >= 0; return '