feat: 新增 artifacts 功能 (#1278)

* feat: 新增 cloud-artifacts 包(Cloudflare Worker HTML artifact 托管)

POST /upload 鉴权上传 HTML 到 R2 返回 hash URL,GET /<7d|30d>/<id>.html
由 Worker 代理读取并直出 text/html。R2 lifecycle rule 自动 7/30 天删除。
独立服务,不被主 CLI 引用(类似 packages/remote-control-server/ 定位)。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs: 完善 cloud-artifacts 文档并统一出口域名

- CLAUDE.md 加 cloud-artifacts 到 Workspace Packages 表和新增 HTML Artifact Hosting 段落
- docs.json 注册 cloud-artifacts 到运行模式 group
- README 加 Quickstart、架构图(含 Deno Deploy 代理层)、Security Considerations、Troubleshooting
- 统一出口域名为 https://cloud-artifacts.claude-code-best.win(wrangler.toml PUBLIC_URL、test.sh 默认 WORKER_URL、所有文档示例)
- test.sh expect() 加 [via body] fallback:经 Deno Deploy 代理(status 抹平为 200)时按 body 的 error 字段断言

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs: 修正 CLAUDE.md cloud-artifacts 引用死链

之前指向不存在的 docs/features/cloud-artifacts.md(用户未注册到 docs.json),
改为指向已存在的 packages/cloud-artifacts/README.md,并补充生产出口域名
与 Deno Deploy status 抹平副作用的说明。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 同步 cloud-artifacts 测试默认 TOKEN 到新值

用户已通过 wrangler secret put 把生产 TOKEN 改为 claude-code-best,
test.sh 的默认值(之前用旧 token 作 fallback)和注释示例同步更新。
现在直接 bash scripts/test.sh 即可跑通(无需显式传 TOKEN)。

src/index.ts 不依赖具体 token 值(只读 env.TOKEN 做比较),
wrangler.toml 不含 secret,README/.dev.vars.example 用 <your-token>
占位符故无需改。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs: add artifacts feature implementation plan

* feat(artifact): add cloud-artifacts config with token/URL defaults

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add HTTP client with body-error parsing

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add tool name, description, and prompt

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add buildTool definition with file validation

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* test(artifact): add end-to-end tool tests for upload/error paths

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): export ArtifactTool from builtin-tools barrel

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): register ArtifactTool in tools list

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add /use-artifacts bundled skill

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add extractArtifacts message scanner

Scans Message[] for artifact tool_use/tool_result pairs, parses URL/id/expires
from the upload response string, and returns ArtifactInfo[] newest-first.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(artifact): scanner type narrowing and url regex

- Use double assertion (`as unknown as Record<string, unknown>`) at lines 30
  and 90 to fix TS2352 per project convention
- Tighten URL_REGEX to avoid capturing trailing punctuation (parens,
  quotes, commas) when URL is embedded in text
- Add test case for array-form tool_result content path

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add ArtifactsMenu Ink component

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): add /artifacts slash command entry

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): register /artifacts command

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(artifact): use setClipboard instead of pbcopy for cross-platform support

* fix(artifact): drop userFacingName override so display matches /artifacts

* fix(rcs): add resJson helper to resolve strict mode type errors in tests

Hono Response.json() returns Promise<unknown> under strict TypeScript,
causing 121 TS errors across middleware and routes test files.

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>

* fix(cloud-artifacts): add type stubs so tsc passes without worker-configuration.d.ts

The wrangler-generated worker-configuration.d.ts is gitignored, causing CI to
fail with missing ExportedHandler/Env/R2Bucket types. This file provides minimal
stubs for all Cloudflare Workers types used by the artifact upload Worker.

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>

* fix(query): shallow-copy messages before stripping toolUseResult

Previously the per-query cleanup mutated messagesForQuery entries in
place via `delete msg.toolUseResult`. Those entries are references
shared with mutableMessages (UI state), so the delete stripped the
field from the live message object. The next query can start within
milliseconds of tool_result creation — before the React UI commit
lands — so UserToolSuccessMessage's `!message.toolUseResult` check
returned null and tool.renderToolResultMessage was never called,
leaving tool-result rows blank.

Map to a stripped copy instead so mutableMessages keeps the original
for the UI. Downstream API transformations (applyToolResultBudget,
snip, microcompact) already build new arrays via .map(), so they
compose cleanly with this copy.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(artifact): show uploaded URL inline below ExecuteExtraTool

