mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
140
src/jobs/__tests__/classifier.test.ts
Normal file
140
src/jobs/__tests__/classifier.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for src/jobs/classifier.ts
|
||||
*
|
||||
* Uses real temp directories instead of mocking fs to avoid
|
||||
* cross-test mock pollution in bun test.
|
||||
*
|
||||
* classifier.ts takes jobDir as a parameter, so no envUtils mock needed.
|
||||
*/
|
||||
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import type { AssistantMessage } from '../../types/message.js'
|
||||
import { classifyAndWriteState } from '../classifier.js'
|
||||
|
||||
// ─── setup: real temp dir ──────────────────────────────────────────────────
|
||||
|
||||
let tempBase: string
|
||||
let jobDir: string
|
||||
let stateFile: string
|
||||
|
||||
tempBase = mkdtempSync(join(tmpdir(), 'classifier-test-'))
|
||||
|
||||
function freshJobDir(): void {
|
||||
jobDir = mkdtempSync(join(tempBase, 'job-'))
|
||||
stateFile = join(jobDir, 'state.json')
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAssistantMessage(
|
||||
content: any[],
|
||||
extra: Record<string, any> = {},
|
||||
): AssistantMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: '00000000-0000-0000-0000-000000000000' as any,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
...extra,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
// ─── lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
freshJobDir()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(tempBase, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('classifyAndWriteState', () => {
|
||||
test('does nothing when state.json is missing', async () => {
|
||||
await classifyAndWriteState(jobDir, [])
|
||||
// stateFile should still not exist
|
||||
let exists = false
|
||||
try {
|
||||
readFileSync(stateFile, 'utf-8')
|
||||
exists = true
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
expect(exists).toBe(false)
|
||||
})
|
||||
|
||||
test('sets status to running when last message has tool_use block', async () => {
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ status: 'created', updatedAt: '2026-01-01' }),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: 'text', text: 'Let me check...' },
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'bash', input: {} },
|
||||
])
|
||||
|
||||
await classifyAndWriteState(jobDir, [msg])
|
||||
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
expect(state.status).toBe('running')
|
||||
})
|
||||
|
||||
test('sets status to completed when stop_reason is end_turn', async () => {
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ status: 'running', updatedAt: '2026-01-01' }),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const msg = makeAssistantMessage([{ type: 'text', text: 'All done.' }], {
|
||||
stop_reason: 'end_turn',
|
||||
})
|
||||
|
||||
await classifyAndWriteState(jobDir, [msg])
|
||||
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
expect(state.status).toBe('completed')
|
||||
})
|
||||
|
||||
test('sets status to running for empty messages (state exists)', async () => {
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ status: 'created', updatedAt: '2026-01-01' }),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
await classifyAndWriteState(jobDir, [])
|
||||
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
expect(state.status).toBe('running')
|
||||
})
|
||||
|
||||
test('sets status to running when stop_reason is max_tokens', async () => {
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ status: 'running', updatedAt: '2026-01-01' }),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const msg = makeAssistantMessage([{ type: 'text', text: 'I need more' }], {
|
||||
stop_reason: 'max_tokens',
|
||||
})
|
||||
|
||||
await classifyAndWriteState(jobDir, [msg])
|
||||
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
expect(state.status).toBe('running')
|
||||
})
|
||||
})
|
||||
91
src/jobs/__tests__/state.test.ts
Normal file
91
src/jobs/__tests__/state.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests for src/jobs/state.ts
|
||||
*
|
||||
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
|
||||
* instead of mocking fs, to avoid cross-test mock pollution.
|
||||
*/
|
||||
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
// ─── setup: real temp dir via env var ──────────────────────────────────────
|
||||
|
||||
const tempBase = mkdtempSync(join(tmpdir(), 'jobs-state-test-'))
|
||||
|
||||
beforeEach(() => {
|
||||
// Each test gets a fresh config dir
|
||||
const tempHome = mkdtempSync(join(tempBase, 'home-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
try {
|
||||
rmSync(tempBase, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
// ─── import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createJob, readJobState, appendJobReply, getJobDir } = await import(
|
||||
'../state.js'
|
||||
)
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createJob', () => {
|
||||
test('creates job directory and writes state, template, and input files', () => {
|
||||
const dir = createJob('job-1', 'my-template', '# Template', 'hello', [
|
||||
'--flag',
|
||||
])
|
||||
expect(dir).toContain('job-1')
|
||||
expect(existsSync(dir)).toBe(true)
|
||||
|
||||
const stateFile = join(dir, 'state.json')
|
||||
expect(existsSync(stateFile)).toBe(true)
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
expect(state.jobId).toBe('job-1')
|
||||
expect(state.templateName).toBe('my-template')
|
||||
expect(state.status).toBe('created')
|
||||
expect(state.args).toEqual(['--flag'])
|
||||
|
||||
expect(readFileSync(join(dir, 'template.md'), 'utf-8')).toBe('# Template')
|
||||
expect(readFileSync(join(dir, 'input.txt'), 'utf-8')).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readJobState', () => {
|
||||
test('returns null when job does not exist', () => {
|
||||
expect(readJobState('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
test('returns parsed state when job exists', () => {
|
||||
createJob('job-2', 'tpl', 'content', 'input', [])
|
||||
const result = readJobState('job-2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.jobId).toBe('job-2')
|
||||
expect(result!.status).toBe('created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendJobReply', () => {
|
||||
test('returns false when job does not exist', () => {
|
||||
expect(appendJobReply('no-job', 'hello')).toBe(false)
|
||||
})
|
||||
|
||||
test('appends reply and updates state', () => {
|
||||
createJob('job-3', 'tpl', 'content', 'input', [])
|
||||
|
||||
const result = appendJobReply('job-3', 'my reply')
|
||||
expect(result).toBe(true)
|
||||
|
||||
const dir = getJobDir('job-3')
|
||||
const repliesPath = join(dir, 'replies.jsonl')
|
||||
expect(existsSync(repliesPath)).toBe(true)
|
||||
const replyLine = JSON.parse(readFileSync(repliesPath, 'utf-8').trim())
|
||||
expect(replyLine.text).toBe('my reply')
|
||||
})
|
||||
})
|
||||
87
src/jobs/__tests__/templates.test.ts
Normal file
87
src/jobs/__tests__/templates.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tests for src/jobs/templates.ts
|
||||
*
|
||||
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
|
||||
* instead of mocking fs, to avoid cross-test mock pollution.
|
||||
*/
|
||||
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
// ─── setup: real temp dir via env var ──────────────────────────────────────
|
||||
|
||||
const tempBase = mkdtempSync(join(tmpdir(), 'jobs-templates-test-'))
|
||||
|
||||
beforeEach(() => {
|
||||
const tempHome = mkdtempSync(join(tempBase, 'home-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
try {
|
||||
rmSync(tempBase, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
// ─── import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { listTemplates, loadTemplate } = await import('../templates.js')
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listTemplates', () => {
|
||||
test('returns empty array when no template dirs exist', () => {
|
||||
const result = listTemplates()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('discovers templates from user-level dir', () => {
|
||||
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
|
||||
mkdirSync(userDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(userDir, 'greeting.md'),
|
||||
'---\ndescription: A greeting template\n---\nHello {{name}}',
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const result = listTemplates()
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]!.name).toBe('greeting')
|
||||
expect(result[0]!.description).toBe('A greeting template')
|
||||
expect(result[0]!.content).toBe('Hello {{name}}')
|
||||
})
|
||||
|
||||
test('skips non-md files', () => {
|
||||
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
|
||||
mkdirSync(userDir, { recursive: true })
|
||||
writeFileSync(join(userDir, 'notes.txt'), 'not a template', 'utf-8')
|
||||
writeFileSync(join(userDir, 'data.json'), '{}', 'utf-8')
|
||||
|
||||
const result = listTemplates()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadTemplate', () => {
|
||||
test('returns null when template not found', () => {
|
||||
expect(loadTemplate('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
test('returns template by name', () => {
|
||||
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
|
||||
mkdirSync(userDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(userDir, 'deploy.md'),
|
||||
'---\ndescription: Deploy script\n---\nrun deploy',
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const result = loadTemplate('deploy')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.name).toBe('deploy')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,67 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const classifyAndWriteState: (...args: unknown[]) => Promise<void> = () => Promise.resolve();
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { AssistantMessage } from '../types/message.js'
|
||||
|
||||
/**
|
||||
* Classify the job status from the turn's assistant messages and update state.json.
|
||||
*
|
||||
* Called by stopHooks.ts after each repl_main_thread turn when CLAUDE_JOB_DIR is set.
|
||||
* Only the main thread calls this (not subagents).
|
||||
*
|
||||
* @param jobDir - Path to the job directory (from CLAUDE_JOB_DIR env)
|
||||
* @param assistantMessages - Assistant messages from this turn
|
||||
*/
|
||||
export async function classifyAndWriteState(
|
||||
jobDir: string,
|
||||
assistantMessages: AssistantMessage[],
|
||||
): Promise<void> {
|
||||
const stateFile = join(jobDir, 'state.json')
|
||||
|
||||
let state: Record<string, unknown>
|
||||
try {
|
||||
state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
} catch {
|
||||
// No state file or corrupt — not a valid job directory
|
||||
return
|
||||
}
|
||||
|
||||
const newStatus = classifyStatus(assistantMessages)
|
||||
state.status = newStatus
|
||||
state.updatedAt = new Date().toISOString()
|
||||
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine job status from assistant messages.
|
||||
*
|
||||
* - Has tool_use blocks → still running (tools executing)
|
||||
* - stop_reason === 'end_turn' → completed (model finished)
|
||||
* - Otherwise → running
|
||||
*/
|
||||
function classifyStatus(messages: AssistantMessage[]): string {
|
||||
if (messages.length === 0) return 'running'
|
||||
|
||||
const lastMessage = messages[messages.length - 1]!
|
||||
const content = lastMessage.message?.content
|
||||
|
||||
// Check if the last message has tool_use blocks (still executing)
|
||||
if (Array.isArray(content)) {
|
||||
const hasToolUse = content.some(
|
||||
block =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block &&
|
||||
block.type === 'tool_use',
|
||||
)
|
||||
if (hasToolUse) return 'running'
|
||||
}
|
||||
|
||||
// Check stop_reason via index signature
|
||||
const stopReason = (lastMessage.message as Record<string, unknown>)
|
||||
?.stop_reason
|
||||
if (stopReason === 'end_turn') return 'completed'
|
||||
if (stopReason === 'max_tokens') return 'running'
|
||||
|
||||
return 'running'
|
||||
}
|
||||
|
||||
102
src/jobs/state.ts
Normal file
102
src/jobs/state.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
|
||||
export interface JobState {
|
||||
jobId: string
|
||||
templateName: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
status: 'created' | 'running' | 'completed' | 'failed'
|
||||
args: string[]
|
||||
}
|
||||
|
||||
function getJobsDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'jobs')
|
||||
}
|
||||
|
||||
export function getJobDir(jobId: string): string {
|
||||
return join(getJobsDir(), jobId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new job directory with initial state.
|
||||
*/
|
||||
export function createJob(
|
||||
jobId: string,
|
||||
templateName: string,
|
||||
templateContent: string,
|
||||
inputText: string,
|
||||
args: string[],
|
||||
): string {
|
||||
const dir = getJobDir(jobId)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const state: JobState = {
|
||||
jobId,
|
||||
templateName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'created',
|
||||
args,
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(dir, 'state.json'),
|
||||
JSON.stringify(state, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
writeFileSync(join(dir, 'template.md'), templateContent, 'utf-8')
|
||||
writeFileSync(join(dir, 'input.txt'), inputText, 'utf-8')
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Read job state from disk.
|
||||
*/
|
||||
export function readJobState(jobId: string): JobState | null {
|
||||
try {
|
||||
const raw = readFileSync(join(getJobDir(jobId), 'state.json'), 'utf-8')
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const obj = parsed as Record<string, unknown>
|
||||
if (typeof obj.jobId !== 'string' || typeof obj.status !== 'string') {
|
||||
return null
|
||||
}
|
||||
return obj as unknown as JobState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a reply to a job.
|
||||
*/
|
||||
export function appendJobReply(jobId: string, text: string): boolean {
|
||||
const dir = getJobDir(jobId)
|
||||
const state = readJobState(jobId)
|
||||
if (!state) return false
|
||||
|
||||
const repliesPath = join(dir, 'replies.jsonl')
|
||||
const entry = JSON.stringify({
|
||||
text,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
try {
|
||||
appendFileSync(repliesPath, entry + '\n', 'utf-8')
|
||||
} catch {
|
||||
writeFileSync(repliesPath, entry + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
const updated = { ...state, updatedAt: new Date().toISOString() }
|
||||
writeFileSync(
|
||||
join(dir, 'state.json'),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
86
src/jobs/templates.ts
Normal file
86
src/jobs/templates.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { readdirSync, readFileSync } from 'fs'
|
||||
import { join, basename } from 'path'
|
||||
import { parseFrontmatter } from '../utils/frontmatterParser.js'
|
||||
import type { FrontmatterData } from '../utils/frontmatterParser.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import {
|
||||
getProjectDirsUpToHome,
|
||||
extractDescriptionFromMarkdown,
|
||||
type ClaudeConfigDirectory,
|
||||
} from '../utils/markdownConfigLoader.js'
|
||||
|
||||
export interface TemplateInfo {
|
||||
name: string
|
||||
description: string
|
||||
filePath: string
|
||||
frontmatter: FrontmatterData
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover .claude/templates directories from CWD up to git root,
|
||||
* plus the user-level ~/.claude/templates.
|
||||
*/
|
||||
function getTemplatesDirs(): string[] {
|
||||
const projectDirs = getProjectDirsUpToHome(
|
||||
'templates' as ClaudeConfigDirectory,
|
||||
process.cwd(),
|
||||
)
|
||||
|
||||
// User-level dir (getProjectDirsUpToHome stops before home)
|
||||
const userDir = join(getClaudeConfigHomeDir(), 'templates')
|
||||
try {
|
||||
readdirSync(userDir)
|
||||
return [...projectDirs, userDir]
|
||||
} catch {
|
||||
return projectDirs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available templates.
|
||||
*/
|
||||
export function listTemplates(): TemplateInfo[] {
|
||||
const templates: TemplateInfo[] = []
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
for (const dir of getTemplatesDirs()) {
|
||||
let files: string[]
|
||||
try {
|
||||
files = readdirSync(dir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) continue
|
||||
const name = basename(file, '.md')
|
||||
if (seenNames.has(name)) continue
|
||||
seenNames.add(name)
|
||||
|
||||
const filePath = join(dir, file)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8')
|
||||
const { frontmatter, content } = parseFrontmatter(raw, filePath)
|
||||
const description =
|
||||
(typeof frontmatter.description === 'string'
|
||||
? frontmatter.description
|
||||
: '') || extractDescriptionFromMarkdown(content, 'No description')
|
||||
|
||||
templates.push({ name, description, filePath, frontmatter, content })
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific template by name.
|
||||
*/
|
||||
export function loadTemplate(name: string): TemplateInfo | null {
|
||||
const all = listTemplates()
|
||||
return all.find(t => t.name === name) ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user