mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
fix(settings): scan all projects, add selectors, fix traversal vuln
Project Rules page: - Rewrite project discovery to mirror memory.ts: scan ~/.claude/projects/ for project IDs, resolve each to a real path via session .jsonl heads (cwd field) + sanitized FS search, using the shared sanitizePath from utils/path.js (the prior naive sanitize never matched real project IDs) - Add project selector dropdown; rules below switch dynamically Skill activation UI: - Center toggle button labels (justify-center + min-w) - Show project selector + active status when scope is "project" Security: - Fix path-traversal write in POST /api/project-rules/create: the new user-controlled filename flowed into path.join unchecked, allowing "../../../x.md" to write outside the project dir. Now reject traversal, absolute paths, and null bytes with 400 via containsPathTraversal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4c9a8f57ca
commit
2f594cf7e3
@ -6,6 +6,7 @@ import { CodeViewer } from '../chat/CodeViewer'
|
|||||||
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
|
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
import { useUIStore } from '../../stores/uiStore'
|
||||||
import { skillsApi } from '../../api/skills'
|
import { skillsApi } from '../../api/skills'
|
||||||
|
import { api } from '../../api/client'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
const META_PRIORITY = [
|
const META_PRIORITY = [
|
||||||
@ -244,20 +245,50 @@ export function SkillDetail() {
|
|||||||
|
|
||||||
type ActivationScope = 'off' | 'global' | 'project'
|
type ActivationScope = 'off' | 'global' | 'project'
|
||||||
|
|
||||||
|
type ProjectChoice = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
projectPath: string | null
|
||||||
|
isCurrent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function SkillActivationScope({ skillName }: { skillName: string }) {
|
function SkillActivationScope({ skillName }: { skillName: string }) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [globalSkills, setGlobalSkills] = useState<string[]>([])
|
const [globalSkills, setGlobalSkills] = useState<string[]>([])
|
||||||
const [projectSkills, setProjectSkills] = useState<string[]>([])
|
const [projectSkills, setProjectSkills] = useState<string[]>([])
|
||||||
|
const [projects, setProjects] = useState<ProjectChoice[]>([])
|
||||||
|
const [selectedProjectPath, setSelectedProjectPath] = useState<string | undefined>(undefined)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
||||||
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
|
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
|
||||||
|
|
||||||
|
// Load the project list (reuse the project-rules endpoint) so the user can
|
||||||
|
// pick WHICH project the skill applies to under 'project' scope.
|
||||||
|
useEffect(() => {
|
||||||
|
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||||
|
api.get<{ projects: ProjectChoice[] }>(`/api/project-rules${query}`)
|
||||||
|
.then((res) => {
|
||||||
|
const resolved = res.projects.filter((p) => p.projectPath)
|
||||||
|
setProjects(resolved)
|
||||||
|
setSelectedProjectPath((prev) => {
|
||||||
|
if (prev && resolved.some((p) => p.projectPath === prev)) return prev
|
||||||
|
const current = resolved.find((p) => p.isCurrent)
|
||||||
|
return current?.projectPath ?? resolved[0]?.projectPath ?? undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [cwd])
|
||||||
|
|
||||||
|
// Load activation state for global + the currently selected project.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
skillsApi.getActiveSkills('global').then(res => setGlobalSkills(res.activeSkills)).catch(() => {})
|
skillsApi.getActiveSkills('global').then(res => setGlobalSkills(res.activeSkills)).catch(() => {})
|
||||||
skillsApi.getActiveSkills('project', cwd).then(res => setProjectSkills(res.activeSkills)).catch(() => {})
|
}, [])
|
||||||
}, [cwd])
|
|
||||||
|
useEffect(() => {
|
||||||
|
skillsApi.getActiveSkills('project', selectedProjectPath).then(res => setProjectSkills(res.activeSkills)).catch(() => {})
|
||||||
|
}, [selectedProjectPath])
|
||||||
|
|
||||||
const currentScope: ActivationScope =
|
const currentScope: ActivationScope =
|
||||||
projectSkills.includes(skillName) ? 'project'
|
projectSkills.includes(skillName) ? 'project'
|
||||||
@ -267,7 +298,6 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
const handleChange = async (scope: ActivationScope) => {
|
const handleChange = async (scope: ActivationScope) => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
// Remove from both first
|
|
||||||
const newGlobal = globalSkills.filter(s => s !== skillName)
|
const newGlobal = globalSkills.filter(s => s !== skillName)
|
||||||
const newProject = projectSkills.filter(s => s !== skillName)
|
const newProject = projectSkills.filter(s => s !== skillName)
|
||||||
|
|
||||||
@ -279,7 +309,7 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
skillsApi.setActiveSkills(newGlobal, 'global'),
|
skillsApi.setActiveSkills(newGlobal, 'global'),
|
||||||
skillsApi.setActiveSkills(newProject, 'project', cwd),
|
skillsApi.setActiveSkills(newProject, 'project', selectedProjectPath),
|
||||||
])
|
])
|
||||||
setGlobalSkills(newGlobal)
|
setGlobalSkills(newGlobal)
|
||||||
setProjectSkills(newProject)
|
setProjectSkills(newProject)
|
||||||
@ -290,6 +320,23 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the user switches the project dropdown, re-evaluate whether the skill
|
||||||
|
// should be re-applied to the newly selected project (if scope was 'project').
|
||||||
|
const handleProjectSwitch = async (projectPath: string) => {
|
||||||
|
const wasProjectScoped = currentScope === 'project'
|
||||||
|
setSelectedProjectPath(projectPath)
|
||||||
|
// Fetch the new project's active skills to reflect its status immediately.
|
||||||
|
try {
|
||||||
|
const res = await skillsApi.getActiveSkills('project', projectPath)
|
||||||
|
setProjectSkills(res.activeSkills)
|
||||||
|
// If the skill was project-scoped on the previous project, do NOT auto-move
|
||||||
|
// it — let the user explicitly click 'project' again for the new one.
|
||||||
|
void wasProjectScoped
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const options: { value: ActivationScope; label: string; desc: string }[] = [
|
const options: { value: ActivationScope; label: string; desc: string }[] = [
|
||||||
{ value: 'off', label: t('settings.skills.activation.off'), desc: t('settings.skills.activation.offDesc') },
|
{ value: 'off', label: t('settings.skills.activation.off'), desc: t('settings.skills.activation.offDesc') },
|
||||||
{ value: 'global', label: t('settings.skills.activation.global'), desc: t('settings.skills.activation.globalDesc') },
|
{ value: 'global', label: t('settings.skills.activation.global'), desc: t('settings.skills.activation.globalDesc') },
|
||||||
@ -318,22 +365,55 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => handleChange(opt.value)}
|
onClick={() => handleChange(opt.value)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
|
className={`flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-sm min-w-[88px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
|
||||||
currentScope === opt.value
|
currentScope === opt.value
|
||||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)] font-medium'
|
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)] font-medium'
|
||||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
title={opt.desc}
|
title={opt.desc}
|
||||||
>
|
>
|
||||||
<span className={`w-2 h-2 rounded-full ${currentScope === opt.value ? 'bg-[var(--color-brand)]' : 'bg-[var(--color-text-muted)]'}`} />
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${currentScope === opt.value ? 'bg-[var(--color-brand)]' : 'bg-[var(--color-text-muted)]'}`} />
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project selector — only relevant when applying at 'project' scope. */}
|
||||||
|
{currentScope === 'project' && projects.length > 0 && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{t('settings.skills.activation.appliesTo')}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={selectedProjectPath ?? ''}
|
||||||
|
onChange={(e) => handleProjectSwitch(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 max-w-[70%] text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.projectPath ?? ''}>
|
||||||
|
{p.isCurrent ? '★ ' : ''}{shortenProjectPath(p.label)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-[var(--color-brand)]">
|
||||||
|
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||||
|
{t('settings.skills.activation.active')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortenProjectPath(p: string): string {
|
||||||
|
const parts = p.split(/[/\\]/).filter(Boolean)
|
||||||
|
if (parts.length <= 2) return p
|
||||||
|
const sep = p.includes('\\') ? '\\' : '/'
|
||||||
|
return '…' + sep + parts.slice(-2).join(sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function TreeView({
|
function TreeView({
|
||||||
nodes,
|
nodes,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
|
|||||||
@ -841,6 +841,8 @@ export const en = {
|
|||||||
'settings.skills.activation.globalDesc': 'Auto-loaded in every conversation across all projects',
|
'settings.skills.activation.globalDesc': 'Auto-loaded in every conversation across all projects',
|
||||||
'settings.skills.activation.project': 'Project',
|
'settings.skills.activation.project': 'Project',
|
||||||
'settings.skills.activation.projectDesc': 'Auto-loaded only in conversations for the current project',
|
'settings.skills.activation.projectDesc': 'Auto-loaded only in conversations for the current project',
|
||||||
|
'settings.skills.activation.appliesTo': 'Applies to:',
|
||||||
|
'settings.skills.activation.active': 'Active',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
'settings.tab.memory': 'Memory',
|
'settings.tab.memory': 'Memory',
|
||||||
|
|||||||
@ -841,6 +841,8 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.globalDesc': 'すべてのプロジェクトの会話で自動読み込み',
|
'settings.skills.activation.globalDesc': 'すべてのプロジェクトの会話で自動読み込み',
|
||||||
'settings.skills.activation.project': 'プロジェクト',
|
'settings.skills.activation.project': 'プロジェクト',
|
||||||
'settings.skills.activation.projectDesc': '現在のプロジェクトの会話でのみ自動読み込み',
|
'settings.skills.activation.projectDesc': '現在のプロジェクトの会話でのみ自動読み込み',
|
||||||
|
'settings.skills.activation.appliesTo': '適用先:',
|
||||||
|
'settings.skills.activation.active': '有効',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
'settings.tab.memory': 'メモリ',
|
'settings.tab.memory': 'メモリ',
|
||||||
|
|||||||
@ -841,6 +841,8 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.globalDesc': '모든 프로젝트의 대화에서 자동 로드',
|
'settings.skills.activation.globalDesc': '모든 프로젝트의 대화에서 자동 로드',
|
||||||
'settings.skills.activation.project': '프로젝트',
|
'settings.skills.activation.project': '프로젝트',
|
||||||
'settings.skills.activation.projectDesc': '현재 프로젝트의 대화에서만 자동 로드',
|
'settings.skills.activation.projectDesc': '현재 프로젝트의 대화에서만 자동 로드',
|
||||||
|
'settings.skills.activation.appliesTo': '적용 대상:',
|
||||||
|
'settings.skills.activation.active': '활성',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
'settings.tab.memory': '메모리',
|
'settings.tab.memory': '메모리',
|
||||||
|
|||||||
@ -841,6 +841,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.globalDesc': '所有專案的每次對話都自動載入',
|
'settings.skills.activation.globalDesc': '所有專案的每次對話都自動載入',
|
||||||
'settings.skills.activation.project': '專案',
|
'settings.skills.activation.project': '專案',
|
||||||
'settings.skills.activation.projectDesc': '僅當前專案的對話自動載入',
|
'settings.skills.activation.projectDesc': '僅當前專案的對話自動載入',
|
||||||
|
'settings.skills.activation.appliesTo': '套用到:',
|
||||||
|
'settings.skills.activation.active': '已啟用',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
'settings.tab.memory': '記憶',
|
'settings.tab.memory': '記憶',
|
||||||
|
|||||||
@ -843,6 +843,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.globalDesc': '所有项目的每次对话都自动加载',
|
'settings.skills.activation.globalDesc': '所有项目的每次对话都自动加载',
|
||||||
'settings.skills.activation.project': '项目',
|
'settings.skills.activation.project': '项目',
|
||||||
'settings.skills.activation.projectDesc': '仅当前项目的对话自动加载',
|
'settings.skills.activation.projectDesc': '仅当前项目的对话自动加载',
|
||||||
|
'settings.skills.activation.appliesTo': '应用到:',
|
||||||
|
'settings.skills.activation.active': '已激活',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
'settings.tab.memory': '记忆',
|
'settings.tab.memory': '记忆',
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export function ProjectRulesSettings() {
|
|||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [data, setData] = useState<ProjectRulesResponse | null>(null)
|
const [data, setData] = useState<ProjectRulesResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
||||||
@ -41,6 +42,12 @@ export function ProjectRulesSettings() {
|
|||||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||||
const res = await api.get<ProjectRulesResponse>(`/api/project-rules${query}`)
|
const res = await api.get<ProjectRulesResponse>(`/api/project-rules${query}`)
|
||||||
setData(res)
|
setData(res)
|
||||||
|
// Default-select the current project (or first project) once loaded.
|
||||||
|
setSelectedProjectId((prev) => {
|
||||||
|
if (prev && res.projects.some((p) => p.id === prev)) return prev
|
||||||
|
const current = res.projects.find((p) => p.isCurrent)
|
||||||
|
return current?.id ?? res.projects[0]?.id ?? null
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setData(null)
|
setData(null)
|
||||||
} finally {
|
} finally {
|
||||||
@ -56,7 +63,7 @@ export function ProjectRulesSettings() {
|
|||||||
try {
|
try {
|
||||||
await getDesktopHost().shell.openPath(filePath)
|
await getDesktopHost().shell.openPath(filePath)
|
||||||
} catch {
|
} catch {
|
||||||
// fallback: ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +86,8 @@ export function ProjectRulesSettings() {
|
|||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
|
const selectedProject = data.projects.find((p) => p.id === selectedProjectId) ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -86,7 +95,7 @@ export function ProjectRulesSettings() {
|
|||||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t('settings.projectRules.description')}</p>
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t('settings.projectRules.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User-level rules (global) */}
|
{/* User-level rules (always shown, apply globally) */}
|
||||||
<Section title={t('settings.projectRules.userFile')} description={t('settings.projectRules.userFileDesc')}>
|
<Section title={t('settings.projectRules.userFile')} description={t('settings.projectRules.userFileDesc')}>
|
||||||
{data.userFiles.map((file) => (
|
{data.userFiles.map((file) => (
|
||||||
<FileRow key={file.path} file={file} onOpen={handleOpen} onCreate={() => {
|
<FileRow key={file.path} file={file} onOpen={handleOpen} onCreate={() => {
|
||||||
@ -94,71 +103,66 @@ export function ProjectRulesSettings() {
|
|||||||
else handleCreate('user')
|
else handleCreate('user')
|
||||||
}} t={t} />
|
}} t={t} />
|
||||||
))}
|
))}
|
||||||
{data.userFiles.every(f => !f.exists) && (
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => handleCreate('user')}>
|
|
||||||
<span className="material-symbols-outlined text-base mr-1">add</span>
|
|
||||||
~/.claude/CLAUDE.md
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Per-project rules */}
|
{/* Project selector + dynamic project rules */}
|
||||||
{data.projects.map((project) => (
|
<div className="border border-[var(--color-border)] rounded-lg p-4 space-y-3">
|
||||||
<ProjectSection
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
key={project.id}
|
<h3 className="text-sm font-medium text-[var(--color-text)]">{t('settings.projectRules.projectFile')}</h3>
|
||||||
project={project}
|
{data.projects.length > 0 && (
|
||||||
onOpen={handleOpen}
|
<select
|
||||||
onCreate={handleCreate}
|
value={selectedProjectId ?? ''}
|
||||||
t={t}
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
/>
|
className="text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 max-w-[60%] text-[var(--color-text)]"
|
||||||
))}
|
>
|
||||||
|
{data.projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.isCurrent ? '★ ' : ''}{shortenPath(p.label)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProject ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] font-mono truncate" title={selectedProject.label}>
|
||||||
|
{selectedProject.label}
|
||||||
|
</p>
|
||||||
|
{selectedProject.files.map((file) => (
|
||||||
|
<FileRow
|
||||||
|
key={file.path}
|
||||||
|
file={file}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onCreate={() => {
|
||||||
|
const projectCwd = selectedProject.projectPath || undefined
|
||||||
|
if (file.label === 'CLAUDE.md') handleCreate('project-root', projectCwd)
|
||||||
|
else if (file.label === '.claude/CLAUDE.md') handleCreate('project', projectCwd)
|
||||||
|
else if (file.label === 'CLAUDE.local.md') handleCreate('local', projectCwd)
|
||||||
|
}}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => handleCreate('project-rules', selectedProject.projectPath || undefined, 'new-rule.md')}>
|
||||||
|
<span className="material-symbols-outlined text-base mr-1">add</span>
|
||||||
|
.claude/rules/
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{t('settings.projectRules.notFound')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectSection({ project, onOpen, onCreate, t }: {
|
function shortenPath(p: string): string {
|
||||||
project: ProjectEntry
|
// Show last 2 segments for readability, full path is in title/tooltip below.
|
||||||
onOpen: (path: string) => void
|
const parts = p.split(/[/\\]/).filter(Boolean)
|
||||||
onCreate: (scope: string, cwd?: string, filename?: string) => void
|
if (parts.length <= 2) return p
|
||||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string
|
return '…' + (p.includes('\\') ? '\\' : '/') + parts.slice(-2).join(p.includes('\\') ? '\\' : '/')
|
||||||
}) {
|
|
||||||
const projectCwd = project.projectPath || undefined
|
|
||||||
const hasExistingFiles = project.files.some(f => f.exists)
|
|
||||||
const title = project.isCurrent
|
|
||||||
? `${t('settings.projectRules.projectFile')} (current)`
|
|
||||||
: t('settings.projectRules.projectFile')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
title={title}
|
|
||||||
description={project.label}
|
|
||||||
>
|
|
||||||
{project.files.filter(f => f.exists).map((file) => (
|
|
||||||
<FileRow key={file.path} file={file} onOpen={onOpen} onCreate={() => {}} t={t} />
|
|
||||||
))}
|
|
||||||
{project.files.filter(f => !f.exists).map((file) => (
|
|
||||||
<FileRow key={file.path} file={file} onOpen={onOpen} onCreate={() => {
|
|
||||||
if (file.label === 'CLAUDE.md') onCreate('project-root', projectCwd)
|
|
||||||
else if (file.label === '.claude/CLAUDE.md') onCreate('project', projectCwd)
|
|
||||||
else if (file.label === 'CLAUDE.local.md') onCreate('local', projectCwd)
|
|
||||||
}} t={t} />
|
|
||||||
))}
|
|
||||||
{!hasExistingFiles && (
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => onCreate('project-root', projectCwd)}>
|
|
||||||
<span className="material-symbols-outlined text-base mr-1">add</span>
|
|
||||||
CLAUDE.md
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => onCreate('project-rules', projectCwd, 'new-rule.md')}>
|
|
||||||
<span className="material-symbols-outlined text-base mr-1">add</span>
|
|
||||||
.claude/rules/
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||||
|
|||||||
@ -138,6 +138,34 @@ describe('project-rules API', () => {
|
|||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /api/project-rules/create rejects path traversal in filename', async () => {
|
||||||
|
const url = new URL('http://localhost/api/project-rules/create')
|
||||||
|
const res = await handleProjectRulesApi(
|
||||||
|
new Request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ scope: 'project-root', cwd: MOCK_PROJECT, filename: '../../../escaped.md' }),
|
||||||
|
}),
|
||||||
|
url,
|
||||||
|
['api', 'project-rules', 'create'],
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const data = await res.json() as { error: string }
|
||||||
|
expect(data.error).toBe('Invalid filename')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /api/project-rules/create rejects absolute filename', async () => {
|
||||||
|
const url = new URL('http://localhost/api/project-rules/create')
|
||||||
|
const res = await handleProjectRulesApi(
|
||||||
|
new Request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ scope: 'project-rules', cwd: MOCK_PROJECT, filename: path.join(MOCK_CLAUDE_HOME, 'evil.md') }),
|
||||||
|
}),
|
||||||
|
url,
|
||||||
|
['api', 'project-rules', 'create'],
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /api/project-rules/create does not overwrite existing file', async () => {
|
it('POST /api/project-rules/create does not overwrite existing file', async () => {
|
||||||
const userPath = path.join(MOCK_CLAUDE_HOME, 'CLAUDE.md')
|
const userPath = path.join(MOCK_CLAUDE_HOME, 'CLAUDE.md')
|
||||||
mockFiles.add(userPath)
|
mockFiles.add(userPath)
|
||||||
|
|||||||
@ -4,17 +4,23 @@
|
|||||||
* GET /api/project-rules — List all known projects and their CLAUDE.md files
|
* GET /api/project-rules — List all known projects and their CLAUDE.md files
|
||||||
* POST /api/project-rules/create — Create a CLAUDE.md file if it doesn't exist
|
* POST /api/project-rules/create — Create a CLAUDE.md file if it doesn't exist
|
||||||
*
|
*
|
||||||
* Scans ~/.claude/projects/ for all known project directories (same as memory),
|
* Project discovery mirrors the memory API: scan ~/.claude/projects/ for known
|
||||||
* then checks each project for CLAUDE.md files at all valid locations:
|
* project IDs, then resolve each ID back to a real filesystem path by reading
|
||||||
|
* session (.jsonl) file heads for a `cwd` field, falling back to a sanitized
|
||||||
|
* directory search. For each resolved project we check all CLAUDE.md locations
|
||||||
|
* the harness actually loads:
|
||||||
* - Project root: CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md, CLAUDE.local.md
|
* - Project root: CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md, CLAUDE.local.md
|
||||||
* - User level: ~/.claude/CLAUDE.md, ~/.claude/rules/*.md
|
* Plus user-level rules shared across all projects:
|
||||||
|
* - ~/.claude/CLAUDE.md, ~/.claude/rules/*.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
|
import { homedir } from 'os'
|
||||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||||
import { getCwd } from '../../utils/cwd.js'
|
import { getCwd } from '../../utils/cwd.js'
|
||||||
import { findCanonicalGitRoot } from '../../utils/git.js'
|
import { findCanonicalGitRoot } from '../../utils/git.js'
|
||||||
|
import { sanitizePath, containsPathTraversal } from '../../utils/path.js'
|
||||||
|
|
||||||
type RuleFile = {
|
type RuleFile = {
|
||||||
path: string
|
path: string
|
||||||
@ -31,6 +37,12 @@ type ProjectRulesEntry = {
|
|||||||
files: RuleFile[]
|
files: RuleFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounds for resolving a project ID back to a filesystem path.
|
||||||
|
const SESSION_SCAN_LIMIT = 5
|
||||||
|
const HEAD_BYTES = 4096
|
||||||
|
const FS_SEARCH_DEPTH = 8
|
||||||
|
const FS_SEARCH_NODE_LIMIT = 4000
|
||||||
|
|
||||||
export async function handleProjectRulesApi(
|
export async function handleProjectRulesApi(
|
||||||
req: Request,
|
req: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
@ -56,92 +68,93 @@ function getProjectsDir(): string {
|
|||||||
return path.join(getClaudeConfigHomeDir(), 'projects')
|
return path.join(getClaudeConfigHomeDir(), 'projects')
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePath(p: string): string {
|
|
||||||
return p.replace(/[<>:"/\\|?*]/g, '-').replace(/^\.+/, '_')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProjectRules(url: URL): Promise<Response> {
|
async function getProjectRules(url: URL): Promise<Response> {
|
||||||
const cwd = url.searchParams.get('cwd') || getCwd()
|
const cwd = url.searchParams.get('cwd') || getCwd()
|
||||||
const claudeHome = getClaudeConfigHomeDir()
|
const claudeHome = getClaudeConfigHomeDir()
|
||||||
const projectsDir = getProjectsDir()
|
const projectsDir = getProjectsDir()
|
||||||
|
|
||||||
// Resolve current project ID
|
// Current project: prefer git root, fall back to cwd.
|
||||||
const currentProjectPath = findCanonicalGitRoot(cwd) ?? cwd
|
const currentProjectPath = (findCanonicalGitRoot(cwd) ?? cwd).normalize('NFC')
|
||||||
const currentProjectId = sanitizePath(currentProjectPath)
|
const currentProjectId = sanitizePath(currentProjectPath)
|
||||||
|
|
||||||
// Scan all known project directories
|
// Collect project IDs: current + every dir under ~/.claude/projects/
|
||||||
const projectMap = new Map<string, { id: string; isCurrent: boolean }>()
|
const projectIds = new Map<string, boolean>() // id -> isCurrent
|
||||||
projectMap.set(currentProjectId, { id: currentProjectId, isCurrent: true })
|
projectIds.set(currentProjectId, true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(projectsDir, { withFileTypes: true })
|
const entries = await fs.readdir(projectsDir, { withFileTypes: true })
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue
|
if (!entry.isDirectory()) continue
|
||||||
if (!projectMap.has(entry.name)) {
|
if (!isValidProjectId(entry.name)) continue
|
||||||
projectMap.set(entry.name, { id: entry.name, isCurrent: false })
|
if (!projectIds.has(entry.name)) projectIds.set(entry.name, false)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// projects dir may not exist
|
// projects dir may not exist yet
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each project, try to resolve its real path and scan rules
|
const projects: ProjectRulesEntry[] = await Promise.all(
|
||||||
const projects: ProjectRulesEntry[] = []
|
Array.from(projectIds.entries()).map(async ([id, isCurrent]) => {
|
||||||
|
const projectPath = isCurrent
|
||||||
for (const [, { id, isCurrent }] of projectMap) {
|
? currentProjectPath
|
||||||
const projectPath = isCurrent ? currentProjectPath : await inferProjectPath(id)
|
: await resolveProjectPath(id)
|
||||||
const files: RuleFile[] = []
|
const files = projectPath ? await scanProjectFiles(projectPath) : []
|
||||||
|
return {
|
||||||
if (projectPath) {
|
id,
|
||||||
// Root CLAUDE.md
|
label: projectPath ?? unsanitizeProjectLabel(id),
|
||||||
const rootMd = path.join(projectPath, 'CLAUDE.md')
|
projectPath,
|
||||||
files.push({ path: rootMd, exists: await fileExists(rootMd), type: 'project', label: 'CLAUDE.md' })
|
isCurrent,
|
||||||
|
files,
|
||||||
// .claude/CLAUDE.md
|
|
||||||
const dotClaudeMd = path.join(projectPath, '.claude', 'CLAUDE.md')
|
|
||||||
files.push({ path: dotClaudeMd, exists: await fileExists(dotClaudeMd), type: 'project', label: '.claude/CLAUDE.md' })
|
|
||||||
|
|
||||||
// .claude/rules/*.md
|
|
||||||
const rulesDir = path.join(projectPath, '.claude', 'rules')
|
|
||||||
for (const rulePath of await listMdFiles(rulesDir)) {
|
|
||||||
files.push({ path: rulePath, exists: true, type: 'project', label: '.claude/rules/' + path.basename(rulePath) })
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
// CLAUDE.local.md
|
)
|
||||||
const localMd = path.join(projectPath, 'CLAUDE.local.md')
|
|
||||||
files.push({ path: localMd, exists: await fileExists(localMd), type: 'local', label: 'CLAUDE.local.md' })
|
|
||||||
}
|
|
||||||
|
|
||||||
projects.push({
|
|
||||||
id,
|
|
||||||
label: projectPath ?? unsanitize(id),
|
|
||||||
projectPath,
|
|
||||||
isCurrent,
|
|
||||||
files,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// User-level rules (shared across all projects)
|
// User-level rules (shared across all projects)
|
||||||
const userFiles: RuleFile[] = []
|
const userFiles: RuleFile[] = []
|
||||||
const userMd = path.join(claudeHome, 'CLAUDE.md')
|
const userMd = path.join(claudeHome, 'CLAUDE.md')
|
||||||
userFiles.push({ path: userMd, exists: await fileExists(userMd), type: 'user', label: '~/.claude/CLAUDE.md' })
|
userFiles.push({ path: userMd, exists: await fileExists(userMd), type: 'user', label: '~/.claude/CLAUDE.md' })
|
||||||
const userRulesDir = path.join(claudeHome, 'rules')
|
for (const rulePath of await listMdFiles(path.join(claudeHome, 'rules'))) {
|
||||||
for (const rulePath of await listMdFiles(userRulesDir)) {
|
|
||||||
userFiles.push({ path: rulePath, exists: true, type: 'user', label: '~/.claude/rules/' + path.basename(rulePath) })
|
userFiles.push({ path: rulePath, exists: true, type: 'user', label: '~/.claude/rules/' + path.basename(rulePath) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: current project first, then alphabetical
|
// Sort: current first, then projects with existing files, then alphabetical.
|
||||||
projects.sort((a, b) => {
|
projects.sort((a, b) => {
|
||||||
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
|
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
|
||||||
|
const aHas = a.files.some(f => f.exists)
|
||||||
|
const bHas = b.files.some(f => f.exists)
|
||||||
|
if (aHas !== bHas) return aHas ? -1 : 1
|
||||||
return a.label.localeCompare(b.label)
|
return a.label.localeCompare(b.label)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter out projects with no existing files and no resolvable path (unless current)
|
// Keep: current project, or any project that resolved to a real path AND has
|
||||||
const filtered = projects.filter(p => p.isCurrent || p.projectPath !== null)
|
// at least one existing rules file. This avoids listing dozens of stale
|
||||||
|
// project entries with no rules.
|
||||||
|
const filtered = projects.filter(
|
||||||
|
p => p.isCurrent || (p.projectPath !== null && p.files.some(f => f.exists)),
|
||||||
|
)
|
||||||
|
|
||||||
return Response.json({ projects: filtered, userFiles, cwd })
|
return Response.json({ projects: filtered, userFiles, cwd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Scan a project directory for all CLAUDE.md rule file locations. */
|
||||||
|
async function scanProjectFiles(projectPath: string): Promise<RuleFile[]> {
|
||||||
|
const files: RuleFile[] = []
|
||||||
|
|
||||||
|
const rootMd = path.join(projectPath, 'CLAUDE.md')
|
||||||
|
files.push({ path: rootMd, exists: await fileExists(rootMd), type: 'project', label: 'CLAUDE.md' })
|
||||||
|
|
||||||
|
const dotClaudeMd = path.join(projectPath, '.claude', 'CLAUDE.md')
|
||||||
|
files.push({ path: dotClaudeMd, exists: await fileExists(dotClaudeMd), type: 'project', label: '.claude/CLAUDE.md' })
|
||||||
|
|
||||||
|
for (const rulePath of await listMdFiles(path.join(projectPath, '.claude', 'rules'))) {
|
||||||
|
files.push({ path: rulePath, exists: true, type: 'project', label: '.claude/rules/' + path.basename(rulePath) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const localMd = path.join(projectPath, 'CLAUDE.local.md')
|
||||||
|
files.push({ path: localMd, exists: await fileExists(localMd), type: 'local', label: 'CLAUDE.local.md' })
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
async function createProjectRulesFile(req: Request, url: URL): Promise<Response> {
|
async function createProjectRulesFile(req: Request, url: URL): Promise<Response> {
|
||||||
let body: { scope?: string; cwd?: string; filename?: string }
|
let body: { scope?: string; cwd?: string; filename?: string }
|
||||||
try {
|
try {
|
||||||
@ -154,6 +167,14 @@ async function createProjectRulesFile(req: Request, url: URL): Promise<Response>
|
|||||||
const cwd = body.cwd || url.searchParams.get('cwd') || getCwd()
|
const cwd = body.cwd || url.searchParams.get('cwd') || getCwd()
|
||||||
const filename = body.filename || 'CLAUDE.md'
|
const filename = body.filename || 'CLAUDE.md'
|
||||||
|
|
||||||
|
// Security: filename is user-controlled and flows into path.join below.
|
||||||
|
// Reject traversal ("../") and absolute paths so a crafted filename cannot
|
||||||
|
// write outside the intended scope directory. The harness only ever sends
|
||||||
|
// basenames, but this endpoint is unauthenticated server input.
|
||||||
|
if (containsPathTraversal(filename) || path.isAbsolute(filename) || filename.includes('\0')) {
|
||||||
|
return Response.json({ error: 'Invalid filename' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
let filePath: string
|
let filePath: string
|
||||||
if (scope === 'project-root') {
|
if (scope === 'project-root') {
|
||||||
filePath = path.join(cwd, filename)
|
filePath = path.join(cwd, filename)
|
||||||
@ -175,16 +196,160 @@ async function createProjectRulesFile(req: Request, url: URL): Promise<Response>
|
|||||||
return Response.json({ ok: true, path: filePath, created: false })
|
return Response.json({ ok: true, path: filePath, created: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = path.dirname(filePath)
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
await fs.mkdir(dir, { recursive: true })
|
await fs.writeFile(filePath, getTemplate(scope, filename), 'utf-8')
|
||||||
|
|
||||||
const template = getTemplate(scope, filename)
|
|
||||||
await fs.writeFile(filePath, template, 'utf-8')
|
|
||||||
|
|
||||||
return Response.json({ ok: true, path: filePath, created: true })
|
return Response.json({ ok: true, path: filePath, created: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Project ID → filesystem path resolution (mirrors memory.ts) ---
|
||||||
|
|
||||||
|
/** Resolve a sanitized project ID back to a real directory path. */
|
||||||
|
async function resolveProjectPath(projectId: string): Promise<string | null> {
|
||||||
|
const fromSession = await inferProjectPathFromSessionFiles(projectId)
|
||||||
|
if (fromSession) return fromSession
|
||||||
|
|
||||||
|
const fromFs = await inferProjectPathFromExistingDirectory(projectId)
|
||||||
|
return fromFs ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inferProjectPathFromSessionFiles(projectId: string): Promise<string | undefined> {
|
||||||
|
const projectDir = path.join(getProjectsDir(), projectId)
|
||||||
|
let entries: import('node:fs').Dirent[]
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(projectDir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
||||||
|
const filePath = path.join(projectDir, entry.name)
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filePath)
|
||||||
|
sessionFiles.push({ filePath, mtimeMs: stat.mtimeMs })
|
||||||
|
} catch {
|
||||||
|
// racing delete — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||||
|
for (const { filePath } of sessionFiles.slice(0, SESSION_SCAN_LIMIT)) {
|
||||||
|
const head = await readFileHead(filePath, HEAD_BYTES)
|
||||||
|
const candidate =
|
||||||
|
extractJsonStringField(head, 'cwd') ??
|
||||||
|
extractJsonStringField(head, 'workDir') ??
|
||||||
|
extractJsonStringField(head, 'projectPath')
|
||||||
|
if (candidate && path.isAbsolute(candidate)) return candidate.normalize('NFC')
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileHead(filePath: string, bytes: number): Promise<string> {
|
||||||
|
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
|
||||||
|
try {
|
||||||
|
handle = await fs.open(filePath, 'r')
|
||||||
|
const buffer = Buffer.alloc(bytes)
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, bytes, 0)
|
||||||
|
return buffer.subarray(0, bytesRead).toString('utf-8')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
} finally {
|
||||||
|
await handle?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonStringField(head: string, field: string): string | undefined {
|
||||||
|
// Cheap extraction without full JSON parse — head may be truncated mid-line.
|
||||||
|
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`)
|
||||||
|
const m = head.match(re)
|
||||||
|
if (!m) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(`"${m[1]}"`)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inferProjectPathFromExistingDirectory(projectId: string): Promise<string | undefined> {
|
||||||
|
const roots = Array.from(new Set([
|
||||||
|
homedir(),
|
||||||
|
process.env.HOME,
|
||||||
|
process.env.USERPROFILE,
|
||||||
|
'/private/tmp',
|
||||||
|
'/tmp',
|
||||||
|
].filter((root): root is string => Boolean(root && path.isAbsolute(root)))))
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const resolvedRoot = path.resolve(root)
|
||||||
|
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(resolvedRoot))) continue
|
||||||
|
const match = await findDirectoryBySanitizedPath(projectId, resolvedRoot, 0, { visited: 0 })
|
||||||
|
if (match) return match.normalize('NFC')
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDirectoryBySanitizedPath(
|
||||||
|
projectId: string,
|
||||||
|
candidate: string,
|
||||||
|
depth: number,
|
||||||
|
state: { visited: number },
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (state.visited >= FS_SEARCH_NODE_LIMIT) return undefined
|
||||||
|
state.visited += 1
|
||||||
|
|
||||||
|
const candidateId = sanitizePath(candidate)
|
||||||
|
if (candidateId === projectId) return candidate
|
||||||
|
if (depth >= FS_SEARCH_DEPTH || !sanitizedPrefixCanMatch(projectId, candidateId)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: import('node:fs').Dirent[]
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(candidate, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||||
|
const child = path.join(candidate, entry.name)
|
||||||
|
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(child))) continue
|
||||||
|
if (entry.isSymbolicLink() && !(await directoryExists(child))) continue
|
||||||
|
const match = await findDirectoryBySanitizedPath(projectId, child, depth + 1, state)
|
||||||
|
if (match) return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedPrefixCanMatch(projectId: string, prefix: string): boolean {
|
||||||
|
if (projectId === prefix) return true
|
||||||
|
return prefix.endsWith('-')
|
||||||
|
? projectId.startsWith(prefix)
|
||||||
|
: projectId.startsWith(`${prefix}-`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Misc helpers ---
|
||||||
|
|
||||||
|
function isValidProjectId(projectId: string): boolean {
|
||||||
|
return (
|
||||||
|
projectId.length > 0 &&
|
||||||
|
!projectId.includes('\0') &&
|
||||||
|
!projectId.includes('/') &&
|
||||||
|
!projectId.includes('\\') &&
|
||||||
|
projectId !== '.' &&
|
||||||
|
projectId !== '..'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsanitizeProjectLabel(projectId: string): string {
|
||||||
|
return projectId.replace(/^-/, '/').replace(/-/g, '/')
|
||||||
|
}
|
||||||
|
|
||||||
function getTemplate(scope: string, filename: string): string {
|
function getTemplate(scope: string, filename: string): string {
|
||||||
if (scope === 'local') {
|
if (scope === 'local') {
|
||||||
@ -209,6 +374,15 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function directoryExists(dir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(dir)
|
||||||
|
return stat.isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function listMdFiles(dirPath: string): Promise<string[]> {
|
async function listMdFiles(dirPath: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||||
@ -219,51 +393,3 @@ async function listMdFiles(dirPath: string): Promise<string[]> {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsanitize(id: string): string {
|
|
||||||
// Best-effort reverse of sanitizePath for display
|
|
||||||
return id.replace(/-/g, path.sep)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inferProjectPath(projectId: string): Promise<string | null> {
|
|
||||||
// Try to read a session file from the project dir to find the real path
|
|
||||||
const projectDir = path.join(getProjectsDir(), projectId)
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(projectDir, { withFileTypes: true })
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
|
||||||
// Read first few bytes to find workDir
|
|
||||||
const filePath = path.join(projectDir, entry.name)
|
|
||||||
const content = await fs.readFile(filePath, { encoding: 'utf-8' })
|
|
||||||
const firstLine = content.split('\n')[0]
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(firstLine)
|
|
||||||
if (obj.cwd && typeof obj.cwd === 'string') {
|
|
||||||
// Verify the directory still exists
|
|
||||||
try {
|
|
||||||
await fs.access(obj.cwd)
|
|
||||||
return obj.cwd
|
|
||||||
} catch {
|
|
||||||
// Directory no longer exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not valid JSON
|
|
||||||
}
|
|
||||||
break // Only check first session file
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Directory not readable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check if unsanitized path exists as a directory
|
|
||||||
const guessedPath = unsanitize(projectId)
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(guessedPath)
|
|
||||||
if (stat.isDirectory()) return guessedPath
|
|
||||||
} catch {
|
|
||||||
// nope
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user