Deferred tools (shouldDefer: true) are invoked via SearchExtraTools →
ExecuteExtraTool, so their tool_result rows used to render blank —
the UI looked up ExecuteExtraTool, which had no renderToolResultMessage,
and returned null. Add a generic delegation in ExecuteTool that forwards
renderToolResultMessage to the inner tool when it defines one, unwrapping
the { result, tool_name } envelope and the params from the input shape.
All 28 deferred tools can now render their own UI by defining
renderToolResultMessage.

For ArtifactTool specifically, render the uploaded URL as an OSC 8
hyperlink (Link component) in warning color so it's visually prominent,
with the expiry timestamp on a second line and a separate error branch.
Also add `error: z.string().optional()` to outputSchema — zod's default
strip mode was dropping the field, so error states never reached the UI.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(cloud-artifacts): make Env stubs actually take effect in CI

The previous stub file (2e29e362) wrapped `interface Env` in
`declare global { ... }`, but the file has no top-level import/export so
it's a script, not a module. TS2669 forbids `declare global` in scripts,
and in .d.ts files that error is silently swallowed — so the Env stubs
were never merged into the global scope. Locally typecheck passed only
because worker-configuration.d.ts (gitignored) provided Env separately;
in CI / fresh clones, `BUCKET`, `MAX_BYTES`, `DEFAULT_TTL_DAYS`,
`PUBLIC_URL` were all missing on Env.

Drop the wrapper. Top-level `interface Env` in a script .d.ts is already
global ambient and merges with worker-configuration.d.ts via interface
declaration merging, so both environments typecheck cleanly.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

---------

Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-20 19:52:08 +08:00
committed by GitHub
parent 5d74071ebf
commit 084e487943
36 changed files with 3713 additions and 113 deletions

View File

@@ -46,6 +46,7 @@ export { MonitorTool } from './tools/MonitorTool/MonitorTool.js'
export { PowerShellTool } from './tools/PowerShellTool/PowerShellTool.js'
export { PushNotificationTool } from './tools/PushNotificationTool/PushNotificationTool.js'
export { REPLTool } from './tools/REPLTool/REPLTool.js'
export { ArtifactTool } from './tools/ArtifactTool/ArtifactTool.js'
export { RemoteTriggerTool } from './tools/RemoteTriggerTool/RemoteTriggerTool.js'
export { ReviewArtifactTool } from './tools/ReviewArtifactTool/ReviewArtifactTool.js'
export { CronCreateTool } from './tools/ScheduleCronTool/CronCreateTool.js'

View File

@@ -0,0 +1,177 @@
import { stat, readFile } from 'fs/promises'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
ARTIFACT_TOOL_NAME,
describeArtifactTool,
getArtifactToolPrompt,
} from './prompt.js'
import { getArtifactsToken, getUploadUrl } from './config.js'
import { uploadArtifact } from './client.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.'),
hash: z
.string()
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
.optional()
.describe(
'If provided, overwrites the existing artifact with this hash (URL stays stable). If omitted, a new random id is generated.',
),
ttl: z
.union([z.literal(7), z.literal(30)])
.default(7)
.describe('Lifetime in days. Must be 7 or 30. Default 7.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type ArtifactInput = z.infer<InputSchema>
const outputSchema = lazySchema(() =>
z.object({
id: z.string(),
url: z.string(),
expiresAt: z.string(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
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',
maxResultSizeChars: 2_000,
shouldDefer: true,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async description() {
return describeArtifactTool()
},
async prompt() {
return getArtifactToolPrompt()
},
isEnabled() {
return true
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return false
},
requiresUserInteraction() {
return true
},
userFacingName() {
return 'Artifact'
},
renderToolUseMessage(input: Partial<ArtifactInput>) {
const hashPart = input.hash ? ` (hash=${input.hash})` : ''
return `Upload artifact: ${input.file_path ?? '...'}${hashPart}`
},
mapToolResultToToolResultBlockParam(
content: ArtifactOutput,
toolUseID: string,
): ToolResultBlockParam {
if (content.error) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
is_error: true,
content: content.error,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
}
},
renderToolResultMessage,
async call(input: ArtifactInput) {
const { file_path, hash, ttl } = input
let size: number
try {
const fileStat = await stat(file_path)
if (!fileStat.isFile()) {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `Path is not a regular file: ${file_path}`,
},
}
}
size = fileStat.size
} catch {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `File does not exist or is not readable: ${file_path}`,
},
}
}
if (size > 10 * 1024 * 1024) {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `File is ${size} bytes; backend limit is 10MB.`,
},
}
}
let html: string
try {
html = await readFile(file_path, 'utf8')
} catch {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `Failed to read file: ${file_path}`,
},
}
}
try {
const result = await uploadArtifact({
html,
token: getArtifactsToken(),
uploadUrl: getUploadUrl(),
hash,
ttl,
})
return { data: result }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
return { data: { id: '', url: '', expiresAt: '', error: message } }
}
},
})

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { Box, Link, Text } from '@anthropic/ink';
import type { ToolProgressData } from 'src/Tool.js';
import type { ProgressMessage } from 'src/types/message.js';
import type { ArtifactOutput } from './ArtifactTool.js';
export function renderToolResultMessage(
content: ArtifactOutput,
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
_options: { verbose: boolean; theme?: string },
): React.ReactNode {
if (content.error) {
return (
<Box>
<Text color="error"> Artifact upload failed: {content.error}</Text>
</Box>
);
}
if (!content.url) return null;
return (
<Box flexDirection="column">
<Box>
<Text>
<Text color="success"></Text> Artifact uploaded:{' '}
<Link url={content.url}>
<Text color="warning">{content.url}</Text>
</Link>
</Text>
</Box>
{content.expiresAt ? (
<Box>
<Text dimColor>expires: {content.expiresAt}</Text>
</Box>
) : null}
</Box>
);
}

