feat(settings): add Project Rules (CLAUDE.md) management UI (#72)

* feat(settings): add Project Rules (CLAUDE.md) management UI

Add a new "Project Rules" tab to the desktop settings page that lets users
view, create, and open their CLAUDE.md files directly from the UI. This
provides a visual entry point for managing project-specific instructions
that are auto-injected into every conversation.

- New settings tab "Project Rules" with i18n across all 5 locales
- ProjectRulesSettings page showing project-level and user-level CLAUDE.md
- Server API: GET/POST /api/project-rules for file status and creation
- "Open" button launches the file in the system editor
- "Create" button scaffolds a template CLAUDE.md if it doesn't exist

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: resolve TypeScript errors in ProjectRulesSettings

- Remove unused 'query' variable
- Fix translation function type to use TranslationKey

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
小橙子 2026-06-18 01:49:43 +08:00 committed by GitHub
parent d4df7e2109
commit 5c612bd967
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 437 additions and 0 deletions

View File

@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest'
import { ProjectRulesSettings } from '../pages/ProjectRulesSettings'
describe('ProjectRulesSettings', () => {
it('exports ProjectRulesSettings component', () => {
expect(typeof ProjectRulesSettings).toBe('function')
})
})

View File

@ -181,6 +181,18 @@ export const en = {
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': 'Plugins',
'settings.tab.diagnostics': 'Diagnostics',
'settings.tab.projectRules': 'Project Rules',
// Settings > Project Rules
'settings.projectRules.title': 'Project Rules (CLAUDE.md)',
'settings.projectRules.description': 'CLAUDE.md files define project-specific instructions that are automatically loaded into every conversation. They control coding style, tool preferences, and project conventions.',
'settings.projectRules.projectFile': 'Project rules',
'settings.projectRules.projectFileDesc': 'Rules for the current project directory (.claude/CLAUDE.md)',
'settings.projectRules.userFile': 'User rules',
'settings.projectRules.userFileDesc': 'Global rules applied to all projects (~/.claude/CLAUDE.md)',
'settings.projectRules.open': 'Open',
'settings.projectRules.create': 'Create',
'settings.projectRules.notFound': 'File does not exist yet',
// Settings > Usage
'settings.activity.title': 'Token usage',

View File

@ -183,6 +183,16 @@ export const jp: Record<TranslationKey, string> = {
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': 'プラグイン',
'settings.tab.diagnostics': '診断',
'settings.tab.projectRules': 'プロジェクトルール',
'settings.projectRules.title': 'プロジェクトルール (CLAUDE.md)',
'settings.projectRules.description': 'CLAUDE.mdファイルはプロジェクト固有の指示を定義し、会話開始時に自動的に読み込まれます。コーディングスタイル、ツール設定、プロジェクト規約を制御します。',
'settings.projectRules.projectFile': 'プロジェクトルール',
'settings.projectRules.projectFileDesc': '現在のプロジェクトディレクトリのルール (.claude/CLAUDE.md)',
'settings.projectRules.userFile': 'ユーザールール',
'settings.projectRules.userFileDesc': 'すべてのプロジェクトに適用されるグローバルルール (~/.claude/CLAUDE.md)',
'settings.projectRules.open': '開く',
'settings.projectRules.create': '作成',
'settings.projectRules.notFound': 'ファイルがまだ存在しません',
// Settings > Usage
'settings.activity.title': 'トークン使用量',

View File

@ -183,6 +183,16 @@ export const kr: Record<TranslationKey, string> = {
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': '플러그인',
'settings.tab.diagnostics': '진단',
'settings.tab.projectRules': '프로젝트 규칙',
'settings.projectRules.title': '프로젝트 규칙 (CLAUDE.md)',
'settings.projectRules.description': 'CLAUDE.md 파일은 프로젝트별 지침을 정의하며, 대화 시작 시 자동으로 로드됩니다. 코딩 스타일, 도구 설정 및 프로젝트 규약을 제어합니다.',
'settings.projectRules.projectFile': '프로젝트 규칙',
'settings.projectRules.projectFileDesc': '현재 프로젝트 디렉토리의 규칙 (.claude/CLAUDE.md)',
'settings.projectRules.userFile': '사용자 규칙',
'settings.projectRules.userFileDesc': '모든 프로젝트에 적용되는 전역 규칙 (~/.claude/CLAUDE.md)',
'settings.projectRules.open': '열기',
'settings.projectRules.create': '생성',
'settings.projectRules.notFound': '파일이 아직 존재하지 않습니다',
// Settings > Usage
'settings.activity.title': '토큰 사용량',

View File

@ -183,6 +183,16 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': '外掛',
'settings.tab.diagnostics': '診斷',
'settings.tab.projectRules': '專案規則',
'settings.projectRules.title': '專案規則 (CLAUDE.md)',
'settings.projectRules.description': 'CLAUDE.md 檔案定義了專案特定的指令,每次對話開始時自動載入。它們控制編碼風格、工具偏好和專案約定。',
'settings.projectRules.projectFile': '專案規則',
'settings.projectRules.projectFileDesc': '當前專案目錄的規則 (.claude/CLAUDE.md)',
'settings.projectRules.userFile': '使用者規則',
'settings.projectRules.userFileDesc': '套用於所有專案的全域規則 (~/.claude/CLAUDE.md)',
'settings.projectRules.open': '開啟',
'settings.projectRules.create': '建立',
'settings.projectRules.notFound': '檔案尚不存在',
// Settings > Usage
'settings.activity.title': 'Token 用量',

View File

@ -183,6 +183,18 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.mcp': 'MCP',
'settings.tab.plugins': '插件',
'settings.tab.diagnostics': '诊断',
'settings.tab.projectRules': '项目规则',
// Settings > Project Rules
'settings.projectRules.title': '项目规则 (CLAUDE.md)',
'settings.projectRules.description': 'CLAUDE.md 文件定义了项目特定的指令,每次对话开始时自动加载。它们控制编码风格、工具偏好和项目约定。',
'settings.projectRules.projectFile': '项目规则',
'settings.projectRules.projectFileDesc': '当前项目目录的规则 (.claude/CLAUDE.md)',
'settings.projectRules.userFile': '用户规则',
'settings.projectRules.userFileDesc': '应用于所有项目的全局规则 (~/.claude/CLAUDE.md)',
'settings.projectRules.open': '打开',
'settings.projectRules.create': '创建',
'settings.projectRules.notFound': '文件尚不存在',
// Settings > Usage
'settings.activity.title': 'Token 用量',

View File

@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { useTranslation, type TranslationKey } from '../i18n'
import { Button } from '../components/shared/Button'
import { useSessionStore } from '../stores/sessionStore'
import { getDesktopHost } from '../lib/desktopHost'
import { api } from '../api/client'
type RulesFileStatus = {
path: string
exists: boolean
}
type ProjectRulesResponse = {
projectFile: RulesFileStatus | null
userFile: RulesFileStatus
}
export function ProjectRulesSettings() {
const t = useTranslation()
const [rules, setRules] = useState<ProjectRulesResponse | null>(null)
const [loading, setLoading] = useState(true)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeSession = sessions.find((s) => s.id === activeSessionId)
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
const fetchRules = async () => {
setLoading(true)
try {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
const res = await api.get<ProjectRulesResponse>(`/api/project-rules${query}`)
setRules(res)
} catch {
setRules(null)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchRules()
}, [cwd])
const handleOpen = async (path: string) => {
try {
await getDesktopHost().shell.openPath(path)
} catch {
// fallback: ignore
}
}
const handleCreate = async (scope: 'project' | 'user') => {
try {
await api.post(`/api/project-rules/create`, { scope, cwd })
await fetchRules()
} catch {
// ignore
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<span className="material-symbols-outlined animate-spin text-[var(--color-text-muted)]">progress_activity</span>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-[var(--color-text)]">{t('settings.projectRules.title')}</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t('settings.projectRules.description')}</p>
</div>
{/* Project-level CLAUDE.md */}
{rules?.projectFile && (
<RuleFileCard
title={t('settings.projectRules.projectFile')}
description={t('settings.projectRules.projectFileDesc')}
path={rules.projectFile.path}
exists={rules.projectFile.exists}
onOpen={() => handleOpen(rules.projectFile!.path)}
onCreate={() => handleCreate('project')}
t={t}
/>
)}
{/* User-level CLAUDE.md */}
{rules?.userFile && (
<RuleFileCard
title={t('settings.projectRules.userFile')}
description={t('settings.projectRules.userFileDesc')}
path={rules.userFile.path}
exists={rules.userFile.exists}
onOpen={() => handleOpen(rules.userFile.path)}
onCreate={() => handleCreate('user')}
t={t}
/>
)}
</div>
)
}
function RuleFileCard({
title,
description,
path,
exists,
onOpen,
onCreate,
t,
}: {
title: string
description: string
path: string
exists: boolean
onOpen: () => void
onCreate: () => void
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}) {
return (
<div className="border border-[var(--color-border)] rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[var(--color-text)]">{title}</h3>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">{description}</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1 font-mono truncate" title={path}>{path}</p>
</div>
<div className="ml-4 flex-shrink-0">
{exists ? (
<Button size="sm" variant="secondary" onClick={onOpen}>
<span className="material-symbols-outlined text-base mr-1">open_in_new</span>
{t('settings.projectRules.open')}
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-muted)]">{t('settings.projectRules.notFound')}</span>
<Button size="sm" variant="primary" onClick={onCreate}>
<span className="material-symbols-outlined text-base mr-1">add</span>
{t('settings.projectRules.create')}
</Button>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -49,6 +49,7 @@ import { DiagnosticsSettings } from './DiagnosticsSettings'
import { TraceList } from './TraceList'
import { ActivitySettings } from './ActivitySettings'
import { MemorySettings } from './MemorySettings'
import { ProjectRulesSettings } from './ProjectRulesSettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
@ -207,6 +208,7 @@ export function Settings() {
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton icon="history_edu" label={t('settings.tab.memory')} active={activeTab === 'memory'} onClick={() => setActiveTab('memory')} />
<TabButton icon="description" label={t('settings.tab.projectRules')} active={activeTab === 'projectRules'} onClick={() => setActiveTab('projectRules')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
@ -230,6 +232,7 @@ export function Settings() {
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'memory' && <MemorySettings />}
{activeTab === 'projectRules' && <ProjectRulesSettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'trace' && <TraceList />}

View File

@ -80,6 +80,7 @@ export type SettingsTab =
| 'computerUse'
| 'trace'
| 'diagnostics'
| 'projectRules'
| 'about'
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'

View File

@ -0,0 +1,121 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test'
import * as path from 'path'
// Mock dependencies
mock.module('../../utils/envUtils.js', () => ({
getClaudeConfigHomeDir: () => '/mock/home/.claude',
}))
mock.module('../../utils/cwd.js', () => ({
getCwd: () => '/mock/project',
}))
const mockFiles = new Set<string>()
mock.module('fs/promises', () => ({
access: async (filePath: string) => {
if (!mockFiles.has(filePath)) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
},
mkdir: async () => {},
writeFile: async (filePath: string) => {
mockFiles.add(filePath)
},
}))
import { handleProjectRulesApi } from '../api/project-rules'
function makeReq(method: string, body?: unknown): Request {
return new Request('http://localhost/api/project-rules', {
method,
...(body ? { body: JSON.stringify(body) } : {}),
})
}
describe('project-rules API', () => {
beforeEach(() => {
mockFiles.clear()
})
it('GET /api/project-rules returns file paths with existence status', async () => {
const url = new URL('http://localhost/api/project-rules?cwd=/mock/project')
const res = await handleProjectRulesApi(
makeReq('GET'),
url,
['api', 'project-rules'],
)
const data = await res.json() as { projectFile: { path: string; exists: boolean }; userFile: { path: string; exists: boolean } }
expect(data.projectFile.path).toContain('CLAUDE.md')
expect(data.projectFile.exists).toBe(false)
expect(data.userFile.path).toContain('CLAUDE.md')
expect(data.userFile.exists).toBe(false)
})
it('GET /api/project-rules shows existing files', async () => {
const projectPath = path.join('/mock/project', '.claude', 'CLAUDE.md')
mockFiles.add(projectPath)
const url = new URL('http://localhost/api/project-rules?cwd=/mock/project')
const res = await handleProjectRulesApi(
makeReq('GET'),
url,
['api', 'project-rules'],
)
const data = await res.json() as { projectFile: { path: string; exists: boolean }; userFile: { path: string; exists: boolean } }
expect(data.projectFile.exists).toBe(true)
expect(data.userFile.exists).toBe(false)
})
it('POST /api/project-rules/create creates user file', async () => {
const url = new URL('http://localhost/api/project-rules/create')
const res = await handleProjectRulesApi(
new Request('http://localhost/api/project-rules/create', {
method: 'POST',
body: JSON.stringify({ scope: 'user' }),
}),
url,
['api', 'project-rules', 'create'],
)
const data = await res.json() as { ok: boolean; created: boolean; path: string }
expect(data.ok).toBe(true)
expect(data.created).toBe(true)
expect(data.path).toContain('CLAUDE.md')
})
it('POST /api/project-rules/create with invalid scope returns 400', async () => {
const url = new URL('http://localhost/api/project-rules/create')
const res = await handleProjectRulesApi(
new Request('http://localhost/api/project-rules/create', {
method: 'POST',
body: JSON.stringify({ scope: 'invalid' }),
}),
url,
['api', 'project-rules', 'create'],
)
expect(res.status).toBe(400)
})
it('POST /api/project-rules/create does not overwrite existing file', async () => {
const userPath = path.join('/mock/home/.claude', 'CLAUDE.md')
mockFiles.add(userPath)
const url = new URL('http://localhost/api/project-rules/create')
const res = await handleProjectRulesApi(
new Request('http://localhost/api/project-rules/create', {
method: 'POST',
body: JSON.stringify({ scope: 'user' }),
}),
url,
['api', 'project-rules', 'create'],
)
const data = await res.json() as { ok: boolean; created: boolean }
expect(data.ok).toBe(true)
expect(data.created).toBe(false)
})
})

View File

@ -0,0 +1,97 @@
/**
* Project Rules (CLAUDE.md) REST API
*
* GET /api/project-rules Get CLAUDE.md file paths and existence status
* POST /api/project-rules/create Create a CLAUDE.md file if it doesn't exist
*/
import * as path from 'path'
import * as fs from 'fs/promises'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getCwd } from '../../utils/cwd.js'
export async function handleProjectRulesApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
const sub = segments[2]
if (req.method === 'GET' && !sub) {
return await getProjectRules(url)
}
if (req.method === 'POST' && sub === 'create') {
return await createProjectRulesFile(req, url)
}
return Response.json(
{ error: 'Not Found', message: `Unknown project-rules endpoint` },
{ status: 404 },
)
}
async function getProjectRules(url: URL): Promise<Response> {
const cwd = url.searchParams.get('cwd') || getCwd()
const userFile = path.join(getClaudeConfigHomeDir(), 'CLAUDE.md')
const projectFile = path.join(cwd, '.claude', 'CLAUDE.md')
const [userExists, projectExists] = await Promise.all([
fileExists(userFile),
fileExists(projectFile),
])
return Response.json({
projectFile: { path: projectFile, exists: projectExists },
userFile: { path: userFile, exists: userExists },
})
}
async function createProjectRulesFile(req: Request, url: URL): Promise<Response> {
let body: { scope?: string; cwd?: string }
try {
body = await req.json() as { scope?: string; cwd?: string }
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const scope = body.scope
const cwd = body.cwd || url.searchParams.get('cwd') || getCwd()
let filePath: string
if (scope === 'project') {
filePath = path.join(cwd, '.claude', 'CLAUDE.md')
} else if (scope === 'user') {
filePath = path.join(getClaudeConfigHomeDir(), 'CLAUDE.md')
} else {
return Response.json({ error: 'Invalid scope, must be "project" or "user"' }, { status: 400 })
}
// Don't overwrite existing files
if (await fileExists(filePath)) {
return Response.json({ ok: true, path: filePath, created: false })
}
// Create directory if needed
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
// Create file with template content
const template = scope === 'project'
? '# Project Rules\n\n<!-- Add project-specific instructions here. These are loaded into every conversation. -->\n'
: '# User Rules\n\n<!-- Add global instructions here. These apply to all projects. -->\n'
await fs.writeFile(filePath, template, 'utf-8')
return Response.json({ ok: true, path: filePath, created: true })
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}

View File

@ -29,6 +29,7 @@ import { handleMemoryApi } from './api/memory.js'
import { handleDesktopUiApi } from './api/desktop-ui.js'
import { handleProjectsApi } from './api/projects.js'
import { handleTracesApi } from './api/traces.js'
import { handleProjectRulesApi } from './api/project-rules.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -118,6 +119,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'memory':
return handleMemoryApi(req, url, segments)
case 'project-rules':
return handleProjectRulesApi(req, url, segments)
case 'desktop-ui':
return handleDesktopUiApi(req, url, segments)