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 = {};