Skip to content
Merged

Gia #33

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
169 changes: 169 additions & 0 deletions lib/providers/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,4 +252,7 @@ class LLMProvider {
module.exports = {
LLMProvider,
safeSend,
cliEnv,
resolveSpawn,
skillAddDirArgs,
};
38 changes: 31 additions & 7 deletions lib/providers/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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));
Expand All @@ -665,19 +685,22 @@ 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,
autopilot: conv.autopilot,
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'],
});

Expand Down Expand Up @@ -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'],
});

Expand Down
19 changes: 13 additions & 6 deletions lib/providers/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
});

Expand Down Expand Up @@ -924,15 +930,16 @@ ${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',
'-m', model,
prompt,
], {
cwd,
env: { ...process.env },
env: cliEnv('codex'),
stdio: ['ignore', 'pipe', 'pipe'],
});

Expand Down
25 changes: 18 additions & 7 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<script>
// Set base href for reverse-proxy path prefixes (e.g. VSCode port forwarding).
(function() {
var base = document.createElement('base');
var loc = window.location;
var path = loc.pathname;
if (!path.endsWith('/')) path = path.substring(0, path.lastIndexOf('/') + 1);
base.href = loc.protocol + '//' + loc.host + path;
document.head.appendChild(base);
})();
</script>
<meta name="google" content="notranslate">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#1F1A16">
Expand All @@ -12,17 +23,17 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@500;600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/css/file-panel.css">
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="css/file-panel.css">
<!-- Theme loaded AFTER base styles so theme overrides work -->
<link id="color-theme-link" rel="stylesheet" href="/css/themes/darjeeling.css">
<link id="color-theme-link" rel="stylesheet" href="css/themes/darjeeling.css">
<script>
// Apply saved color theme immediately to prevent flash
(function() {
var saved = localStorage.getItem('colorTheme');
if (saved && saved !== 'darjeeling') {
document.getElementById('color-theme-link').href = '/css/themes/' + saved + '.css';
document.getElementById('color-theme-link').href = 'css/themes/' + saved + '.css';
}
})();
</script>
Expand Down Expand Up @@ -843,7 +854,7 @@ <h2>Commands &amp; Skills</h2>
</div>
</div>

<script src="/lib/highlight.min.js"></script>
<script type="module" src="/js/app.js"></script>
<script src="lib/highlight.min.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>
Loading