View File

@@ -0,0 +1,112 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { ArtifactTool } from '../ArtifactTool.js'
const TEST_DIR = join(tmpdir(), 'artifact-tool-test')
const TEST_FILE = join(TEST_DIR, 'report.html')
const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html')
const DIR_AS_FILE = TEST_DIR
const originalFetch = globalThis.fetch
function mockFetchSuccess(body: object): typeof fetch {
return mock(() =>
Promise.resolve(
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
) as unknown as typeof fetch
}
describe('ArtifactTool.call', () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
writeFileSync(TEST_FILE, '<h1>test report</h1>', 'utf8')
process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token'
process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test'
})
afterEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
delete process.env.CLAUDE_ARTIFACTS_TOKEN
delete process.env.CLAUDE_ARTIFACTS_URL
globalThis.fetch = originalFetch
})
test('uploads existing HTML file and returns id/url/expiresAt', async () => {
globalThis.fetch = mockFetchSuccess({
id: 'abc123',
url: 'https://example.test/7d/abc123.html',
expiresAt: '2026-06-27T10:00:00.000Z',
})
const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 })
expect(result.data).toMatchObject({
id: 'abc123',
url: 'https://example.test/7d/abc123.html',
expiresAt: '2026-06-27T10:00:00.000Z',
})
expect((result.data as { error?: string }).error).toBeUndefined()
})
test('passes hash through when overwriting', async () => {
const fetchMock = mockFetchSuccess({
id: 'stable-id',
url: 'https://example.test/7d/stable-id.html',
expiresAt: '2026-06-27T10:00:00.000Z',
})
globalThis.fetch = fetchMock
await ArtifactTool.call({ file_path: TEST_FILE, hash: 'stable-id', ttl: 7 })
const calledUrl = (
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
).mock.calls[0][0]
expect(calledUrl.toString()).toContain('hash=stable-id')
})
test('returns error when file does not exist (no HTTP call)', 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: MISSING_FILE, ttl: 7 })
expect(fetchCalled).toBe(false)
expect((result.data as { error?: string }).error).toContain(
'does not exist',
)
})
test('returns error when path is a directory', async () => {
const result = await ArtifactTool.call({ file_path: DIR_AS_FILE, ttl: 7 })
expect((result.data as { error?: string }).error).toContain(
'not a regular file',
)
})
test('returns error verbatim when backend rejects', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 200,
}),
),
) as unknown as typeof fetch
// Force the size guard to pass by writing a small file but having backend complain.
const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 })
expect((result.data as { error?: string }).error).toContain(
'payload_too_large',
)
})
})

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import * as React from 'react';
import type { ProgressMessage } from 'src/types/message.js';
import type { ToolProgressData } from 'src/Tool.js';
import { renderToolResultMessage } from '../UI.js';
import type { ArtifactOutput } from '../ArtifactTool.js';
const NO_PROGRESS: ProgressMessage<ToolProgressData>[] = [];
const OPTIONS = { verbose: false, theme: 'dark' } as never;
/** Walk a React element tree and concatenate all string/number children. */
function extractText(node: React.ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string') return node;
if (typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(extractText).join('');
if (React.isValidElement(node)) {
const children = (node.props as { children?: React.ReactNode }).children;
return extractText(children);
}
return '';
}
describe('ArtifactTool UI.renderToolResultMessage', () => {
test('renders the uploaded URL and expiry on success', () => {
const content: ArtifactOutput = {
id: 'abc123',
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc123.html',
expiresAt: '2026-06-27T10:00:00.000Z',
};
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(React.isValidElement(node)).toBe(true);
const text = extractText(node);
expect(text).toContain(content.url);
expect(text).toContain(content.expiresAt);
expect(text).toContain('Artifact uploaded');
});
test('renders the error message on failure', () => {
const content: ArtifactOutput = {
id: '',
url: '',
expiresAt: '',
error: 'File does not exist or is not readable: /tmp/missing.html',
};
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(React.isValidElement(node)).toBe(true);
const text = extractText(node);
expect(text).toContain('Artifact upload failed');
expect(text).toContain('/tmp/missing.html');
});
test('returns null when url is empty without error', () => {
const content: ArtifactOutput = { id: '', url: '', expiresAt: '' };
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(node).toBeNull();
});
test('omits the expiry line when expiresAt is empty', () => {
const content: ArtifactOutput = {
id: 'abc',
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc.html',
expiresAt: '',
};
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(React.isValidElement(node)).toBe(true);
// Sanity: still renders URL even without expiry
expect(extractText(node)).toContain(content.url);
});
});

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { uploadArtifact } from '../client.js'
const originalFetch = globalThis.fetch
function mockFetch(body: object, status = 200): typeof fetch {
return mock((_url: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
}),
),
) as unknown as typeof fetch
}
describe('uploadArtifact', () => {
afterEach(() => {
globalThis.fetch = originalFetch
})
test('returns id/url/expiresAt on successful upload', async () => {
globalThis.fetch = mockFetch({
id: 'V1StGXR8_Z5jdHi6B',
url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
expiresAt: '2026-06-27T10:00:00.000Z',
})
const result = await uploadArtifact({
html: '<h1>hello</h1>',
token: 'test-token',
uploadUrl: 'https://example.test/upload',
})
expect(result).toEqual({
id: 'V1StGXR8_Z5jdHi6B',
url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
expiresAt: '2026-06-27T10:00:00.000Z',
})
})
test('passes hash as query param when provided', async () => {
const fetchMock = mockFetch({
id: 'my-id',
url: 'https://x/y.html',
expiresAt: '2026-06-27T00:00:00.000Z',
})
globalThis.fetch = fetchMock
await uploadArtifact({
html: '<p>x</p>',
token: 't',
uploadUrl: 'https://example.test/upload',
hash: 'my-id',
})
const calledUrl = (
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
).mock.calls[0][0]
expect(calledUrl.toString()).toContain('hash=my-id')
})
test('passes ttl=30 query param when provided', async () => {
const fetchMock = mockFetch({
id: 'x',
url: 'https://x',
expiresAt: '2026-07-20T00:00:00.000Z',
})
globalThis.fetch = fetchMock
await uploadArtifact({
html: '<p>x</p>',
token: 't',
uploadUrl: 'https://example.test/upload',
ttl: 30,
})
const calledUrl = (
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
).mock.calls[0][0]
expect(calledUrl.toString()).toContain('ttl=30')
})
test('throws with error code when body contains {error} (Deno Deploy flattens status)', async () => {
globalThis.fetch = mockFetch({ error: 'payload_too_large' }, 200)
await expect(
uploadArtifact({
html: 'x'.repeat(100),
token: 't',
uploadUrl: 'https://example.test/upload',
}),
).rejects.toThrow(/payload_too_large/)
})
test('throws on non-JSON body', async () => {
globalThis.fetch = mock((_u: string | URL | Request) =>
Promise.resolve(new Response('Internal Server Error', { status: 500 })),
) as unknown as typeof fetch
await expect(
uploadArtifact({
html: '<p/>',
token: 't',
uploadUrl: 'https://example.test/upload',
}),
).rejects.toThrow()
})
})

