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
6 changes: 6 additions & 0 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,15 @@ def _dag_warnings_suppressed(silent: bool) -> Iterator[None]:
# transcript; see #94 for the wider "propagate this state to
# surrounding messages" follow-up.
"permission-mode", # {permissionMode: 'acceptEdits'|...}
"mode", # {mode: 'normal'|...}
"custom-title", # {customTitle: <str>}
"agent-name", # {agentName: <str>}
"agent-color", # {agentColor: <str>}
# Written alongside a successful Artifact publish (#257):
# {path, frameUrl, timestamp} maps the source file to the deployed
# claude.ai page. No uuid; fully redundant with the Artifact
# tool_result (same path and URL), which is rendered.
"frame-link",
}
)

Expand Down
60 changes: 60 additions & 0 deletions claude_code_log/factories/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
SkillInput,
WebSearchInput,
WebFetchInput,
ArtifactInput,
WriteInput,
# Tool output models
AskUserQuestionAnswer,
Expand All @@ -85,6 +86,7 @@
WebSearchLink,
WebSearchOutput,
WebFetchOutput,
ArtifactOutput,
WriteOutput,
)

Expand Down Expand Up @@ -115,6 +117,9 @@
"ExitPlanMode": ExitPlanModeInput,
"WebSearch": WebSearchInput,
"WebFetch": WebFetchInput,
# Deploys a self-contained HTML/Markdown file as a claude.ai page
# (Claude Code 2.1.172+, issue #257).
"Artifact": ArtifactInput,
"Monitor": MonitorInput,
"ScheduleWakeup": ScheduleWakeupInput,
"CronCreate": CronCreateInput,
Expand Down Expand Up @@ -918,6 +923,59 @@ def parse_webfetch_output(
return None


# Text form of a successful Artifact deploy: ``Published <path> at <url>``.
# Fallback for entries that lack the structured toolUseResult.
_ARTIFACT_PUBLISHED_RE = re.compile(
r"^Published (?P<path>.+) at (?P<url>https?://\S+)$"
)


def parse_artifact_output(
tool_result: ToolResultContent,
file_path: Optional[str],
tool_use_result: Optional[ToolUseResult] = None,
) -> Optional[ArtifactOutput]:
"""Parse Artifact tool result from structured toolUseResult or text.

Wire format (observed on Claude Code 2.1.198):

- toolUseResult: ``{url, path, title}`` — ``title`` is the page's
``<title>`` for HTML sources, the file basename for Markdown ones.
- text content: ``Published <path> at <url>`` on success; a plain
error message (``is_error: true``, no toolUseResult) on denial.

Args:
tool_result: The tool result content (used as fallback)
file_path: Unused for the Artifact tool
tool_use_result: Structured result with url/path/title

Returns:
ArtifactOutput if parsing succeeds, None otherwise
"""
del file_path # Unused

if isinstance(tool_use_result, dict):
url = tool_use_result.get("url")
if isinstance(url, str) and url:
path = tool_use_result.get("path")
title = tool_use_result.get("title")
return ArtifactOutput(
url=url,
path=path if isinstance(path, str) else None,
title=title if isinstance(title, str) else None,
)

# Fallback: text content — the success line, or an error/denial message.
text = _extract_tool_result_text(tool_result)
if text:
match = _ARTIFACT_PUBLISHED_RE.match(text.strip())
if match:
return ArtifactOutput(url=match.group("url"), path=match.group("path"))
return ArtifactOutput(raw_text=text)

return None


# Match ``Monitor started (task <id>, …)`` so the task id is available
# to downstream consumers without re-parsing the full body. The id is
# the short alphanumeric form (e.g. ``b07h5t4ng``) the harness echoes
Expand Down Expand Up @@ -1421,6 +1479,7 @@ def parse_taskstop_output(
"ExitPlanMode": parse_exitplanmode_output,
"WebSearch": parse_websearch_output,
"WebFetch": parse_webfetch_output,
"Artifact": parse_artifact_output,
"Monitor": parse_monitor_output,
"ScheduleWakeup": parse_schedulewakeup_output,
"CronCreate": parse_croncreate_output,
Expand All @@ -1443,6 +1502,7 @@ def parse_taskstop_output(
PARSERS_WITH_TOOL_USE_RESULT: set[str] = {
"WebSearch",
"WebFetch",
"Artifact",
"Bash",
"TaskStop",
"Read",
Expand Down
4 changes: 4 additions & 0 deletions claude_code_log/html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from .tool_formatters import (
# Tool input formatters (called by HtmlRenderer.format_{InputClass})
format_artifact_input,
format_askuserquestion_input,
format_bash_input,
format_edit_input,
Expand All @@ -28,6 +29,7 @@
format_webfetch_input,
format_write_input,
# Tool output formatters (called by HtmlRenderer.format_{OutputClass})
format_artifact_output,
format_askuserquestion_output,
format_bash_output,
format_edit_output,
Expand Down Expand Up @@ -110,6 +112,8 @@
"format_read_input",
"format_task_input",
"format_todowrite_input",
"format_artifact_input",
"format_artifact_output",
"format_webfetch_input",
"format_write_input",
# tool_formatters (output) - called by HtmlRenderer.format_{OutputClass}
Expand Down
19 changes: 19 additions & 0 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
ToolUseContent,
SkillInput,
WebSearchInput,
ArtifactInput,
WebFetchInput,
WorkflowToolInput,
MonitorInput,
Expand Down Expand Up @@ -88,6 +89,7 @@
TeamDeleteOutput,
ToolResultContent,
WebSearchOutput,
ArtifactOutput,
WebFetchOutput,
WriteOutput,
)
Expand Down Expand Up @@ -171,6 +173,8 @@
format_grep_input,
format_websearch_input,
format_websearch_output,
format_artifact_input,
format_artifact_output,
format_webfetch_input,
format_workflow_input,
format_workflow_phase_content,
Expand Down Expand Up @@ -853,6 +857,14 @@ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> s
"""Format → collapsible markdown with metadata badge."""
return format_webfetch_output(output)

def format_ArtifactInput(self, input: ArtifactInput, _: TemplateMessage) -> str:
"""Format → description + favicon/label/redeploy meta line."""
return format_artifact_input(input)

def format_ArtifactOutput(self, output: ArtifactOutput, _: TemplateMessage) -> str:
"""Format → 'Published <title> → <url>' with scheme-guarded link."""
return format_artifact_output(output)

def format_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str:
"""Format → 4-row params table with collapsible command."""
return format_monitor_input(input)
Expand Down Expand Up @@ -1212,6 +1224,13 @@ def title_WebFetchInput(
"""Title → '🌐 WebFetch <url>'."""
return self._tool_title(message, "🌐", input.url)

def title_ArtifactInput(
self, input: ArtifactInput, message: TemplateMessage
) -> str:
"""Title → '🖼️ Artifact <file_path>' (same full-path recipe as
Read/Write — the deployed page is identified by its source file)."""
return self._tool_title(message, "🖼️", input.file_path)

def title_MonitorInput(self, input: MonitorInput, message: TemplateMessage) -> str:
"""Title → '🔭 Monitor <description>'."""
return self._tool_title(message, "🔭", input.description)
Expand Down
78 changes: 77 additions & 1 deletion claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
render_user_markdown_collapsible,
resolve_memory_body_links,
)
from ..utils import strip_error_tags
from ..utils import is_safe_web_url, strip_error_tags
from ..workflow import resolve_workflow_header, resolve_workflow_script
from ..models import (
AskUserQuestionInput,
Expand Down Expand Up @@ -69,6 +69,8 @@
ToolResultContent,
WebSearchInput,
WebSearchOutput,
ArtifactInput,
ArtifactOutput,
WebFetchInput,
WebFetchOutput,
WorkflowAgentMessage,
Expand Down Expand Up @@ -857,6 +859,78 @@ def format_webfetch_output(output: WebFetchOutput) -> str:
return f"{badge_html}{content_html}"


# -- Artifact Tool ------------------------------------------------------------


def _artifact_link(url: str) -> str:
"""Render an artifact URL as a link, or as escaped text for odd schemes.

The URL is transcript-derived and thus attacker-controllable; the
shared ``is_safe_web_url`` scheme gate decides link-worthiness
(escaping alone doesn't neutralise a hostile ``javascript:`` scheme).
"""
escaped = escape_html(url)
if is_safe_web_url(url):
return f'<a href="{escaped}">{escaped}</a>'
return escaped


def format_artifact_input(artifact_input: ArtifactInput) -> str:
"""Format Artifact tool use content.

The source file path is shown in the title; the body carries the
optional metadata (description, favicon, version label, redeploy
target) when present. The page content itself never appears in the
transcript — the tool takes a file path — so there is nothing to
render live; every field is escaped.
"""
rows: list[str] = []
if artifact_input.description:
rows.append(
f'<div class="artifact-description">'
f"{escape_html(artifact_input.description)}</div>"
)
meta_parts: list[str] = []
if artifact_input.favicon:
meta_parts.append(
f'<span class="artifact-favicon">{escape_html(artifact_input.favicon)}</span>'
)
if artifact_input.label:
meta_parts.append(
f'<span class="artifact-label">{escape_html(artifact_input.label)}</span>'
)
if artifact_input.url:
meta_parts.append(
f'<span class="artifact-redeploy">redeploys {_artifact_link(artifact_input.url)}</span>'
)
if artifact_input.force:
meta_parts.append('<span class="artifact-force">force</span>')
if meta_parts:
rows.append(f'<div class="artifact-meta">{" · ".join(meta_parts)}</div>')
return "".join(rows)


def format_artifact_output(output: ArtifactOutput) -> str:
"""Format Artifact tool result — published page title + link.

Falls back to the escaped raw text for error/denial results that
carry no structured data.
"""
if output.url:
title_html = (
f'<span class="artifact-title">{escape_html(output.title)}</span> → '
if output.title
else ""
)
return (
f'<div class="artifact-output">Published '
f"{title_html}{_artifact_link(output.url)}</div>"
)
if output.raw_text:
return f'<div class="artifact-output">{escape_html(output.raw_text)}</div>'
return ""


# -- Monitor Tool -------------------------------------------------------------


Expand Down Expand Up @@ -1683,6 +1757,8 @@ def format_workflow_agent_content(content: WorkflowAgentMessage) -> str:
"format_taskstop_input",
"format_grep_input",
"format_websearch_input",
"format_artifact_input",
"format_artifact_output",
"format_webfetch_input",
"format_workflow_input",
"format_workflow_phase_content",
Expand Down
Loading
Loading