Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/builtin-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"./utils": "./src/utils.ts"
},
"dependencies": {
"@claude-code-best/agent-tools": "workspace:*"
"@claude-code-best/agent-tools": "workspace:*",
"marked": "^17.0.6"
}
}
28 changes: 24 additions & 4 deletions packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import {
} from './prompt.js'
import { getArtifactsToken, getUploadUrl } from './config.js'
import { uploadArtifact } from './client.js'
import { markdownToHtml } from './markdown.js'
import { renderToolResultMessage } from './UI.js'

const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z
.string()
.describe('Absolute path to a local HTML file to upload.'),
.describe(
'Absolute path to a local HTML (.html/.htm) or Markdown (.md/.markdown) file to upload. Markdown is converted to styled HTML before upload.',
),
hash: z
.string()
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
Expand Down Expand Up @@ -47,7 +50,7 @@ export type ArtifactOutput = z.infer<OutputSchema>
export const ArtifactTool = buildTool({
name: ARTIFACT_TOOL_NAME,
searchHint:
'upload html artifact share url cloud publish progress report public link',
'upload html markdown artifact share url cloud publish progress report public link',
maxResultSizeChars: 2_000,
shouldDefer: true,
strict: true,
Expand Down Expand Up @@ -146,9 +149,9 @@ export const ArtifactTool = buildTool({
}
}

let html: string
let rawContent: string
try {
html = await readFile(file_path, 'utf8')
rawContent = await readFile(file_path, 'utf8')
} catch {
return {
data: {
Expand All @@ -160,6 +163,23 @@ export const ArtifactTool = buildTool({
}
}

const lowerPath = file_path.toLowerCase()
let html: string
if (lowerPath.endsWith('.html') || lowerPath.endsWith('.htm')) {
html = rawContent
} else if (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')) {
html = markdownToHtml(rawContent, file_path)
} else {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `Unsupported file extension. Accepted: .html, .htm, .md, .markdown — got: ${file_path}`,
},
}
}

try {
const result = await uploadArtifact({
html,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { ArtifactTool } from '../ArtifactTool.js'

const TEST_DIR = join(tmpdir(), 'artifact-tool-test')
const TEST_FILE = join(TEST_DIR, 'report.html')
const MD_FILE = join(TEST_DIR, 'report.md')
const TXT_FILE = join(TEST_DIR, 'notes.txt')
const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html')
const DIR_AS_FILE = TEST_DIR

Expand All @@ -26,6 +28,12 @@ describe('ArtifactTool.call', () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
writeFileSync(TEST_FILE, '<h1>test report</h1>', 'utf8')
writeFileSync(
MD_FILE,
'# MD Report\n\n| a | b |\n| - | - |\n| 1 | 2 |\n',
'utf8',
)
writeFileSync(TXT_FILE, 'plain text content', 'utf8')
process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token'
process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test'
})
Expand Down Expand Up @@ -109,4 +117,48 @@ describe('ArtifactTool.call', () => {
'payload_too_large',
)
})

test('converts .md file to styled HTML before upload', async () => {
const fetchMock = mockFetchSuccess({
id: 'md-id',
url: 'https://example.test/7d/md-id.html',
expiresAt: '2026-06-27T10:00:00.000Z',
})
globalThis.fetch = fetchMock

const result = await ArtifactTool.call({ file_path: MD_FILE, ttl: 7 })

expect(result.data).toMatchObject({ id: 'md-id' })
expect((result.data as { error?: string }).error).toBeUndefined()

const calls = (
fetchMock as unknown as {
mock: { calls: [string | URL | Request, RequestInit | undefined][] }
}
).mock.calls
expect(calls.length).toBe(1)
const body = String(calls[0][1]?.body)
expect(body.startsWith('<!DOCTYPE html>')).toBe(true)
expect(body).toContain('<h1>MD Report</h1>')
expect(body).toContain('<table>')
expect(body).toContain('<title>MD Report</title>')
// Content-Type header must remain text/html — the Worker only accepts HTML.
expect(calls[0][1]?.headers).toMatchObject({ 'Content-Type': 'text/html' })
})

test('rejects unsupported extension without calling fetch', async () => {
let fetchCalled = false
globalThis.fetch = mock(() => {
fetchCalled = true
return Promise.resolve(new Response('{}'))
}) as unknown as typeof fetch

const result = await ArtifactTool.call({ file_path: TXT_FILE, ttl: 7 })

expect(fetchCalled).toBe(false)
expect((result.data as { error?: string }).error).toContain(
'Unsupported file extension',
)
expect((result.data as { error?: string }).error).toContain('.md')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, test } from 'bun:test'
import { markdownToHtml } from '../markdown.js'

describe('markdownToHtml', () => {
test('wraps body in a full HTML document', () => {
const out = markdownToHtml('# Hello')
expect(out.startsWith('<!DOCTYPE html>')).toBe(true)
expect(out).toContain('<html')
expect(out).toContain('</html>')
expect(out).toContain('<style>')
})

test('extracts H1 as <title>', () => {
const out = markdownToHtml('# Hello World\n\nbody')
expect(out).toContain('<title>Hello World</title>')
expect(out).toContain('<h1>Hello World</h1>')
})

test('renders GFM tables', () => {
const md = ['| A | B |', '| - | - |', '| 1 | 2 |'].join('\n')
const out = markdownToHtml(md)
expect(out).toContain('<table>')
expect(out).toContain('<th>A</th>')
expect(out).toContain('<td>1</td>')
})

test('preserves fenced code language class', () => {
const md = '```ts\nconst x = 1\n```'
const out = markdownToHtml(md)
expect(out).toContain('class="language-ts"')
expect(out).toContain('<pre>')
})

test('passes through inline raw HTML', () => {
const out = markdownToHtml('<strong>raw</strong>')
expect(out).toContain('<strong>raw</strong>')
})

test('renders blockquotes', () => {
const out = markdownToHtml('> quoted')
expect(out).toContain('<blockquote>')
})

test('falls back to basename when no H1 present', () => {
const out = markdownToHtml('just body text', '/tmp/My Report.md')
expect(out).toContain('<title>My Report</title>')
})

test('falls back to default when no H1 and no filename', () => {
const out = markdownToHtml('just body text')
expect(out).toContain('<title>Artifact</title>')
})

test('strips .markdown suffix in fallback title', () => {
const out = markdownToHtml('body', '/x/foo.markdown')
expect(out).toContain('<title>foo</title>')
})

test('escapes HTML in title to prevent injection', () => {
const out = markdownToHtml('# Title <script>alert(1)</script>')
// Title tag must not contain a literal <script>
const titleMatch = out.match(/<title>([\s\S]*?)<\/title>/)
expect(titleMatch).not.toBeNull()
expect(titleMatch![1]).not.toContain('<script>')
expect(titleMatch![1]).toContain('&lt;script&gt;')
})

test('embeds highlight.js and mermaid via unpkg CDN', () => {
const out = markdownToHtml('body')
expect(out).toContain(
'https://unpkg.com/@highlightjs/cdn-assets@11.10.0/highlight.min.js',
)
expect(out).toContain(
'https://unpkg.com/@highlightjs/cdn-assets@11.10.0/styles/github.min.css',
)
expect(out).toContain('https://unpkg.com/mermaid@11/dist/mermaid.min.js')
// Init script must run after both libraries load.
expect(out).toContain('hljs.highlightAll()')
expect(out).toContain('mermaid.initialize(')
expect(out).toContain('language-mermaid')
const mermaidIdx = out.indexOf('mermaid@11/dist/mermaid.min.js')
const hljsIdx = out.indexOf('highlight.min.js')
const initIdx = out.indexOf('hljs.highlightAll')
expect(mermaidIdx).toBeGreaterThan(0)
expect(hljsIdx).toBeGreaterThan(mermaidIdx)
expect(initIdx).toBeGreaterThan(hljsIdx)
})
})
Loading