From 8c0275f91197871357926a6c0adfb10bbed9078c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 23:18:08 +0900 Subject: [PATCH 1/2] feat(config): implement unified configuration system Implement a unified configuration system replacing legacy config file names: - Config location: .please/config.json or .please/config.yml - Added LSP configuration schema with server enable/disable and custom root paths - Added shared settings: language (en/ko) and ignore_patterns - LSPManager now supports config via constructor or static fromProject() method - Updated format loader to search new config location - Removed legacy opencode.json and dora.json references Changes: - packages/format/src/config/loader.ts: Updated search paths - packages/format/src/config/schema.ts: Added LspConfigSchema and unified Config - packages/format/package.json: Added subpath export for config - packages/lsp/src/config.ts: New module with LSP config utilities - packages/lsp/src/index.ts: Integrated config loading - packages/lsp/package.json: Added @pleaseai/code-format dependency - CLAUDE.md: Added configuration documentation - bun.lock: Updated dependencies --- CLAUDE.md | 47 +++++++++++++++++++++- bun.lock | 7 ++-- packages/format/package.json | 3 +- packages/format/src/config/loader.ts | 30 ++++++++++++-- packages/format/src/config/schema.ts | 32 ++++++++++++++- packages/lsp/package.json | 1 + packages/lsp/src/config.ts | 60 ++++++++++++++++++++++++++++ packages/lsp/src/index.ts | 31 +++++++++++++- 8 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 packages/lsp/src/config.ts diff --git a/CLAUDE.md b/CLAUDE.md index fa93567..f367ed8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ packages/code/ packages/format/ ├── index.ts # Public API ├── formatter.ts # Formatter definitions (biome, prettier, etc.) -└── config/ # Config loading (dora.json, opencode.json) +└── config/ # Config loading (.please/config.json or .please/config.yml) ``` ### packages/lsp @@ -91,6 +91,7 @@ packages/lsp/ ├── index.ts # Public API ├── client.ts # LSP client implementation ├── server.ts # LSP server definitions +├── config.ts # LSP config from unified config file └── language.ts # Language detection ``` @@ -133,6 +134,50 @@ Claude/MCP Client <-> StdioTransport <-> McpServer <-> Providers biome, prettier, gofmt, mix (Elixir), zig fmt, clang-format, ktlint (Kotlin), ruff, air (R), uv format, rubocop, standardrb, htmlbeautifier, dart, ocamlformat, terraform, latexindent, gleam, prisma +### Configuration + +Configuration is loaded from `.please/config.json` or `.please/config.yml` in the project root. + +```yaml +# .please/config.yml + +# Shared settings +language: en +ignore_patterns: + - node_modules + - dist + +# Formatter settings +formatter: + biome: + command: ["biome", "format", "--write", "$FILE"] + extensions: [".ts", ".tsx", ".js", ".jsx"] + prettier: + disabled: true # Disable a built-in formatter + +# LSP settings +lsp: + typescript: + enabled: true + vue: + enabled: false # Disable a specific LSP server + pyright: + root: "./backend" # Custom root path +``` + +**Formatter Config:** +- `disabled: true` - Disable a built-in formatter +- `command: [...]` - Override command (use `$FILE` placeholder) +- `extensions: [...]` - Override file extensions +- `environment: {...}` - Environment variables + +**LSP Config:** +- `enabled: true/false` - Enable/disable specific LSP servers +- `root: "path"` - Custom project root path +- `command: [...]` - Custom spawn command + +Set `lsp: false` or `formatter: false` to globally disable. + ### Name Path Convention (JetBrains) Symbols are identified by "name paths" - paths in the symbol tree within a source file: diff --git a/bun.lock b/bun.lock index 1e8f66b..9651f70 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/code": { "name": "@pleaseai/code", - "version": "0.1.3", + "version": "0.1.4", "bin": { "code": "src/cli.ts", }, @@ -38,7 +38,7 @@ }, "packages/dora": { "name": "@pleaseai/dora", - "version": "0.1.2", + "version": "0.1.3", "bin": { "dora": "src/cli.ts", }, @@ -65,8 +65,9 @@ }, "packages/lsp": { "name": "@pleaseai/code-lsp", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { + "@pleaseai/code-format": "workspace:*", "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-types": "^3.17.5", "zod": "^3.25.32", diff --git a/packages/format/package.json b/packages/format/package.json index 3b88eb9..908e3c8 100644 --- a/packages/format/package.json +++ b/packages/format/package.json @@ -16,7 +16,8 @@ "ai-coding" ], "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./config": "./src/config/index.ts" }, "main": "src/index.ts", "files": [ diff --git a/packages/format/src/config/loader.ts b/packages/format/src/config/loader.ts index 0adc2d5..17f5305 100644 --- a/packages/format/src/config/loader.ts +++ b/packages/format/src/config/loader.ts @@ -7,12 +7,9 @@ import { ConfigSchema, defaultConfig } from './schema' * Configuration file names to search for (in order of priority) */ const CONFIG_FILES = [ - 'opencode.json', - 'dora.json', + '.please/config.json', '.please/config.yml', '.please/config.yaml', - '.dora/config.yml', - '.dora/config.yaml', ] /** @@ -99,6 +96,15 @@ export async function loadConfig(projectDir: string): Promise { export function mergeConfig(base: Config, source: Partial): Config { const merged: Config = { ...base } + // Merge shared settings + if (source.language !== undefined) { + merged.language = source.language + } + if (source.ignore_patterns !== undefined) { + merged.ignore_patterns = source.ignore_patterns + } + + // Merge formatter config if (source.formatter !== undefined) { if (source.formatter === false) { merged.formatter = false @@ -114,5 +120,21 @@ export function mergeConfig(base: Config, source: Partial): Config { } } + // Merge LSP config + if (source.lsp !== undefined) { + if (source.lsp === false) { + merged.lsp = false + } + else if (base.lsp === false) { + merged.lsp = source.lsp + } + else { + merged.lsp = { + ...(base.lsp ?? {}), + ...source.lsp, + } + } + } + return merged } diff --git a/packages/format/src/config/schema.ts b/packages/format/src/config/schema.ts index 1a67a5f..6ea508f 100644 --- a/packages/format/src/config/schema.ts +++ b/packages/format/src/config/schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod' /** * Formatter configuration schema - * Compatible with opencode.json and .please/config.yml + * Compatible with .please/config.json and .please/config.yml */ export const FormatterItemSchema = z.object({ /** Disable this specific formatter */ @@ -25,11 +25,38 @@ export const FormatterConfigSchema = z.union([ export type FormatterConfig = z.infer /** - * Dora configuration schema + * LSP server configuration schema + */ +export const LspItemSchema = z.object({ + /** Enable/disable this LSP server */ + enabled: z.boolean().optional(), + /** Custom project root path for this server */ + root: z.string().optional(), + /** Custom command to spawn the server */ + command: z.array(z.string()).optional(), +}) + +export type LspItem = z.infer + +export const LspConfigSchema = z.union([ + z.literal(false), + z.record(z.string(), LspItemSchema), +]) + +export type LspConfig = z.infer + +/** + * Unified configuration schema */ export const ConfigSchema = z.object({ + /** Language for messages (en or ko) */ + language: z.enum(['en', 'ko']).optional(), + /** Patterns to ignore */ + ignore_patterns: z.array(z.string()).optional(), /** Formatter configuration */ formatter: FormatterConfigSchema.optional(), + /** LSP configuration */ + lsp: LspConfigSchema.optional(), }) export type Config = z.infer @@ -39,4 +66,5 @@ export type Config = z.infer */ export const defaultConfig: Config = { formatter: {}, + lsp: {}, } diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 57d0f34..fa9aedd 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -30,6 +30,7 @@ "test:integration": "bun test ./test/integration" }, "dependencies": { + "@pleaseai/code-format": "workspace:*", "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-types": "^3.17.5", "zod": "^3.25.32" diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts new file mode 100644 index 0000000..cdb3050 --- /dev/null +++ b/packages/lsp/src/config.ts @@ -0,0 +1,60 @@ +/** + * LSP Configuration + * Loads LSP settings from unified config file (.please/config.json or .please/config.yml) + */ + +import type { LspConfig, LspItem } from '@pleaseai/code-format/config' +import { loadConfig } from '@pleaseai/code-format/config' + +export type { LspConfig, LspItem } + +/** + * Load LSP configuration from project directory + */ +export async function loadLspConfig(projectDir: string): Promise { + const config = await loadConfig(projectDir) + return config.lsp +} + +/** + * Check if a server is enabled in config + * Returns true if: + * - Config is undefined (default: all enabled) + * - Config is not false (globally disabled) + * - Server is not explicitly disabled + */ +export function isServerEnabled(config: LspConfig | undefined, serverId: string): boolean { + // No config or empty config means all servers enabled + if (config === undefined || config === false) { + return config !== false + } + + const serverConfig = config[serverId] + if (!serverConfig) { + return true // Not configured means enabled + } + + return serverConfig.enabled !== false +} + +/** + * Get custom root path for a server + */ +export function getServerRoot(config: LspConfig | undefined, serverId: string): string | undefined { + if (!config || typeof config === 'boolean') { + return undefined + } + + return config[serverId]?.root +} + +/** + * Get custom command for a server + */ +export function getServerCommand(config: LspConfig | undefined, serverId: string): string[] | undefined { + if (!config || typeof config === 'boolean') { + return undefined + } + + return config[serverId]?.command +} diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index 1f8ba5f..065c6ea 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -5,6 +5,7 @@ * Based on opencode reference implementation */ +import type { LspConfig } from '@pleaseai/code-format/config' import type { Diagnostic as VSCodeDiagnostic } from 'vscode-languageserver-types' import type { LSPClientInfo, LSPServerHandle } from './client' import type { LSPServerInfo } from './server' @@ -15,6 +16,7 @@ import { createLSPClient, } from './client' +import { isServerEnabled, loadLspConfig } from './config' import { LSP_SERVERS } from './server' export type Diagnostic = VSCodeDiagnostic @@ -230,10 +232,12 @@ export class LSPManager { private projectPath: string private enabled: boolean = true + private lspConfig: LspConfig | undefined - constructor(projectPath: string, options?: { enabled?: boolean }) { + constructor(projectPath: string, options?: { enabled?: boolean, lspConfig?: LspConfig }) { this.projectPath = projectPath this.enabled = options?.enabled ?? true + this.lspConfig = options?.lspConfig // Register servers for (const server of LSP_SERVERS) { @@ -241,6 +245,18 @@ export class LSPManager { } } + /** + * Create LSPManager with config loaded from project + */ + static async fromProject(projectPath: string, options?: { enabled?: boolean }): Promise { + const lspConfig = await loadLspConfig(projectPath) + const managerOptions: { enabled?: boolean, lspConfig?: LspConfig } = { ...options } + if (lspConfig !== undefined) { + managerOptions.lspConfig = lspConfig + } + return new LSPManager(projectPath, managerOptions) + } + /** * Get status of connected LSP servers */ @@ -312,6 +328,11 @@ export class LSPManager { } for (const server of this.servers.values()) { + // Check if server is enabled in config + if (!isServerEnabled(this.lspConfig, server.id)) { + continue + } + if ( server.extensions.length && !server.extensions.includes(extension) @@ -887,6 +908,14 @@ export function formatDiagnostic(diagnostic: Diagnostic): string { } export { createLSPClient, type LSPClientInfo } from './client' +export { + getServerCommand, + getServerRoot, + isServerEnabled, + loadLspConfig, + type LspConfig, + type LspItem, +} from './config' // Re-export specific items to avoid conflicts export { getLanguageId, LANGUAGE_EXTENSIONS } from './language' export { From 482e3af01f5fe11ce3bc13bc7be12e8b7877846c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Dec 2025 00:04:16 +0900 Subject: [PATCH 2/2] fix(config): improve error handling and config validation - Add ConfigValidationError and ConfigLoadError custom error classes - Throw errors instead of silently falling back to defaults - Only catch SyntaxError in JSON/YAML parsing to avoid swallowing errors - Add proper error context for file read/parse operations - Add min(1) validation to LspItemSchema for root and command fields - Clarify isServerEnabled logic with explicit boolean checks - Integrate getServerRoot into LSPManager for custom root paths - Update packages/lsp/CLAUDE.md with config documentation - Use stderr for config loading message to avoid JSON output interference --- packages/format/src/config/loader.ts | 86 +++++++++++++++++++++++++--- packages/format/src/config/schema.ts | 8 +-- packages/lsp/CLAUDE.md | 40 ++++++++++++- packages/lsp/src/config.ts | 18 ++++-- packages/lsp/src/index.ts | 8 ++- 5 files changed, 140 insertions(+), 20 deletions(-) diff --git a/packages/format/src/config/loader.ts b/packages/format/src/config/loader.ts index 17f5305..97f419d 100644 --- a/packages/format/src/config/loader.ts +++ b/packages/format/src/config/loader.ts @@ -3,6 +3,34 @@ import path from 'node:path' import YAML from 'yaml' import { ConfigSchema, defaultConfig } from './schema' +/** + * Error thrown when configuration validation fails + */ +export class ConfigValidationError extends Error { + constructor( + message: string, + public readonly filePath: string, + public readonly issues: Array<{ path: string[], message: string }>, + ) { + super(message) + this.name = 'ConfigValidationError' + } +} + +/** + * Error thrown when configuration file cannot be read or parsed + */ +export class ConfigLoadError extends Error { + constructor( + message: string, + public readonly filePath: string, + public readonly cause?: unknown, + ) { + super(message) + this.name = 'ConfigLoadError' + } +} + /** * Configuration file names to search for (in order of priority) */ @@ -47,17 +75,24 @@ function parseConfigContent(content: string, filePath: string): unknown { if (ext === '.yml' || ext === '.yaml') { return YAML.parse(content) } - // Try JSON first, then YAML + // Try JSON first, then YAML (only catch syntax errors) try { return JSON.parse(content) } - catch { - return YAML.parse(content) + catch (err) { + if (err instanceof SyntaxError) { + // JSON syntax error - try YAML as fallback + return YAML.parse(content) + } + // Re-throw unexpected errors (memory, stack overflow, etc.) + throw err } } /** * Load configuration from a specific file + * @throws {ConfigLoadError} If file cannot be read or parsed + * @throws {ConfigValidationError} If configuration fails validation */ export async function loadConfigFromFile(filePath: string): Promise { const file = Bun.file(filePath) @@ -65,13 +100,45 @@ export async function loadConfigFromFile(filePath: string): Promise { return defaultConfig } - const content = await file.text() - const parsed = parseConfigContent(content, filePath) - const result = ConfigSchema.safeParse(parsed) + // Read file with error context + let content: string + try { + content = await file.text() + } + catch (err) { + throw new ConfigLoadError( + `Failed to read configuration file: ${err instanceof Error ? err.message : String(err)}`, + filePath, + err, + ) + } + // Parse file with error context + let parsed: unknown + try { + parsed = parseConfigContent(content, filePath) + } + catch (err) { + throw new ConfigLoadError( + `Failed to parse configuration file: ${err instanceof Error ? err.message : String(err)}`, + filePath, + err, + ) + } + + // Validate configuration - throw on validation errors + const result = ConfigSchema.safeParse(parsed) if (!result.success) { - console.error(`[config] Invalid configuration in ${filePath}:`, result.error.message) - return defaultConfig + const issues = result.error.issues.map(i => ({ + path: i.path.map(String), + message: i.message, + })) + const issueMessages = issues.map(i => ` - ${i.path.join('.')}: ${i.message}`).join('\n') + throw new ConfigValidationError( + `Invalid configuration in ${filePath}:\n${issueMessages}`, + filePath, + issues, + ) } return result.data @@ -79,6 +146,8 @@ export async function loadConfigFromFile(filePath: string): Promise { /** * Load configuration by searching from projectDir upward + * @throws {ConfigLoadError} If file cannot be read or parsed + * @throws {ConfigValidationError} If configuration fails validation */ export async function loadConfig(projectDir: string): Promise { const configPath = await findConfigFile(projectDir) @@ -86,6 +155,7 @@ export async function loadConfig(projectDir: string): Promise { return defaultConfig } + // Use stderr to avoid interfering with JSON output on stdout console.error(`[config] Loading configuration from ${configPath}`) return loadConfigFromFile(configPath) } diff --git a/packages/format/src/config/schema.ts b/packages/format/src/config/schema.ts index 6ea508f..48066ab 100644 --- a/packages/format/src/config/schema.ts +++ b/packages/format/src/config/schema.ts @@ -30,10 +30,10 @@ export type FormatterConfig = z.infer export const LspItemSchema = z.object({ /** Enable/disable this LSP server */ enabled: z.boolean().optional(), - /** Custom project root path for this server */ - root: z.string().optional(), - /** Custom command to spawn the server */ - command: z.array(z.string()).optional(), + /** Custom project root path for this server (must be non-empty if provided) */ + root: z.string().min(1, 'root path cannot be empty').optional(), + /** Custom command to spawn the server (must have at least one element if provided) */ + command: z.array(z.string()).min(1, 'command must have at least one element').optional(), }) export type LspItem = z.infer diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md index 50f5894..0497894 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -12,6 +12,7 @@ This package provides a unified interface for interacting with multiple language src/ ├── index.ts # Public API, LSPManager class ├── client.ts # LSP client implementation (JSON-RPC) +├── config.ts # LSP config from unified config file ├── server.ts # LSP server definitions └── language.ts # Language ID mapping ``` @@ -81,7 +82,11 @@ async function setupKotlinDependencies(platformId: PlatformId) { Main entry point for managing LSP clients: ```typescript -const manager = new LSPManager(projectPath) +// Create manager with auto-loaded config from .please/config.yml +const manager = await LSPManager.fromProject(projectPath) + +// Or create with explicit config +const manager = new LSPManager(projectPath, { lspConfig: myConfig }) // Touch file to initialize LSP await manager.touchFile('src/index.ts', true) @@ -142,6 +147,38 @@ const server = getServerById('typescript') const servers = getServersForExtension('.ts') ``` +## Configuration + +LSP servers can be configured via `.please/config.json` or `.please/config.yml`: + +```yaml +lsp: + # Disable a specific server + typescript: + enabled: false + + # Use custom root path + pyright: + root: "./backend" + + # Globally disable all LSP servers + # lsp: false +``` + +**Config Options per Server:** +- `enabled: boolean` - Enable/disable server (default: true) +- `root: string` - Custom project root path (must be non-empty) +- `command: string[]` - Custom spawn command (must have at least one element) + +**Config Utilities:** +```typescript +import { isServerEnabled, getServerRoot, loadLspConfig } from '@pleaseai/code-lsp' + +const config = await loadLspConfig(projectDir) +const enabled = isServerEnabled(config, 'typescript') +const customRoot = getServerRoot(config, 'pyright') +``` + ## Testing ```bash @@ -152,3 +189,4 @@ Tests cover: - Server definitions (ID, extensions, root, spawn functions) - LSP client lifecycle - Manager operations +- Config loading and validation diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts index cdb3050..04543ff 100644 --- a/packages/lsp/src/config.ts +++ b/packages/lsp/src/config.ts @@ -24,11 +24,17 @@ export async function loadLspConfig(projectDir: string): Promise