View File

@@ -0,0 +1,59 @@
export type UploadResult = {
id: string
url: string
expiresAt: string
}
export type UploadParams = {
html: string
token: string
uploadUrl: string
hash?: string
ttl?: 7 | 30
}
export async function uploadArtifact(
params: UploadParams,
): Promise<UploadResult> {
const url = new URL(params.uploadUrl)
if (params.hash) url.searchParams.set('hash', params.hash)
if (params.ttl) url.searchParams.set('ttl', String(params.ttl))
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${params.token}`,
'Content-Type': 'text/html',
},
body: params.html,
})
// Deno Deploy proxy flattens upstream status to 200; the Worker embeds the
// real error in the body as `{ "error": "<code>" }`. Always parse body first.
const text = await response.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error(
`Artifact upload failed: HTTP ${response.status} (non-JSON body)`,
)
}
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
const code = (parsed as { error: unknown }).error
throw new Error(`Artifact upload failed: ${String(code)}`)
}
const data = parsed as Partial<UploadResult>
if (
typeof data.id !== 'string' ||
typeof data.url !== 'string' ||
typeof data.expiresAt !== 'string'
) {
throw new Error(
`Artifact upload returned malformed body: ${text.slice(0, 200)}`,
)
}
return { id: data.id, url: data.url, expiresAt: data.expiresAt }
}

View File

@@ -0,0 +1,21 @@
/**
* Cloud Artifacts service configuration.
* Token/URL have hardcoded production defaults; env vars override for self-hosted deployments.
*/
export const ARTIFACTS_DEFAULT_TOKEN = 'claude-code-best'
export const ARTIFACTS_DEFAULT_URL =
'https://cloud-artifacts.claude-code-best.win'
export function getArtifactsToken(): string {
return process.env.CLAUDE_ARTIFACTS_TOKEN ?? ARTIFACTS_DEFAULT_TOKEN
}
export function getArtifactsBaseUrl(): string {
return process.env.CLAUDE_ARTIFACTS_URL ?? ARTIFACTS_DEFAULT_URL
}
/** Strip trailing slash so `${base}/upload` is well-formed. */
export function getUploadUrl(): string {
const base = getArtifactsBaseUrl()
return base.endsWith('/') ? `${base}upload` : `${base}/upload`
}

View File

@@ -0,0 +1,25 @@
export const ARTIFACT_TOOL_NAME = 'artifact'
export async function describeArtifactTool(): Promise<string> {
return 'Upload an HTML file to the cloud-artifacts hosting service and get back a public URL. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).'
}
export async function getArtifactToolPrompt(): Promise<string> {
return `Upload an HTML file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash").
## Inputs
- \`file_path\` (required): absolute path to a local HTML file.
- \`hash\` (optional): if provided, overwrites the artifact with the same hash (URL stays the same). If omitted, a new random id is generated.
- \`ttl\` (optional, default \`7\`): artifact lifetime in days. Must be \`7\` or \`30\`.
## Output
\`{ id, url, expiresAt }\`\`id\` is the hash (save it for future overwrite calls), \`url\` is publicly accessible.
## Workflow
1. Use the Write tool to create a local HTML file.
2. Call this tool with its \`file_path\`.
3. If iterating on the same artifact, pass back the \`id\` returned from the first call as \`hash\` so the URL stays stable.
## Errors
The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist or is not a regular file, the tool returns an \`error\` field without making an HTTP request.`
}

