Skip to content
Open
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
76 changes: 75 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';

import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, loadGlobalSettings, saveGlobalSettings, getMergedSettings, saveProjectSettings } from './projects.js';
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
Expand Down Expand Up @@ -254,6 +254,80 @@ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) =>
}
});

// Settings API endpoints

/**
* GET /api/settings/global - Get global settings from ~/.claude/settings.json
* @route GET /api/settings/global
* @returns {Object} Global settings object
*/
app.get('/api/settings/global', authenticateToken, async (req, res) => {
try {
const globalSettings = await loadGlobalSettings();
res.json(globalSettings);
} catch (error) {
console.error('Error loading global settings:', error);
res.status(500).json({ error: 'Failed to load global settings' });
}
});

/**
* POST /api/settings/global - Save global settings to ~/.claude/settings.json
* @route POST /api/settings/global
* @body {Object} settings - Settings object to save
* @returns {Object} Success response
*/
app.post('/api/settings/global', authenticateToken, async (req, res) => {
try {
const settings = req.body;
await saveGlobalSettings(settings);
res.json({ success: true });
} catch (error) {
console.error('Error saving global settings:', error);
res.status(500).json({ error: 'Failed to save global settings' });
}
});

/**
* GET /api/settings/merged - Get merged settings with hierarchy applied
* @route GET /api/settings/merged
* @query {string} [projectPath] - Optional project path for project-specific settings
* @returns {Object} Merged settings object
*/
app.get('/api/settings/merged', authenticateToken, async (req, res) => {
try {
const { projectPath } = req.query;
const mergedSettings = await getMergedSettings(projectPath || null);
res.json(mergedSettings);
} catch (error) {
console.error('Error getting merged settings:', error);
res.status(500).json({ error: 'Failed to get merged settings' });
}
});

/**
* POST /api/settings/project - Save project-specific settings
* @route POST /api/settings/project
* @body {string} projectPath - Project directory path
* @body {Object} settings - Settings object to save
* @returns {Object} Success response
*/
app.post('/api/settings/project', authenticateToken, async (req, res) => {
try {
const { projectPath, settings } = req.body;

if (!projectPath) {
return res.status(400).json({ error: 'Project path is required' });
}

await saveProjectSettings(projectPath, settings);
res.json({ success: true });
} catch (error) {
console.error('Error saving project settings:', error);
res.status(500).json({ error: 'Failed to save project settings' });
}
});

