diff --git a/README.md b/README.md index b4f361d..e21b757 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ No port forwarding, no exposing to the internet, and you get valid HTTPS certs. | `OLLAMA_HOST` | `http://localhost:11434` | Ollama API endpoint | | `GOOGLE_APPLICATION_CREDENTIALS` | _(unset)_ | Optional path to a service-account JSON for BigQuery ADC | | `BIGQUERY_EXPORT_MAX_ROWS` | `250000` | Max rows allowed for full BigQuery save/download exports (0 disables limit) | +| `CONCIERGE_CLAUDE_CMD` | _(unset)_ | Override Claude CLI command (default: `claude` on PATH) | +| `CONCIERGE_CLAUDE_ARGS` | _(unset)_ | Extra args prepended to Claude CLI command (JSON array or quoted string) | +| `CONCIERGE_CODEX_CMD` | _(unset)_ | Override Codex CLI command (default: `codex` on PATH) | +| `CONCIERGE_CODEX_ARGS` | _(unset)_ | Extra args prepended to Codex CLI command (JSON array or quoted string) | +| `CONCIERGE_CLI_SKILLS_DIRS` | _(unset)_ | Colon/semicolon-delimited skill dirs added via `--add-dir` for all CLI providers | +| `CONCIERGE_CLAUDE_SKILLS_DIRS` | _(unset)_ | Additional skill dirs for Claude only | +| `CONCIERGE_CODEX_SKILLS_DIRS` | _(unset)_ | Additional skill dirs for Codex only | +| `CONCIERGE_CLI_ENV_FILE` | _(unset)_ | JSON file of env vars to merge into CLI process environment | +| `CONCIERGE_CLAUDE_ENV_FILE` | _(unset)_ | JSON env overrides for Claude only | +| `CONCIERGE_CODEX_ENV_FILE` | _(unset)_ | JSON env overrides for Codex only | BigQuery uses Google Application Default Credentials (ADC). Typical local setup: diff --git a/lib/providers/base.js b/lib/providers/base.js index 4042c2e..18a44ee 100644 --- a/lib/providers/base.js +++ b/lib/providers/base.js @@ -4,6 +4,172 @@ */ const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); + +const CLI_ENV_OVERRIDES = { + claude: { + cmd: 'CONCIERGE_CLAUDE_CMD', + args: 'CONCIERGE_CLAUDE_ARGS', + envFile: 'CONCIERGE_CLAUDE_ENV_FILE', + skills: 'CONCIERGE_CLAUDE_SKILLS_DIRS', + }, + codex: { + cmd: 'CONCIERGE_CODEX_CMD', + args: 'CONCIERGE_CODEX_ARGS', + envFile: 'CONCIERGE_CODEX_ENV_FILE', + skills: 'CONCIERGE_CODEX_SKILLS_DIRS', + }, +}; + +const GLOBAL_SKILLS_ENV = 'CONCIERGE_CLI_SKILLS_DIRS'; +const GLOBAL_ENV_FILE = 'CONCIERGE_CLI_ENV_FILE'; +const GLOBAL_ENV_ALLOWLIST = 'CONCIERGE_CLI_ENV_ALLOWLIST'; +const GLOBAL_RUNTIME_DIR = 'CONCIERGE_CLI_RUNTIME_DIR'; +const GLOBAL_PREPEND_SKILL_BINS = 'CONCIERGE_CLI_PREPEND_SKILL_BINS'; +const CLAUDE_DISABLE_BETAS = 'CONCIERGE_CLAUDE_DISABLE_EXPERIMENTAL_BETAS'; +const CLAUDE_DISABLE_AUTO_UPDATE = 'CONCIERGE_CLAUDE_DISABLE_AUTO_UPDATE'; +const CLAUDE_DISABLE_UPDATE_NAG = 'CONCIERGE_CLAUDE_DISABLE_UPDATE_NAG'; + +function parseArgString(value) { + if (!value) return []; + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { /* fall back to tokenizing */ } + } + return trimmed + .match(/(?:[^\s"]+|"[^"]*")+/g) + ?.map((token) => token.replace(/^"|"$/g, '')) || []; +} + +function parseSkillDirs(value) { + if (!value) return []; + return value + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parseAllowlist(value) { + if (!value) return null; + const items = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return items.length > 0 ? items : null; +} + +function loadEnvFile(filePath) { + if (!filePath) return null; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + if (data && typeof data === 'object' && !Array.isArray(data)) { + return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])); + } + } catch { /* ignore invalid env files */ } + return null; +} + +function filterEnv(env, allowlist) { + if (!allowlist) return env; + const filtered = {}; + for (const key of allowlist) { + if (Object.prototype.hasOwnProperty.call(env, key)) { + filtered[key] = env[key]; + } + } + return filtered; +} + +function resolveSpawn(cliName) { + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const cmd = process.env[overrides.cmd]; + if (cmd) { + const args = parseArgString(process.env[overrides.args]); + return { command: cmd, prefixArgs: args }; + } + return { command: cliName, prefixArgs: [] }; +} + +function cliEnv(cliName) { + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const env = { ...process.env }; + + const allowlist = parseAllowlist(process.env[GLOBAL_ENV_ALLOWLIST]); + const globalEnv = loadEnvFile(process.env[GLOBAL_ENV_FILE]); + if (globalEnv) Object.assign(env, filterEnv(globalEnv, allowlist)); + + const providerEnv = loadEnvFile(process.env[overrides.envFile]); + if (providerEnv) Object.assign(env, filterEnv(providerEnv, allowlist)); + + const runtimeDir = process.env[GLOBAL_RUNTIME_DIR]; + if (runtimeDir) { + const nodeBin = path.join(runtimeDir, 'node', 'bin'); + env.PATH = nodeBin + (env.PATH ? path.delimiter + env.PATH : ''); + env.NODE_PATH = path.join(runtimeDir, 'clis', 'node_modules'); + const libDir = path.join(runtimeDir, 'lib'); + env.LD_LIBRARY_PATH = libDir + (env.LD_LIBRARY_PATH ? path.delimiter + env.LD_LIBRARY_PATH : ''); + } + + if (cliName === 'claude') { + if (process.env[CLAUDE_DISABLE_BETAS] === '1') { + env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1'; + } + if (process.env[CLAUDE_DISABLE_AUTO_UPDATE] === '1') { + env.CLAUDE_CODE_DISABLE_AUTO_UPDATE = '1'; + } + if (process.env[CLAUDE_DISABLE_UPDATE_NAG] === '1') { + env.CLAUDE_CODE_DISABLE_UPDATE_NAG = '1'; + } + } + + prependSkillBins(env, cliName); + + return env; +} + +function skillAddDirArgs(cliName) { + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const dirs = [ + ...parseSkillDirs(process.env[GLOBAL_SKILLS_ENV]), + ...parseSkillDirs(process.env[overrides.skills]), + ].filter((dir) => fs.existsSync(dir)); + + const args = []; + for (const dir of dirs) { + args.push('--add-dir', dir); + } + return args; +} + +function prependSkillBins(env, cliName) { + if (process.env[GLOBAL_PREPEND_SKILL_BINS] !== '1') return; + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const dirs = [ + ...parseSkillDirs(process.env[GLOBAL_SKILLS_ENV]), + ...parseSkillDirs(process.env[overrides.skills]), + ].filter((dir) => fs.existsSync(dir)); + + const binDirs = []; + for (const dir of dirs) { + try { + const entries = fs.readdirSync(dir); + for (const name of entries) { + const binDir = path.join(dir, name, 'bin'); + if (fs.existsSync(binDir)) binDirs.push(binDir); + } + } catch { /* ignore */ } + } + + if (binDirs.length > 0) { + env.PATH = binDirs.join(path.delimiter) + (env.PATH ? path.delimiter + env.PATH : ''); + } +} /** * Safe WebSocket send - checks connection state before sending @@ -86,4 +252,7 @@ class LLMProvider { module.exports = { LLMProvider, safeSend, + cliEnv, + resolveSpawn, + skillAddDirArgs, }; diff --git a/lib/providers/claude.js b/lib/providers/claude.js index b9d7545..cdfd6d3 100644 --- a/lib/providers/claude.js +++ b/lib/providers/claude.js @@ -6,7 +6,7 @@ const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { LLMProvider, safeSend } = require('./base'); +const { LLMProvider, safeSend, cliEnv, resolveSpawn, skillAddDirArgs } = require('./base'); const { embedConversation, hasEmbedding } = require('../embeddings'); const { resolveConversationExecutionMode, modeAllowsWrites } = require('../workflow/execution-mode'); @@ -596,13 +596,30 @@ class ClaudeProvider extends LLMProvider { `Edit(/${conv.cwd}/**)`, `Write(/${conv.cwd}/**)`, ] : []; + const extraAllow = (process.env.CONCIERGE_CLAUDE_SANDBOX_ALLOW || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + if (extraAllow.length > 0) { + allow.push(...extraAllow); + } + const extraDomains = (process.env.CONCIERGE_CLAUDE_SANDBOX_ALLOWED_DOMAINS || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); const sandboxSettings = { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, network: { - allowedDomains: ['github.com', '*.npmjs.org', 'registry.yarnpkg.com', 'api.github.com'] + allowedDomains: [ + 'github.com', + '*.npmjs.org', + 'registry.yarnpkg.com', + 'api.github.com', + ...extraDomains, + ] } }, permissions: { @@ -650,6 +667,9 @@ Please continue the conversation naturally, using the above context as backgroun args.push('--add-dir', path.join(uploadDir, conversationId)); } + // Inject --add-dir for configured skill directories + args.push(...skillAddDirArgs('claude')); + // Append attachment file paths to the prompt if (attachments && attachments.length > 0) { const imageAtts = attachments.filter(a => a.path && /\.(png|jpg|jpeg|gif|webp)$/i.test(a.path)); @@ -665,6 +685,7 @@ Please continue the conversation naturally, using the above context as backgroun } // Debug sandbox settings + const { command: spawnCmd, prefixArgs } = resolveSpawn('claude'); debug('SPAWN', { cwd: conv.cwd, sandboxed: conv.sandboxed, @@ -672,12 +693,14 @@ Please continue the conversation naturally, using the above context as backgroun executionMode, hasSessionId: !!conv.claudeSessionId, hasNativeForkSession: useNativeForkSession, - args: args, + command: spawnCmd, + args: [...prefixArgs, ...args], }, { truncate: 0 }); - const proc = spawn('claude', args, { + const env = cliEnv('claude'); + const proc = spawn(spawnCmd, [...prefixArgs, ...args], { cwd: conv.cwd, - env: { ...process.env }, + env, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -879,13 +902,14 @@ ${conversationText} Write your summary:`; return new Promise((resolve, reject) => { - const proc = spawn('claude', [ + const { command: sumCmd, prefixArgs: sumPrefix } = resolveSpawn('claude'); + const proc = spawn(sumCmd, [...sumPrefix, '-p', prompt, '--model', model, '--output-format', 'text', ], { cwd, - env: { ...process.env }, + env: cliEnv('claude'), stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/lib/providers/codex.js b/lib/providers/codex.js index a1963f4..6081de2 100644 --- a/lib/providers/codex.js +++ b/lib/providers/codex.js @@ -5,7 +5,7 @@ const { spawn } = require('child_process'); const path = require('path'); -const { LLMProvider, safeSend } = require('./base'); +const { LLMProvider, safeSend, cliEnv, resolveSpawn, skillAddDirArgs } = require('./base'); const { resolveConversationExecutionMode, modeAllowsWrites } = require('../workflow/execution-mode'); // Debug logging - enabled via DEBUG_CODEX=1 environment variable @@ -701,14 +701,20 @@ class CodexProvider extends LLMProvider { conv._typedInputTokens = estimateTypedInputTokens(text); conv._codexDisplayInputTokens = conv._typedInputTokens; + // Inject --add-dir for configured skill directories (before prompt) + if (!isResume) { + args.push(...skillAddDirArgs('codex')); + } + // Prompt must be last args.push(prompt); - debug('SPAWN', { cwd: conv.cwd, executionMode, args }, { truncate: 0 }); + const { command: spawnCmd, prefixArgs } = resolveSpawn('codex'); + debug('SPAWN', { cwd: conv.cwd, executionMode, command: spawnCmd, args: [...prefixArgs, ...args] }, { truncate: 0 }); - const proc = spawn('codex', args, { + const proc = spawn(spawnCmd, [...prefixArgs, ...args], { cwd: conv.cwd, - env: { ...process.env }, + env: cliEnv('codex'), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -924,7 +930,8 @@ ${conversationText} Write your summary:`; return new Promise((resolve, reject) => { - const proc = spawn('codex', [ + const { command: sumCmd, prefixArgs: sumPrefix } = resolveSpawn('codex'); + const proc = spawn(sumCmd, [...sumPrefix, 'exec', '--json', '--skip-git-repo-check', @@ -932,7 +939,7 @@ Write your summary:`; prompt, ], { cwd, - env: { ...process.env }, + env: cliEnv('codex'), stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/public/index.html b/public/index.html index 5129de5..0f7ddc4 100644 --- a/public/index.html +++ b/public/index.html @@ -2,6 +2,17 @@ + @@ -12,17 +23,17 @@ - - - + + + - + @@ -843,7 +854,7 @@

Commands & Skills

- - + + diff --git a/public/js/explorer/file-viewer-content.js b/public/js/explorer/file-viewer-content.js index 8bb5d4e..070362d 100644 --- a/public/js/explorer/file-viewer-content.js +++ b/public/js/explorer/file-viewer-content.js @@ -11,6 +11,7 @@ import { selectGeoFeature, fitGeoPreviewToBounds, } from './geo-preview.js'; +import { basePath } from '../utils.js'; const DEFAULT_PREVIEWABLE_EXTS = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']); const GEO_PREVIEW_EXTS = new Set(['geojson', 'json', 'topojson', 'jsonl', 'ndjson']); @@ -527,8 +528,8 @@ export function renderFileViewerContent({ unmountGeoPreview(container); const ext = toSafeString(data.ext).toLowerCase(); - const fileUrl = context.getFileDownloadUrl(filePath, { inline: true }); - const downloadUrl = context.getFileDownloadUrl(filePath); + const fileUrl = basePath(context.getFileDownloadUrl(filePath, { inline: true })); + const downloadUrl = basePath(context.getFileDownloadUrl(filePath)); const fileLikeIcon = icons.document || icons.file || ''; const openExternalIcon = icons.openExternal || DEFAULT_OPEN_EXTERNAL_ICON; const attachIcon = icons.attach || DEFAULT_ATTACH_ICON; diff --git a/public/js/explorer/files-core.js b/public/js/explorer/files-core.js index d235583..3b3a106 100644 --- a/public/js/explorer/files-core.js +++ b/public/js/explorer/files-core.js @@ -1,4 +1,5 @@ import { buildDeleteFileUrl } from './context.js'; +import { basePath } from '../utils.js'; export function sortEntries(entries) { return [...entries].sort((a, b) => { @@ -13,7 +14,7 @@ export function getViewableFiles(entries) { } export function getInlineDownloadUrl(context, filePath) { - return context.getFileDownloadUrl(filePath, { inline: true }); + return basePath(context.getFileDownloadUrl(filePath, { inline: true })); } export async function fetchDirectoryData(context, targetPath, apiFetch, options = {}) { diff --git a/public/js/file-panel/data.js b/public/js/file-panel/data.js index bb62ded..c1a6ffb 100644 --- a/public/js/file-panel/data.js +++ b/public/js/file-panel/data.js @@ -1,6 +1,6 @@ // --- Data Tab (DuckDB + BigQuery SQL Analysis) --- import { escapeHtml } from '../markdown.js'; -import { haptic, showToast, apiFetch, showDialog } from '../utils.js'; +import { haptic, showToast, apiFetch, showDialog, basePath } from '../utils.js'; import * as state from '../state.js'; import { showSaveLocationPicker } from '../ui/save-location-picker.js'; @@ -1229,7 +1229,7 @@ async function downloadBigQueryResults(format) { if (!filename) return; try { - const res = await fetch('/api/bigquery/query/download', { + const res = await fetch(basePath('/api/bigquery/query/download'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1342,7 +1342,7 @@ async function downloadDuckDbResults(format) { showToast('Exporting...', { duration: 1000 }); try { - const res = await fetch('/api/duckdb/export', { + const res = await fetch(basePath('/api/duckdb/export'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/public/js/ui.js b/public/js/ui.js index 82b9e82..003c291 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,6 +1,6 @@ // --- UI interactions (core message/input handling) --- import { escapeHtml } from './markdown.js'; -import { formatTime, haptic, showToast, showDialog, getDialogOverlay, getDialogCancel, apiFetch, setupLongPressHandler } from './utils.js'; +import { formatTime, haptic, showToast, showDialog, getDialogOverlay, getDialogCancel, apiFetch, setupLongPressHandler, basePath } from './utils.js'; import { HEADER_COMPACT_ENTER, HEADER_COMPACT_EXIT, MESSAGE_INPUT_MAX_HEIGHT } from './constants.js'; import { getWS } from './websocket.js'; import { loadConversations, deleteConversation, forkConversation, showListView, triggerSearch, hideActionPopup, renameConversation } from './conversations.js'; @@ -152,7 +152,7 @@ function _getCurrentConversationCwd() { async function postWorkflowLock(endpoint, payload) { try { - const res = await fetch(`/api/workflow/lock/${endpoint}`, { + const res = await fetch(basePath(`/api/workflow/lock/${endpoint}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), diff --git a/public/js/ui/theme.js b/public/js/ui/theme.js index 7e6f37f..96eea25 100644 --- a/public/js/ui/theme.js +++ b/public/js/ui/theme.js @@ -1,5 +1,5 @@ // --- Theme management (light/dark/auto and color themes) --- -import { haptic, showToast } from '../utils.js'; +import { haptic, showToast, basePath } from '../utils.js'; import * as state from '../state.js'; import { THEME_TRANSITION_DURATION } from '../constants.js'; @@ -204,7 +204,7 @@ export function applyColorTheme(animate = false) { setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), THEME_TRANSITION_DURATION); } - colorThemeLink.href = `/css/themes/${theme}.css`; + colorThemeLink.href = basePath(`/css/themes/${theme}.css`); // Update status bar color after CSS loads const meta = document.querySelector('meta[name="theme-color"]'); diff --git a/public/js/utils.js b/public/js/utils.js index 4994b64..94e3022 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,6 +1,18 @@ // --- Utility functions --- import { HAPTIC_LIGHT, HAPTIC_MEDIUM, TOAST_DURATION_DEFAULT, LONG_PRESS_DURATION } from './constants.js'; +// Derive base path for reverse-proxy setups (e.g. VSCode port forwarding, JupyterHub proxy). +const _bp = (() => { + const p = window.location.pathname; + return p.endsWith('/') ? p : p.substring(0, p.lastIndexOf('/') + 1); +})(); + +/** Resolve an absolute path (e.g. '/api/foo') relative to the proxy base path. */ +export function basePath(url) { + if (!url.startsWith('/')) return url; + return _bp + url.substring(1); +} + export function haptic(ms = HAPTIC_LIGHT) { navigator.vibrate?.(ms); } @@ -279,7 +291,7 @@ export function setupLongPressHandler(element, handlers) { export async function apiFetch(url, options = {}) { const { silent = false, ...fetchOptions } = options; try { - const res = await fetch(url, fetchOptions); + const res = await fetch(basePath(url), fetchOptions); if (!res.ok) { const data = await res.json().catch(() => ({})); const msg = data.error || `Request failed (${res.status})`; diff --git a/public/js/websocket.js b/public/js/websocket.js index 1e10139..8b39886 100644 --- a/public/js/websocket.js +++ b/public/js/websocket.js @@ -1,5 +1,5 @@ // --- WebSocket connection management --- -import { showToast } from './utils.js'; +import { showToast, basePath } from './utils.js'; import { appendDelta, finalizeMessage, renderMessages } from './render.js'; import { loadConversations } from './conversations.js'; import * as state from './state.js'; @@ -34,7 +34,7 @@ export function connectWS() { } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${proto}//${location.host}`); + ws = new WebSocket(`${proto}//${location.host}${basePath('/')}`); ws.onopen = () => { clearTimeout(reconnectTimer); diff --git a/public/sw.js b/public/sw.js index ed95f48..24d35ea 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,45 +1,49 @@ -const CACHE_NAME = 'concierge-v127'; -const STATIC_ASSETS = [ - '/', - '/index.html', - '/style.css', - '/css/themes/darjeeling.css', - '/css/themes/budapest.css', - '/css/themes/moonrise.css', - '/css/themes/aquatic.css', - '/css/themes/solarized.css', - '/css/themes/catppuccin.css', - '/css/themes/fjord.css', - '/css/themes/monokai.css', - '/css/themes/paper.css', - '/css/base.css', - '/css/layout.css', - '/css/components.css', - '/css/messages.css', - '/css/list.css', - '/css/file-panel.css', - '/css/branches.css', - '/js/app.js', - '/js/utils.js', - '/js/state.js', - '/js/websocket.js', - '/js/render.js', - '/js/markdown.js', - '/js/conversations.js', - '/js/ui.js', - '/js/file-panel.js', - '/js/branches.js', - '/manifest.json', - '/lib/highlight.min.js', - '/icons/icon-192.png', - '/icons/icon-512.png', - '/icons/icon-maskable-512.png', - '/icons/screenshot-narrow.png', - '/icons/screenshot-wide.png', +const CACHE_NAME = 'concierge-v128'; +const STATIC_ASSET_PATHS = [ + '', + 'index.html', + 'style.css', + 'css/themes/darjeeling.css', + 'css/themes/budapest.css', + 'css/themes/moonrise.css', + 'css/themes/aquatic.css', + 'css/themes/solarized.css', + 'css/themes/catppuccin.css', + 'css/themes/fjord.css', + 'css/themes/monokai.css', + 'css/themes/paper.css', + 'css/base.css', + 'css/layout.css', + 'css/components.css', + 'css/messages.css', + 'css/list.css', + 'css/file-panel.css', + 'css/branches.css', + 'js/app.js', + 'js/utils.js', + 'js/state.js', + 'js/websocket.js', + 'js/render.js', + 'js/markdown.js', + 'js/conversations.js', + 'js/ui.js', + 'js/file-panel.js', + 'js/branches.js', + 'manifest.json', + 'lib/highlight.min.js', + 'icons/icon-192.png', + 'icons/icon-512.png', + 'icons/icon-maskable-512.png', + 'icons/screenshot-narrow.png', + 'icons/screenshot-wide.png', ]; +// Resolve asset paths relative to SW scope (handles proxy prefixes). +const SCOPE = self.registration?.scope || self.location.href.replace(/sw\.js.*$/, ''); +const STATIC_ASSETS = STATIC_ASSET_PATHS.map((p) => new URL(p, SCOPE).href); + // API routes to cache for offline use -const CACHED_API_ROUTES = ['/api/conversations']; +const CACHED_API_ROUTES = ['api/conversations']; // Install: cache static assets self.addEventListener('install', (event) => { @@ -67,7 +71,11 @@ self.addEventListener('fetch', (event) => { if (event.request.headers.get('upgrade') === 'websocket') return; // Cacheable API routes: network-first, fall back to cache - if (CACHED_API_ROUTES.some(route => url.pathname === route || url.pathname.startsWith(route + '?'))) { + const scopePath = new URL(SCOPE).pathname; + if (CACHED_API_ROUTES.some(route => { + const full = scopePath + route; + return url.pathname === full || url.pathname.startsWith(full + '?'); + })) { event.respondWith( fetch(event.request).then((response) => { if (response.ok) { @@ -81,7 +89,7 @@ self.addEventListener('fetch', (event) => { } // Skip other API requests - if (url.pathname.startsWith('/api/')) return; + if (url.pathname.startsWith(scopePath + 'api/')) return; // Static assets: cache-first event.respondWith( diff --git a/scripts/example-bundled-env.sh b/scripts/example-bundled-env.sh new file mode 100755 index 0000000..e6ed624 --- /dev/null +++ b/scripts/example-bundled-env.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Example: Configure Concierge to use a bundled Node.js runtime and CLIs. +# +# This script demonstrates how to set CONCIERGE_* environment variables +# for running Claude Code and Codex from a self-contained runtime directory +# (e.g., installed by an enterprise deployment tool). +# +# Adapt the paths and credential file locations to your environment. +set -euo pipefail + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat <<'EOF' +Usage: source scripts/example-bundled-env.sh + eval "$(scripts/example-bundled-env.sh)" + +Example script showing how to configure Concierge to use a bundled CLI runtime +and skills directories. Adapt paths and credential locations to your setup. + +When sourced or eval'd, exports CONCIERGE_* variables for: + - Custom CLI command/args (CONCIERGE_CLAUDE_CMD, CONCIERGE_CLAUDE_ARGS, etc.) + - Environment injection from JSON (CONCIERGE_CLI_ENV_FILE) + - Skill directories (CONCIERGE_CLI_SKILLS_DIRS) + - Sandbox permissions (CONCIERGE_CLAUDE_SANDBOX_ALLOW, etc.) +EOF + exit 0 +fi + +# --- CUSTOMIZE: Set runtime_base to your bundled runtime location --- +# This example looks for a runtime directory containing a .runtime-ready marker. +cache_root="${XDG_CACHE_HOME:-$HOME/.cache}" +runtime_base="${cache_root}/gia/runtime" # Change this path for your setup +runtime_dir="" + +if [[ -d "${runtime_base}" ]]; then + while IFS= read -r -d '' marker; do + runtime_dir="$(dirname "$marker")" + break + done < <(find "${runtime_base}" -maxdepth 2 -type f -name .runtime-ready -print0 2>/dev/null | sort -z) +fi + +if [[ -z "${runtime_dir}" ]]; then + echo "No bundled runtime found under ${runtime_base} (missing .runtime-ready marker)." >&2 + exit 1 +fi + +node_bin="${runtime_dir}/node/bin/node" +cli_root="${runtime_dir}/clis" + +if [[ ! -x "${node_bin}" ]]; then + echo "Node.js runtime not found at ${node_bin}." >&2 + exit 1 +fi + +resolve_entry() { + local package_name="$1" + local bin_hint="$2" + python - "$cli_root" "$package_name" "$bin_hint" <<'PY' +import json +import os +import sys + +cli_root, package_name, bin_hint = sys.argv[1:] +pkg_dir = os.path.join(cli_root, "node_modules", *package_name.split("/")) +pkg_path = os.path.join(pkg_dir, "package.json") +if not os.path.exists(pkg_path): + sys.exit(1) +with open(pkg_path, "r", encoding="utf-8") as f: + pkg = json.load(f) +bin_entry = pkg.get("bin") +entry = None +if isinstance(bin_entry, str): + entry = os.path.join(pkg_dir, bin_entry) +elif isinstance(bin_entry, dict): + entry = bin_entry.get(bin_hint) + if entry: + entry = os.path.join(pkg_dir, entry) +if not entry or not os.path.exists(entry): + sys.exit(2) +print(entry) +PY +} + +json_array() { + python - "$1" <<'PY' +import json +import sys +print(json.dumps([sys.argv[1]])) +PY +} + +emit_exports() { + local name="$1" + local cmd_env="$2" + local args_env="$3" + local entrypoint="$4" + + local args_json + args_json="$(json_array "$entrypoint")" + + printf 'export %s="%s"\n' "${cmd_env}" "${node_bin}" + printf 'export %s=%q\n' "${args_env}" "${args_json}" +} + +claude_entry="$(resolve_entry "@anthropic-ai/claude-code" "claude" || true)" +codex_entry="$(resolve_entry "@openai/codex" "codex" || true)" + +exports=() + +if [[ -n "${claude_entry}" ]]; then + exports+=("CONCIERGE_CLAUDE_CMD=${node_bin}") + exports+=("CONCIERGE_CLAUDE_ARGS=$(json_array "${claude_entry}")") +fi + +if [[ -n "${codex_entry}" ]]; then + exports+=("CONCIERGE_CODEX_CMD=${node_bin}") + exports+=("CONCIERGE_CODEX_ARGS=$(json_array "${codex_entry}")") +fi + +exports+=("CONCIERGE_CLI_RUNTIME_DIR=${runtime_dir}") + +# --- CUSTOMIZE: Credential file locations --- +# This example reads API tokens from JSON/text files and injects them as env vars. +# Adapt the file paths and env var names to your credential storage. +env_json_path="/tmp/concierge-bundled-env.json" +python - "$env_json_path" <<'PY' +import json +import os +import sys + +out_path = sys.argv[1] +env = {} + +def load_json(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + +def load_text(path): + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception: + return None + +genai = load_json(os.path.expanduser("~/genai.json")) +if isinstance(genai, dict): + token = genai.get("token") + base_url = genai.get("base_url") + if token: + env["ANTHROPIC_AUTH_TOKEN"] = token + env["OPENAI_API_KEY"] = token + if base_url: + base = str(base_url).rstrip("/") + env["ANTHROPIC_BASE_URL"] = base + env["OPENAI_BASE_URL"] = base + +jira = load_json(os.path.expanduser("~/jira.json")) +if isinstance(jira, dict): + email = jira.get("email") + token = jira.get("token") + base_url = jira.get("base_url") + if email: + env["JIRA_EMAIL"] = email + if token: + env["JIRA_API_TOKEN"] = token + if base_url: + env["JIRA_BASE_URL"] = base_url + +gitlab_pat = load_text(os.path.expanduser("~/gitlab-pat.txt")) +if gitlab_pat: + env["GITLAB_TOKEN"] = gitlab_pat + +if env: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(env, f) +else: + # Ensure we don't point at a stale env file + try: + os.remove(out_path) + except FileNotFoundError: + pass +PY + +if [[ -f "${env_json_path}" ]]; then + exports+=("CONCIERGE_CLI_ENV_FILE=${env_json_path}") + exports+=("CONCIERGE_CLI_ENV_ALLOWLIST=OPENAI_API_KEY,OPENAI_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_BASE_URL,JIRA_EMAIL,JIRA_API_TOKEN,JIRA_BASE_URL,GITLAB_TOKEN") +fi + +exports+=("CONCIERGE_CLI_PREPEND_SKILL_BINS=1") +exports+=("CONCIERGE_CLAUDE_DISABLE_EXPERIMENTAL_BETAS=1") +exports+=("CONCIERGE_CLAUDE_DISABLE_AUTO_UPDATE=1") +exports+=("CONCIERGE_CLAUDE_DISABLE_UPDATE_NAG=1") + +# --- CUSTOMIZE: Extra sandbox file read permissions --- +# Allow Claude to read credential files needed by your skills. +exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOW=Read(${HOME}/.config/gcloud/**)") + +# Skills directories (MySkills as global, AI-specific as per-provider). +global_skill_dirs=() +[[ -d "${HOME}/MySkills" ]] && global_skill_dirs+=("${HOME}/MySkills") + +if [[ -d "${HOME}/.claude/skills" ]]; then + exports+=("CONCIERGE_CLAUDE_SKILLS_DIRS=${HOME}/.claude/skills") +fi + +if [[ -d "${HOME}/.codex/skills" ]]; then + exports+=("CONCIERGE_CODEX_SKILLS_DIRS=${HOME}/.codex/skills") +fi + +if [[ ${#global_skill_dirs[@]} -gt 0 ]]; then + (IFS=':'; exports+=("CONCIERGE_CLI_SKILLS_DIRS=${global_skill_dirs[*]}")) +fi + +# --- CUSTOMIZE: Dynamic sandbox domain allowlist --- +# Add domains your skills need network access to. This example detects installed +# skills and adds their required domains conditionally. +domains=() +skill_base="${HOME}/.claude/skills" +if [[ -d "${skill_base}" ]]; then + # Example: Jira skill needs Atlassian domains + [[ -d "${skill_base}/cli-jira" ]] && domains+=("your-org.atlassian.net" "id.atlassian.com") + # Example: GitLab skill + [[ -d "${skill_base}/cli-gitlab" ]] && domains+=("gitlab.your-org.com") + # Example: Google APIs for BigQuery/Workspace + [[ -d "${skill_base}/cli-bigquery" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com" "bigquery.googleapis.com") + [[ -d "${skill_base}/cli-workspace" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com" "docs.google.com" "drive.google.com") + [[ -d "${skill_base}/util-auth" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com") + # Example: Figma API + [[ -d "${skill_base}/cli-figma" ]] && domains+=("api.figma.com") +fi + +# Add any always-required domains (e.g., your API proxy) +# domains+=("api-proxy.your-org.com") + +# Deduplicate domains using associative array +declare -A seen_domains +uniq_domains=() +for d in ${domains[@]+"${domains[@]}"}; do + if [[ -z "${seen_domains[$d]:-}" ]]; then + seen_domains[$d]=1 + uniq_domains+=("$d") + fi +done + +if [[ ${#uniq_domains[@]} -gt 0 ]]; then + (IFS=','; exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOWED_DOMAINS=${uniq_domains[*]}")) +fi + +is_sourced=0 +if [[ -n "${ZSH_EVAL_CONTEXT-}" ]]; then + case "${ZSH_EVAL_CONTEXT}" in + *:file) is_sourced=1 ;; + esac +elif [[ -n "${BASH_VERSION-}" ]]; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + is_sourced=1 + fi +else + case "$0" in + *bash|*zsh|*sh) is_sourced=1 ;; + esac +fi + +if [[ "${is_sourced}" -eq 1 ]]; then + for entry in "${exports[@]}"; do + export "${entry}" + done +else + for entry in "${exports[@]}"; do + printf 'export %s\n' "${entry}" + done +fi diff --git a/test/context-bar.test.js b/test/context-bar.test.js index 95a55dd..d3847da 100644 --- a/test/context-bar.test.js +++ b/test/context-bar.test.js @@ -21,6 +21,7 @@ describe('context bar token calculation', async () => { }; globalThis.window = { dispatchEvent() {}, + location: { pathname: '/' }, }; after(() => { diff --git a/test/explorer-files-core.test.js b/test/explorer-files-core.test.js index 77ac326..88289bd 100644 --- a/test/explorer-files-core.test.js +++ b/test/explorer-files-core.test.js @@ -1,4 +1,4 @@ -const { describe, it } = require('node:test'); +const { describe, it, after } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -6,6 +6,17 @@ const { pathToFileURL } = require('node:url'); const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'public', 'js', 'explorer', 'files-core.js')).href; describe('explorer files core', async () => { + const originalWindow = globalThis.window; + globalThis.window = { + location: { pathname: '/' }, + dispatchEvent() {}, + }; + + after(() => { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }); + const { sortEntries, getViewableFiles, diff --git a/test/explorer-shell.test.js b/test/explorer-shell.test.js index aa64f6e..f51431c 100644 --- a/test/explorer-shell.test.js +++ b/test/explorer-shell.test.js @@ -69,9 +69,13 @@ function makeViewer() { } describe('explorer shell refresh behavior', async () => { - const { createExplorerShell } = await import(moduleUrl); const previousWindow = globalThis.window; - globalThis.window = {}; + globalThis.window = { + location: { pathname: '/' }, + dispatchEvent() {}, + }; + + const { createExplorerShell } = await import(moduleUrl); after(() => { if (previousWindow === undefined) { diff --git a/test/render-compression.test.js b/test/render-compression.test.js index e84312c..91dc881 100644 --- a/test/render-compression.test.js +++ b/test/render-compression.test.js @@ -25,6 +25,7 @@ describe('render compressed message UI', async () => { globalThis.window = { dispatchEvent() {}, speechSynthesis: null, + location: { pathname: '/' }, }; globalThis.navigator = {};