diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..92722160 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +node_modules +**/node_modules +**/.cache +third_party/skiller-desktop-skills-manager/out diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..7c169f03 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/skiller-desktop-skills-manager"] + path = third_party/skiller-desktop-skills-manager + url = https://github.com/beautyfree/skiller-desktop-skills-manager.git diff --git a/docs/integrations/skiller.md b/docs/integrations/skiller.md new file mode 100644 index 00000000..9cf18739 --- /dev/null +++ b/docs/integrations/skiller.md @@ -0,0 +1,78 @@ +# Skiller Integration + +Skiller is included as an isolated git submodule so docker-git can reuse the upstream desktop skills manager without mixing Electron dependencies into the docker-git Bun workspace. + +## Upstream + +- Repository: https://github.com/beautyfree/skiller-desktop-skills-manager +- Path: `third_party/skiller-desktop-skills-manager` +- Pinned version: `v0.2.14` +- Pinned commit: `6ff6b9ca1ff2d78d3af7dac47b03ed1c315dab6b` +- License: MIT, copyright 2025 Skiller Contributors + +The submodule is intentionally outside `packages/*` and is not listed in the root workspace. This keeps the existing docker-git `build`, `check`, `typecheck`, and `test` scripts scoped to docker-git packages unless a Skiller-specific script is run. + +## Commands + +Initialize the pinned submodule: + +```bash +bun run skiller:init +``` + +Install Skiller dependencies inside the submodule: + +```bash +bun run skiller:install +``` + +Run Skiller as its own Electron app: + +```bash +bun run skiller:dev +``` + +Run Skiller checks: + +```bash +bun run skiller:check +``` + +## docker-git Web Launch + +The docker-git web terminal header includes a `Skiller` button next to `Open browser`. In a project terminal the button opens `/api/ssh/session/:sessionId/skiller/app/` immediately, using the same terminal session id that is present in `/ssh/session/:sessionId`. It also calls `POST /projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open`, which launches the pinned submodule Electron app as a separate process and writes launcher output to `~/.docker-git/logs/skiller.log`. + +docker-git serves Skiller's built renderer from the submodule and proxies `/api/ssh/session/:sessionId/skiller/trpc/*` to the running Skiller tRPC backend, so the user sees the actual Skiller UI instead of an invisible background desktop process. The session id is part of the URL so a Skiller tab can be tied back to the terminal container that opened it. + +For project terminals, docker-git scopes Skiller to the active project container filesystem. The API inspects the selected project container mounts, maps `/home/` and the project `targetDir` to the controller-visible Docker volume path, launches Skiller with `HOME` set to that mapped home directory, and registers the mapped project directory in Skiller. This makes global skill operations target the selected container home and project skill operations target the selected container project directory. If the controller cannot access the Docker volume path, the endpoint fails instead of opening Skiller against the wrong filesystem. + +When the API process has no `$DISPLAY`, the launcher uses `xvfb-run` if it is available so Skiller can still start in a headless controller environment. + +## PR #238 Proof + +The latest Playwright proof screenshots are checked in under `docs/screenshots/issue-237/proof/`: + +- `pr238-proof-27-terminal-skiller-same-session.png` shows the attached terminal with the `Skiller` button. +- `pr238-proof-28-skiller-session-scoped-ui.png` shows the real Skiller UI opened from that button. + +## Updating the Pin + +Update Skiller only as an explicit dependency change: + +```bash +git -C third_party/skiller-desktop-skills-manager fetch --tags origin +git -C third_party/skiller-desktop-skills-manager checkout +git add third_party/skiller-desktop-skills-manager +``` + +After changing the pin, run both docker-git checks and Skiller checks: + +```bash +bun run typecheck +bun run check +bun run skiller:check +``` + +## Integration Boundary + +This integration makes Skiller part of the docker-git checkout and developer workflow. docker-git keeps Skiller as an isolated submodule and does not import Skiller source into the docker-git web bundle. The visible browser view is served from Skiller's own built renderer and backed by Skiller's own tRPC process. diff --git a/docs/pr-screenshots/issue-237/codex-extra-skills-terminal.png b/docs/pr-screenshots/issue-237/codex-extra-skills-terminal.png new file mode 100644 index 00000000..9e19d44a Binary files /dev/null and b/docs/pr-screenshots/issue-237/codex-extra-skills-terminal.png differ diff --git a/docs/pr-screenshots/issue-237/prompt-overrides-terminal.png b/docs/pr-screenshots/issue-237/prompt-overrides-terminal.png new file mode 100644 index 00000000..3b341ce8 Binary files /dev/null and b/docs/pr-screenshots/issue-237/prompt-overrides-terminal.png differ diff --git a/docs/screenshots/issue-237/menu-prompts-skills.png b/docs/screenshots/issue-237/menu-prompts-skills.png new file mode 100644 index 00000000..4a66c836 Binary files /dev/null and b/docs/screenshots/issue-237/menu-prompts-skills.png differ diff --git a/docs/screenshots/issue-237/panel-prompts.png b/docs/screenshots/issue-237/panel-prompts.png new file mode 100644 index 00000000..34cbe9a9 Binary files /dev/null and b/docs/screenshots/issue-237/panel-prompts.png differ diff --git a/docs/screenshots/issue-237/panel-skills.png b/docs/screenshots/issue-237/panel-skills.png new file mode 100644 index 00000000..0f8a6431 Binary files /dev/null and b/docs/screenshots/issue-237/panel-skills.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-01-menu-prompts-skills.png b/docs/screenshots/issue-237/proof/pr238-proof-01-menu-prompts-skills.png new file mode 100644 index 00000000..483e5506 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-01-menu-prompts-skills.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-02-prompts-editors.png b/docs/screenshots/issue-237/proof/pr238-proof-02-prompts-editors.png new file mode 100644 index 00000000..64c911fa Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-02-prompts-editors.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-03-prompts-save-proof.png b/docs/screenshots/issue-237/proof/pr238-proof-03-prompts-save-proof.png new file mode 100644 index 00000000..899b94f1 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-03-prompts-save-proof.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-04-skills-manager.png b/docs/screenshots/issue-237/proof/pr238-proof-04-skills-manager.png new file mode 100644 index 00000000..1dcd975b Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-04-skills-manager.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-05-skills-create-proof.png b/docs/screenshots/issue-237/proof/pr238-proof-05-skills-create-proof.png new file mode 100644 index 00000000..5e9aa5b6 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-05-skills-create-proof.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-app-onboarding.png b/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-app-onboarding.png new file mode 100644 index 00000000..79fe15e1 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-app-onboarding.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-07-skiller-app-dashboard.png b/docs/screenshots/issue-237/proof/pr238-proof-07-skiller-app-dashboard.png new file mode 100644 index 00000000..22eef7aa Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-07-skiller-app-dashboard.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-08-skiller-app-skills-manager.png b/docs/screenshots/issue-237/proof/pr238-proof-08-skiller-app-skills-manager.png new file mode 100644 index 00000000..644d5d6d Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-08-skiller-app-skills-manager.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-09-skiller-app-projects.png b/docs/screenshots/issue-237/proof/pr238-proof-09-skiller-app-projects.png new file mode 100644 index 00000000..4d52fdcc Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-09-skiller-app-projects.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-10-skiller-app-settings.png b/docs/screenshots/issue-237/proof/pr238-proof-10-skiller-app-settings.png new file mode 100644 index 00000000..00befd3c Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-10-skiller-app-settings.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-11-skiller-terminal-header-button.png b/docs/screenshots/issue-237/proof/pr238-proof-11-skiller-terminal-header-button.png new file mode 100644 index 00000000..4e535392 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-11-skiller-terminal-header-button.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-12-skiller-button-launch-action.png b/docs/screenshots/issue-237/proof/pr238-proof-12-skiller-button-launch-action.png new file mode 100644 index 00000000..6b147ca8 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-12-skiller-button-launch-action.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-13-skiller-opened-from-terminal-button.png b/docs/screenshots/issue-237/proof/pr238-proof-13-skiller-opened-from-terminal-button.png new file mode 100644 index 00000000..cd591c87 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-13-skiller-opened-from-terminal-button.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-18-skiller-main-after-ui-click-xvfb.png b/docs/screenshots/issue-237/proof/pr238-proof-18-skiller-main-after-ui-click-xvfb.png new file mode 100644 index 00000000..9fe5527e Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-18-skiller-main-after-ui-click-xvfb.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-19-terminal-attached-skiller-clicked-cloudflare.png b/docs/screenshots/issue-237/proof/pr238-proof-19-terminal-attached-skiller-clicked-cloudflare.png new file mode 100644 index 00000000..1ace7045 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-19-terminal-attached-skiller-clicked-cloudflare.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-21-skiller-dashboard-browser-app.png b/docs/screenshots/issue-237/proof/pr238-proof-21-skiller-dashboard-browser-app.png new file mode 100644 index 00000000..b249a0ae Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-21-skiller-dashboard-browser-app.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-22-terminal-skiller-opens-tab.png b/docs/screenshots/issue-237/proof/pr238-proof-22-terminal-skiller-opens-tab.png new file mode 100644 index 00000000..8b12494f Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-22-terminal-skiller-opens-tab.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-23-skiller-scoped-dashboard.png b/docs/screenshots/issue-237/proof/pr238-proof-23-skiller-scoped-dashboard.png new file mode 100644 index 00000000..05abc73b Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-23-skiller-scoped-dashboard.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-24-skiller-scoped-projects.png b/docs/screenshots/issue-237/proof/pr238-proof-24-skiller-scoped-projects.png new file mode 100644 index 00000000..598c8043 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-24-skiller-scoped-projects.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-25-terminal-skiller-button-live.png b/docs/screenshots/issue-237/proof/pr238-proof-25-terminal-skiller-button-live.png new file mode 100644 index 00000000..31e251ec Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-25-terminal-skiller-button-live.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-26-button-opened-skiller-projects.png b/docs/screenshots/issue-237/proof/pr238-proof-26-button-opened-skiller-projects.png new file mode 100644 index 00000000..aef3b3c8 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-26-button-opened-skiller-projects.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-27-terminal-skiller-same-session.png b/docs/screenshots/issue-237/proof/pr238-proof-27-terminal-skiller-same-session.png new file mode 100644 index 00000000..daffbb5a Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-27-terminal-skiller-same-session.png differ diff --git a/docs/screenshots/issue-237/proof/pr238-proof-28-skiller-session-scoped-ui.png b/docs/screenshots/issue-237/proof/pr238-proof-28-skiller-session-scoped-ui.png new file mode 100644 index 00000000..76c07326 Binary files /dev/null and b/docs/screenshots/issue-237/proof/pr238-proof-28-skiller-session-scoped-ui.png differ diff --git a/experiments/render-examples-output.txt b/experiments/render-examples-output.txt new file mode 100644 index 00000000..bb9a9527 --- /dev/null +++ b/experiments/render-examples-output.txt @@ -0,0 +1,533 @@ + +================================================================================ +CLAUDE.md prompt setup (~/.claude/CLAUDE.md) +================================================================================ + +# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) +CLAUDE_GLOBAL_PROMPT_FILE="/home/dev/.claude/CLAUDE.md" +CLAUDE_AUTO_SYSTEM_PROMPT="${CLAUDE_AUTO_SYSTEM_PROMPT:-1}" +CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" +REPO_REF_VALUE="${REPO_REF:-issue-237}" +REPO_URL_VALUE="${REPO_URL:-https://github.com/ProverCoderAI/docker-git.git}" + +if [[ "$REPO_REF_VALUE" == issue-* ]]; then + ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" + ISSUE_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then + ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO_VALUE" ]]; then + ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" + fi + fi + if [[ -n "$ISSUE_URL_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" + else + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE" + fi +elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then + PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then + PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO_VALUE" ]]; then + PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" + fi + fi + if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" + elif [[ -n "$PR_ID_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE" + else + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)" + fi +fi + +CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE="${CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE:-}" +CLAUDE_SYSTEM_PROMPT_OVERRIDE="${CLAUDE_SYSTEM_PROMPT_OVERRIDE:-}" +CLAUDE_DEFAULT_PROMPT_BODY="$(cat </dev/null || true + if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then + cat < "$CLAUDE_GLOBAL_PROMPT_FILE" + +$CLAUDE_PROMPT_BODY + +EOF + chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true + chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true + fi +fi + +export CLAUDE_AUTO_SYSTEM_PROMPT + +================================================================================ +.codex/AGENTS.md prompt setup +================================================================================ + +# Ensure global AGENTS.md exists for container context +AGENTS_PATH="/home/dev/.codex/AGENTS.md" +LEGACY_AGENTS_PATH="/home/dev/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): /home/dev/workspaces/ProverCoderAI/docker-git/issue-237" +WORKSPACES_LINE="Доступные workspace пути: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237" +WORKSPACE_INFO_LINE="Контекст workspace: repository" +FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237" +INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +SUBAGENTS_LINE="Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю." +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" + fi +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" + else + WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" + fi +fi +MANAGED_START="" +MANAGED_END="" +CODEX_SYSTEM_PROMPT_OVERRIDE_FILE="${CODEX_SYSTEM_PROMPT_OVERRIDE_FILE:-}" +CODEX_SYSTEM_PROMPT_OVERRIDE="${CODEX_SYSTEM_PROMPT_OVERRIDE:-}" +if [[ -n "$CODEX_SYSTEM_PROMPT_OVERRIDE_FILE" && -r "$CODEX_SYSTEM_PROMPT_OVERRIDE_FILE" ]]; then + MANAGED_LINES="$(cat "$CODEX_SYSTEM_PROMPT_OVERRIDE_FILE")" +elif [[ -n "$CODEX_SYSTEM_PROMPT_OVERRIDE" ]]; then + MANAGED_LINES="$CODEX_SYSTEM_PROMPT_OVERRIDE" +else + MANAGED_LINES="$(cat < "$AGENTS_PATH" +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, git, node и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +$MANAGED_BLOCK +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +EOF + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$AGENTS_PATH" ]]; then + MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" + else + sed \ + -e '/^Рабочая папка проекта (git clone):/d' \ + -e '/^Доступные workspace пути:/d' \ + -e '/^Контекст workspace:/d' \ + -e '/^Фокус задачи:/d' \ + -e '/^Issue AGENTS.md:/d' \ + -e '/^Доступ к интернету:/d' \ + -e '/^Для решения задач обязательно используй subagents[.]/d' \ + "$AGENTS_PATH" > "$TMP_AGENTS_PATH" + if [[ -s "$TMP_AGENTS_PATH" ]]; then + printf "\n" >> "$TMP_AGENTS_PATH" + fi + printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" + fi + mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then + LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then + rm -f "$LEGACY_AGENTS_PATH" + fi +fi + +================================================================================ +GEMINI.md prompt setup (full Gemini config block) +================================================================================ + +# Gemini CLI: keep ~/.gemini as a real home directory while sharing auth files from ~/.docker-git/.orch/auth/gemini +GEMINI_LABEL_RAW="$GEMINI_AUTH_LABEL" +if [[ -z "$GEMINI_LABEL_RAW" ]]; then + GEMINI_LABEL_RAW="default" +fi + +GEMINI_LABEL_NORM="$(printf "%s" "$GEMINI_LABEL_RAW" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" +if [[ -z "$GEMINI_LABEL_NORM" ]]; then + GEMINI_LABEL_NORM="default" +fi + +GEMINI_AUTH_ROOT="/home/dev/.docker-git/.orch/auth/gemini" +export GEMINI_CONFIG_DIR="$GEMINI_AUTH_ROOT/$GEMINI_LABEL_NORM" + +mkdir -p "$GEMINI_CONFIG_DIR" || true +GEMINI_HOME_DIR="/home/dev/.gemini" +mkdir -p "$GEMINI_HOME_DIR" || true +GEMINI_SHARED_HOME_DIR="$GEMINI_CONFIG_DIR/.gemini" +mkdir -p "$GEMINI_SHARED_HOME_DIR" || true + +docker_git_link_gemini_file() { + local source_path="$1" + local link_path="$2" + + if [[ -e "$link_path" && ! -L "$link_path" ]]; then + if [[ -f "$link_path" && ! -e "$source_path" ]]; then + cp "$link_path" "$source_path" || true + chmod 0600 "$source_path" || true + fi + return 0 + fi + + ln -sfn "$source_path" "$link_path" || true +} + +docker_git_prepare_gemini_home_dir() { + if [[ -L "$GEMINI_HOME_DIR" ]]; then + local previous_target + previous_target="$(readlink -f "$GEMINI_HOME_DIR" || true)" + rm -f "$GEMINI_HOME_DIR" || true + mkdir -p "$GEMINI_HOME_DIR" || true + if [[ -n "$previous_target" && -d "$previous_target" ]]; then + cp -a "$previous_target"/. "$GEMINI_HOME_DIR"/ 2>/dev/null || true + fi + return 0 + fi + + mkdir -p "$GEMINI_HOME_DIR" || true +} + +docker_git_prepare_gemini_home_dir + +# Link .api-key and .env from central auth storage to container home +docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.api-key" "$GEMINI_HOME_DIR/.api-key" +docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.env" "$GEMINI_HOME_DIR/.env" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth_creds.json" "$GEMINI_HOME_DIR/oauth_creds.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth-tokens.json" "$GEMINI_HOME_DIR/oauth-tokens.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/credentials.json" "$GEMINI_HOME_DIR/credentials.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/application_default_credentials.json" "$GEMINI_HOME_DIR/application_default_credentials.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/google_accounts.json" "$GEMINI_HOME_DIR/google_accounts.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/projects.json" "$GEMINI_HOME_DIR/projects.json" + +# Ensure gemini YOLO wrapper exists +GEMINI_REAL_BIN="$(command -v gemini || echo "/usr/local/bin/gemini")" +GEMINI_WRAPPER_BIN="/usr/local/bin/gemini-wrapper" +if [[ -f "$GEMINI_REAL_BIN" && "$GEMINI_REAL_BIN" != "$GEMINI_WRAPPER_BIN" ]]; then + if [[ ! -f "$GEMINI_WRAPPER_BIN" ]]; then + cat <<'EOF' > "$GEMINI_WRAPPER_BIN" +#!/usr/bin/env bash +GEMINI_ORIGINAL_BIN="__GEMINI_REAL_BIN__" +exec "$GEMINI_ORIGINAL_BIN" --yolo "$@" +EOF + sed -i "s#__GEMINI_REAL_BIN__#$GEMINI_REAL_BIN#g" "$GEMINI_WRAPPER_BIN" || true + chmod 0755 "$GEMINI_WRAPPER_BIN" || true + # Create an alias or symlink if needed, but here we just ensure it exists + fi +fi + +docker_git_refresh_gemini_env() { + # If .api-key exists, export it as GEMINI_API_KEY + if [[ -f "$GEMINI_HOME_DIR/.api-key" ]]; then + export GEMINI_API_KEY="$(cat "$GEMINI_HOME_DIR/.api-key" | tr -d '\r\n')" + elif [[ -f "$GEMINI_HOME_DIR/.env" ]]; then + # Parse GEMINI_API_KEY from .env + API_KEY="$(grep "^GEMINI_API_KEY=" "$GEMINI_HOME_DIR/.env" | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//")" + if [[ -n "$API_KEY" ]]; then + export GEMINI_API_KEY="$API_KEY" + fi + fi +} + +docker_git_refresh_gemini_env + +# Gemini CLI: keep trust settings in sync with docker-git defaults +GEMINI_SETTINGS_DIR="/home/dev/.gemini" +GEMINI_TRUST_SETTINGS_FILE="$GEMINI_SETTINGS_DIR/trustedFolders.json" +GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_SETTINGS_DIR/settings.json" + +# Wait for symlink to be established by the auth config step +mkdir -p "$GEMINI_SETTINGS_DIR" || true + +# Disable folder trust prompt and enable auto-approval in settings.json +cat <<'EOF' > "$GEMINI_CONFIG_SETTINGS_FILE" +{ + "model": { + "name": "gemini-3.1-pro-preview", + "compressionThreshold": 0.9, + "disableLoopDetection": true + }, + "modelConfigs": { + "customAliases": { + "yolo-ultra": { + "modelConfig": { + "model": "gemini-3.1-pro-preview", + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + }, + { + "urlContext": {} + } + ] + } + } + } + } + }, + "general": { + "defaultApprovalMode": "auto_edit" + }, + "tools": { + "allowed": [ + "run_shell_command", + "write_file", + "googleSearch", + "urlContext" + ] + }, + "sandbox": { + "enabled": false + }, + "security": { + "folderTrust": { + "enabled": false + }, + "auth": { + "selectedType": "oauth-personal" + }, + "disableYoloMode": false + }, + "mcpServers": { + "playwright": { + "command": "docker-git-playwright-mcp", + "args": [], + "trust": true + } + } +} +EOF + +# Pre-trust important directories in trustedFolders.json +# Use flat mapping as required by recent Gemini CLI versions +cat <<'EOF' > "$GEMINI_TRUST_SETTINGS_FILE" +{ + "/": "TRUST_FOLDER", + "/home/dev/.gemini": "TRUST_FOLDER", + "/home/dev/workspaces/ProverCoderAI/docker-git/issue-237": "TRUST_FOLDER" +} +EOF + +chown -R 1000:1000 "$GEMINI_SETTINGS_DIR" || true +chmod 0600 "$GEMINI_TRUST_SETTINGS_FILE" "$GEMINI_CONFIG_SETTINGS_FILE" 2>/dev/null || true + +# Gemini CLI: keep Playwright MCP config in sync (TODO: Gemini CLI MCP integration format) +# For now, Gemini CLI uses MCP via ~/.gemini/settings.json or command line. +# We'll ensure it has the same Playwright capability as Claude/Codex once format is confirmed. + +# Gemini CLI: allow passwordless sudo for agent tasks +if [[ -d /etc/sudoers.d ]]; then + echo "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/gemini-agent + chmod 0440 /etc/sudoers.d/gemini-agent +fi + +GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" +printf "export GEMINI_AUTH_LABEL=%q\n" "$GEMINI_AUTH_LABEL" > "$GEMINI_PROFILE" +printf "export GEMINI_HOME=%q\n" "/home/dev/.gemini" >> "$GEMINI_PROFILE" +printf "export GEMINI_CLI_DISABLE_UPDATE_CHECK=true\n" >> "$GEMINI_PROFILE" +printf "export GEMINI_CLI_NONINTERACTIVE=true\n" >> "$GEMINI_PROFILE" +printf "export GEMINI_CLI_APPROVAL_MODE=yolo\n" >> "$GEMINI_PROFILE" +printf "alias gemini='/usr/local/bin/gemini-wrapper'\n" >> "$GEMINI_PROFILE" +cat <<'EOF' >> "$GEMINI_PROFILE" +if [[ -f "$GEMINI_HOME/.api-key" ]]; then + export GEMINI_API_KEY="$(cat "$GEMINI_HOME/.api-key" | tr -d '\r\n')" +fi +EOF +chmod 0644 "$GEMINI_PROFILE" || true + +docker_git_upsert_ssh_env "GEMINI_AUTH_LABEL" "$GEMINI_AUTH_LABEL" +docker_git_upsert_ssh_env "GEMINI_API_KEY" "\${GEMINI_API_KEY:-}" +docker_git_upsert_ssh_env "GEMINI_CLI_DISABLE_UPDATE_CHECK" "true" +docker_git_upsert_ssh_env "GEMINI_CLI_NONINTERACTIVE" "true" +docker_git_upsert_ssh_env "GEMINI_CLI_APPROVAL_MODE" "yolo" + +# Ensure global GEMINI.md exists for container context +GEMINI_MD_PATH="/home/dev/.gemini/GEMINI.md" +GEMINI_WORKSPACE_CONTEXT="Контекст workspace: repository" +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID" + fi +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID" + else + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF)" + fi +fi + +GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE="${GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE:-}" +GEMINI_SYSTEM_PROMPT_OVERRIDE="${GEMINI_SYSTEM_PROMPT_OVERRIDE:-}" +GEMINI_DEFAULT_PROMPT_BODY="$(cat < "$GEMINI_MD_PATH" + +$GEMINI_PROMPT_BODY + +EOF +chown 1000:1000 "$GEMINI_MD_PATH" || true + +================================================================================ +Codex project skills sync (with CODEX_EXTRA_SKILLS_PATHS support) +================================================================================ + +# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. +docker_git_sync_project_codex_skills() { + local codex_home="${CODEX_HOME:-/home/dev/.codex}" + local project_dir="${TARGET_DIR:-}" + local project_skills_root="$codex_home/skills/.docker-git-project" + local linked=0 + local spec="" + local mount_name="" + local relative_path="" + + if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then + return 0 + fi + + mkdir -p "$codex_home/skills" + rm -rf "$project_skills_root" + mkdir -p "$project_skills_root" + + # Priority goes from generic/shared skill trees -> Codex-specific trees. + for spec in \ + "10-root-skills::.skills" \ + "20-agents-skills::.agents/skills" \ + "30-agents-dot-skills::.agents/.skills" \ + "80-codex-skills::.codex/skills" \ + "90-codex-dot-skills::.codex/.skills"; do + mount_name="${spec%%::*}" + relative_path="${spec#*::}" + + if [[ -d "$project_dir/$relative_path" ]]; then + ln -sfn "$project_dir/$relative_path" "$project_skills_root/$mount_name" + chown -h 1000:1000 "$project_skills_root/$mount_name" 2>/dev/null || true + linked=1 + fi + done + + # Extra entries via CODEX_EXTRA_SKILLS_PATHS (comma- or newline-separated "prio-name::relative/path"). + local extra_specs="${CODEX_EXTRA_SKILLS_PATHS:-}" + if [[ -n "$extra_specs" ]]; then + extra_specs="${extra_specs//,/$'\n'}" + while IFS= read -r spec; do + [[ -z "$spec" ]] && continue + mount_name="${spec%%::*}" + relative_path="${spec#*::}" + if [[ -d "$project_dir/$relative_path" ]]; then + ln -sfn "$project_dir/$relative_path" "$project_skills_root/$mount_name" + chown -h 1000:1000 "$project_skills_root/$mount_name" 2>/dev/null || true + linked=1 + fi + done <<< "$extra_specs" + fi + + chown 1000:1000 "$codex_home/skills" "$project_skills_root" 2>/dev/null || true + + if [[ "$linked" -eq 1 ]]; then + echo "[codex-skills] linked project skill trees into $project_skills_root" + fi +} \ No newline at end of file diff --git a/experiments/render-examples.mjs b/experiments/render-examples.mjs new file mode 100644 index 00000000..0430a9d2 --- /dev/null +++ b/experiments/render-examples.mjs @@ -0,0 +1,40 @@ +import { writeFileSync } from "node:fs" +import { defaultTemplateConfig } from "../packages/lib/dist/core/domain.js" +import { renderClaudeGlobalPromptSetup } from "../packages/lib/dist/core/templates-entrypoint/claude-extra-config.js" +import { renderEntrypointAgentsNotice } from "../packages/lib/dist/core/templates-entrypoint/agents-notice.js" +import { renderEntrypointProjectCodexSkillsSync } from "../packages/lib/dist/core/templates-entrypoint/codex.js" +import { renderEntrypointGeminiConfig } from "../packages/lib/dist/core/templates-entrypoint/gemini.js" + +const cfg = { + ...defaultTemplateConfig, + repoUrl: "https://github.com/ProverCoderAI/docker-git.git", + containerName: "dg-docker-git", + serviceName: "dg-docker-git", + sshUser: "dev", + targetDir: "/home/dev/workspaces/ProverCoderAI/docker-git/issue-237", + volumeName: "dg-docker-git-home", + dockerGitPath: "/home/dev/.docker-git", + authorizedKeysPath: "/home/dev/.docker-git/authorized_keys", + envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/workspaces/ProverCoderAI/docker-git/issue-237/.orch/env/project.env", + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + codexSharedAuthPath: "/home/dev/.docker-git/.orch/auth/codex-shared", + geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", + repoRef: "issue-237" +} + +const banner = (title) => `\n${"=".repeat(80)}\n${title}\n${"=".repeat(80)}\n` + +const output = [ + banner("CLAUDE.md prompt setup (~/.claude/CLAUDE.md)"), + renderClaudeGlobalPromptSetup(cfg), + banner(".codex/AGENTS.md prompt setup"), + renderEntrypointAgentsNotice(cfg), + banner("GEMINI.md prompt setup (full Gemini config block)"), + renderEntrypointGeminiConfig(cfg), + banner("Codex project skills sync (with CODEX_EXTRA_SKILLS_PATHS support)"), + renderEntrypointProjectCodexSkillsSync(cfg) +].join("\n") + +writeFileSync("experiments/render-examples-output.txt", output) +console.log(`Wrote ${output.length} chars`) diff --git a/experiments/render-examples.ts b/experiments/render-examples.ts new file mode 100644 index 00000000..1f01ee07 --- /dev/null +++ b/experiments/render-examples.ts @@ -0,0 +1,44 @@ +import { defaultTemplateConfig, type TemplateConfig } from "../packages/lib/src/core/domain.ts" +import { renderClaudeGlobalPromptSetup } from "../packages/lib/src/core/templates-entrypoint/claude-extra-config.ts" +import { renderEntrypointAgentsNotice } from "../packages/lib/src/core/templates-entrypoint/agents-notice.ts" +import { + renderEntrypointProjectCodexSkillsSync +} from "../packages/lib/src/core/templates-entrypoint/codex.ts" +import { renderEntrypointGeminiConfig } from "../packages/lib/src/core/templates-entrypoint/gemini.ts" + +const cfg: TemplateConfig = { + ...defaultTemplateConfig, + repoUrl: "https://github.com/ProverCoderAI/docker-git.git", + containerName: "dg-docker-git", + serviceName: "dg-docker-git", + sshUser: "dev", + targetDir: "/home/dev/workspaces/ProverCoderAI/docker-git/issue-237", + volumeName: "dg-docker-git-home", + dockerGitPath: "/home/dev/.docker-git", + authorizedKeysPath: "/home/dev/.docker-git/authorized_keys", + envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/workspaces/ProverCoderAI/docker-git/issue-237/.orch/env/project.env", + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + codexSharedAuthPath: "/home/dev/.docker-git/.orch/auth/codex-shared", + geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", + repoRef: "issue-237" +} + +import { writeFileSync } from "node:fs" + +const banner = (title: string): string => + `\n${"=".repeat(80)}\n${title}\n${"=".repeat(80)}\n` + +const output = [ + banner("CLAUDE.md prompt setup (~/.claude/CLAUDE.md)"), + renderClaudeGlobalPromptSetup(cfg), + banner(".codex/AGENTS.md prompt setup"), + renderEntrypointAgentsNotice(cfg), + banner("GEMINI.md prompt setup (full Gemini config block)"), + renderEntrypointGeminiConfig(cfg), + banner("Codex project skills sync (with CODEX_EXTRA_SKILLS_PATHS support)"), + renderEntrypointProjectCodexSkillsSync(cfg) +].join("\n") + +writeFileSync("experiments/render-examples-output.txt", output) +console.log(`Wrote ${output.length} bytes to experiments/render-examples-output.txt`) diff --git a/experiments/ui-examples.md b/experiments/ui-examples.md new file mode 100644 index 00000000..756e73ea --- /dev/null +++ b/experiments/ui-examples.md @@ -0,0 +1,165 @@ +# Container UI examples (issue #237) + +These are the rendered files that the user sees inside a running container after +the entrypoint executes. The new `*_SYSTEM_PROMPT_OVERRIDE` and +`*_SYSTEM_PROMPT_OVERRIDE_FILE` env vars (plus `CODEX_EXTRA_SKILLS_PATHS`) let +operators replace the body of any of these files without forking the templates. + +--- + +## 1. Default behaviour (no overrides set) + +Container env: `CLAUDE_AUTO_SYSTEM_PROMPT=1` (default), no override vars. + +### `~/.claude/CLAUDE.md` + +```markdown + +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, claude, git, node и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +Рабочая папка проекта (git clone): /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Доступные workspace пути: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Контекст workspace: issue #237 (https://github.com/ProverCoderAI/docker-git/issues/237) +Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю. +Если ты видишь файлы AGENTS.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции. + +``` + +### `~/.codex/AGENTS.md` + +```markdown +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, git, node и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ + +Рабочая папка проекта (git clone): /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Доступные workspace пути: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Контекст workspace: issue #237 (https://github.com/ProverCoderAI/docker-git/issues/237) +Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю. + +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +``` + +### `~/.gemini/GEMINI.md` + +```markdown + +Ты автономный агент Gemini, у тебя есть доступ к sudo, gh, gemini-cli, bun, git, node и всем остальным. Проекты с которыми идёт работа лежат по пути ~ +Рабочая папка проекта (git clone): /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Доступные workspace пути: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Контекст workspace: issue #237 (https://github.com/ProverCoderAI/docker-git/issues/237) +Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237 +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю. + +``` + +### `ls ~/.codex/skills/.docker-git-project/` (no extra skills) + +``` +20-agents-skills -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/.agents/skills +``` + +--- + +## 2. Inline override via `CLAUDE_SYSTEM_PROMPT_OVERRIDE` + +Container env (in `.orch/env/project.env`): + +```bash +CLAUDE_SYSTEM_PROMPT_OVERRIDE="You are a senior reviewer. Be terse. Only modify files in /home/dev/workspaces/ProverCoderAI/docker-git/issue-237." +``` + +### `~/.claude/CLAUDE.md` + +```markdown + +You are a senior reviewer. Be terse. Only modify files in /home/dev/workspaces/ProverCoderAI/docker-git/issue-237. + +``` + +The managed-block markers are preserved, so the next container restart still +detects the file as docker-git-managed and refreshes it idempotently. + +--- + +## 3. File override via `CODEX_SYSTEM_PROMPT_OVERRIDE_FILE` + +```bash +# .orch/env/project.env +CODEX_SYSTEM_PROMPT_OVERRIDE_FILE=/home/dev/.docker-git/prompts/codex.txt +``` + +```bash +# /home/dev/.docker-git/prompts/codex.txt +You are running inside docker-git. Workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237. +Always start by running `git status` and `gh issue view 237`. +``` + +### `~/.codex/AGENTS.md` (managed lines replaced) + +```markdown +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, git, node и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ + +You are running inside docker-git. Workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237. +Always start by running `git status` and `gh issue view 237`. + +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +``` + +`*_OVERRIDE_FILE` always wins over `*_OVERRIDE`. If neither is set, the default +content above is used. + +--- + +## 4. Extra skills via `CODEX_EXTRA_SKILLS_PATHS` + +```bash +# .orch/env/project.env +CODEX_EXTRA_SKILLS_PATHS="50-team-skills::team/skills,60-shared-rituals::infra/codex/rituals" +``` + +Project layout: + +``` +/home/dev/workspaces/ProverCoderAI/docker-git/issue-237/ +├── .agents/skills/... +├── team/skills/... +└── infra/codex/rituals/... +``` + +### `ls ~/.codex/skills/.docker-git-project/` (extras now mounted) + +``` +20-agents-skills -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/.agents/skills +50-team-skills -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/team/skills +60-shared-rituals -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/infra/codex/rituals +``` + +The built-in priority list (`.skills`, `.agents/skills`, `.agents/.skills`, +`.codex/skills`, `.codex/.skills`) is preserved. Extras are appended only when +the relative path exists, so misconfigured entries are silently ignored. + +--- + +## 5. Container terminal session showing the override hooks + +```text +dev@dg-docker-git:~$ cat ~/.codex/AGENTS.md | head -3 +Ты автономный агент, который имеет полностью все права управления контейнером... + +You are running inside docker-git. Workspace: /home/dev/workspaces/ProverCoderAI/docker-git/issue-237. + +dev@dg-docker-git:~$ ls -l ~/.codex/skills/.docker-git-project/ +total 0 +lrwxrwxrwx 1 dev dev 65 May 5 12:30 20-agents-skills -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/.agents/skills +lrwxrwxrwx 1 dev dev 60 May 5 12:30 50-team-skills -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/team/skills +lrwxrwxrwx 1 dev dev 64 May 5 12:30 60-shared-rituals -> /home/dev/workspaces/ProverCoderAI/docker-git/issue-237/infra/codex/rituals + +dev@dg-docker-git:~$ env | grep -E '_(SYSTEM_PROMPT_OVERRIDE|EXTRA_SKILLS)' +CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE=/home/dev/.docker-git/prompts/claude.txt +CODEX_SYSTEM_PROMPT_OVERRIDE_FILE=/home/dev/.docker-git/prompts/codex.txt +GEMINI_SYSTEM_PROMPT_OVERRIDE=You are running inside docker-git... +CODEX_EXTRA_SKILLS_PATHS=50-team-skills::team/skills,60-shared-rituals::infra/codex/rituals +``` diff --git a/package.json b/package.json index aabde763..7301a4b8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "clone": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js clone \"$@\"' --", "open": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js open \"$@\"' --", "docker-git": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --", + "skiller:init": "git submodule update --init --checkout third_party/skiller-desktop-skills-manager", + "skiller:install": "bun install --cwd third_party/skiller-desktop-skills-manager --frozen-lockfile", + "skiller:dev": "bun run --cwd third_party/skiller-desktop-skills-manager dev", + "skiller:check": "bun run --cwd third_party/skiller-desktop-skills-manager typecheck && bun run --cwd third_party/skiller-desktop-skills-manager test", "e2e": "bash scripts/e2e/run-all.sh", "e2e:clone-cache": "bash scripts/e2e/clone-cache.sh", "e2e:browser-command": "bash scripts/e2e/browser-command.sh", diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 296348f3..d453fa4d 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -31,6 +31,9 @@ RUN set -eu; \ done; \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ ca-certificates curl git docker.io docker-compose-v2 openssh-client sshpass python3 make g++ unzip \ + xvfb libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 \ + libnss3 libpango-1.0-0 libx11-xcb1 libxcb-dri3-0 libxcomposite1 libxdamage1 libxfixes3 \ + libxkbcommon0 libxrandr2 libxshmfence1 \ && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ @@ -43,6 +46,8 @@ COPY package.json bun.lock bunfig.toml tsconfig.base.json tsconfig.json ./ COPY patches ./patches COPY scripts ./scripts COPY packages ./packages +COPY .gitmodules ./.gitmodules +COPY third_party ./third_party RUN set -eu; \ for attempt in 1 2 3 4 5; do \ @@ -57,6 +62,9 @@ RUN set -eu; \ exit 1 RUN bun run --cwd packages/lib build RUN bun run --cwd packages/api build +RUN bun install --cwd third_party/skiller-desktop-skills-manager --frozen-lockfile --silent \ + && bun run --cwd third_party/skiller-desktop-skills-manager build \ + && ln -sf index.mjs third_party/skiller-desktop-skills-manager/out/preload/index.js ENV DOCKER_GIT_API_PORT=3334 ENV DOCKER_GIT_DOCKER_RUNTIME=isolated diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e326c9ba..312cc024 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -250,6 +250,68 @@ export type ProjectAuthRequest = { readonly label?: string | null | undefined } +export type ProjectPromptKind = "claude" | "codex" | "gemini" + +export type ProjectPromptFile = { + readonly kind: ProjectPromptKind + readonly fileName: string + readonly relativePath: string + readonly absolutePath: string + readonly exists: boolean + readonly bytes: number + readonly content: string +} + +export type ProjectPromptsSnapshot = { + readonly projectId: string + readonly projectKey: string + readonly projectDir: string + readonly prompts: ReadonlyArray +} + +export type ProjectPromptUpdateRequest = { + readonly content: string +} + +export type ProjectSkillScope = + | "skills" + | "agents/skills" + | "agents/.skills" + | "claude/skills" + | "codex/skills" + | "gemini/skills" + +export type ProjectSkillFile = { + readonly id: string + readonly scope: ProjectSkillScope + readonly name: string + readonly relativePath: string + readonly absolutePath: string + readonly bytes: number + readonly content: string + readonly updatedAtIso: string | null +} + +export type ProjectSkillScopeInfo = { + readonly scope: ProjectSkillScope + readonly relativeRoot: string + readonly absoluteRoot: string +} + +export type ProjectSkillsSnapshot = { + readonly projectId: string + readonly projectKey: string + readonly projectDir: string + readonly skills: ReadonlyArray + readonly scopes: ReadonlyArray +} + +export type ProjectSkillUpdateRequest = { + readonly scope: ProjectSkillScope + readonly name: string + readonly content: string +} + export type StateInitRequest = { readonly repoUrl: string readonly repoRef?: string | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 5fa180f1..988c86e8 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -103,6 +103,27 @@ export const ProjectAuthRequestSchema = Schema.Struct({ label: OptionalNullableString }) +export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini") + +export const ProjectPromptUpdateRequestSchema = Schema.Struct({ + content: Schema.String +}) + +export const ProjectSkillScopeSchema = Schema.Literal( + "skills", + "agents/skills", + "agents/.skills", + "claude/skills", + "codex/skills", + "gemini/skills" +) + +export const ProjectSkillUpdateRequestSchema = Schema.Struct({ + scope: ProjectSkillScopeSchema, + name: Schema.String, + content: Schema.String +}) + export const StateInitRequestSchema = Schema.Struct({ repoUrl: Schema.String, repoRef: OptionalString @@ -289,6 +310,10 @@ export type CodexAuthImportRequestInput = Schema.Schema.Type export type CodexAuthLogoutRequestInput = Schema.Schema.Type export type ProjectAuthRequestInput = Schema.Schema.Type +export type ProjectPromptKindInput = Schema.Schema.Type +export type ProjectPromptUpdateRequestInput = Schema.Schema.Type +export type ProjectSkillScopeInput = Schema.Schema.Type +export type ProjectSkillUpdateRequestInput = Schema.Schema.Type export type StateInitRequestInput = Schema.Schema.Type export type StateCommitRequestInput = Schema.Schema.Type export type StateSyncRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 107aa25e..0cbcac8c 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -28,6 +28,8 @@ import { ProjectDatabaseProfileRequestSchema, ProjectAuthRequestSchema, ProjectPortForwardRequestSchema, + ProjectPromptUpdateRequestSchema, + ProjectSkillUpdateRequestSchema, StartProjectTerminalSessionRequestSchema, StateCommitRequestSchema, StateInitRequestSchema, @@ -84,6 +86,18 @@ import { upProject } from "./services/projects.js" import { readProjectAuthSnapshot, runProjectAuthFlow } from "./services/project-auth.js" +import { + deleteProjectPrompt, + readProjectPromptsSnapshot, + writeProjectPrompt +} from "./services/project-prompts.js" +import type { ProjectPromptKind } from "./services/project-prompts.js" +import { + deleteProjectSkill, + readProjectSkillsSnapshot, + writeProjectSkill +} from "./services/project-skills.js" +import type { ProjectSkillScope } from "./services/project-skills.js" import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js" import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js" import { @@ -118,6 +132,13 @@ import { readProjectTerminalImage, startTerminalSession } from "./services/terminal-sessions.js" +import { + openSkiller, + openSkillerForTerminalSession, + parseSkillerRoute, + proxySkillerTrpc, + serveSkillerApp +} from "./services/skiller.js" import { commitStateFromRequest, initStateFromRequest, @@ -146,6 +167,17 @@ const ProjectDatabaseProfileParamsSchema = Schema.Struct({ profileId: Schema.String }) +const ProjectPromptParamsSchema = Schema.Struct({ + projectId: Schema.String, + kind: Schema.Literal("claude", "codex", "gemini") +}) + +const ProjectSkillParamsSchema = Schema.Struct({ + projectId: Schema.String, + scopeId: Schema.String, + name: Schema.String +}) + const AgentParamsSchema = Schema.Struct({ projectId: Schema.String, agentId: Schema.String @@ -342,6 +374,8 @@ const projectParams = HttpRouter.schemaParams(ProjectParamsSchema) const projectKeyParams = HttpRouter.schemaParams(ProjectKeyParamsSchema) const projectPortForwardParams = HttpRouter.schemaParams(ProjectPortForwardParamsSchema) const projectDatabaseProfileParams = HttpRouter.schemaParams(ProjectDatabaseProfileParamsSchema) +const projectPromptParams = HttpRouter.schemaParams(ProjectPromptParamsSchema) +const projectSkillParams = HttpRouter.schemaParams(ProjectSkillParamsSchema) const agentParams = HttpRouter.schemaParams(AgentParamsSchema) const terminalSessionParams = HttpRouter.schemaParams(TerminalSessionParamsSchema) const terminalSessionByProjectKeyParams = HttpRouter.schemaParams(TerminalSessionByProjectKeyParamsSchema) @@ -358,6 +392,58 @@ const readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexA const readCodexAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLoginRequestSchema) const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema) const readProjectAuthRequest = () => HttpServerRequest.schemaBodyJson(ProjectAuthRequestSchema) +const readProjectPromptUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectPromptUpdateRequestSchema) +const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectSkillUpdateRequestSchema) + +const skillScopeFromId = (scopeId: string): ProjectSkillScope | null => { + switch (scopeId) { + case "skills": + return "skills" + case "agents-skills": + return "agents/skills" + case "agents-dot-skills": + return "agents/.skills" + case "claude-skills": + return "claude/skills" + case "codex-skills": + return "codex/skills" + case "gemini-skills": + return "gemini/skills" + default: + return null + } +} + +export const skillScopeToId = (scope: ProjectSkillScope): string => { + switch (scope) { + case "skills": + return "skills" + case "agents/skills": + return "agents-skills" + case "agents/.skills": + return "agents-dot-skills" + case "claude/skills": + return "claude-skills" + case "codex/skills": + return "codex-skills" + case "gemini/skills": + return "gemini-skills" + } +} + +const skillScopeFromBody = (scope: string): ProjectSkillScope | null => { + switch (scope) { + case "skills": + case "agents/skills": + case "agents/.skills": + case "claude/skills": + case "codex/skills": + case "gemini/skills": + return scope as ProjectSkillScope + default: + return null + } +} const readProjectPortForwardRequest = () => HttpServerRequest.schemaBodyJson(ProjectPortForwardRequestSchema) const readProjectDatabaseProfileRequest = () => HttpServerRequest.schemaBodyJson(ProjectDatabaseProfileRequestSchema) const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitRequestSchema) @@ -450,6 +536,12 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) { const projectProxyResponse = Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const pathname = new URL(request.url, "http://localhost").pathname + const skillerRoute = parseSkillerRoute(pathname) + if (skillerRoute !== null) { + return skillerRoute._tag === "App" + ? yield* _(serveSkillerApp(skillerRoute)) + : yield* _(proxySkillerTrpc(request, skillerRoute)) + } const browserTarget = parseProjectBrowserProxyPath(pathname) if (browserTarget !== null) { return yield* _(proxyProjectBrowser(request, browserTarget, resolveRequestOrigin(request))) @@ -491,6 +583,29 @@ export const makeRouter = () => { const projectsRoot = defaultProjectsRoot(cwd) return yield* _(jsonResponse({ ok: true, revision: controllerRevision, cwd, projectsRoot }, 200)) }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/skiller/open", + openSkiller().pipe( + Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/by-key/:projectKey/skiller/open", + projectKeyParams.pipe( + Effect.flatMap(({ projectKey }) => openSkiller(projectKey)), + Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open", + terminalSessionByProjectKeyParams.pipe( + Effect.flatMap(({ projectKey, sessionId }) => openSkillerForTerminalSession(projectKey, sessionId)), + Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)), + Effect.catchAll(errorResponse) + ) ) ) @@ -840,6 +955,88 @@ export const makeRouter = () => { return yield* _(jsonResponse({ ok: true, snapshot }, 200)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.get( + "/projects/:projectId/prompts", + projectParams.pipe( + Effect.flatMap(({ projectId }) => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId)) + const snapshot = yield* _(readProjectPromptsSnapshot(project)) + return { snapshot } + }) + ), + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.put( + "/projects/:projectId/prompts/:kind", + Effect.gen(function*(_) { + const { projectId, kind } = yield* _(projectPromptParams) + const request = yield* _(readProjectPromptUpdateRequest()) + const project = yield* _(getProject(projectId)) + const prompt = yield* _(writeProjectPrompt(project, kind as ProjectPromptKind, request.content)) + const snapshot = yield* _(readProjectPromptsSnapshot(project)) + return yield* _(jsonResponse({ ok: true, prompt, snapshot }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.del( + "/projects/:projectId/prompts/:kind", + Effect.gen(function*(_) { + const { projectId, kind } = yield* _(projectPromptParams) + const project = yield* _(getProject(projectId)) + yield* _(deleteProjectPrompt(project, kind as ProjectPromptKind)) + const snapshot = yield* _(readProjectPromptsSnapshot(project)) + return yield* _(jsonResponse({ ok: true, snapshot }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/projects/:projectId/skills", + projectParams.pipe( + Effect.flatMap(({ projectId }) => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId)) + const snapshot = yield* _(readProjectSkillsSnapshot(project)) + return { snapshot } + }) + ), + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/:projectId/skills", + Effect.gen(function*(_) { + const { projectId } = yield* _(projectParams) + const request = yield* _(readProjectSkillUpdateRequest()) + const scope = skillScopeFromBody(request.scope) + if (scope === null) { + return yield* _( + Effect.fail(new ApiBadRequestError({ message: `Unknown skill scope: ${request.scope}` })) + ) + } + const project = yield* _(getProject(projectId)) + const skill = yield* _(writeProjectSkill(project, scope, request.name, request.content)) + const snapshot = yield* _(readProjectSkillsSnapshot(project)) + return yield* _(jsonResponse({ ok: true, skill, snapshot }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.del( + "/projects/:projectId/skills/:scopeId/:name", + Effect.gen(function*(_) { + const { projectId, scopeId, name } = yield* _(projectSkillParams) + const scope = skillScopeFromId(scopeId) + if (scope === null) { + return yield* _( + Effect.fail(new ApiBadRequestError({ message: `Unknown skill scope: ${scopeId}` })) + ) + } + const project = yield* _(getProject(projectId)) + yield* _(deleteProjectSkill(project, scope, name)) + const snapshot = yield* _(readProjectSkillsSnapshot(project)) + return yield* _(jsonResponse({ ok: true, snapshot }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/projects/:projectId/ports", projectParams.pipe( diff --git a/packages/api/src/services/project-prompts.ts b/packages/api/src/services/project-prompts.ts new file mode 100644 index 00000000..5bbc84a6 --- /dev/null +++ b/packages/api/src/services/project-prompts.ts @@ -0,0 +1,158 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import type { PlatformError } from "@effect/platform/Error" +import * as Path from "@effect/platform/Path" +import { Effect, pipe } from "effect" + +import type { ProjectDetails } from "../api/contracts.js" +import { ApiBadRequestError } from "../api/errors.js" + +export type ProjectPromptKind = "claude" | "codex" | "gemini" + +export type ProjectPromptFile = { + readonly kind: ProjectPromptKind + readonly fileName: string + readonly relativePath: string + readonly absolutePath: string + readonly exists: boolean + readonly bytes: number + readonly content: string +} + +export type ProjectPromptsSnapshot = { + readonly projectId: string + readonly projectKey: string + readonly projectDir: string + readonly prompts: ReadonlyArray +} + +const promptDescriptors: ReadonlyArray<{ kind: ProjectPromptKind; fileName: string }> = [ + { kind: "claude", fileName: "CLAUDE.md" }, + { kind: "codex", fileName: "AGENTS.md" }, + { kind: "gemini", fileName: "GEMINI.md" } +] + +const maxPromptBytes = 1024 * 256 + +const hasFileAtPath = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) + +const readPromptFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + projectDir: string, + kind: ProjectPromptKind, + fileName: string +): Effect.Effect => + Effect.gen(function*(_) { + const absolutePath = path.join(projectDir, fileName) + const present = yield* _(hasFileAtPath(fs, absolutePath)) + if (!present) { + return { + kind, + fileName, + relativePath: fileName, + absolutePath, + exists: false, + bytes: 0, + content: "" + } + } + const content = yield* _(fs.readFileString(absolutePath)) + return { + kind, + fileName, + relativePath: fileName, + absolutePath, + exists: true, + bytes: Buffer.byteLength(content, "utf8"), + content + } + }) + +export const readProjectPromptsSnapshot = ( + project: ProjectDetails +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const prompts: Array = [] + for (const descriptor of promptDescriptors) { + const file = yield* _(readPromptFile(fs, path, project.projectDir, descriptor.kind, descriptor.fileName)) + prompts.push(file) + } + return { + projectId: project.id, + projectKey: project.projectKey, + projectDir: project.projectDir, + prompts + } + }) + +const findDescriptor = (kind: ProjectPromptKind): { kind: ProjectPromptKind; fileName: string } => { + const found = promptDescriptors.find((descriptor) => descriptor.kind === kind) + if (found === undefined) { + throw new Error(`Unknown prompt kind: ${kind}`) + } + return found +} + +export const writeProjectPrompt = ( + project: ProjectDetails, + kind: ProjectPromptKind, + content: string +): Effect.Effect => { + if (Buffer.byteLength(content, "utf8") > maxPromptBytes) { + return Effect.fail( + new ApiBadRequestError({ message: `Prompt is too large: maximum ${maxPromptBytes} bytes.` }) + ) + } + const descriptor = findDescriptor(kind) + return pipe( + Effect.all({ + fs: FileSystem.FileSystem, + path: Path.Path + }), + Effect.flatMap(({ fs, path }) => + Effect.gen(function*(_) { + const projectDirExists = yield* _(fs.exists(project.projectDir)) + if (!projectDirExists) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ message: `Project directory does not exist: ${project.projectDir}` }) + ) + ) + } + const absolutePath = path.join(project.projectDir, descriptor.fileName) + yield* _(fs.writeFileString(absolutePath, content)) + return yield* _(readPromptFile(fs, path, project.projectDir, descriptor.kind, descriptor.fileName)) + }) + ) + ) +} + +export const deleteProjectPrompt = ( + project: ProjectDetails, + kind: ProjectPromptKind +): Effect.Effect => { + const descriptor = findDescriptor(kind) + return Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const absolutePath = path.join(project.projectDir, descriptor.fileName) + const present = yield* _(hasFileAtPath(fs, absolutePath)) + if (present) { + yield* _(fs.remove(absolutePath)) + } + return yield* _(readPromptFile(fs, path, project.projectDir, descriptor.kind, descriptor.fileName)) + }) +} diff --git a/packages/api/src/services/project-skills.ts b/packages/api/src/services/project-skills.ts new file mode 100644 index 00000000..5e9f5982 --- /dev/null +++ b/packages/api/src/services/project-skills.ts @@ -0,0 +1,266 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import type { PlatformError } from "@effect/platform/Error" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { ProjectDetails } from "../api/contracts.js" +import { ApiBadRequestError, ApiNotFoundError } from "../api/errors.js" + +export type ProjectSkillScope = + | "skills" + | "agents/skills" + | "agents/.skills" + | "claude/skills" + | "codex/skills" + | "gemini/skills" + +export type ProjectSkillFile = { + readonly id: string + readonly scope: ProjectSkillScope + readonly name: string + readonly relativePath: string + readonly absolutePath: string + readonly bytes: number + readonly content: string + readonly updatedAtIso: string | null +} + +export type ProjectSkillsSnapshot = { + readonly projectId: string + readonly projectKey: string + readonly projectDir: string + readonly skills: ReadonlyArray + readonly scopes: ReadonlyArray<{ readonly scope: ProjectSkillScope; readonly relativeRoot: string; readonly absoluteRoot: string }> +} + +const skillScopes: ReadonlyArray<{ scope: ProjectSkillScope; relativeRoot: string }> = [ + { scope: "skills", relativeRoot: ".skills" }, + { scope: "agents/skills", relativeRoot: ".agents/skills" }, + { scope: "agents/.skills", relativeRoot: ".agents/.skills" }, + { scope: "claude/skills", relativeRoot: ".claude/skills" }, + { scope: "codex/skills", relativeRoot: ".codex/skills" }, + { scope: "gemini/skills", relativeRoot: ".gemini/skills" } +] + +const skillFileName = "SKILL.md" +const maxSkillBytes = 1024 * 256 +const maxSkillNameLength = 96 + +const safeSkillNameRegex = /^[a-zA-Z0-9._-]+$/u + +const isSafeSkillName = (name: string): boolean => + name.length > 0 && + name.length <= maxSkillNameLength && + safeSkillNameRegex.test(name) && + name !== "." && + name !== ".." && + !name.startsWith(".") + +const ensureWithinProject = ( + path: Path.Path, + projectDir: string, + candidate: string +): boolean => { + const normalizedProject = path.resolve(projectDir) + const normalizedCandidate = path.resolve(candidate) + if (normalizedCandidate === normalizedProject) { + return false + } + const prefix = normalizedProject.endsWith(path.sep) ? normalizedProject : `${normalizedProject}${path.sep}` + return normalizedCandidate.startsWith(prefix) +} + +const findScopeByValue = (scope: ProjectSkillScope): { scope: ProjectSkillScope; relativeRoot: string } => { + const found = skillScopes.find((entry) => entry.scope === scope) + if (found === undefined) { + throw new Error(`Unknown skill scope: ${scope}`) + } + return found +} + +const hasFileAtPath = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) + +const readSkillFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + projectDir: string, + scope: ProjectSkillScope, + relativeRoot: string, + name: string +): Effect.Effect => + Effect.gen(function*(_) { + const absoluteSkillDir = path.join(projectDir, relativeRoot, name) + const absolutePath = path.join(absoluteSkillDir, skillFileName) + const present = yield* _(hasFileAtPath(fs, absolutePath)) + if (!present) { + return null + } + const stat = yield* _(fs.stat(absolutePath)) + const content = yield* _(fs.readFileString(absolutePath)) + const updatedAtIso = + stat.mtime._tag === "Some" ? stat.mtime.value.toISOString() : null + return { + id: `${scope}:${name}`, + scope, + name, + relativePath: path.join(relativeRoot, name, skillFileName), + absolutePath, + bytes: Buffer.byteLength(content, "utf8"), + content, + updatedAtIso + } + }) + +const listSkillsForScope = ( + fs: FileSystem.FileSystem, + path: Path.Path, + projectDir: string, + scope: ProjectSkillScope, + relativeRoot: string +): Effect.Effect, PlatformError> => + Effect.gen(function*(_) { + const absoluteRoot = path.join(projectDir, relativeRoot) + const exists = yield* _(fs.exists(absoluteRoot)) + if (!exists) { + return [] + } + const info = yield* _(fs.stat(absoluteRoot)) + if (info.type !== "Directory") { + return [] + } + const entries = yield* _(fs.readDirectory(absoluteRoot)) + const skills: Array = [] + for (const entry of entries) { + if (!isSafeSkillName(entry)) { + continue + } + const subPath = path.join(absoluteRoot, entry) + const subInfo = yield* _(fs.stat(subPath)) + if (subInfo.type !== "Directory") { + continue + } + const skill = yield* _(readSkillFile(fs, path, projectDir, scope, relativeRoot, entry)) + if (skill !== null) { + skills.push(skill) + } + } + return skills + }) + +export const readProjectSkillsSnapshot = ( + project: ProjectDetails +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const skills: Array = [] + const scopes: Array<{ scope: ProjectSkillScope; relativeRoot: string; absoluteRoot: string }> = [] + for (const entry of skillScopes) { + const absoluteRoot = path.join(project.projectDir, entry.relativeRoot) + scopes.push({ scope: entry.scope, relativeRoot: entry.relativeRoot, absoluteRoot }) + const found = yield* _(listSkillsForScope(fs, path, project.projectDir, entry.scope, entry.relativeRoot)) + for (const skill of found) { + skills.push(skill) + } + } + return { + projectId: project.id, + projectKey: project.projectKey, + projectDir: project.projectDir, + skills, + scopes + } + }) + +export const writeProjectSkill = ( + project: ProjectDetails, + scope: ProjectSkillScope, + name: string, + content: string +): Effect.Effect => { + if (!isSafeSkillName(name)) { + return Effect.fail( + new ApiBadRequestError({ + message: + "Invalid skill name: only [A-Za-z0-9._-] (no leading dot, max 96 chars) characters are allowed." + }) + ) + } + if (Buffer.byteLength(content, "utf8") > maxSkillBytes) { + return Effect.fail( + new ApiBadRequestError({ message: `Skill is too large: maximum ${maxSkillBytes} bytes.` }) + ) + } + const descriptor = findScopeByValue(scope) + return Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectDirExists = yield* _(fs.exists(project.projectDir)) + if (!projectDirExists) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ message: `Project directory does not exist: ${project.projectDir}` }) + ) + ) + } + const absoluteSkillDir = path.join(project.projectDir, descriptor.relativeRoot, name) + if (!ensureWithinProject(path, project.projectDir, absoluteSkillDir)) { + return yield* _( + Effect.fail(new ApiBadRequestError({ message: "Skill path escapes project directory." })) + ) + } + yield* _(fs.makeDirectory(absoluteSkillDir, { recursive: true })) + const absolutePath = path.join(absoluteSkillDir, skillFileName) + yield* _(fs.writeFileString(absolutePath, content)) + const skill = yield* _(readSkillFile(fs, path, project.projectDir, descriptor.scope, descriptor.relativeRoot, name)) + if (skill === null) { + return yield* _( + Effect.fail(new ApiBadRequestError({ message: "Failed to read skill after writing." })) + ) + } + return skill + }) +} + +export const deleteProjectSkill = ( + project: ProjectDetails, + scope: ProjectSkillScope, + name: string +): Effect.Effect => { + if (!isSafeSkillName(name)) { + return Effect.fail( + new ApiBadRequestError({ + message: "Invalid skill name." + }) + ) + } + const descriptor = findScopeByValue(scope) + return Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const absoluteSkillDir = path.join(project.projectDir, descriptor.relativeRoot, name) + if (!ensureWithinProject(path, project.projectDir, absoluteSkillDir)) { + return yield* _( + Effect.fail(new ApiBadRequestError({ message: "Skill path escapes project directory." })) + ) + } + const present = yield* _(fs.exists(absoluteSkillDir)) + if (!present) { + return yield* _( + Effect.fail(new ApiNotFoundError({ message: `Skill not found: ${descriptor.relativeRoot}/${name}` })) + ) + } + yield* _(fs.remove(absoluteSkillDir, { recursive: true })) + }) +} diff --git a/packages/api/src/services/skiller-core.ts b/packages/api/src/services/skiller-core.ts new file mode 100644 index 00000000..e62f906e --- /dev/null +++ b/packages/api/src/services/skiller-core.ts @@ -0,0 +1,87 @@ +import { join, posix } from "node:path" + +export type DockerContainerMount = { + readonly destination: string + readonly rw: boolean + readonly source: string +} + +export type SkillerContainerScope = { + readonly containerHomePath: string + readonly containerName: string + readonly containerProjectPath: string + readonly hostHomePath: string + readonly hostProjectPath: string + readonly projectId: string + readonly projectKey: string + readonly sshUser: string +} + +export const parseDockerMountLines = (output: string): ReadonlyArray => + output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .flatMap((line) => { + const [source = "", destination = "", rawRw = ""] = line.split("\t") + const normalizedSource = source.trim() + const normalizedDestination = destination.trim() + return normalizedSource.length === 0 || normalizedDestination.length === 0 + ? [] + : [{ + destination: normalizeContainerPath(normalizedDestination), + rw: rawRw.trim().toLowerCase() === "true", + source: normalizedSource + }] + }) + +export const normalizeContainerPath = (path: string): string => { + const trimmed = path.trim() + const absolute = trimmed.startsWith("/") ? trimmed : `/${trimmed}` + return posix.normalize(absolute) +} + +const isPathInside = (basePath: string, targetPath: string): boolean => + targetPath === basePath || targetPath.startsWith(`${basePath}/`) + +const mountedPathDepth = (mount: DockerContainerMount): number => + mount.destination.split("/").filter((part) => part.length > 0).length + +const selectWritableMount = ( + mounts: ReadonlyArray, + containerPath: string +): DockerContainerMount | null => { + const normalizedPath = normalizeContainerPath(containerPath) + const matches = mounts + .filter((mount) => mount.rw && isPathInside(mount.destination, normalizedPath)) + .sort((left, right) => mountedPathDepth(right) - mountedPathDepth(left)) + return matches[0] ?? null +} + +export const remapContainerPathToMountedHost = ( + mounts: ReadonlyArray, + containerPath: string +): string | null => { + const normalizedPath = normalizeContainerPath(containerPath) + const mount = selectWritableMount(mounts, normalizedPath) + if (mount === null) { + return null + } + const relativePath = posix.relative(mount.destination, normalizedPath) + return relativePath.length === 0 + ? mount.source + : join(mount.source, ...relativePath.split(posix.sep)) +} + +export const sameSkillerScope = ( + left: SkillerContainerScope | null, + right: SkillerContainerScope | null +): boolean => { + if (left === null || right === null) { + return left === right + } + return left.projectKey === right.projectKey && + left.containerName === right.containerName && + left.hostHomePath === right.hostHomePath && + left.hostProjectPath === right.hostProjectPath +} diff --git a/packages/api/src/services/skiller.ts b/packages/api/src/services/skiller.ts new file mode 100644 index 00000000..5c95e39f --- /dev/null +++ b/packages/api/src/services/skiller.ts @@ -0,0 +1,698 @@ +import { spawn, type ChildProcess } from "node:child_process" +import { chownSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs" +import { createServer } from "node:net" +import { homedir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { runCommandCapture } from "@effect-template/lib/shell/command-runner" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ListProjectsContext } from "@effect-template/lib/usecases/projects-list" +import { NodeContext } from "@effect/platform-node" +import type { PlatformError } from "@effect/platform/Error" +import type * as HttpServerError from "@effect/platform/HttpServerError" +import * as HttpServerRequest from "@effect/platform/HttpServerRequest" +import * as HttpServerResponse from "@effect/platform/HttpServerResponse" +import { Effect } from "effect" +import * as Stream from "effect/Stream" + +import { ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js" +import { + parseDockerMountLines, + remapContainerPathToMountedHost, + sameSkillerScope, + type SkillerContainerScope +} from "./skiller-core.js" +import { getProjectItemByKey } from "./projects.js" +import { getProjectTerminalSession } from "./terminal-sessions.js" + +export type SkillerLaunch = { + readonly alreadyRunning: boolean + readonly appPath: string + readonly logPath: string + readonly pid: number | null + readonly scope: SkillerContainerScope | null + readonly startedAtIso: string + readonly trpcBasePath: string + readonly trpcPort: number +} + +type SkillerProcess = { + readonly appPath: string + readonly logPath: string + readonly process: ChildProcess + readonly scope: SkillerContainerScope | null + readonly startedAtIso: string + readonly trpcBasePath: string + readonly trpcPort: number +} + +type SkillerProcessUser = { + readonly gid: number + readonly uid: number +} + +type SkillerRoute = + | { readonly _tag: "App"; readonly relativePath: string } + | { readonly _tag: "Trpc"; readonly sessionId: string | null; readonly upstreamPath: string } + +const submoduleRelativePath = join("third_party", "skiller-desktop-skills-manager") +const launchLogPath = join(homedir(), ".docker-git", "logs", "skiller.log") +const skillerAppPath = "/api/skiller/app/" +const skillerTrpcBasePath = "/api/skiller" +const skillerPreferredTrpcPort = 17888 + +let currentProcess: SkillerProcess | null = null +const sessionScopes = new Map() + +const isRunning = (process: ChildProcess): boolean => + process.exitCode === null && process.signalCode === null && !process.killed + +const findWorkspaceRoot = (startDir: string): string | null => { + let current = resolve(startDir) + for (;;) { + if (existsSync(join(current, ".gitmodules")) && existsSync(join(current, submoduleRelativePath))) { + return current + } + const parent = dirname(current) + if (parent === current) { + return null + } + current = parent + } +} + +const resolveSkillerDir = (): Effect.Effect => + Effect.gen(function*(_) { + const root = findWorkspaceRoot(process.cwd()) + if (root === null) { + return yield* _(Effect.fail(new ApiNotFoundError({ + message: "docker-git workspace root with Skiller submodule was not found." + }))) + } + const skillerDir = join(root, submoduleRelativePath) + if (!existsSync(join(skillerDir, "package.json"))) { + return yield* _(Effect.fail(new ApiNotFoundError({ + message: `Skiller submodule is not initialized at ${skillerDir}. Run bun run skiller:init first.` + }))) + } + return skillerDir + }) + +const sessionSkillerAppPath = (sessionId: string): string => + `/api/ssh/session/${encodeURIComponent(sessionId)}/skiller/app/` + +const sessionSkillerTrpcBasePath = (sessionId: string): string => + `/api/ssh/session/${encodeURIComponent(sessionId)}/skiller` + +const toLaunch = ( + process: SkillerProcess, + alreadyRunning: boolean, + sessionId: string | undefined +): SkillerLaunch => ({ + alreadyRunning, + appPath: sessionId === undefined ? process.appPath : sessionSkillerAppPath(sessionId), + logPath: process.logPath, + pid: process.process.pid ?? null, + scope: process.scope, + startedAtIso: process.startedAtIso, + trpcBasePath: sessionId === undefined ? process.trpcBasePath : sessionSkillerTrpcBasePath(sessionId), + trpcPort: process.trpcPort +}) + +const dockerCapture = ( + args: ReadonlyArray, + command: string +): Effect.Effect => + runCommandCapture( + { + args, + command: "docker", + cwd: process.cwd() + }, + [0], + (exitCode) => new CommandFailedError({ command, exitCode }) + ).pipe( + Effect.mapError((cause: CommandFailedError | PlatformError) => + new ApiInternalError({ message: `Failed to inspect Docker container for Skiller: ${command}`, cause }) + ), + Effect.provide(NodeContext.layer) + ) + +const isPortAvailable = (port: number): Promise => + new Promise((resolve) => { + const server = createServer() + server.once("error", () => { + resolve(false) + }) + server.once("listening", () => { + server.close(() => { + resolve(true) + }) + }) + server.listen({ host: "127.0.0.1", port }) + }) + +const findAvailablePort = async (preferredPort: number): Promise => { + for (let port = preferredPort; port < preferredPort + 100; port += 1) { + if (await isPortAvailable(port)) { + return port + } + } + throw new Error(`No available Skiller tRPC port in range ${preferredPort}-${preferredPort + 99}.`) +} + +const sleep = (durationMs: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, durationMs) + }) + +const containerHomePath = (sshUser: string): string => `/home/${sshUser}` + +const inspectContainerMounts = ( + containerName: string +): Effect.Effect, ApiInternalError> => + dockerCapture( + [ + "inspect", + "-f", + String.raw`{{range .Mounts}}{{println .Source "\t" .Destination "\t" .RW}}{{end}}`, + containerName + ], + "docker inspect mounts" + ).pipe(Effect.map(parseDockerMountLines)) + +const requireAccessibleDirectory = ( + path: string, + label: string +): Effect.Effect => + Effect.try({ + catch: () => new ApiConflictError({ + message: `Skiller cannot access the selected container ${label} at ${path}. The docker-git controller must run with Docker data mounted into /var/lib/docker.` + }), + try: () => { + if (!existsSync(path) || !statSync(path).isDirectory()) { + throw new Error(`Missing directory: ${path}`) + } + } + }) + +const resolveSkillerScope = ( + projectKey: string, + project: ProjectItem +): Effect.Effect => + Effect.gen(function*(_) { + const mounts = yield* _(inspectContainerMounts(project.containerName)) + const containerHome = containerHomePath(project.sshUser) + const hostHomePath = remapContainerPathToMountedHost(mounts, containerHome) + const hostProjectPath = remapContainerPathToMountedHost(mounts, project.targetDir) + if (hostHomePath === null) { + return yield* _(Effect.fail(new ApiConflictError({ + message: `Skiller cannot find a writable Docker mount for ${containerHome} in ${project.containerName}.` + }))) + } + if (hostProjectPath === null) { + return yield* _(Effect.fail(new ApiConflictError({ + message: `Skiller cannot find a writable Docker mount for ${project.targetDir} in ${project.containerName}.` + }))) + } + yield* _(requireAccessibleDirectory(hostHomePath, "home volume")) + yield* _(requireAccessibleDirectory(hostProjectPath, "project directory")) + return { + containerHomePath: containerHome, + containerName: project.containerName, + containerProjectPath: project.targetDir, + hostHomePath, + hostProjectPath, + projectId: project.projectDir, + projectKey, + sshUser: project.sshUser + } + }) + +const resolveRequestedSkillerScope = ( + projectKey: string | undefined +): Effect.Effect< + SkillerContainerScope | null, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + projectKey === undefined + ? Effect.succeed(null) + : getProjectItemByKey(projectKey).pipe( + Effect.flatMap((project) => resolveSkillerScope(projectKey, project)) + ) + +const waitForSkillerReady = (trpcPort: number): Effect.Effect => + Effect.tryPromise({ + catch: (cause) => new ApiInternalError({ + message: "Skiller started but did not become ready.", + cause + }), + try: async () => { + const deadline = Date.now() + 180_000 + let lastError: unknown = null + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${trpcPort}/trpc/get_app_version`) + if (response.ok) { + return + } + lastError = new Error(`HTTP ${response.status}`) + } catch (error) { + lastError = error + } + await sleep(1_000) + } + throw lastError ?? new Error("Timed out waiting for Skiller tRPC.") + } + }) + +const launchScript = [ + "set -euo pipefail", + "if [ ! -d node_modules ]; then bun install --frozen-lockfile; fi", + "if [ ! -f out/main/index.js ] || [ ! -f out/renderer/index.html ]; then bun run build; fi", + "if [ ! -e out/preload/index.js ]; then ln -sf index.mjs out/preload/index.js; fi", + "if [ -z \"${DISPLAY:-}\" ] && command -v xvfb-run >/dev/null 2>&1; then", + " exec xvfb-run -a ./node_modules/electron/dist/electron --no-sandbox out/main/index.js", + "fi", + "exec ./node_modules/electron/dist/electron --no-sandbox out/main/index.js" +].join("\n") + +const skillerHomeEnv = ( + scope: SkillerContainerScope | null +): Record => + scope === null + ? {} + : { + HOME: scope.hostHomePath, + USER: scope.sshUser, + XDG_CACHE_HOME: join(scope.hostHomePath, ".cache"), + XDG_CONFIG_HOME: join(scope.hostHomePath, ".config"), + XDG_DATA_HOME: join(scope.hostHomePath, ".local", "share") + } + +const scopedProcessUser = ( + scope: SkillerContainerScope | null +): SkillerProcessUser | null => { + if (scope === null) { + return null + } + const stats = statSync(scope.hostHomePath) + return { gid: stats.gid, uid: stats.uid } +} + +const ensureOwnedDirectory = (path: string, user: SkillerProcessUser): void => { + mkdirSync(path, { recursive: true }) + const stats = statSync(path) + if (stats.uid !== user.uid || stats.gid !== user.gid) { + chownSync(path, user.uid, user.gid) + } +} + +const chownIfExists = (path: string, user: SkillerProcessUser): void => { + if (existsSync(path)) { + const stats = statSync(path) + if (stats.uid !== user.uid || stats.gid !== user.gid) { + chownSync(path, user.uid, user.gid) + } + } +} + +const skillerLaunchCommand = ( + user: SkillerProcessUser | null +): readonly [string, ReadonlyArray] => + user === null + ? ["bash", ["-lc", launchScript]] + : [ + "setpriv", + [ + `--reuid=${user.uid}`, + `--regid=${user.gid}`, + "--clear-groups", + "bash", + "-lc", + launchScript + ] + ] + +const stopSkillerProcess = (process: SkillerProcess): void => { + const pid = process.process.pid + if (pid === undefined) { + process.process.kill("SIGTERM") + return + } + try { + globalThis.process.kill(-pid, "SIGTERM") + } catch { + process.process.kill("SIGTERM") + } +} + +const registerSkillerProject = ( + trpcPort: number, + scope: SkillerContainerScope | null +): Effect.Effect => + scope === null + ? Effect.void + : Effect.tryPromise({ + catch: (cause) => new ApiInternalError({ + message: "Skiller started but docker-git could not register the selected project path.", + cause + }), + try: async () => { + const response = await fetch(`http://127.0.0.1:${trpcPort}/trpc/add_project`, { + body: JSON.stringify({ path: scope.hostProjectPath }), + headers: { "content-type": "application/json" }, + method: "POST" + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`) + } + } + }) + +const launchSkillerProcess = ( + skillerDir: string, + trpcPort: number, + scope: SkillerContainerScope | null +): SkillerLaunch => { + mkdirSync(dirname(launchLogPath), { recursive: true }) + const processUser = scopedProcessUser(scope) + if (scope !== null && processUser !== null) { + ensureOwnedDirectory(join(scope.hostHomePath, ".agents"), processUser) + ensureOwnedDirectory(join(scope.hostHomePath, ".agents", "skills"), processUser) + ensureOwnedDirectory(join(scope.hostHomePath, ".config"), processUser) + ensureOwnedDirectory(join(scope.hostHomePath, ".cache"), processUser) + ensureOwnedDirectory(join(scope.hostHomePath, ".local", "share"), processUser) + ensureOwnedDirectory(join(scope.hostHomePath, ".skiller"), processUser) + chownIfExists(join(scope.hostHomePath, ".skiller", "config.toml"), processUser) + } + const logFd = openSync(launchLogPath, "a") + try { + const [command, args] = skillerLaunchCommand(processUser) + const child = spawn(command, args, { + cwd: skillerDir, + detached: true, + env: { + ...process.env, + AGENTSKILLS_TRPC_PORT: String(trpcPort), + ELECTRON_ENABLE_LOGGING: "1", + ...skillerHomeEnv(scope) + }, + stdio: ["ignore", logFd, logFd] + }) + const startedAtIso = new Date().toISOString() + currentProcess = { + appPath: skillerAppPath, + logPath: launchLogPath, + process: child, + scope, + startedAtIso, + trpcBasePath: skillerTrpcBasePath, + trpcPort + } + child.once("exit", () => { + if (currentProcess?.process.pid === child.pid) { + currentProcess = null + } + }) + child.unref() + return toLaunch(currentProcess, false, undefined) + } finally { + closeSync(logFd) + } +} + +const rememberSessionScope = (sessionId: string | undefined, scope: SkillerContainerScope | null): void => { + if (sessionId !== undefined) { + sessionScopes.set(sessionId, scope) + } +} + +export const openSkiller = ( + projectKey?: string, + sessionId?: string +): Effect.Effect< + SkillerLaunch, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + Effect.gen(function*(_) { + const scope = yield* _(resolveRequestedSkillerScope(projectKey)) + rememberSessionScope(sessionId, scope) + if (currentProcess !== null && isRunning(currentProcess.process)) { + if (sameSkillerScope(currentProcess.scope, scope)) { + yield* _(waitForSkillerReady(currentProcess.trpcPort)) + yield* _(registerSkillerProject(currentProcess.trpcPort, scope)) + return toLaunch(currentProcess, true, sessionId) + } + stopSkillerProcess(currentProcess) + currentProcess = null + } + const skillerDir = yield* _(resolveSkillerDir()) + const trpcPort = yield* _(Effect.tryPromise({ + catch: (cause) => new ApiInternalError({ + message: "Failed to reserve Skiller tRPC port.", + cause + }), + try: () => findAvailablePort(skillerPreferredTrpcPort) + })) + const launch = yield* _(Effect.try({ + catch: (cause) => new ApiInternalError({ + message: "Failed to launch Skiller.", + cause + }), + try: () => launchSkillerProcess(skillerDir, trpcPort, scope) + })) + yield* _(waitForSkillerReady(trpcPort)) + yield* _(registerSkillerProject(trpcPort, scope)) + return sessionId === undefined || currentProcess === null ? launch : toLaunch(currentProcess, false, sessionId) + }) + +export const openSkillerForTerminalSession = ( + projectKey: string, + sessionId: string +): Effect.Effect< + SkillerLaunch, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + getProjectItemByKey(projectKey).pipe( + Effect.flatMap((project) => + getProjectTerminalSession(project.projectDir, sessionId).pipe( + Effect.as(projectKey) + ) + ), + Effect.flatMap((resolvedProjectKey) => openSkiller(resolvedProjectKey, sessionId)) + ) + +export const parseSkillerRoute = (pathname: string): SkillerRoute | null => { + const normalized = pathname.startsWith("/api/") ? pathname.slice("/api".length) : pathname + const sessionMatch = /^\/ssh\/session\/([^/]+)\/skiller(?:\/(app|trpc)(\/.*)?)?$/u.exec(normalized) + if (sessionMatch !== null) { + const sessionId = decodeURIComponent(sessionMatch[1] ?? "") + const routeKind = sessionMatch[2] ?? "" + const tail = sessionMatch[3] ?? "" + if (routeKind === "app" || routeKind === "") { + return { _tag: "App", relativePath: tail.length === 0 ? "/" : tail } + } + if (routeKind === "trpc") { + return { _tag: "Trpc", sessionId, upstreamPath: `/trpc${tail}` } + } + } + if (normalized === "/skiller/app" || normalized === "/skiller/app/") { + return { _tag: "App", relativePath: "/" } + } + if (normalized.startsWith("/skiller/app/")) { + return { _tag: "App", relativePath: normalized.slice("/skiller/app".length) } + } + if (normalized === "/skiller/trpc" || normalized.startsWith("/skiller/trpc/")) { + return { _tag: "Trpc", sessionId: null, upstreamPath: normalized.slice("/skiller".length) || "/trpc" } + } + return null +} + +const contentTypeForPath = (path: string): string => { + if (path.endsWith(".css")) { + return "text/css; charset=utf-8" + } + if (path.endsWith(".js")) { + return "application/javascript; charset=utf-8" + } + if (path.endsWith(".png")) { + return "image/png" + } + if (path.endsWith(".svg")) { + return "image/svg+xml" + } + if (path.endsWith(".html")) { + return "text/html; charset=utf-8" + } + return "application/octet-stream" +} + +const browserTrpcBaseBootstrap = [ + "" +].join("") + +const injectBrowserTrpcBase = (html: string): string => + html.includes("") + ? html.replace("", `${browserTrpcBaseBootstrap}`) + : `${browserTrpcBaseBootstrap}${html}` + +const safeRendererPath = (skillerDir: string, relativePath: string): string => { + const rendererDir = resolve(skillerDir, "out", "renderer") + const rawRelativePath = relativePath === "/" ? "/index.html" : relativePath + const decoded = decodeURIComponent(rawRelativePath) + const target = resolve(rendererDir, `.${decoded}`) + if (target !== rendererDir && !target.startsWith(`${rendererDir}/`)) { + throw new Error("Skiller asset path escapes renderer directory.") + } + const stats = statSync(target) + return stats.isDirectory() ? join(target, "index.html") : target +} + +export const serveSkillerApp = ( + route: Extract +): Effect.Effect => + Effect.gen(function*(_) { + const skillerDir = yield* _(resolveSkillerDir()) + const filePath = yield* _(Effect.try({ + catch: () => new ApiNotFoundError({ + message: "Skiller app asset was not found. Open Skiller once and wait for the build to finish." + }), + try: () => safeRendererPath(skillerDir, route.relativePath) + })) + const content = yield* _(Effect.try({ + catch: (cause) => new ApiInternalError({ + message: "Failed to read Skiller app asset.", + cause + }), + try: () => readFileSync(filePath) + })) + if (filePath.endsWith(".html")) { + return HttpServerResponse.text(injectBrowserTrpcBase(content.toString("utf8")), { + contentType: "text/html; charset=utf-8", + headers: { "cache-control": "no-store" } + }) + } + return HttpServerResponse.uint8Array(new Uint8Array(content), { + contentType: contentTypeForPath(filePath), + headers: { "cache-control": "no-store" } + }) + }) + +const hopByHopHeaders = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]) + +const copyRequestHeaders = (request: HttpServerRequest.HttpServerRequest): Headers => { + const headers = new Headers() + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === "string" && !hopByHopHeaders.has(key.toLowerCase())) { + headers.set(key, value) + } + } + return headers +} + +const copyResponseHeaders = (response: Response): Record => { + const headers: Record = { "cache-control": "no-store" } + response.headers.forEach((value, key) => { + if (!hopByHopHeaders.has(key.toLowerCase())) { + headers[key] = value + } + }) + return headers +} + +const verifySkillerRouteScope = ( + route: Extract +): Effect.Effect => { + if (route.sessionId === null) { + return Effect.void + } + const scope = sessionScopes.get(route.sessionId) + if (scope === undefined) { + return Effect.fail(new ApiConflictError({ + message: `Skiller session is not registered: ${route.sessionId}. Click the terminal Skiller button again.` + })) + } + if (currentProcess === null || !sameSkillerScope(currentProcess.scope, scope)) { + return Effect.fail(new ApiConflictError({ + message: `Skiller is not running for terminal session ${route.sessionId}. Click the terminal Skiller button again.` + })) + } + return Effect.void +} + +export const proxySkillerTrpc = ( + request: HttpServerRequest.HttpServerRequest, + route: Extract +): Effect.Effect< + HttpServerResponse.HttpServerResponse, + ApiConflictError | ApiInternalError | HttpServerError.RequestError +> => + Effect.gen(function*(_) { + if (currentProcess === null || !isRunning(currentProcess.process)) { + return yield* _(Effect.fail(new ApiConflictError({ + message: "Skiller is not running. Click the Skiller button first." + }))) + } + yield* _(verifySkillerRouteScope(route)) + const parsed = new URL(request.url, "http://localhost") + const upstreamUrl = new URL( + `${route.upstreamPath}${parsed.search}`, + `http://127.0.0.1:${currentProcess.trpcPort}` + ) + const body = request.method === "GET" || request.method === "HEAD" + ? undefined + : yield* _(request.arrayBuffer) + const upstreamResponse = yield* _(Effect.tryPromise({ + catch: (cause) => new ApiInternalError({ + message: "Failed to proxy Skiller tRPC.", + cause + }), + try: () => fetch(upstreamUrl, { + ...(body === undefined ? {} : { body }), + headers: copyRequestHeaders(request), + method: request.method + }) + })) + const headers = copyResponseHeaders(upstreamResponse) + if (request.method === "HEAD" || upstreamResponse.body === null) { + return HttpServerResponse.empty({ headers, status: upstreamResponse.status }) + } + return HttpServerResponse.stream( + Stream.fromReadableStream( + () => upstreamResponse.body as ReadableStream, + (cause) => new ApiInternalError({ message: "Failed to read Skiller tRPC response.", cause }) + ), + { + headers, + status: upstreamResponse.status, + statusText: upstreamResponse.statusText + } + ) + }) diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 021bf14d..1974f991 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -10,7 +10,9 @@ import { Effect, Either } from "effect" import { Buffer } from "node:buffer" import { spawn } from "node:child_process" import { randomUUID } from "node:crypto" +import { existsSync } from "node:fs" import type { IncomingMessage, Server as HttpServer } from "node:http" +import os from "node:os" import type { Duplex } from "node:stream" import { WebSocket, WebSocketServer, type RawData } from "ws" @@ -140,7 +142,13 @@ type ContainerNetworkEntry = { readonly name: string } -const dockerGitApiContainerName = (): string => process.env["DOCKER_GIT_API_CONTAINER_NAME"]?.trim() || "docker-git-api" +const dockerGitApiContainerName = (): string => + process.env["DOCKER_GIT_API_CONTAINER_NAME"]?.trim() || os.hostname().trim() || "docker-git-api" + +const isContainerizedController = (): boolean => { + const configuredName = process.env["DOCKER_GIT_API_CONTAINER_NAME"]?.trim() + return (configuredName !== undefined && configuredName.length > 0) || existsSync("/.dockerenv") +} const parseContainerNetworkEntries = (output: string): ReadonlyArray => output @@ -150,9 +158,23 @@ const parseContainerNetworkEntries = (output: string): ReadonlyArray ({ name, ipAddress })) const selectReachableProjectNetwork = ( + projectEntries: ReadonlyArray, + controllerEntries: ReadonlyArray +): ContainerNetworkEntry | null => + projectEntries.find((entry) => + entry.name !== "bridge" && controllerEntries.some((controllerEntry) => controllerEntry.name === entry.name) + ) ?? + projectEntries.find((entry) => + controllerEntries.some((controllerEntry) => controllerEntry.name === entry.name) + ) ?? + null + +const selectFallbackProjectNetwork = ( entries: ReadonlyArray ): ContainerNetworkEntry | null => - entries.find((entry) => entry.name !== "bridge") ?? entries[0] ?? null + isContainerizedController() + ? entries.find((entry) => entry.name === "bridge") ?? entries[0] ?? null + : null const inspectContainerNetworks = ( containerName: string @@ -177,7 +199,7 @@ const connectContainerToNetwork = ( containerName: string ) => networkName === "bridge" - ? Effect.void + ? Effect.succeed(true) : runCommandCapture( { cwd: process.cwd(), @@ -187,23 +209,36 @@ const connectContainerToNetwork = ( [0], (exitCode) => new CommandFailedError({ command: `docker network connect ${networkName}`, exitCode }) ).pipe( - Effect.asVoid, - Effect.orElseSucceed(() => void 0) + Effect.as(true), + Effect.orElseSucceed(() => false) ) const resolveControllerReachableProject = ( projectItem: ProjectItem ) => Effect.gen(function*(_) { + const controllerContainer = dockerGitApiContainerName() const networkEntries = yield* _(inspectContainerNetworks(projectItem.containerName).pipe(Effect.orElseSucceed(() => []))) + const controllerNetworks = yield* _(inspectContainerNetworks(controllerContainer).pipe(Effect.orElseSucceed(() => []))) + const alreadyReachable = selectReachableProjectNetwork(networkEntries, controllerNetworks) + if (alreadyReachable !== null) { + return { + ...projectItem, + ipAddress: alreadyReachable.ipAddress + } + } yield* _( Effect.forEach( networkEntries.filter((entry) => entry.name !== "bridge"), - (entry) => connectContainerToNetwork(entry.name, dockerGitApiContainerName()), + (entry) => connectContainerToNetwork(entry.name, controllerContainer), { discard: true } ) ) - const preferredNetwork = selectReachableProjectNetwork(networkEntries) + const refreshedControllerNetworks = yield* _( + inspectContainerNetworks(controllerContainer).pipe(Effect.orElseSucceed(() => [])) + ) + const preferredNetwork = selectReachableProjectNetwork(networkEntries, refreshedControllerNetworks) ?? + selectFallbackProjectNetwork(networkEntries) if (preferredNetwork === null) { return projectItem } diff --git a/packages/api/tests/skiller-core.test.ts b/packages/api/tests/skiller-core.test.ts new file mode 100644 index 00000000..9d433c70 --- /dev/null +++ b/packages/api/tests/skiller-core.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + parseDockerMountLines, + remapContainerPathToMountedHost, + sameSkillerScope +} from "../src/services/skiller-core.js" + +describe("skiller container filesystem mapping", () => { + it("maps a project container path through the most specific writable Docker mount", () => { + const mounts = parseDockerMountLines([ + "/var/lib/docker/volumes/project-home/_data\t/home/dev\ttrue", + "/var/lib/docker/volumes/project-cache/_data\t/home/dev/.docker-git/.cache\ttrue", + "/bootstrap\t/opt/docker-git/bootstrap/source\tfalse" + ].join("\n")) + + expect(remapContainerPathToMountedHost(mounts, "/home/dev/app")).toBe( + "/var/lib/docker/volumes/project-home/_data/app" + ) + expect(remapContainerPathToMountedHost(mounts, "/home/dev/.docker-git/.cache/bun")).toBe( + "/var/lib/docker/volumes/project-cache/_data/bun" + ) + expect(remapContainerPathToMountedHost(mounts, "/opt/docker-git/bootstrap/source")).toBeNull() + }) + + it("treats identical Skiller scopes as reusable and different scopes as isolated", () => { + const scope = { + containerHomePath: "/home/dev", + containerName: "dg-project", + containerProjectPath: "/home/dev/app", + hostHomePath: "/var/lib/docker/volumes/project-home/_data", + hostProjectPath: "/var/lib/docker/volumes/project-home/_data/app", + projectId: "/home/dev/.docker-git/project", + projectKey: "abc123", + sshUser: "dev" + } + + expect(sameSkillerScope(scope, scope)).toBe(true) + expect(sameSkillerScope(scope, { ...scope, projectKey: "def456" })).toBe(false) + expect(sameSkillerScope(scope, null)).toBe(false) + expect(sameSkillerScope(null, null)).toBe(true) + }) +}) diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index c672cdda..56311862 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -80,6 +80,13 @@ Container runtime env (set via .orch/env/project.env): CODEX_SHARE_AUTH=1|0 Share Codex auth.json across projects (default: 1) CODEX_AUTO_UPDATE=1|0 Auto-update Codex CLI on container start (default: 1) CLAUDE_AUTO_SYSTEM_PROMPT=1|0 Auto-attach docker-git managed system prompt to claude (default: 1) + CLAUDE_SYSTEM_PROMPT_OVERRIDE= Custom Claude system prompt body (overrides default Russian template) + CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE= Path to file with custom Claude prompt (takes precedence over OVERRIDE) + CODEX_SYSTEM_PROMPT_OVERRIDE= Custom Codex managed-block content for AGENTS.md + CODEX_SYSTEM_PROMPT_OVERRIDE_FILE= Path to file with custom Codex managed-block content (takes precedence) + GEMINI_SYSTEM_PROMPT_OVERRIDE= Custom Gemini system prompt body + GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE= Path to file with custom Gemini prompt (takes precedence over OVERRIDE) + CODEX_EXTRA_SKILLS_PATHS=[,...] Extra skill trees mounted into Codex (format: "prio-name::relative/path"; comma- or newline-separated) DOCKER_GIT_ZSH_AUTOSUGGEST=1|0 Enable zsh-autosuggestions (default: 0) DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=... zsh-autosuggestions highlight style (default: fg=8,italic) DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=... Suggestion sources (default: history completion) diff --git a/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts b/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts index 813ee0f6..16cab761 100644 --- a/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts +++ b/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts @@ -43,15 +43,27 @@ elif [[ "$REPO_REF" == refs/pull/*/head ]]; then fi MANAGED_START="" MANAGED_END="" -if [[ ! -f "$AGENTS_PATH" ]]; then - MANAGED_BLOCK="$(cat </dev/null || true - if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then - cat < "$CLAUDE_GLOBAL_PROMPT_FILE" - +CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE:-}" +CLAUDE_SYSTEM_PROMPT_OVERRIDE="${"$"}{CLAUDE_SYSTEM_PROMPT_OVERRIDE:-}" +CLAUDE_DEFAULT_PROMPT_BODY="$(cat </dev/null || true + if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then + cat < "$CLAUDE_GLOBAL_PROMPT_FILE" + +$CLAUDE_PROMPT_BODY EOF chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true diff --git a/packages/app/src/lib/core/templates-entrypoint/codex.ts b/packages/app/src/lib/core/templates-entrypoint/codex.ts index e63ea77e..718c5800 100644 --- a/packages/app/src/lib/core/templates-entrypoint/codex.ts +++ b/packages/app/src/lib/core/templates-entrypoint/codex.ts @@ -164,6 +164,22 @@ docker_git_sync_project_codex_skills() { fi done + # Extra entries via CODEX_EXTRA_SKILLS_PATHS (comma- or newline-separated "prio-name::relative/path"). + local extra_specs="${"$"}{CODEX_EXTRA_SKILLS_PATHS:-}" + if [[ -n "$extra_specs" ]]; then + extra_specs="${"$"}{extra_specs//,/$'\n'}" + while IFS= read -r spec; do + [[ -z "$spec" ]] && continue + mount_name="${"$"}{spec%%::*}" + relative_path="${"$"}{spec#*::}" + if [[ -d "$project_dir/$relative_path" ]]; then + ln -sfn "$project_dir/$relative_path" "$project_skills_root/$mount_name" + chown -h 1000:1000 "$project_skills_root/$mount_name" 2>/dev/null || true + linked=1 + fi + done <<< "$extra_specs" + fi + chown 1000:1000 "$codex_home/skills" "$project_skills_root" 2>/dev/null || true if [[ "$linked" -eq 1 ]]; then diff --git a/packages/app/src/lib/core/templates-entrypoint/gemini.ts b/packages/app/src/lib/core/templates-entrypoint/gemini.ts index 36c86eeb..fd91cd28 100644 --- a/packages/app/src/lib/core/templates-entrypoint/gemini.ts +++ b/packages/app/src/lib/core/templates-entrypoint/gemini.ts @@ -265,8 +265,9 @@ elif [[ "$REPO_REF" == refs/pull/*/head ]]; then fi fi -cat < "$GEMINI_MD_PATH" - +GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE:-}" +GEMINI_SYSTEM_PROMPT_OVERRIDE="${"$"}{GEMINI_SYSTEM_PROMPT_OVERRIDE:-}" +GEMINI_DEFAULT_PROMPT_BODY="$(cat < "$GEMINI_MD_PATH" + +$GEMINI_PROMPT_BODY EOF chown 1000:1000 "$GEMINI_MD_PATH" || true` diff --git a/packages/app/src/ui/primitives-web.tsx b/packages/app/src/ui/primitives-web.tsx index 4ad5e67b..7f558344 100644 --- a/packages/app/src/ui/primitives-web.tsx +++ b/packages/app/src/ui/primitives-web.tsx @@ -109,24 +109,23 @@ export const webPrimitives = { children, style: textStyle(props) }), - TextInput: ({ - ariaLabel, - autoFocus, - onChange, - onEnter, - onEscape, - placeholder, - secret, - value - }: UiTextInputProps): JSX.Element => ( - + props.multiline === true ? : +} as const + +const MultilineTextInput = ( + { ariaLabel, autoFocus, minRows, onChange, onEnter, onEscape, placeholder, value }: UiTextInputProps +): JSX.Element => { + const rows = minRows ?? 6 + return ( +