View File

@@ -236,4 +236,29 @@ export const ExecuteTool = buildTool({
content: JSON.stringify(content),
}
},
// Output shape: { result: <inner tool output>, tool_name: string }.
// Delegate rendering to the inner tool when it defines its own
// renderToolResultMessage so deferred tools can show their own UI
// (e.g. ArtifactTool displays its uploaded URL). Without this, the
// ExecuteExtraTool tool_result row renders nothing below the tool_use
// line. The inner tool expects its own input shape, so unwrap params.
//
// Inline the lookup rather than calling findToolByName — deferred tools
// are matched by exact name (no aliases needed), and avoiding the
// shared helper keeps this method resilient to src/Tool.js mocks in
// co-located test files (process-global mock.module pollution).
renderToolResultMessage(content, progressMessages, options) {
const innerTool = options.tools.find(t => t.name === content.tool_name)
if (!innerTool?.renderToolResultMessage) return null
const innerInput = (options.input as { params?: unknown } | undefined)
?.params
return innerTool.renderToolResultMessage(
content.result as never,
progressMessages,
{
...options,
input: innerInput,
},
)
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,167 @@
import { describe, expect, test, mock } from 'bun:test'
import { logMock } from '../../../../../../tests/mocks/log'
import { debugMock } from '../../../../../../tests/mocks/debug'
// Same mock setup as ExecuteTool.runner.ts — ExecuteTool's import chain
// (growthbook, searchExtraTools, messages) loads real modules with side
// effects otherwise. mock.module is process-global; identical setup in
// sibling test files in this directory is safe (last-write-wins, same stubs).
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
getFeatureValue_DEPRECATED: async () => undefined,
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
hasGrowthBookEnvOverride: () => false,
getAllGrowthBookFeatures: () => ({}),
getGrowthBookConfigOverrides: () => ({}),
setGrowthBookConfigOverride: () => {},
clearGrowthBookConfigOverrides: () => {},
getApiBaseUrlHost: () => undefined,
onGrowthBookRefresh: () => {},
initializeGrowthBook: async () => {},
checkSecurityRestrictionGate: async () => false,
checkGate_CACHED_OR_BLOCKING: async () => false,
refreshGrowthBookAfterAuthChange: () => {},
resetGrowthBook: () => {},
refreshGrowthBookFeatures: async () => {},
setupPeriodicGrowthBookRefresh: () => {},
stopPeriodicGrowthBookRefresh: () => {},
}))
mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: () => true,
isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set<string>(),
isDeferredToolsDeltaEnabled: () => false,
getDeferredToolsDelta: () => null,
}))
mock.module('src/constants/tools.js', () => ({
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
}))
mock.module('src/utils/messages.js', () => ({
createUserMessage: ({ content }: { content: string }) => ({
type: 'user' as const,
content,
uuid: 'test-uuid',
}),
INTERRUPT_MESSAGE_FOR_TOOL_USE: '[Request interrupted]',
}))
mock.module('src/utils/toolErrors.js', () => ({
formatZodValidationError: (_name: string, error: unknown) =>
`validation error: ${JSON.stringify(error)}`,
}))
const { ExecuteTool } = await import('../ExecuteTool.js')
type RenderResult = React.ReactNode
describe('ExecuteTool.renderToolResultMessage delegation', () => {
test('delegates to inner tool with content.result and unwrapped params', () => {
const seen: Array<{
content: unknown
input: unknown
}> = []
const innerRender = (
content: unknown,
_progress: unknown,
options: { input?: unknown },
): RenderResult => {
seen.push({ content, input: options.input })
return 'RENDERED' as unknown as RenderResult
}
const innerTool = {
name: 'artifact',
renderToolResultMessage: innerRender,
}
const tools = [innerTool] as never
const result = ExecuteTool.renderToolResultMessage(
{
result: {
id: 'abc',
url: 'https://example.com/x.html',
expiresAt: 'T',
},
tool_name: 'artifact',
},
[],
{
tools,
input: {
tool_name: 'artifact',
params: { file_path: '/tmp/x.html', ttl: 7 },
},
} as never,
)
expect(result).toBe('RENDERED')
expect(seen).toHaveLength(1)
expect(seen[0]?.content).toEqual({
id: 'abc',
url: 'https://example.com/x.html',
expiresAt: 'T',
})
// Inner tool should see its own params shape, not the ExecuteExtraTool wrapper
expect(seen[0]?.input).toEqual({ file_path: '/tmp/x.html', ttl: 7 })
})
test('returns null when inner tool has no renderToolResultMessage', () => {
const innerTool = { name: 'bare' }
const tools = [innerTool] as never
const result = ExecuteTool.renderToolResultMessage(
{ result: { ok: true }, tool_name: 'bare' },
[],
{ tools, input: { tool_name: 'bare', params: {} } } as never,
)
expect(result).toBeNull()
})
test('returns null when inner tool is not found in tools list', () => {
const tools = [] as never
const result = ExecuteTool.renderToolResultMessage(
{ result: { ok: true }, tool_name: 'missing' },
[],
{ tools, input: { tool_name: 'missing', params: {} } } as never,
)
expect(result).toBeNull()
})
test('passes through undefined input safely when input is missing', () => {
const seen: unknown[] = []
const innerTool = {
name: 'artifact',
renderToolResultMessage: (
_content: unknown,
_progress: unknown,
options: { input?: unknown },
): RenderResult => {
seen.push(options.input)
return null
},
}
const tools = [innerTool] as never
const result = ExecuteTool.renderToolResultMessage(
{ result: { ok: true }, tool_name: 'artifact' },
[],
{ tools } as never,
)
expect(result).toBeNull()
expect(seen[0]).toBeUndefined()
})
})