pi-agent-cli-lc 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .venv/
11
+ .venv-*/
12
+ venv/
13
+ .env
14
+ # Rust TUI workspace (Apache-2.0 fork under tui/)
15
+ tui/target/
16
+ **/*.rs.bk
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: pi-agent-cli-lc
3
+ Version: 0.2.0
4
+ Summary: Standard ACP agent over AgentHarness (no x.ai extensions)
5
+ Project-URL: Homepage, https://github.com/zy1233/pi-python
6
+ Project-URL: Repository, https://github.com/zy1233/pi-python
7
+ Project-URL: Issues, https://github.com/zy1233/pi-python/issues
8
+ Project-URL: Changelog, https://github.com/zy1233/pi-python/blob/main/CHANGELOG.md
9
+ Author-email: zy1233 <zy1233@users.noreply.github.com>
10
+ License-Expression: MIT
11
+ Keywords: acp,agent,ai,cli,coding-agent,llm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: agent-client-protocol>=0.12.0
23
+ Requires-Dist: pi-agent-core-lc==0.2.0
24
+ Requires-Dist: pi-agent-harness-lc==0.2.0
25
+ Requires-Dist: pydantic>=2.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pi-agent-cli-lc
29
+
30
+ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent over `pi-agent-harness`.
31
+
32
+ - stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
33
+ - headless one-shot: `python -m pi_agent_cli -p "..."`
34
+ - Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
35
+ - No `x.ai/*` vendor RPCs — core + harness + ACP only
36
+
37
+ Install:
38
+
39
+ ```bash
40
+ pip install pi-agent-cli-lc
41
+ # needs a LangChain provider, e.g.:
42
+ pip install pi-agent-core-lc[deepseek]
43
+ ```
44
+
45
+ See the [repository README](https://github.com/zy1233/pi-python#install) for development setup and Windows/WSL notes.
@@ -0,0 +1,18 @@
1
+ # pi-agent-cli-lc
2
+
3
+ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent over `pi-agent-harness`.
4
+
5
+ - stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
6
+ - headless one-shot: `python -m pi_agent_cli -p "..."`
7
+ - Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
8
+ - No `x.ai/*` vendor RPCs — core + harness + ACP only
9
+
10
+ Install:
11
+
12
+ ```bash
13
+ pip install pi-agent-cli-lc
14
+ # needs a LangChain provider, e.g.:
15
+ pip install pi-agent-core-lc[deepseek]
16
+ ```
17
+
18
+ See the [repository README](https://github.com/zy1233/pi-python#install) for development setup and Windows/WSL notes.
@@ -0,0 +1,22 @@
1
+ # Copy to ~/.pi-python/agent.toml (or set PI_HOME).
2
+ # Python ACP agent settings ONLY — do not duplicate these keys in config.toml
3
+ # (the Rust TUI parses config.toml as grok-shell config).
4
+ # LLM keys are read from environment variables — never put secrets in this file.
5
+
6
+ permission = "ask" # ask | auto | always-approve
7
+
8
+ thinking_level = "off" # off | minimal | low | medium | high | xhigh
9
+ max_turns = 50
10
+
11
+ [model]
12
+ provider = "deepseek"
13
+ id = "deepseek-ai/DeepSeek-V4-Flash"
14
+ base_url = "https://api.siliconflow.cn/v1"
15
+ api_key_env = "REAL_LLM_API_KEY"
16
+
17
+ [skills]
18
+ paths = ["~/.pi-python/skills", ".pi/skills"]
19
+
20
+ [agent]
21
+ # Used by the Rust TUI when PI_AGENT_COMMAND is unset (Windows: prefer a venv python.exe).
22
+ command = "python -m pi_agent_cli"
@@ -0,0 +1,7 @@
1
+ # Rust TUI (grok-shell) config for ~/.pi-python/config.toml
2
+ #
3
+ # Leave this file empty or use grok-compatible keys only.
4
+ # Python agent settings belong in agent.toml — NOT here.
5
+ #
6
+ # Top-level ``permission = "ask"`` will break TUI startup (grok expects a
7
+ # ``[permission]`` table, not a string).
@@ -0,0 +1,3 @@
1
+ # Copy to ~/.pi-python/local.env (not in git). Loaded by pi_agent_cli on startup.
2
+ # WSL: this file is read by the Windows Python child when pi spawns from Linux.
3
+ REAL_LLM_API_KEY=sk-...
@@ -0,0 +1,19 @@
1
+ """Standard ACP agent over AgentHarness. Vendor extension RPCs are not implemented."""
2
+
3
+ from pi_agent_cli.agent import PiAcpAgent
4
+ from pi_agent_cli.config import (
5
+ CliConfig,
6
+ expand_config_path,
7
+ load_config,
8
+ make_get_api_key,
9
+ pi_home,
10
+ )
11
+
12
+ __all__ = [
13
+ "CliConfig",
14
+ "PiAcpAgent",
15
+ "expand_config_path",
16
+ "load_config",
17
+ "make_get_api_key",
18
+ "pi_home",
19
+ ]
@@ -0,0 +1,81 @@
1
+ """stdio entry: python -m pi_agent_cli
2
+
3
+ Default: ACP agent on stdio.
4
+ `python -m pi_agent_cli -p "..."`: one-shot headless turn (no TUI).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from acp import run_agent
16
+
17
+ from pi_agent_cli.agent import PiAcpAgent
18
+ from pi_agent_cli.config import load_local_env
19
+ from pi_agent_cli.headless import resolve_print_prompt, run_print
20
+
21
+
22
+ async def _amain() -> None:
23
+ await run_agent(PiAcpAgent())
24
+
25
+
26
+ def _build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="pi-agent-cli",
29
+ description="Standard ACP agent over AgentHarness (stdio), or one-shot -p print.",
30
+ )
31
+ src = parser.add_mutually_exclusive_group()
32
+ src.add_argument(
33
+ "-p",
34
+ "--print",
35
+ dest="print_prompt",
36
+ metavar="PROMPT",
37
+ help="Run one prompt, print the assistant text, and exit (no TUI, no ACP stdio).",
38
+ )
39
+ src.add_argument(
40
+ "--prompt-json",
41
+ metavar="JSON",
42
+ help="Single-turn prompt as a JSON string or list of content blocks.",
43
+ )
44
+ src.add_argument(
45
+ "--prompt-file",
46
+ metavar="PATH",
47
+ type=Path,
48
+ help="Read the single-turn prompt from a file.",
49
+ )
50
+ parser.add_argument(
51
+ "--cwd",
52
+ metavar="PATH",
53
+ type=Path,
54
+ help="Working directory for the headless session (default: process cwd).",
55
+ )
56
+ return parser
57
+
58
+
59
+ def main() -> None:
60
+ load_local_env()
61
+ parser = _build_parser()
62
+ args = parser.parse_args()
63
+ headless = any(
64
+ value is not None for value in (args.print_prompt, args.prompt_json, args.prompt_file)
65
+ )
66
+ if headless:
67
+ try:
68
+ prompt = resolve_print_prompt(
69
+ print_prompt=args.print_prompt,
70
+ prompt_json=args.prompt_json,
71
+ prompt_file=args.prompt_file,
72
+ )
73
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
74
+ print(f"error: {exc}", file=sys.stderr)
75
+ raise SystemExit(2) from exc
76
+ raise SystemExit(asyncio.run(run_print(prompt, cwd=args.cwd)))
77
+ asyncio.run(_amain())
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,297 @@
1
+ """ACP Agent: standard methods only; AgentHarness is the engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from acp import PROTOCOL_VERSION, RequestError
10
+ from acp.interfaces import Agent, Client
11
+ from acp.schema import (
12
+ AgentCapabilities,
13
+ AudioContentBlock,
14
+ ClientCapabilities,
15
+ CloseSessionResponse,
16
+ EmbeddedResourceContentBlock,
17
+ HttpMcpServer,
18
+ ImageContentBlock,
19
+ Implementation,
20
+ InitializeResponse,
21
+ ListSessionsResponse,
22
+ LoadSessionResponse,
23
+ McpServerStdio,
24
+ NewSessionResponse,
25
+ PromptCapabilities,
26
+ PromptResponse,
27
+ ResourceContentBlock,
28
+ SessionCapabilities,
29
+ SessionCloseCapabilities,
30
+ SessionInfo,
31
+ SessionListCapabilities,
32
+ SessionResumeCapabilities,
33
+ SseMcpServer,
34
+ TextContentBlock,
35
+ )
36
+
37
+ from pi_agent_cli.config import CliConfig, load_config, pi_home
38
+ from pi_agent_cli.events import project_event
39
+ from pi_agent_cli.factory import create_session_harness, default_stream_fn, load_session_resources
40
+ from pi_agent_cli.permissions import (
41
+ PERMISSION_OPTIONS,
42
+ needs_permission,
43
+ outcome_allows,
44
+ permission_tool_call,
45
+ )
46
+ from pi_agent_core.coding_tools.path_utils import normalize_host_path
47
+ from pi_agent_core.messages import ImageContent
48
+ from pi_agent_core.types import StreamFn
49
+ from pi_agent_harness import AgentHarness, JsonlSessionRepo, Session
50
+
51
+ _AGENT_INFO = Implementation(name="pi-agent-cli", title="pi-python ACP agent", version="0.1.0")
52
+
53
+
54
+ class PiAcpAgent(Agent):
55
+ """Standard-ACP-only agent. Does not register any vendor extension methods."""
56
+
57
+ _conn: Client | None
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ stream_fn: StreamFn | None = None,
63
+ home: Path | str | None = None,
64
+ config: CliConfig | None = None,
65
+ repo: JsonlSessionRepo | None = None,
66
+ ) -> None:
67
+ self._conn = None
68
+ self._home = pi_home(home)
69
+ self._config = config if config is not None else load_config(self._home)
70
+ self._stream_fn = stream_fn if stream_fn is not None else default_stream_fn()
71
+ sessions_dir = self._home / "sessions"
72
+ sessions_dir.mkdir(parents=True, exist_ok=True)
73
+ self._repo = repo if repo is not None else JsonlSessionRepo(sessions_dir)
74
+ self._harnesses: dict[str, AgentHarness] = {}
75
+ self._abort_tasks: set[asyncio.Task[Any]] = set()
76
+
77
+ def on_connect(self, conn: Client) -> None:
78
+ self._conn = conn
79
+
80
+ async def initialize(
81
+ self,
82
+ protocol_version: int,
83
+ client_capabilities: ClientCapabilities | None = None,
84
+ client_info: Implementation | None = None,
85
+ **kwargs: Any,
86
+ ) -> InitializeResponse:
87
+ return InitializeResponse(
88
+ protocol_version=min(protocol_version, PROTOCOL_VERSION),
89
+ agent_capabilities=AgentCapabilities(
90
+ load_session=True,
91
+ prompt_capabilities=PromptCapabilities(
92
+ image=True, audio=False, embedded_context=False
93
+ ),
94
+ session_capabilities=SessionCapabilities(
95
+ list=SessionListCapabilities(),
96
+ resume=SessionResumeCapabilities(),
97
+ close=SessionCloseCapabilities(),
98
+ ),
99
+ ),
100
+ auth_methods=[],
101
+ agent_info=_AGENT_INFO,
102
+ )
103
+
104
+ async def new_session(
105
+ self,
106
+ cwd: str,
107
+ additional_directories: list[str] | None = None,
108
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
109
+ **kwargs: Any,
110
+ ) -> NewSessionResponse:
111
+ session = await self._repo.create({"cwd": cwd})
112
+ session_id = (await session.get_metadata()).id
113
+ await self._bind_session(session_id, session, cwd)
114
+ return NewSessionResponse(session_id=session_id)
115
+
116
+ async def load_session(
117
+ self,
118
+ cwd: str,
119
+ session_id: str,
120
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
121
+ additional_directories: list[str] | None = None,
122
+ **kwargs: Any,
123
+ ) -> LoadSessionResponse | None:
124
+ metadata = await self._find_metadata(session_id)
125
+ if metadata is None:
126
+ raise RequestError.resource_not_found(session_id)
127
+ session = await self._repo.open(metadata)
128
+ await self._bind_session(session_id, session, metadata.cwd or cwd)
129
+ return LoadSessionResponse()
130
+
131
+ async def list_sessions(
132
+ self, cwd: str | None = None, cursor: str | None = None, **kwargs: Any
133
+ ) -> ListSessionsResponse:
134
+ listed = await self._repo.list({"cwd": cwd} if cwd is not None else None)
135
+ sessions = [
136
+ SessionInfo(
137
+ session_id=item.id,
138
+ cwd=item.cwd,
139
+ title=_session_title(item.id, item.createdAt),
140
+ updated_at=item.createdAt,
141
+ )
142
+ for item in listed
143
+ ]
144
+ return ListSessionsResponse(sessions=sessions)
145
+
146
+ async def close_session(self, session_id: str, **kwargs: Any) -> CloseSessionResponse | None:
147
+ self._harnesses.pop(session_id, None)
148
+ return CloseSessionResponse()
149
+
150
+ async def prompt(
151
+ self,
152
+ session_id: str,
153
+ prompt: list[
154
+ TextContentBlock
155
+ | ImageContentBlock
156
+ | AudioContentBlock
157
+ | ResourceContentBlock
158
+ | EmbeddedResourceContentBlock
159
+ ],
160
+ **kwargs: Any,
161
+ ) -> PromptResponse:
162
+ harness = self._require_harness(session_id)
163
+ text, images = _prompt_to_text_images(prompt)
164
+ try:
165
+ message = await harness.prompt(text, images or None)
166
+ except Exception as exc:
167
+ if type(exc).__name__ == "AgentHarnessError" and getattr(exc, "code", None) == "busy":
168
+ raise RequestError.invalid_params({"reason": "busy"}) from exc
169
+ raise
170
+ return PromptResponse(stop_reason=_stop_reason(message))
171
+
172
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
173
+ harness = self._harnesses.get(session_id)
174
+ if harness is None:
175
+ return
176
+ task = asyncio.create_task(harness.abort())
177
+ self._abort_tasks.add(task)
178
+ task.add_done_callback(self._abort_tasks.discard)
179
+
180
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
181
+ raise RequestError.method_not_found(method)
182
+
183
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
184
+ return None
185
+
186
+ def _require_harness(self, session_id: str) -> AgentHarness:
187
+ harness = self._harnesses.get(session_id)
188
+ if harness is None:
189
+ raise RequestError.invalid_params(
190
+ {"sessionId": session_id, "reason": "unknown session"}
191
+ )
192
+ return harness
193
+
194
+ async def _find_metadata(self, session_id: str) -> Any:
195
+ for item in await self._repo.list():
196
+ if item.id == session_id:
197
+ return item
198
+ return None
199
+
200
+ async def _bind_session(self, session_id: str, session: Session, cwd: str) -> None:
201
+ cwd = normalize_host_path(cwd)
202
+
203
+ async def on_tool_call(event: Any) -> dict[str, Any] | None:
204
+ return await self._handle_tool_call(session_id, event)
205
+
206
+ resources = await load_session_resources(cwd=cwd, config=self._config)
207
+ harness = create_session_harness(
208
+ session=session,
209
+ cwd=cwd,
210
+ config=self._config,
211
+ stream_fn=self._stream_fn,
212
+ resources=resources,
213
+ on_tool_call=on_tool_call,
214
+ )
215
+
216
+ async def on_event(event: Any, signal: Any | None = None) -> None:
217
+ await self._emit_updates(session_id, event)
218
+
219
+ harness.subscribe(on_event)
220
+ self._harnesses[session_id] = harness
221
+
222
+ async def _emit_updates(self, session_id: str, event: Any) -> None:
223
+ if self._conn is None:
224
+ return
225
+ for update in project_event(event):
226
+ await self._conn.session_update(session_id=session_id, update=update)
227
+
228
+ async def _handle_tool_call(self, session_id: str, event: Any) -> dict[str, Any] | None:
229
+ name = event.toolName
230
+ if not needs_permission(name, self._config.permission):
231
+ return None
232
+ if self._conn is None:
233
+ return {"block": True, "reason": "No ACP client connected"}
234
+ raw_input = dict(event.input or {})
235
+ response = await self._conn.request_permission(
236
+ session_id=session_id,
237
+ tool_call=permission_tool_call(event.toolCallId, name, raw_input),
238
+ options=list(PERMISSION_OPTIONS),
239
+ )
240
+ if outcome_allows(response.outcome):
241
+ return None
242
+ return {"block": True, "reason": "User denied permission"}
243
+
244
+
245
+ def _session_title(session_id: str, created_at: str) -> str:
246
+ short = session_id[:8] if len(session_id) > 8 else session_id
247
+ return f"{created_at} ({short})"
248
+
249
+
250
+ def _prompt_to_text_images(
251
+ prompt: list[Any],
252
+ ) -> tuple[str, list[ImageContent]]:
253
+ texts: list[str] = []
254
+ images: list[ImageContent] = []
255
+ for block in prompt:
256
+ if isinstance(block, dict):
257
+ btype = block.get("type")
258
+ if btype == "text":
259
+ texts.append(str(block.get("text") or ""))
260
+ elif btype == "image":
261
+ images.append(
262
+ {
263
+ "type": "image",
264
+ "data": str(block.get("data") or ""),
265
+ "mimeType": str(
266
+ block.get("mimeType") or block.get("mime_type") or "image/png"
267
+ ),
268
+ }
269
+ )
270
+ continue
271
+ btype = getattr(block, "type", None)
272
+ if btype == "text":
273
+ texts.append(str(getattr(block, "text", "") or ""))
274
+ elif btype == "image":
275
+ images.append(
276
+ {
277
+ "type": "image",
278
+ "data": str(getattr(block, "data", "") or ""),
279
+ "mimeType": str(
280
+ getattr(block, "mime_type", None)
281
+ or getattr(block, "mimeType", None)
282
+ or "image/png"
283
+ ),
284
+ }
285
+ )
286
+ return "".join(texts), images
287
+
288
+
289
+ def _stop_reason(message: Any) -> str:
290
+ reason = getattr(message, "stopReason", None) or "stop"
291
+ if reason == "aborted":
292
+ return "cancelled"
293
+ if reason == "length":
294
+ return "max_tokens"
295
+ if reason == "error":
296
+ return "refusal"
297
+ return "end_turn"
@@ -0,0 +1,15 @@
1
+ """Product-level smoke benchmarks for pi-agent-cli / pi TUI."""
2
+
3
+ from pi_agent_cli.benchmarks.pelican import (
4
+ PELICAN_PROMPT,
5
+ PelicanSvgReport,
6
+ extract_svg,
7
+ validate_pelican_svg,
8
+ )
9
+
10
+ __all__ = [
11
+ "PELICAN_PROMPT",
12
+ "PelicanSvgReport",
13
+ "extract_svg",
14
+ "validate_pelican_svg",
15
+ ]
@@ -0,0 +1,90 @@
1
+ """Pelican-on-a-bicycle benchmark (Simon Willison / Karpathy-style SVG smoke test).
2
+
3
+ See docs/benchmarks/PELCAN-BICYCLE.md.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ PELICAN_PROMPT = (
14
+ "Generate an SVG of a pelican riding a bicycle. "
15
+ "Output only valid SVG markup with xmlns and a viewBox, no markdown fences or explanation."
16
+ )
17
+
18
+ _FENCE_RE = re.compile(r"```(?:svg|xml)?\s*\n?(.*?)```", re.DOTALL | re.IGNORECASE)
19
+ _SVG_RE = re.compile(r"(<svg[\s\S]*?</svg>)", re.IGNORECASE)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PelicanSvgReport:
24
+ ok: bool
25
+ checks: dict[str, bool]
26
+ svg: str | None
27
+ notes: tuple[str, ...] = ()
28
+
29
+
30
+ def extract_svg(text: str) -> str | None:
31
+ """Pull the first SVG document from model text (raw or fenced)."""
32
+ raw = text.strip()
33
+ if not raw:
34
+ return None
35
+ for match in _FENCE_RE.finditer(raw):
36
+ inner = match.group(1).strip()
37
+ svg = _SVG_RE.search(inner)
38
+ if svg:
39
+ return svg.group(1).strip()
40
+ if inner.lower().startswith("<svg"):
41
+ return inner
42
+ svg = _SVG_RE.search(raw)
43
+ if svg:
44
+ return svg.group(1).strip()
45
+ if raw.lower().startswith("<svg"):
46
+ return raw
47
+ return None
48
+
49
+
50
+ def validate_pelican_svg(svg: str | None) -> PelicanSvgReport:
51
+ """Heuristic pass/fail for the pelican bicycle benchmark (not artistic scoring)."""
52
+ notes: list[str] = []
53
+ if svg is None:
54
+ return PelicanSvgReport(
55
+ ok=False,
56
+ checks={"extracted": False},
57
+ svg=None,
58
+ notes=("no SVG found in model output",),
59
+ )
60
+ lower = svg.lower()
61
+ checks = {
62
+ "extracted": True,
63
+ "svg_root": "<svg" in lower and "</svg>" in lower,
64
+ "xmlns": "xmlns" in lower,
65
+ "viewbox": "viewbox" in lower,
66
+ "geometry": any(
67
+ tag in lower for tag in ("<path", "<circle", "<rect", "<ellipse", "<polygon")
68
+ ),
69
+ "min_size": len(svg.strip()) >= 200,
70
+ }
71
+ if not checks["geometry"]:
72
+ notes.append("no path/circle/rect/ellipse/polygon elements")
73
+ if not checks["min_size"]:
74
+ notes.append("SVG shorter than 200 characters")
75
+ return PelicanSvgReport(ok=all(checks.values()), checks=checks, svg=svg, notes=tuple(notes))
76
+
77
+
78
+ def default_artifact_dir(home: Path | str | None = None) -> Path:
79
+ from pi_agent_cli.config import pi_home
80
+
81
+ return pi_home(home) / "benchmarks" / "pelican"
82
+
83
+
84
+ def save_pelican_artifact(svg: str, *, home: Path | str | None = None) -> Path:
85
+ out_dir = default_artifact_dir(home)
86
+ out_dir.mkdir(parents=True, exist_ok=True)
87
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
88
+ path = out_dir / f"pelican-{stamp}.svg"
89
+ path.write_text(svg, encoding="utf-8")
90
+ return path