From fd6e0d72fba6cb4ff050fec2ede615db3b648943 Mon Sep 17 00:00:00 2001 From: jon Date: Sun, 15 Feb 2026 20:21:16 +0000 Subject: [PATCH] fix: improve settings distributor robustness and security - Fix TOML string escaping to handle newlines, tabs, carriage returns - Fix TOML inline table parsing to handle commas in quoted strings - Fix TOML scalar insertion to only target top-level keys, avoid section overwrites - Add warning for Cursor project-level permission limitations per security advisory - Add warning for unsupported Codex permission keys to clarify what's actually mapped - Deduplicate SETTINGS_TARGETS constant and resolveTargets logic across modules - Add process.env fallback for env resolution (after file lookup) - Add .env.agents to default lookup order for dedicated agent secrets - Improve test coverage for new fallback behavior Co-Authored-By: Claude Haiku 4.5 --- README.md | 37 ++ docs/plans/settings-support.md | 177 ++++++ examples/full.config.ts | 14 + src/agents/settings-distributor.ts | 451 ++++++++++++++ src/agents/settings-provider-map.ts | 22 + src/core/config-loader.ts | 6 +- src/core/env-resolver.ts | 113 ++++ src/core/gitignore.ts | 14 + src/core/index.ts | 14 + src/index.ts | 4 +- src/types/index.ts | 59 ++ tests/unit/config-loader.test.ts | 136 +++++ tests/unit/core-coverage.test.ts | 7 +- tests/unit/distributor-symlink.test.ts | 1 + tests/unit/env-resolver.test.ts | 123 ++++ tests/unit/gitignore.test.ts | 56 ++ tests/unit/project-structure-errors.test.ts | 21 + tests/unit/settings-distributor.test.ts | 640 ++++++++++++++++++++ tests/unit/types.test.ts | 91 +++ 19 files changed, 1983 insertions(+), 3 deletions(-) create mode 100644 docs/plans/settings-support.md create mode 100644 src/agents/settings-distributor.ts create mode 100644 src/agents/settings-provider-map.ts create mode 100644 src/core/env-resolver.ts create mode 100644 tests/unit/env-resolver.test.ts create mode 100644 tests/unit/settings-distributor.test.ts diff --git a/README.md b/README.md index 978f9e6..df8412d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Sync AI coding assistant configurations across Claude Code, Cursor, Codex, OpenC - **Agents** - Custom agent definitions - **MCP Servers** - Model Context Protocol configurations - **Agent Hooks** - Lifecycle hooks for Claude Code and Cursor +- **Settings Merge** - Merge shared env/permissions into provider-native settings files ## Why glooit? @@ -98,6 +99,7 @@ Run `glooit sync` (or `bunx glooit sync` / `npx glooit sync`) and it creates: | Agents | ✓ | ✓ | ✓ | - | ✓*** | - | - | | MCP Servers | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Hooks | ✓ | ✓ | - | - | ✓ | - | - | +| Settings | ✓ | ✓ | ✓ | ✓ | - | - | - | *Codex uses `.codex/prompts` for commands **OpenCode uses Claude-compatible skills path (`.claude/skills/`) @@ -322,6 +324,41 @@ export default defineRules({ - `.js` files: Run with `node` - `.sh` files: Run directly +### Settings Merge + +Merge shared settings into provider-native settings files: + +```typescript +export default defineRules({ + rules: [...], + settings: { + targets: ['claude', 'cursor', 'codex', 'opencode'], + env: ['GEMINI_API_KEY', 'OPENAI_API_KEY'], + envFiles: ['.env.agents', '.env.local', '.env'], // optional (default order shown) + permissions: { + allow: ['Read', 'Grep'] + }, + merge: true // optional; defaults to true + } +}); +``` + +Resolution and precedence rules: + +- `merge` defaults to `true` (set `merge: false` to overwrite instead of merge). +- `envFiles` is optional; default lookup is `['.env.agents', '.env.local', '.env']`. +- For each key in `settings.env`, the first matching file in `envFiles` order wins. +- `.env.agents` is checked first, giving you a dedicated file for agent-specific secrets. +- If a key is not found in any env file, `process.env` is used as a fallback (useful for CI and shell-exported vars). +- Safety guard: if env values would be written to git-tracked settings files (for `claude`/`codex`), sync fails with a warning to prevent committing secrets. + +Default settings output files: + +- `claude` → `.claude/settings.local.json` +- `cursor` → `.cursor/cli.json` +- `codex` → `.codex/config.toml` +- `opencode` → `opencode.json` + ### MCP Configuration Configure Model Context Protocol servers: diff --git a/docs/plans/settings-support.md b/docs/plans/settings-support.md new file mode 100644 index 0000000..f239e5f --- /dev/null +++ b/docs/plans/settings-support.md @@ -0,0 +1,177 @@ +# Settings Support Plan (Provider-Native Merge) + +## Goal + +Implement a single **generic glooit settings config** that is merged into each provider's own settings file format. + +The key principle: + +- Users define settings once in glooit. +- glooit maps and merges that into each provider's native file structure. +- No provider-specific top-level config sections like `claude: {...}`. + +## Scope + +In scope: + +- Generic config schema for settings. +- Provider adapters for `claude`, `cursor`, `codex`, `opencode` (and `factory` if supported). +- Merge logic into existing provider files. +- Env key lookup from optional env files and process env. +- Permission handling mapped per provider. + +Out of scope: + +- MCP config changes. +- Rule/commands/skills sync changes. +- Hook behavior changes. + +## Product Requirements + +1. Single glooit settings entrypoint. +2. Merge into existing provider settings (never full overwrite unless explicitly configured). +3. Provider-native output format: + - JSON for tools that use JSON. + - TOML for Codex. + - Any provider-specific shape preserved. +4. Deterministic merge precedence. +5. Graceful handling where a provider does not support a generic field directly. + +## Proposed Config Shape + +```ts +settings: { + targets?: ['claude', 'cursor', 'codex', 'opencode', 'factory'], + env?: string[], // e.g. ['GEMINI_API_KEY', 'OPENAI_API_KEY'] + envFiles?: string[], // optional, e.g. ['.env', '.env.local'] + permissions?: Record, + merge?: boolean // default: true +} +``` + +Notes: + +- Keep this under `settings` (generic), not `runtime` and not per-provider top-level blocks. +- `targets` defaults to all supported providers for settings merging. +- `envFiles` is optional; if omitted, resolve from `process.env` only. + +## Merge Semantics + +### Env resolution + +For each key in `settings.env`, resolve value with precedence: + +1. `process.env[KEY]` +2. First match from `settings.envFiles` in declared order (if provided) + +If key is not found, skip and warn. + +When provider supports stored env in settings, merge resolved keys into provider format. + +### Permissions merge + +Final permissions for an agent: + +1. Existing file permissions block +2. `permissions` + +Use deep object merge for permission objects. + +### File behavior + +- `merge: true`: + - Load existing provider settings file. + - Merge only mapped keys. + - Preserve unknown keys. +- `merge: false`: + - Start from empty provider settings object/file. + - Write only mapped keys. + +## Provider Mapping Plan + +## Claude + +- File: `.claude/settings.local.json` (default) +- Env: `env` object +- Permissions: `permissions` object + +## Cursor + +- File: `.cursor/cli.json` (default) +- Permissions: mapped to Cursor permissions section +- Env: apply only if Cursor has stable native settings env mapping; otherwise warn and skip env keys + +## Codex + +- File: `.codex/config.toml` (default) +- Env: map into Codex shell env policy section (provider-native TOML) +- Permissions: map `approval_policy`, `sandbox_mode`, and related policy keys + +## OpenCode + +- File: `opencode.json` (default) +- Permissions: `permission` block +- Env: map using OpenCode native env strategy in settings (not MCP) + +## Factory (optional in phase 1) + +- File: provider-native settings file +- Map only fields with clear support. + +## Error/Warning Strategy + +Warn (non-fatal) when: + +- A field is requested but provider has no stable native mapping. +- Existing file is invalid; recover by starting fresh (if merge mode). + +Error when: + +- Output path is invalid/unwritable. +- `settings.targets` contains unknown agents. + +## Implementation Plan + +1. Add `SettingsConfig` type to `src/types/index.ts`. +2. Add a dedicated settings distributor/adapter layer: + - `src/agents/settings-distributor.ts` + - Provider-specific mapping helpers (JSON/TOML writers). +3. Wire distributor into `AIRulesCore.sync()` after rule/mcp/hooks sync. +4. Add generated settings paths into: + - manifest tracking + - gitignore manager +5. Add simple env resolver utility for `envFiles` + `process.env`. +6. Update README + `examples/full.config.ts` to use generic `settings`. + +## Test Plan + +Unit tests: + +- Generic merge precedence for env + permissions. +- Env key resolution from `process.env` and optional `envFiles`. +- Provider adapters: + - Claude JSON merge + - Cursor JSON merge + - Codex TOML upsert/merge + - OpenCode JSON merge +- Invalid existing file recovery. +- `merge: false` overwrite behavior. + +Integration tests: + +- End-to-end `glooit sync` with `settings` for multiple targets. +- Gitignore + manifest include generated settings files. + +## Acceptance Criteria + +1. One generic `settings` config applies across providers. +2. Existing provider settings are preserved and merged in merge mode. +3. Provider-specific output files are valid in native format. +4. Unsupported mappings produce clear warnings, not silent drops. +5. Test suite passes with coverage thresholds. + +## Open Questions + +1. Should `settings.targets` default include `factory` in phase 1? +2. For OpenCode env, should glooit write provider env refs directly or only scaffold placeholders? +3. Should missing env keys be warning-only or fail in strict mode? diff --git a/examples/full.config.ts b/examples/full.config.ts index 311b020..2400cb4 100644 --- a/examples/full.config.ts +++ b/examples/full.config.ts @@ -219,6 +219,20 @@ export default defineRules({ } ], + // ───────────────────────────────────────────────────────────── + // SETTINGS - Shared env/permissions merged per provider format + // ───────────────────────────────────────────────────────────── + + settings: { + targets: ['claude', 'cursor', 'codex', 'opencode'], + env: ['GEMINI_API_KEY', 'OPENAI_API_KEY'], + envFiles: ['.env.agents', '.env.local', '.env'], // Optional (default order shown) + permissions: { + allow: ['Read', 'Grep'] + }, + merge: true + }, + // ───────────────────────────────────────────────────────────── // BACKUP - Automatic backup before sync // ───────────────────────────────────────────────────────────── diff --git a/src/agents/settings-distributor.ts b/src/agents/settings-distributor.ts new file mode 100644 index 0000000..a150319 --- /dev/null +++ b/src/agents/settings-distributor.ts @@ -0,0 +1,451 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; +import { execFileSync } from 'child_process'; +import { resolveEnvKeys } from '../core/env-resolver'; +import type { Config, SettingsConfig, SettingsTarget } from '../types'; +import { getSettingsPath, resolveSettingsTargets, supportsStoredEnv } from './settings-provider-map'; + +type JsonObject = Record; + +function isPlainObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function deepMerge(base: JsonObject, override: JsonObject): JsonObject { + const result: JsonObject = { ...base }; + + for (const [key, value] of Object.entries(override)) { + const existing = result[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + result[key] = deepMerge(existing, value); + } else { + result[key] = value; + } + } + + return result; +} + +function normalizeStringMap(value: unknown): Record { + const normalized: Record = {}; + if (!isPlainObject(value)) { + return normalized; + } + + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'string') { + normalized[key] = item; + } + } + + return normalized; +} + +function toObject(value: unknown): JsonObject { + if (!isPlainObject(value)) { + return {}; + } + return value; +} + +function escapeTomlString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +function toTomlValue(value: unknown): string { + if (typeof value === 'string') { + return `"${escapeTomlString(value)}"`; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (Array.isArray(value)) { + const items = value.map(item => toTomlValue(item)); + return `[${items.join(', ')}]`; + } + if (isPlainObject(value)) { + const entries = Object.entries(value).map(([key, item]) => { + return `${key} = ${toTomlValue(item)}`; + }); + return `{ ${entries.join(', ')} }`; + } + return '""'; +} + +function getTomlSectionBounds(lines: string[], sectionName: string): { start: number; end: number } | null { + const sectionHeader = `[${sectionName}]`; + const start = lines.findIndex(line => line.trim() === sectionHeader); + if (start < 0) { + return null; + } + + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + const trimmed = lines[i]?.trim() || ''; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + end = i; + break; + } + } + + return { start, end }; +} + +function upsertTomlScalar(content: string, key: string, value: unknown): string { + const lines = content.length > 0 ? content.split('\n') : []; + const keyPrefix = `${key} = `; + const nextLine = `${key} = ${toTomlValue(value)}`; + + // Only match top-level keys (before the first section header) + let topLevelEnd = lines.length; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i]?.trim() || ''; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + topLevelEnd = i; + break; + } + } + + let index = -1; + for (let i = 0; i < topLevelEnd; i++) { + if (lines[i]?.trim().startsWith(keyPrefix)) { + index = i; + break; + } + } + + if (index >= 0) { + lines[index] = nextLine; + } else { + // Insert before the first section header (or at end if no sections) + lines.splice(topLevelEnd, 0, nextLine); + } + + return lines.join('\n').trimEnd() + '\n'; +} + +function upsertTomlSectionEntry(content: string, section: string, key: string, value: unknown): string { + const lines = content.length > 0 ? content.split('\n') : []; + const bounds = getTomlSectionBounds(lines, section); + const entryLine = `${key} = ${toTomlValue(value)}`; + + if (!bounds) { + if (lines.length > 0 && lines[lines.length - 1]?.trim() !== '') { + lines.push(''); + } + lines.push(`[${section}]`); + lines.push(entryLine); + return lines.join('\n').trimEnd() + '\n'; + } + + const sectionLines = lines.slice(bounds.start, bounds.end); + const localIndex = sectionLines.findIndex(line => line.trim().startsWith(`${key} = `)); + + if (localIndex >= 0) { + lines[bounds.start + localIndex] = entryLine; + } else { + lines.splice(bounds.end, 0, entryLine); + } + + return lines.join('\n').trimEnd() + '\n'; +} + +function parseTomlInlineTable(value: string): Record { + const result: Record = {}; + const trimmed = value.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return result; + } + + const inner = trimmed.slice(1, -1).trim(); + if (!inner) { + return result; + } + + const parts: string[] = []; + let current = ''; + let inQuotes = false; + for (const ch of inner) { + if (ch === '"' && (current.length === 0 || current[current.length - 1] !== '\\')) { + inQuotes = !inQuotes; + } + if (ch === ',' && !inQuotes) { + parts.push(current); + current = ''; + } else { + current += ch; + } + } + if (current) { + parts.push(current); + } + + for (const part of parts) { + const match = part.trim().match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"((?:\\.|[^"])*)"$/); + if (!match || !match[1]) { + continue; + } + result[match[1]] = (match[2] || '') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + + return result; +} + +function readTomlSectionStringMap(content: string, section: string, key: string): Record { + if (!content) { + return {}; + } + + const lines = content.split('\n'); + const bounds = getTomlSectionBounds(lines, section); + if (!bounds) { + return {}; + } + + const sectionLines = lines.slice(bounds.start + 1, bounds.end); + const entry = sectionLines.find(line => line.trim().startsWith(`${key} = `)); + if (!entry) { + return {}; + } + + const value = entry.split('=').slice(1).join('=').trim(); + return parseTomlInlineTable(value); +} + +function getGitTrackedPaths(paths: string[]): string[] { + if (paths.length === 0) { + return []; + } + + try { + const output = execFileSync('git', ['ls-files', '--', ...paths], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return output.split(/\r?\n/).filter(Boolean); + } catch { + // Not in a git repo, git unavailable, or no tracked matches. + return []; + } +} + +export class AgentSettingsDistributor { + constructor(private config: Config) {} + + async distributeSettings(): Promise { + const settings = this.config.settings; + if (!settings) { + return; + } + + const targets = this.resolveTargets(settings); + const { values: envValues, missing } = resolveEnvKeys(settings.env, settings.envFiles); + const hasPermissions = isPlainObject(settings.permissions) && Object.keys(settings.permissions).length > 0; + const hasEnv = Object.keys(envValues).length > 0; + + for (const key of missing) { + console.warn(`Settings env key '${key}' was not found in env files.`); + } + + const envWriteTargets = hasEnv ? targets.filter(target => supportsStoredEnv(target)) : []; + if (envWriteTargets.length > 0) { + const trackedPaths = getGitTrackedPaths(envWriteTargets.map(target => getSettingsPath(target))); + if (trackedPaths.length > 0) { + const trackedList = trackedPaths.map(path => `- ${path}`).join('\n'); + console.warn( + [ + 'WARNING: Refusing to write settings env values into git-tracked files.', + 'Tracked files:', + trackedList, + 'Untrack these files (or remove settings.env) before running sync again.', + ].join('\n') + ); + throw new Error(`Refusing to write env values into git-tracked settings files: ${trackedPaths.join(', ')}`); + } + } + + if (!hasPermissions && !hasEnv) { + return; + } + + for (const target of targets) { + const outputPath = getSettingsPath(target); + const merge = settings.merge !== false; + + if (target === 'codex') { + this.writeCodexSettings(outputPath, envValues, toObject(settings.permissions), merge); + continue; + } + + if (target === 'cursor' && hasPermissions) { + console.warn( + 'Note: Cursor may ignore project-level permissions in .cursor/cli.json due to security restrictions. ' + + 'Consider setting permissions in the global ~/.cursor/cli-config.json instead.' + ); + } + + if ((target === 'cursor' || target === 'opencode') && hasEnv) { + console.warn(`Settings env is not mapped for ${target}; skipping env values for that target.`); + } + + if (target === 'opencode') { + this.writeJsonSettings({ + outputPath, + merge, + permissionKey: 'permission', + includeEnv: false, + envValues, + permissions: toObject(settings.permissions), + }); + continue; + } + + this.writeJsonSettings({ + outputPath, + merge, + permissionKey: 'permissions', + includeEnv: supportsStoredEnv(target), + envValues, + permissions: toObject(settings.permissions), + }); + } + } + + getGeneratedPaths(): string[] { + const settings = this.config.settings; + if (!settings) { + return []; + } + + const hasEnvKeys = Array.isArray(settings.env) && settings.env.length > 0; + const hasPermissions = isPlainObject(settings.permissions) && Object.keys(settings.permissions).length > 0; + if (!hasEnvKeys && !hasPermissions) { + return []; + } + + return this.resolveTargets(settings).map(target => getSettingsPath(target)); + } + + private resolveTargets(settings: SettingsConfig): SettingsTarget[] { + return resolveSettingsTargets(settings.targets); + } + + private writeJsonSettings(options: { + outputPath: string; + merge: boolean; + permissionKey: 'permission' | 'permissions'; + includeEnv: boolean; + envValues: Record; + permissions: JsonObject; + }): void { + const hasEnvData = options.includeEnv && Object.keys(options.envValues).length > 0; + const hasPermissionData = Object.keys(options.permissions).length > 0; + if (!hasEnvData && !hasPermissionData) { + return; + } + + let existing: JsonObject = {}; + if (options.merge && existsSync(options.outputPath)) { + try { + existing = JSON.parse(readFileSync(options.outputPath, 'utf-8')) as JsonObject; + } catch { + existing = {}; + } + } + + const next: JsonObject = { ...existing }; + + if (options.includeEnv && Object.keys(options.envValues).length > 0) { + const existingEnv = normalizeStringMap(next.env); + next.env = { + ...existingEnv, + ...options.envValues, + }; + } + + if (Object.keys(options.permissions).length > 0) { + const existingPermissions = toObject(next[options.permissionKey]); + next[options.permissionKey] = deepMerge(existingPermissions, options.permissions); + } + + mkdirSync(dirname(options.outputPath), { recursive: true }); + writeFileSync(options.outputPath, JSON.stringify(next, null, 2), 'utf-8'); + } + + private static CODEX_KNOWN_KEYS = new Set([ + 'approval_policy', 'approvalPolicy', + 'sandbox_mode', 'sandboxMode', + 'shell_environment_policy', 'shellEnvironmentPolicy', + ]); + + private writeCodexSettings( + outputPath: string, + envValues: Record, + permissions: JsonObject, + merge: boolean, + ): void { + const unknownKeys = Object.keys(permissions).filter(k => !AgentSettingsDistributor.CODEX_KNOWN_KEYS.has(k)); + if (unknownKeys.length > 0) { + console.warn( + `Settings permissions keys not mapped for codex (ignored): ${unknownKeys.join(', ')}. ` + + `Codex supports: approval_policy, sandbox_mode, shell_environment_policy.` + ); + } + + let content = ''; + if (merge && existsSync(outputPath)) { + content = readFileSync(outputPath, 'utf-8'); + } + + let shouldWrite = false; + + const approvalPolicy = permissions.approval_policy ?? permissions.approvalPolicy; + if (approvalPolicy !== undefined) { + content = upsertTomlScalar(content, 'approval_policy', approvalPolicy); + shouldWrite = true; + } + + const sandboxMode = permissions.sandbox_mode ?? permissions.sandboxMode; + if (sandboxMode !== undefined) { + content = upsertTomlScalar(content, 'sandbox_mode', sandboxMode); + shouldWrite = true; + } + + const shellPolicyRaw = permissions.shell_environment_policy ?? permissions.shellEnvironmentPolicy; + const shellPolicy = toObject(shellPolicyRaw); + const shellPolicySet = normalizeStringMap(shellPolicy.set); + const existingSet = merge ? readTomlSectionStringMap(content, 'shell_environment_policy', 'set') : {}; + const mergedSet = { + ...existingSet, + ...shellPolicySet, + ...envValues, + }; + + if (Object.keys(mergedSet).length > 0) { + content = upsertTomlSectionEntry(content, 'shell_environment_policy', 'set', mergedSet); + shouldWrite = true; + } + + for (const [key, value] of Object.entries(shellPolicy)) { + if (key === 'set') { + continue; + } + content = upsertTomlSectionEntry(content, 'shell_environment_policy', key, value); + shouldWrite = true; + } + + if (!shouldWrite) { + return; + } + + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, content.trimEnd() + '\n', 'utf-8'); + } +} diff --git a/src/agents/settings-provider-map.ts b/src/agents/settings-provider-map.ts new file mode 100644 index 0000000..34d6a2f --- /dev/null +++ b/src/agents/settings-provider-map.ts @@ -0,0 +1,22 @@ +import { SETTINGS_TARGETS, type SettingsTarget } from '../types'; + +export const DEFAULT_SETTINGS_TARGETS: SettingsTarget[] = SETTINGS_TARGETS; + +export const SETTINGS_PATHS: Record = { + claude: '.claude/settings.local.json', + cursor: '.cursor/cli.json', + codex: '.codex/config.toml', + opencode: 'opencode.json', +}; + +export function getSettingsPath(target: SettingsTarget): string { + return SETTINGS_PATHS[target]; +} + +export function supportsStoredEnv(target: SettingsTarget): boolean { + return target === 'claude' || target === 'codex'; +} + +export function resolveSettingsTargets(targets: SettingsTarget[] | undefined): SettingsTarget[] { + return targets && targets.length > 0 ? targets : DEFAULT_SETTINGS_TARGETS; +} diff --git a/src/core/config-loader.ts b/src/core/config-loader.ts index 9a97d13..766a9d6 100644 --- a/src/core/config-loader.ts +++ b/src/core/config-loader.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from 'fs'; import { join, basename } from 'path'; -import type { Config } from '../types'; +import { validateSettingsConfig, type Config } from '../types'; import { isKnownDirectoryType } from '../agents'; import { resolveConfigDir } from './utils'; @@ -116,6 +116,10 @@ export class ConfigLoader { } }); } + + if (c.settings !== undefined) { + validateSettingsConfig(c.settings); + } } private static validateRule(rule: unknown, index: number): void { diff --git a/src/core/env-resolver.ts b/src/core/env-resolver.ts new file mode 100644 index 0000000..52555c2 --- /dev/null +++ b/src/core/env-resolver.ts @@ -0,0 +1,113 @@ +import { existsSync, readFileSync } from 'fs'; + +const DEFAULT_ENV_FILES = ['.env.agents', '.env.local', '.env']; + +function unquote(value: string): string { + const trimmed = value.trim(); + + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + const inner = trimmed.slice(1, -1); + return inner + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +export function parseEnvContent(content: string): Record { + const parsed: Record = {}; + const lines = content.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const withoutExport = trimmed.startsWith('export ') + ? trimmed.slice('export '.length).trim() + : trimmed; + + const separatorIndex = withoutExport.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + + const key = withoutExport.slice(0, separatorIndex).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + continue; + } + + const rawValue = withoutExport.slice(separatorIndex + 1); + parsed[key] = unquote(rawValue); + } + + return parsed; +} + +function loadEnvFiles(envFiles: string[]): Record[] { + const loaded: Record[] = []; + + for (const file of envFiles) { + if (!existsSync(file)) { + loaded.push({}); + continue; + } + + const content = readFileSync(file, 'utf-8'); + loaded.push(parseEnvContent(content)); + } + + return loaded; +} + +export interface ResolvedEnvResult { + values: Record; + missing: string[]; +} + +export function resolveEnvKeys(keys: string[] | undefined, envFiles: string[] | undefined): ResolvedEnvResult { + const values: Record = {}; + const missing: string[] = []; + + if (!keys || keys.length === 0) { + return { values, missing }; + } + + const filesToLoad = envFiles && envFiles.length > 0 ? envFiles : DEFAULT_ENV_FILES; + const fileMaps = loadEnvFiles(filesToLoad); + + for (const key of keys) { + let fileValue: string | undefined; + for (const fileMap of fileMaps) { + if (Object.prototype.hasOwnProperty.call(fileMap, key)) { + fileValue = fileMap[key]; + break; + } + } + + if (fileValue !== undefined) { + values[key] = fileValue; + continue; + } + + // Fall back to process.env + const envValue = process.env[key]; + if (envValue !== undefined) { + values[key] = envValue; + continue; + } + + missing.push(key); + } + + return { values, missing }; +} diff --git a/src/core/gitignore.ts b/src/core/gitignore.ts index 12e457f..447c647 100644 --- a/src/core/gitignore.ts +++ b/src/core/gitignore.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import type { Config, Agent, AgentName, DirectorySync } from '../types'; import { getAgentPath, getAgentDirectory, KNOWN_DIRECTORY_TYPES, getAgentDirectoryPath } from '../agents'; +import { SETTINGS_PATHS, resolveSettingsTargets } from '../agents/settings-provider-map'; export class GitIgnoreManager { private gitignorePath = '.gitignore'; @@ -117,6 +118,19 @@ export class GitIgnoreManager { } } + if (this.config.settings) { + const hasEnvKeys = Array.isArray(this.config.settings.env) && this.config.settings.env.length > 0; + const p = this.config.settings.permissions; + const hasPermissions = typeof p === 'object' && p !== null && !Array.isArray(p) && Object.keys(p).length > 0; + if (hasEnvKeys || hasPermissions) { + const targets = resolveSettingsTargets(this.config.settings.targets); + + for (const target of targets) { + paths.add(this.normalizeGitignorePath(SETTINGS_PATHS[target])); + } + } + } + // Always add manifest file to gitignore const manifestPath = `${this.config.configDir || '.agents'}/manifest.json`; paths.add(this.normalizeGitignorePath(manifestPath)); diff --git a/src/core/index.ts b/src/core/index.ts index 07c7931..5c7909c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,7 @@ interface McpConfigFile { } import { AgentDistributor } from '../agents/distributor'; import { AgentHooksDistributor } from '../agents/hooks-distributor'; +import { AgentSettingsDistributor } from '../agents/settings-distributor'; import { BackupManager } from './backup'; import { GitIgnoreManager } from './gitignore'; import { ManifestManager } from './manifest'; @@ -27,6 +28,7 @@ import { AgentWriterFactory } from '../agents/writers'; export class AIRulesCore { private distributor: AgentDistributor; private hooksDistributor: AgentHooksDistributor; + private settingsDistributor: AgentSettingsDistributor; private backupManager: BackupManager; private gitIgnoreManager: GitIgnoreManager; private manifestManager: ManifestManager; @@ -35,6 +37,7 @@ export class AIRulesCore { constructor(private config: Config) { this.distributor = new AgentDistributor(config, this.symlinkPaths); this.hooksDistributor = new AgentHooksDistributor(config); + this.settingsDistributor = new AgentSettingsDistributor(config); this.backupManager = new BackupManager(config); this.gitIgnoreManager = new GitIgnoreManager(config); this.manifestManager = new ManifestManager(config.configDir); @@ -71,6 +74,10 @@ export class AIRulesCore { await this.hooksDistributor.distributeHooks(); } + if (this.config.settings) { + await this.settingsDistributor.distributeSettings(); + } + await this.gitIgnoreManager.updateGitIgnore(); // Update manifest with current paths @@ -323,6 +330,13 @@ export class AIRulesCore { } } + const settingsPaths = this.settingsDistributor.getGeneratedPaths(); + for (const settingsPath of settingsPaths) { + if (!paths.includes(settingsPath)) { + paths.push(settingsPath); + } + } + return paths; } } diff --git a/src/index.ts b/src/index.ts index a42b20b..7466e91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export type { DirectorySyncConfig, Mcp, Agent, + SettingsConfig, + SettingsTarget, AgentHook, AgentHookEvent, SyncContext, @@ -21,4 +23,4 @@ export { GitIgnoreManager } from './core/gitignore'; export { HookManager } from './hooks'; // Built-in transforms for content modification during sync -export * as transforms from './hooks/builtin'; \ No newline at end of file +export * as transforms from './hooks/builtin'; diff --git a/src/types/index.ts b/src/types/index.ts index c703ef3..ffda133 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,8 @@ import { resolveConfigDir } from '../core/utils'; export type AgentName = 'claude' | 'cursor' | 'codex' | 'roocode' | 'opencode' | 'factory' | 'generic'; +export type SettingsTarget = 'claude' | 'cursor' | 'codex' | 'opencode'; +export const SETTINGS_TARGETS: SettingsTarget[] = ['claude', 'cursor', 'codex', 'opencode']; export interface AgentTarget { name: AgentName; @@ -109,6 +111,57 @@ export interface AgentHook { targets: AgentName[]; } +export interface SettingsConfig { + /** Target agents for settings merge (default: claude, cursor, codex, opencode) */ + targets?: SettingsTarget[]; + /** Environment variable keys to resolve and merge */ + env?: string[]; + /** Optional env files used to resolve keys (default: ['.env.local', '.env']) */ + envFiles?: string[]; + /** Generic permissions payload mapped per provider format */ + permissions?: Record; + /** Merge with existing settings files (default: true) */ + merge?: boolean; +} + +export function validateSettingsConfig(settings: unknown): asserts settings is SettingsConfig { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + throw new Error('Config.settings must be an object'); + } + + const s = settings as Record; + + if (s.targets !== undefined) { + if (!Array.isArray(s.targets) || !s.targets.every((target: unknown) => { + return typeof target === 'string' && SETTINGS_TARGETS.includes(target as SettingsTarget); + })) { + throw new Error(`Config.settings.targets must contain valid agents: ${SETTINGS_TARGETS.join(', ')}`); + } + } + + if (s.env !== undefined) { + if (!Array.isArray(s.env) || !s.env.every((key: unknown) => typeof key === 'string')) { + throw new Error('Config.settings.env must be an array of strings'); + } + } + + if (s.envFiles !== undefined) { + if (!Array.isArray(s.envFiles) || !s.envFiles.every((file: unknown) => typeof file === 'string')) { + throw new Error('Config.settings.envFiles must be an array of strings'); + } + } + + if (s.permissions !== undefined) { + if (!s.permissions || typeof s.permissions !== 'object' || Array.isArray(s.permissions)) { + throw new Error('Config.settings.permissions must be an object'); + } + } + + if (s.merge !== undefined && typeof s.merge !== 'boolean') { + throw new Error('Config.settings.merge must be a boolean'); + } +} + export interface Config { configDir?: string; mode?: 'copy' | 'symlink'; @@ -126,6 +179,8 @@ export interface Config { transforms?: Transforms; /** Agent lifecycle hooks - configure Claude Code/Cursor hooks */ hooks?: AgentHook[]; + /** Generic settings merged into provider-native settings formats */ + settings?: SettingsConfig; backup?: BackupConfig; gitignore?: boolean; } @@ -249,6 +304,10 @@ function validateConfig(config: unknown): asserts config is Config { } }); } + + if (c.settings !== undefined) { + validateSettingsConfig(c.settings); + } } export function defineRules(config: Config): Config { diff --git a/tests/unit/config-loader.test.ts b/tests/unit/config-loader.test.ts index 4a06e84..0759451 100644 --- a/tests/unit/config-loader.test.ts +++ b/tests/unit/config-loader.test.ts @@ -224,6 +224,142 @@ describe('ConfigLoader', () => { } }); + it('should accept valid settings config', async () => { + const settingsConfigPath = 'test-config-settings.js'; + const validConfig = ` + export default { + rules: [{ + file: '.agents/test.md', + to: './', + targets: ['claude'] + }], + settings: { + targets: ['claude', 'codex'], + env: ['GEMINI_API_KEY'], + envFiles: ['.env'], + permissions: { allow: ['Read'] }, + merge: true + } + }; + `; + + writeFileSync(settingsConfigPath, validConfig); + const config = await ConfigLoader.load(settingsConfigPath); + expect(config.settings?.targets).toEqual(['claude', 'codex']); + if (existsSync(settingsConfigPath)) { + unlinkSync(settingsConfigPath); + } + }); + + it('should reject invalid settings config', async () => { + const settingsConfigPath = 'test-config-settings-invalid.js'; + const invalidConfig = ` + export default { + rules: [{ + file: '.agents/test.md', + to: './', + targets: ['claude'] + }], + settings: { + targets: ['factory'], + env: [123] + } + }; + `; + + writeFileSync(settingsConfigPath, invalidConfig); + await expect(ConfigLoader.load(settingsConfigPath)).rejects.toThrow(/Config.settings/); + if (existsSync(settingsConfigPath)) { + unlinkSync(settingsConfigPath); + } + }); + + it('should reject non-object settings', async () => { + const settingsConfigPath = 'test-config-settings-not-object.js'; + const invalidConfig = ` + export default { + rules: [{ + file: '.agents/test.md', + to: './', + targets: ['claude'] + }], + settings: 'bad' + }; + `; + + writeFileSync(settingsConfigPath, invalidConfig); + await expect(ConfigLoader.load(settingsConfigPath)).rejects.toThrow('Config.settings must be an object'); + if (existsSync(settingsConfigPath)) { + unlinkSync(settingsConfigPath); + } + }); + + it('should reject invalid settings envFiles', async () => { + const settingsConfigPath = 'test-config-settings-envfiles-invalid.js'; + const invalidConfig = ` + export default { + rules: [{ + file: '.agents/test.md', + to: './', + targets: ['claude'] + }], + settings: { + envFiles: [123] + } + }; + `; + + writeFileSync(settingsConfigPath, invalidConfig); + await expect(ConfigLoader.load(settingsConfigPath)).rejects.toThrow('Config.settings.envFiles must be an array of strings'); + if (existsSync(settingsConfigPath)) { + unlinkSync(settingsConfigPath); + } + }); + + it('should reject invalid settings permissions', async () => { + const settingsConfigPath = 'test-config-settings-permissions-invalid.js'; + const invalidConfig = ` + export default { + rules: [{ + file: '.agents/test.md', + to: './', + targets: ['claude'] + }], + settings: { + permissions: ['bad'] + } + }; + `; + + writeFileSync(settingsConfigPath, invalidConfig); + await expect(ConfigLoader.load(settingsConfigPath)).rejects.toThrow('Config.settings.permissions must be an object'); + if (existsSync(settingsConfigPath)) { + unlinkSync(settingsConfigPath); + } + }); + + it('should reject invalid settings merge value', async () => { + const settingsConfigPath = 'test-config-settings-merge-invalid.js'; + const invalidConfig = ` + export default { + rules: [{ + file: '.agents/test.md', + to: './', + targets: ['claude'] + }], + settings: { + merge: 'yes' + } + }; + `; + + writeFileSync(settingsConfigPath, invalidConfig); + await expect(ConfigLoader.load(settingsConfigPath)).rejects.toThrow('Config.settings.merge must be a boolean'); + if (existsSync(settingsConfigPath)) { + unlinkSync(settingsConfigPath); + } + }); + it('should load config from function export', async () => { const functionalConfig = ` export default () => ({ diff --git a/tests/unit/core-coverage.test.ts b/tests/unit/core-coverage.test.ts index d59ceec..848f851 100644 --- a/tests/unit/core-coverage.test.ts +++ b/tests/unit/core-coverage.test.ts @@ -36,7 +36,11 @@ describe('AIRulesCore coverage', () => { ], hooks: [ { event: 'beforeShellExecution', command: 'echo ok', targets: ['claude', 'cursor'] } - ] + ], + settings: { + targets: ['claude'], + permissions: { allow: ['Read'] } + } }; const core = new AIRulesCore(config); @@ -51,6 +55,7 @@ describe('AIRulesCore coverage', () => { expect(paths).toContain('.mcp.json'); expect(paths).toContain('.claude/settings.json'); expect(paths).toContain('.cursor/hooks.json'); + expect(paths).toContain('.claude/settings.local.json'); }); it('sync handles directory sync with unsupported targets', async () => { diff --git a/tests/unit/distributor-symlink.test.ts b/tests/unit/distributor-symlink.test.ts index 54658c7..a901157 100644 --- a/tests/unit/distributor-symlink.test.ts +++ b/tests/unit/distributor-symlink.test.ts @@ -194,4 +194,5 @@ describe('AgentDistributor symlink mode', () => { await expect(distributor.distributeRule(rule)).rejects.toThrow(/Failed to symlink directory/); }); + }); diff --git a/tests/unit/env-resolver.test.ts b/tests/unit/env-resolver.test.ts new file mode 100644 index 0000000..2b3e3a2 --- /dev/null +++ b/tests/unit/env-resolver.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { parseEnvContent, resolveEnvKeys } from '../../src/core/env-resolver'; + +describe('env resolver', () => { + const testDir = `/tmp/test-env-resolver-${Date.now()}`; + const originalCwd = process.cwd(); + + beforeEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + mkdirSync(testDir, { recursive: true }); + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('parses dotenv content with export, quotes, and ignores invalid lines', () => { + const parsed = parseEnvContent([ + '# comment', + 'export A=one', + 'B="two\\nline"', + "C='three'", + 'INVALID LINE', + '1BAD=value', + 'D=plain', + ].join('\n')); + + expect(parsed).toEqual({ + A: 'one', + B: 'two\nline', + C: 'three', + D: 'plain', + }); + }); + + it('resolves from default env files and prefers .env.local over .env', () => { + writeFileSync('.env', 'FROM_ENV=from-env\nOVERRIDE=from-env\nEMPTY_VALUE=\n'); + writeFileSync('.env.local', 'OVERRIDE=from-env-local\n'); + + const resolved = resolveEnvKeys( + ['FROM_ENV', 'OVERRIDE', 'EMPTY_VALUE'], + undefined, + ); + + expect(resolved.values).toEqual({ + FROM_ENV: 'from-env', + OVERRIDE: 'from-env-local', + EMPTY_VALUE: '', + }); + expect(resolved.missing).toEqual([]); + }); + + it('handles missing keys and missing files gracefully', () => { + writeFileSync('.env.local', 'ONLY_LOCAL=local\n'); + + const key = '__GLOOIT_TEST_MISSING_KEY__'; + delete process.env[key]; + + const resolved = resolveEnvKeys( + [key, 'ONLY_LOCAL'], + ['.env', '.env.local'], + ); + + expect(resolved.values).toEqual({ + ONLY_LOCAL: 'local', + }); + expect(resolved.missing).toEqual([key]); + }); + + it('falls back to process.env when key is not in env files', () => { + const key = '__GLOOIT_TEST_PROCESS_ENV__'; + process.env[key] = 'from-process'; + + try { + const resolved = resolveEnvKeys([key], ['.env']); + + expect(resolved.values).toEqual({ [key]: 'from-process' }); + expect(resolved.missing).toEqual([]); + } finally { + delete process.env[key]; + } + }); + + it('prefers env file values over process.env', () => { + const key = '__GLOOIT_TEST_FILE_WINS__'; + process.env[key] = 'from-process'; + writeFileSync('.env.local', `${key}=from-file\n`); + + try { + const resolved = resolveEnvKeys([key], undefined); + + expect(resolved.values).toEqual({ [key]: 'from-file' }); + } finally { + delete process.env[key]; + } + }); + + it('returns empty result when keys are empty or undefined', () => { + expect(resolveEnvKeys([], ['.env'])).toEqual({ values: {}, missing: [] }); + expect(resolveEnvKeys(undefined, ['.env'])).toEqual({ values: {}, missing: [] }); + }); + + it('uses explicit envFiles order when provided', () => { + writeFileSync('.env', 'DUPLICATE=from-env\n'); + writeFileSync('.env.local', 'DUPLICATE=from-env-local\n'); + + const resolved = resolveEnvKeys( + ['DUPLICATE'], + ['.env', '.env.local'], + ); + + expect(resolved.values).toEqual({ + DUPLICATE: 'from-env', + }); + }); +}); diff --git a/tests/unit/gitignore.test.ts b/tests/unit/gitignore.test.ts index a4651ff..66d6d9b 100644 --- a/tests/unit/gitignore.test.ts +++ b/tests/unit/gitignore.test.ts @@ -299,6 +299,62 @@ other-file.txt`; expect(content).not.toContain('merged-no-ignore.md'); } }); + + it('should add settings-generated paths for default targets', async () => { + const config: Config = { + rules: [], + settings: { + env: ['GEMINI_API_KEY'] + } + }; + + const manager = new GitIgnoreManager(config); + await manager.updateGitIgnore(); + + const content = readFileSync('.gitignore', 'utf-8'); + expect(content).toContain('.claude/settings.local.json'); + expect(content).toContain('.cursor/cli.json'); + expect(content).toContain('.codex/config.toml'); + expect(content).toContain('opencode.json'); + }); + + it('should add settings paths only for selected targets', async () => { + const config: Config = { + rules: [], + settings: { + targets: ['claude', 'codex'], + permissions: { allow: ['Read'] } + } + }; + + const manager = new GitIgnoreManager(config); + await manager.updateGitIgnore(); + + const content = readFileSync('.gitignore', 'utf-8'); + expect(content).toContain('.claude/settings.local.json'); + expect(content).toContain('.codex/config.toml'); + expect(content).not.toContain('.cursor/cli.json'); + expect(content).not.toContain('opencode.json'); + }); + + it('should not add settings paths for empty permissions object', async () => { + const config: Config = { + rules: [], + settings: { + permissions: {} + } + }; + + const manager = new GitIgnoreManager(config); + await manager.updateGitIgnore(); + + const content = readFileSync('.gitignore', 'utf-8'); + expect(content).not.toContain('.claude/settings.local.json'); + expect(content).not.toContain('.cursor/cli.json'); + expect(content).not.toContain('.codex/config.toml'); + expect(content).not.toContain('opencode.json'); + expect(content).toContain('.agents/manifest.json'); + }); }); describe('cleanupGitIgnore', () => { diff --git a/tests/unit/project-structure-errors.test.ts b/tests/unit/project-structure-errors.test.ts index 44ad430..1340049 100644 --- a/tests/unit/project-structure-errors.test.ts +++ b/tests/unit/project-structure-errors.test.ts @@ -30,4 +30,25 @@ describe('replaceStructure', () => { expect(result).toContain('a'); }); + + it('returns unavailable fallback when tree serialization throws', async () => { + const originalJoin = Array.prototype.join; + Array.prototype.join = function mockedJoin(this: unknown[]): string { + throw new Error('join-failed'); + }; + + try { + const result = await replaceStructure({ + config: { rules: [] }, + rule: { file: 'a.md', to: './', targets: ['claude'] }, + content: '__STRUCTURE__', + targetPath: 'x', + agent: 'claude' + }); + + expect(result).toContain('Project structure unavailable'); + } finally { + Array.prototype.join = originalJoin; + } + }); }); diff --git a/tests/unit/settings-distributor.test.ts b/tests/unit/settings-distributor.test.ts new file mode 100644 index 0000000..b935e9f --- /dev/null +++ b/tests/unit/settings-distributor.test.ts @@ -0,0 +1,640 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { execSync } from 'child_process'; +import { AIRulesCore } from '../../src/core'; +import { AgentSettingsDistributor } from '../../src/agents/settings-distributor'; +import type { Config } from '../../src/types'; + +describe('AgentSettingsDistributor', () => { + const testDir = `/tmp/test-settings-distributor-${Date.now()}`; + const originalCwd = process.cwd(); + + beforeEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + mkdirSync(testDir, { recursive: true }); + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('returns no generated paths when settings are absent', async () => { + const distributor = new AgentSettingsDistributor({ rules: [] }); + await distributor.distributeSettings(); + expect(distributor.getGeneratedPaths()).toEqual([]); + }); + + it('returns default generated paths when settings are configured', () => { + const distributor = new AgentSettingsDistributor({ + rules: [], + settings: { + env: ['GEMINI_API_KEY'], + }, + }); + + expect(distributor.getGeneratedPaths()).toEqual([ + '.claude/settings.local.json', + '.cursor/cli.json', + '.codex/config.toml', + 'opencode.json', + ]); + }); + + it('returns custom target paths only when targets are specified', () => { + const distributor = new AgentSettingsDistributor({ + rules: [], + settings: { + targets: ['claude', 'codex'], + permissions: { allow: ['Read'] }, + }, + }); + + expect(distributor.getGeneratedPaths()).toEqual([ + '.claude/settings.local.json', + '.codex/config.toml', + ]); + }); + + it('returns no generated paths for empty settings objects', () => { + const distributor = new AgentSettingsDistributor({ + rules: [], + settings: {}, + }); + + expect(distributor.getGeneratedPaths()).toEqual([]); + }); + + it('merges Claude settings env and permissions using default env files', async () => { + mkdirSync('.claude', { recursive: true }); + writeFileSync('.claude/settings.local.json', JSON.stringify({ + env: { EXISTING: 'keep' }, + permissions: { allow: ['Read'] }, + other: true, + }, null, 2)); + writeFileSync('.env', 'GEMINI_API_KEY=from-env\nOPENAI_API_KEY=from-env\n'); + writeFileSync('.env.local', 'OPENAI_API_KEY=from-local\n'); + + const config: Config = { + rules: [], + settings: { + targets: ['claude'], + env: ['GEMINI_API_KEY', 'OPENAI_API_KEY'], + permissions: { + deny: ['Bash'], + nested: { strict: true }, + }, + }, + }; + + const core = new AIRulesCore(config); + await core.sync(); + + const claude = JSON.parse(readFileSync('.claude/settings.local.json', 'utf-8')); + expect(claude.env).toEqual({ + EXISTING: 'keep', + GEMINI_API_KEY: 'from-env', + OPENAI_API_KEY: 'from-local', + }); + expect(claude.permissions).toEqual({ + allow: ['Read'], + deny: ['Bash'], + nested: { strict: true }, + }); + expect(claude.other).toBe(true); + }); + + it('resets settings content when merge is false', async () => { + mkdirSync('.claude', { recursive: true }); + writeFileSync('.claude/settings.local.json', JSON.stringify({ + env: { OLD: 'old' }, + permissions: { allow: ['Read'] }, + old: true, + }, null, 2)); + writeFileSync('.env.local', 'GEMINI_API_KEY=new\n'); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['claude'], + env: ['GEMINI_API_KEY'], + merge: false, + }, + }); + await core.sync(); + + const claude = JSON.parse(readFileSync('.claude/settings.local.json', 'utf-8')); + expect(claude).toEqual({ + env: { + GEMINI_API_KEY: 'new', + }, + }); + }); + + it('writes Cursor CLI permissions and warns that env mapping is skipped', async () => { + writeFileSync('.env.local', 'GEMINI_API_KEY=abc\n'); + const previousWarn = console.warn; + const warnings: string[] = []; + console.warn = (message?: unknown) => { + warnings.push(String(message)); + }; + + try { + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['cursor'], + env: ['GEMINI_API_KEY'], + permissions: { + allow: ['Read'], + }, + }, + }); + await core.sync(); + } finally { + console.warn = previousWarn; + } + + const cursor = JSON.parse(readFileSync('.cursor/cli.json', 'utf-8')); + expect(cursor.permissions).toEqual({ + allow: ['Read'], + }); + expect(warnings.some(w => w.includes('Settings env is not mapped for cursor'))).toBe(true); + expect(warnings.some(w => w.includes('Cursor may ignore project-level permissions'))).toBe(true); + }); + + it('preserves unrelated Cursor CLI config while merging permissions', async () => { + mkdirSync('.cursor', { recursive: true }); + writeFileSync( + '.cursor/cli.json', + JSON.stringify( + { + version: 1, + editor: { vimMode: true }, + permissions: { + allow: ['Read'], + deny: ['Delete'], + }, + }, + null, + 2 + ) + ); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['cursor'], + permissions: { + allow: ['Read', 'Grep'], + }, + }, + }); + await core.sync(); + + const cursor = JSON.parse(readFileSync('.cursor/cli.json', 'utf-8')); + expect(cursor.version).toBe(1); + expect(cursor.editor).toEqual({ vimMode: true }); + expect(cursor.permissions).toEqual({ + allow: ['Read', 'Grep'], + deny: ['Delete'], + }); + }); + + it('skips Cursor settings file creation when only env keys are provided', async () => { + writeFileSync('.env.local', 'GEMINI_API_KEY=abc\n'); + const previousWarn = console.warn; + const warnings: string[] = []; + console.warn = (message?: unknown) => { + warnings.push(String(message)); + }; + + try { + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['cursor'], + env: ['GEMINI_API_KEY'], + }, + }); + await core.sync(); + } finally { + console.warn = previousWarn; + } + + expect(existsSync('.cursor/cli.json')).toBe(false); + expect(warnings.some(w => w.includes('Settings env is not mapped for cursor'))).toBe(true); + }); + + it('writes OpenCode permission block and recovers from invalid existing JSON', async () => { + writeFileSync('opencode.json', '{ invalid-json'); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['opencode'], + permissions: { + bash: 'ask', + edit: 'allow', + }, + }, + }); + + await core.sync(); + + const opencode = JSON.parse(readFileSync('opencode.json', 'utf-8')); + expect(opencode.permission).toEqual({ + bash: 'ask', + edit: 'allow', + }); + }); + + it('deep-merges nested Claude permission objects', async () => { + mkdirSync('.claude', { recursive: true }); + writeFileSync( + '.claude/settings.local.json', + JSON.stringify( + { + permissions: { + tools: { + bash: { + mode: 'ask', + timeout: 10, + }, + }, + }, + }, + null, + 2 + ) + ); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['claude'], + permissions: { + tools: { + bash: { + timeout: 30, + network: true, + }, + }, + }, + }, + }); + await core.sync(); + + const claude = JSON.parse(readFileSync('.claude/settings.local.json', 'utf-8')); + expect(claude.permissions).toEqual({ + tools: { + bash: { + mode: 'ask', + timeout: 30, + network: true, + }, + }, + }); + }); + + it('recursively merges Claude permissions without removing unrelated nested keys', async () => { + mkdirSync('.claude', { recursive: true }); + writeFileSync( + '.claude/settings.local.json', + JSON.stringify( + { + permissions: { + tools: { + bash: { + mode: 'ask', + timeout: 10, + }, + edit: { + mode: 'allow', + }, + }, + deny: ['Delete'], + }, + }, + null, + 2 + ) + ); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['claude'], + permissions: { + tools: { + bash: { + timeout: 30, + }, + }, + }, + }, + }); + await core.sync(); + + const claude = JSON.parse(readFileSync('.claude/settings.local.json', 'utf-8')); + expect(claude.permissions).toEqual({ + tools: { + bash: { + mode: 'ask', + timeout: 30, + }, + edit: { + mode: 'allow', + }, + }, + deny: ['Delete'], + }); + }); + + it('writes and merges Codex TOML settings with env and shell policy', async () => { + mkdirSync('.codex', { recursive: true }); + writeFileSync('.codex/config.toml', [ + 'approval_policy = "never"', + '', + '[shell_environment_policy]', + 'set = { EXISTING = "yes" }', + 'include_only = ["PATH"]', + ].join('\n')); + writeFileSync('.env.local', 'GEMINI_API_KEY=from-local\n'); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + permissions: { + approvalPolicy: 'on-request', + sandboxMode: 'workspace-write', + shellEnvironmentPolicy: { + include_only: ['PATH', 'HOME'], + inherit: false, + }, + }, + }, + }); + await core.sync(); + + const codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('approval_policy = "on-request"'); + expect(codex).toContain('sandbox_mode = "workspace-write"'); + expect(codex).toContain('[shell_environment_policy]'); + expect(codex).toContain('set = { EXISTING = "yes", GEMINI_API_KEY = "from-local" }'); + expect(codex).toContain('include_only = ["PATH", "HOME"]'); + expect(codex).toContain('inherit = false'); + }); + + it('creates shell_environment_policy section when missing and inserts before next section', async () => { + mkdirSync('.codex', { recursive: true }); + writeFileSync('.codex/config.toml', [ + 'approval_policy = "never"', + '[other_section]', + 'enabled = true', + ].join('\n')); + writeFileSync('.env.local', 'GEMINI_API_KEY=from-local\n'); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + permissions: { + shellEnvironmentPolicy: { + include_only: ['PATH'], + }, + }, + }, + }); + await core.sync(); + + const codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('[shell_environment_policy]'); + expect(codex).toContain('set = { GEMINI_API_KEY = "from-local" }'); + expect(codex).toContain('include_only = ["PATH"]'); + expect(codex).toContain('[other_section]'); + }); + + it('preserves unrelated Codex TOML content when merge is true', async () => { + mkdirSync('.codex', { recursive: true }); + writeFileSync('.codex/config.toml', [ + '# user-defined content', + 'model = "gpt-5"', + '', + '[profile.fast]', + 'reasoning = "low"', + '', + '[shell_environment_policy]', + 'include_only = ["PATH"]', + ].join('\n')); + writeFileSync('.env.local', 'GEMINI_API_KEY=from-local\n'); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + permissions: { + approvalPolicy: 'on-request', + }, + }, + }); + await core.sync(); + + const codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('# user-defined content'); + expect(codex).toContain('model = "gpt-5"'); + expect(codex).toContain('[profile.fast]'); + expect(codex).toContain('reasoning = "low"'); + expect(codex).toContain('[shell_environment_policy]'); + expect(codex).toContain('include_only = ["PATH"]'); + expect(codex).toContain('set = { GEMINI_API_KEY = "from-local" }'); + expect(codex).toContain('approval_policy = "on-request"'); + }); + + it('handles Codex set parsing edge cases while merging env values', async () => { + mkdirSync('.codex', { recursive: true }); + writeFileSync('.codex/config.toml', [ + '[shell_environment_policy]', + 'set = { KEEP = "yes", BROKEN = 1 }', + '[other]', + 'flag = true', + ].join('\n')); + writeFileSync('.env.local', 'GEMINI_API_KEY=from-local\n'); + + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + permissions: { + shellEnvironmentPolicy: { + set: { + NEW_KEY: 'new', + }, + fallback: null, + }, + }, + }, + }); + await core.sync(); + + const codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('set = { KEEP = "yes", NEW_KEY = "new", GEMINI_API_KEY = "from-local" }'); + expect(codex).toContain('fallback = ""'); + }); + + it('handles empty and invalid pre-existing Codex set entries', async () => { + mkdirSync('.codex', { recursive: true }); + writeFileSync('.env.local', 'GEMINI_API_KEY=from-local\n'); + + writeFileSync('.codex/config.toml', ''); + await new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + }, + }).sync(); + let codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('set = { GEMINI_API_KEY = "from-local" }'); + + writeFileSync('.codex/config.toml', [ + '[shell_environment_policy]', + 'include_only = ["PATH"]', + ].join('\n')); + await new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + }, + }).sync(); + codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('set = { GEMINI_API_KEY = "from-local" }'); + + writeFileSync('.codex/config.toml', [ + '[shell_environment_policy]', + 'set = {}', + ].join('\n')); + await new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + }, + }).sync(); + codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('set = { GEMINI_API_KEY = "from-local" }'); + + writeFileSync('.codex/config.toml', [ + '[shell_environment_policy]', + 'set = "invalid"', + ].join('\n')); + await new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + env: ['GEMINI_API_KEY'], + }, + }).sync(); + codex = readFileSync('.codex/config.toml', 'utf-8'); + expect(codex).toContain('set = { GEMINI_API_KEY = "from-local" }'); + }); + + it('skips Codex writes when no supported settings are provided and warns about unknown keys', async () => { + const previousWarn = console.warn; + const warnings: string[] = []; + console.warn = (message?: unknown) => { + warnings.push(String(message)); + }; + + try { + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['codex'], + permissions: { + unknown: true, + }, + }, + }); + await core.sync(); + } finally { + console.warn = previousWarn; + } + + expect(existsSync('.codex/config.toml')).toBe(false); + expect(warnings.some(w => w.includes('not mapped for codex') && w.includes('unknown'))).toBe(true); + }); + + it('warns for missing env keys and does not create files when nothing resolves', async () => { + const previousWarn = console.warn; + const warnings: string[] = []; + console.warn = (message?: unknown) => { + warnings.push(String(message)); + }; + + try { + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['claude'], + env: ['GEMINI_API_KEY'], + }, + }); + await core.sync(); + } finally { + console.warn = previousWarn; + } + + expect(warnings.some(w => w.includes("Settings env key 'GEMINI_API_KEY' was not found"))).toBe(true); + expect(existsSync('.claude/settings.local.json')).toBe(false); + }); + + it('refuses to write env values into git-tracked settings files', async () => { + try { + execSync('git --version', { stdio: 'ignore' }); + } catch { + return; + } + + execSync('git init', { stdio: 'ignore' }); + mkdirSync('.claude', { recursive: true }); + writeFileSync( + '.claude/settings.local.json', + JSON.stringify( + { + env: { + SAFE: 'keep', + }, + }, + null, + 2 + ) + ); + execSync('git add .claude/settings.local.json', { stdio: 'ignore' }); + writeFileSync('.env.local', 'GEMINI_API_KEY=secret\n'); + + const before = readFileSync('.claude/settings.local.json', 'utf-8'); + const core = new AIRulesCore({ + rules: [], + settings: { + targets: ['claude'], + env: ['GEMINI_API_KEY'], + }, + }); + + await expect(core.sync()).rejects.toThrow('Refusing to write env values into git-tracked settings files'); + const after = readFileSync('.claude/settings.local.json', 'utf-8'); + expect(after).toBe(before); + }); +}); diff --git a/tests/unit/types.test.ts b/tests/unit/types.test.ts index c95d14e..b6ec789 100644 --- a/tests/unit/types.test.ts +++ b/tests/unit/types.test.ts @@ -247,4 +247,95 @@ describe('defineRules', () => { expect(config.rules[0]?.targets.length).toBe(6); }); + + it('accepts valid settings config', () => { + const config = defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: { + targets: ['claude', 'codex'], + env: ['GEMINI_API_KEY'], + envFiles: ['.env', '.env.local'], + permissions: { allow: ['Read'] }, + merge: true + } + }); + + expect(config.settings?.targets).toEqual(['claude', 'codex']); + }); + + it('rejects invalid settings targets', () => { + expect(() => { + defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: { + targets: ['factory'] as unknown as NonNullable['targets'] + } + }); + }).toThrow('Config.settings.targets must contain valid agents'); + }); + + it('rejects invalid settings env and permissions shapes', () => { + expect(() => { + defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: { + env: [123] as unknown as string[] + } + }); + }).toThrow('Config.settings.env must be an array of strings'); + + expect(() => { + defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: { + permissions: 'bad' as unknown as Record + } + }); + }).toThrow('Config.settings.permissions must be an object'); + }); + + it('rejects non-object settings', () => { + expect(() => { + defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: 'bad' as unknown as NonNullable + }); + }).toThrow('Config.settings must be an object'); + }); + + it('rejects invalid settings envFiles', () => { + expect(() => { + defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: { + envFiles: [123] as unknown as string[] + } + }); + }).toThrow('Config.settings.envFiles must be an array of strings'); + }); + + it('rejects invalid settings merge value', () => { + expect(() => { + defineRules({ + rules: [ + { file: 'a.md', to: './', targets: ['claude'] } + ], + settings: { + merge: 'yes' as unknown as boolean + } + }); + }).toThrow('Config.settings.merge must be a boolean'); + }); });