// Create project endpoint
app.post('/api/projects/create', authenticateToken, async (req, res) => {
try {
Expand Down
228 changes: 227 additions & 1 deletion server/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,226 @@ function clearProjectDirectoryCache() {
cacheTimestamp = Date.now();
}

/**
* Load global settings file from ~/.claude/settings.json
* @returns {Promise<Object>} Global settings object
*/
async function loadGlobalSettings() {
const settingsPath = path.join(process.env.HOME, '.claude', 'settings.json');
try {
const settingsData = await fs.readFile(settingsPath, 'utf8');
const rawSettings = JSON.parse(settingsData);

// Convert from Claude CLI format to claudecodeui format
return convertSettingsFormat(rawSettings);
} catch (error) {
// Return empty settings if file doesn't exist
return {};
}
}

/**
* Load project-specific settings file from {projectPath}/.claude/settings.json
* @param {string|null} projectPath - Path to the project directory
* @returns {Promise<Object>} Project settings object
*/
async function loadProjectSettings(projectPath) {
if (!projectPath) return {};

const settingsPath = path.join(projectPath, '.claude', 'settings.json');
try {
const settingsData = await fs.readFile(settingsPath, 'utf8');
const rawSettings = JSON.parse(settingsData);

// Convert from Claude CLI format to claudecodeui format
return convertSettingsFormat(rawSettings);
} catch (error) {
// Return empty settings if file doesn't exist
return {};
}
}

/**
* Convert Claude CLI format settings to claudecodeui format
* @param {Object} rawSettings - Raw settings in Claude CLI format
* @returns {Object} Settings in claudecodeui format
*/
function convertSettingsFormat(rawSettings) {
// Validate input
if (!rawSettings || typeof rawSettings !== 'object') {
return {};
}

// Return as-is if already in claudecodeui format
if (rawSettings.allowedTools !== undefined || rawSettings.disallowedTools !== undefined) {
return rawSettings;
}

// Convert if in Claude CLI format
const converted = { ...rawSettings };

if (rawSettings.permissions) {
// permissions.allow -> allowedTools
if (Array.isArray(rawSettings.permissions.allow)) {
converted.allowedTools = rawSettings.permissions.allow;
}

// permissions.deny -> disallowedTools
if (Array.isArray(rawSettings.permissions.deny)) {
converted.disallowedTools = rawSettings.permissions.deny;
}

// permissions.defaultMode -> skipPermissions
// dangerous-type values are treated as permission skip
const dangerousModes = ['dangerous', 'dangerously-skip-permissions', 'skip', 'bypass', 'bypasspermissions'];
const defaultMode = rawSettings.permissions.defaultMode || 'default';
converted.skipPermissions = dangerousModes.includes(defaultMode.toLowerCase());

// Remove permissions section
delete converted.permissions;
}

return converted;
}

/**
* Save global settings to ~/.claude/settings.json
* @param {Object} settings - Settings object to save
* @throws {Error} If save operation fails
*/
async function saveGlobalSettings(settings) {
const settingsPath = path.join(process.env.HOME, '.claude', 'settings.json');
const claudeDir = path.dirname(settingsPath);

try {
await fs.mkdir(claudeDir, { recursive: true });
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
} catch (error) {
console.error('Error saving global settings:', error);
throw error;
}
}

/**
* Save project-specific settings to {projectPath}/.claude/settings.json
* @param {string} projectPath - Path to the project directory
* @param {Object} settings - Settings object to save
* @throws {Error} If project path is not provided or save fails
*/
async function saveProjectSettings(projectPath, settings) {
if (!projectPath) throw new Error('Project path is required');

const claudeDir = path.join(projectPath, '.claude');
const settingsPath = path.join(claudeDir, 'settings.json');

try {
await fs.mkdir(claudeDir, { recursive: true });
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
} catch (error) {
console.error('Error saving project settings:', error);
throw error;
}
}

/**
* Merge settings with priority: project > global > defaults
* @param {Object} globalSettings - Global settings from ~/.claude/settings.json
* @param {Object} projectSettings - Project-specific settings
* @param {Object} [defaults={}] - Default settings
* @returns {Object} Merged settings object
*/
function mergeSettings(globalSettings, projectSettings, defaults = {}) {
const mergeStrategies = {
// Objects: deep merge
ui: 'deepMerge',
editor: 'deepMerge',
whisper: 'deepMerge',

// Arrays: complete replacement if project settings exist
allowedTools: 'replace',
disallowedTools: 'replace',
customTools: 'replace',

// Primitives: override
skipPermissions: 'override',
projectSortOrder: 'override',
autoExpandTools: 'override',
showRawParameters: 'override',
autoScrollToBottom: 'override',
sendByCtrlEnter: 'override',
whisperMode: 'override',
theme: 'override'
};

function isObject(item) {
return item && typeof item === 'object' && !Array.isArray(item);
}

function deepMergeWithStrategy(target, source) {
const result = { ...target };

for (const key in source) {
if (source[key] === null || source[key] === undefined) continue;

const strategy = mergeStrategies[key] || 'deepMerge';

switch (strategy) {
case 'replace':
result[key] = source[key];
break;
case 'override':
result[key] = source[key];
break;
case 'deepMerge':
if (isObject(target[key]) && isObject(source[key])) {
result[key] = deepMergeWithStrategy(target[key], source[key]);
} else {
result[key] = source[key];
}
break;
}
}

return result;
}

// Apply settings in order: defaults -> global -> project
let merged = { ...defaults };
merged = deepMergeWithStrategy(merged, globalSettings);
merged = deepMergeWithStrategy(merged, projectSettings);

return merged;
}

/**
* Get merged settings for a specific project
* @param {string|null} [projectPath=null] - Project path for project-specific settings
* @returns {Promise<Object>} Merged settings with applied hierarchy
*/
async function getMergedSettings(projectPath = null) {
const defaults = {
ui: {
theme: 'system',
autoExpandTools: false,
showRawParameters: false,
autoScrollToBottom: true,
sendByCtrlEnter: false
},
whisper: {
mode: 'default'
},
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name'
};

const globalSettings = await loadGlobalSettings();
const projectSettings = projectPath ? await loadProjectSettings(projectPath) : {};

return mergeSettings(globalSettings, projectSettings, defaults);
}

// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
Expand Down Expand Up @@ -607,5 +827,11 @@ export {
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
clearProjectDirectoryCache,
loadGlobalSettings,
loadProjectSettings,
saveGlobalSettings,
saveProjectSettings,
mergeSettings,
getMergedSettings
};
Loading