iconflow 0.5.0__py3-none-any.whl
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.
- iconflow/__init__.py +16 -0
- iconflow/__main__.py +8 -0
- iconflow/agentkit.py +520 -0
- iconflow/assemble.py +325 -0
- iconflow/build.py +338 -0
- iconflow/casebook.py +820 -0
- iconflow/cli.py +1632 -0
- iconflow/config.py +654 -0
- iconflow/htmlhead.py +146 -0
- iconflow/qa.py +499 -0
- iconflow/rasterize.py +287 -0
- iconflow/resources/__init__.py +3 -0
- iconflow/resources/demo/iconflow.toml +43 -0
- iconflow/resources/demo/master-review.json +42 -0
- iconflow/resources/demo/master.svg +18 -0
- iconflow/resources/demo/tray.svg +26 -0
- iconflow/resources/docs/AGENT_CONTRACT.md +122 -0
- iconflow/resources/docs/BRAND_SYSTEM.md +188 -0
- iconflow/resources/docs/CONCEPTING.md +248 -0
- iconflow/resources/docs/DESIGN_PLAYBOOK.md +352 -0
- iconflow/resources/docs/EMOJI_MATRIX.md +107 -0
- iconflow/resources/docs/EVOLUTION.md +139 -0
- iconflow/resources/docs/GALLERY.md +72 -0
- iconflow/resources/docs/I18N_PLAN.md +208 -0
- iconflow/resources/docs/ISSUE_SEEDS.md +114 -0
- iconflow/resources/docs/LAUNCH_READINESS.md +233 -0
- iconflow/resources/docs/LAUNCH_SITE.md +221 -0
- iconflow/resources/docs/LEARNINGS.md +809 -0
- iconflow/resources/docs/LICENSE +18 -0
- iconflow/resources/docs/MILESTONE_v0.5.md +97 -0
- iconflow/resources/docs/OUTPUT_TARGETS.md +313 -0
- iconflow/resources/docs/PROOF_ACTION.md +138 -0
- iconflow/resources/docs/PROVENANCE.md +216 -0
- iconflow/resources/docs/RELEASING.md +193 -0
- iconflow/resources/docs/REVIEW_CHECKLIST.md +76 -0
- iconflow/resources/docs/SHOWCASE_PLAN.md +109 -0
- iconflow/resources/docs/SOCIAL_SIGNALS.md +94 -0
- iconflow/resources/docs/STYLE_CATALOG.md +146 -0
- iconflow/resources/docs/SVG_TECHNIQUES.md +330 -0
- iconflow/resources/docs/WORKFLOW.md +127 -0
- iconflow/resources/docs/assets/LICENSE +36 -0
- iconflow/resources/docs/assets/concept-bake.png +0 -0
- iconflow/resources/docs/assets/hero-flow.svg +119 -0
- iconflow/resources/docs/assets/review-proof.png +0 -0
- iconflow/resources/docs/assets/social-preview.png +0 -0
- iconflow/resources/docs/assets/social-preview.svg +65 -0
- iconflow/resources/docs/assets/style-gallery.png +0 -0
- iconflow/resources/presets/blueprint.svg +27 -0
- iconflow/resources/presets/cel-shaded.svg +32 -0
- iconflow/resources/presets/chrome.svg +35 -0
- iconflow/resources/presets/clay.svg +25 -0
- iconflow/resources/presets/cut-paper.svg +23 -0
- iconflow/resources/presets/duotone-cut.svg +26 -0
- iconflow/resources/presets/enamel-pin.svg +24 -0
- iconflow/resources/presets/flat-geometric.svg +16 -0
- iconflow/resources/presets/glass-stack.svg +31 -0
- iconflow/resources/presets/gradient-glow.svg +34 -0
- iconflow/resources/presets/ink-brush.svg +17 -0
- iconflow/resources/presets/isometric.svg +22 -0
- iconflow/resources/presets/line-mark.svg +19 -0
- iconflow/resources/presets/mascot.svg +26 -0
- iconflow/resources/presets/pixel-grid.svg +15 -0
- iconflow/resources/presets/risograph.svg +25 -0
- iconflow/resources/presets/stained-glass.svg +31 -0
- iconflow/resources/presets/stencil-cut.svg +24 -0
- iconflow/resources/presets/woodcut.svg +19 -0
- iconflow/resources/presets/woven.svg +21 -0
- iconflow/resources/skill/LICENSE +18 -0
- iconflow/resources/skill/SKILL.md +249 -0
- iconflow/resources/skill/agents/openai.yaml +4 -0
- iconflow/resources/templates/LICENSE +24 -0
- iconflow/resources/templates/grid-overlay.svg +22 -0
- iconflow/resources/templates/master.svg +27 -0
- iconflow/review.py +857 -0
- iconflow/shortcut.py +189 -0
- iconflow/styles.py +227 -0
- iconflow-0.5.0.dist-info/METADATA +555 -0
- iconflow-0.5.0.dist-info/RECORD +89 -0
- iconflow-0.5.0.dist-info/WHEEL +5 -0
- iconflow-0.5.0.dist-info/entry_points.txt +2 -0
- iconflow-0.5.0.dist-info/licenses/LICENSE +201 -0
- iconflow-0.5.0.dist-info/licenses/LICENSES.md +244 -0
- iconflow-0.5.0.dist-info/licenses/NOTICE +64 -0
- iconflow-0.5.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +31 -0
- iconflow-0.5.0.dist-info/licenses/TRADEMARKS.md +59 -0
- iconflow-0.5.0.dist-info/licenses/licenses/CC-BY-4.0.txt +396 -0
- iconflow-0.5.0.dist-info/licenses/licenses/CC-BY-SA-4.0.txt +428 -0
- iconflow-0.5.0.dist-info/licenses/licenses/CC0-1.0.txt +121 -0
- iconflow-0.5.0.dist-info/top_level.txt +1 -0
iconflow/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 snowyukitty · https://ai-iconflow.com
|
|
3
|
+
"""IconFlow — turn one semantic SVG master into a reviewed icon family.
|
|
4
|
+
|
|
5
|
+
The design step follows docs/DESIGN_PLAYBOOK.md. This package is the
|
|
6
|
+
deterministic build engine: it rasterizes SVG with network-isolated headless
|
|
7
|
+
Chromium and assembles crisp, per-size .ico / .icns / .png sets for web, PWA,
|
|
8
|
+
Tauri desktop, Electron, and tray use.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.5.0"
|
|
12
|
+
|
|
13
|
+
from .rasterize import Rasterizer, load_svg
|
|
14
|
+
from .build import build, RenderCache
|
|
15
|
+
|
|
16
|
+
__all__ = ["Rasterizer", "load_svg", "build", "RenderCache", "__version__"]
|
iconflow/__main__.py
ADDED
iconflow/agentkit.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 snowyukitty · https://ai-iconflow.com
|
|
3
|
+
"""The packaged front door for agents that have no source checkout.
|
|
4
|
+
|
|
5
|
+
A foreign agent installs IconFlow as a wheel and then needs three things the
|
|
6
|
+
repository used to be the only source of: the design procedure (``SKILL.md``),
|
|
7
|
+
the reference documents the procedure cites (``docs/*.md``), and a way to put
|
|
8
|
+
that skill where its own client discovers skills. This module resolves all of
|
|
9
|
+
them out of the package, so ``iconflow docs`` and ``iconflow skill`` behave the
|
|
10
|
+
same from a clone, an editable install, and an isolated wheel.
|
|
11
|
+
|
|
12
|
+
It also owns the single resource resolver the rest of the CLI uses.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import importlib.resources
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Packaged resource sets, mapped to where they live in the source checkout.
|
|
22
|
+
RESOURCE_DIRS: dict[str, tuple[str, ...]] = {
|
|
23
|
+
"presets": ("templates", "presets"),
|
|
24
|
+
"templates": ("templates",),
|
|
25
|
+
"docs": ("docs",),
|
|
26
|
+
"demo": ("demo",),
|
|
27
|
+
"skill": ("skills", "iconflow"),
|
|
28
|
+
}
|
|
29
|
+
# Legacy top-level namespace packages, for source execution without an install.
|
|
30
|
+
_NAMESPACE_FALLBACK = {
|
|
31
|
+
"presets": "templates.presets",
|
|
32
|
+
"templates": "templates",
|
|
33
|
+
"docs": "docs",
|
|
34
|
+
"demo": "demo",
|
|
35
|
+
"skill": "skills.iconflow",
|
|
36
|
+
}
|
|
37
|
+
# Every file `iconflow skill install` deploys, relative to the skill resource.
|
|
38
|
+
SKILL_FILES: tuple[str, ...] = ("SKILL.md", "LICENSE", "agents/openai.yaml")
|
|
39
|
+
# User-level skill discovery roots shared by current open Agent Skills clients.
|
|
40
|
+
SKILL_HOMES: tuple[tuple[str, ...], ...] = (
|
|
41
|
+
(".claude", "skills"),
|
|
42
|
+
(".agents", "skills"),
|
|
43
|
+
(".copilot", "skills"),
|
|
44
|
+
)
|
|
45
|
+
# Codex reads `.agents`; a second copy under `.codex` is discovered twice
|
|
46
|
+
# because Codex does not merge same-named skills across roots.
|
|
47
|
+
LEGACY_SKILL_HOMES: tuple[tuple[str, ...], ...] = ((".codex", "skills"),)
|
|
48
|
+
# Files an older deployment wrote that the current skill package does not own.
|
|
49
|
+
STALE_SKILL_FILES: tuple[str, ...] = ("README.md",)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def checkout_root(candidate: Path | None = None) -> Path | None:
|
|
53
|
+
"""The IconFlow source checkout this package lives in, if it is one.
|
|
54
|
+
|
|
55
|
+
On a wheel install the directory above ``iconflow/`` is ``site-packages``,
|
|
56
|
+
where an unrelated distribution's top-level ``docs`` or ``templates``
|
|
57
|
+
directory would otherwise shadow our packaged resources. Two markers only a
|
|
58
|
+
real checkout has — this project's ``pyproject.toml`` beside the package
|
|
59
|
+
source — make that impossible.
|
|
60
|
+
|
|
61
|
+
``candidate`` is for tests; production always asks about this file's own
|
|
62
|
+
parent directory.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
candidate = (
|
|
66
|
+
Path(__file__).resolve().parent.parent if candidate is None else Path(candidate)
|
|
67
|
+
)
|
|
68
|
+
manifest = candidate / "pyproject.toml"
|
|
69
|
+
if not manifest.is_file() or not (candidate / "iconflow" / "cli.py").is_file():
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
if 'name = "iconflow"' not in manifest.read_text(encoding="utf-8"):
|
|
73
|
+
return None
|
|
74
|
+
except OSError:
|
|
75
|
+
return None
|
|
76
|
+
return candidate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resource_root(package: str):
|
|
80
|
+
"""Return the root Traversable for one packaged resource set.
|
|
81
|
+
|
|
82
|
+
Resolution order, most reliable first:
|
|
83
|
+
|
|
84
|
+
1. The on-disk source tree next to this package — an editable install
|
|
85
|
+
(``pip install -e .``), a plain checkout, or CI. A modern *strict*
|
|
86
|
+
editable finder does not expose the ``package-dir``-remapped resource
|
|
87
|
+
subpackages to ``importlib.resources`` (nor put the repo root on
|
|
88
|
+
``sys.path`` for the namespace fallback), so the checkout layout is the
|
|
89
|
+
dependable source there. :func:`checkout_root` proves it *is* a checkout
|
|
90
|
+
before this branch is trusted.
|
|
91
|
+
2. The packaged ``iconflow.resources.*`` subpackage — a real wheel install,
|
|
92
|
+
where there is no checkout so step 1 is skipped.
|
|
93
|
+
3. The top-level namespace directories — legacy source execution.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
subdir = RESOURCE_DIRS.get(package)
|
|
97
|
+
checkout = checkout_root()
|
|
98
|
+
if subdir is not None and checkout is not None:
|
|
99
|
+
source = checkout.joinpath(*subdir)
|
|
100
|
+
if source.is_dir():
|
|
101
|
+
return source
|
|
102
|
+
try:
|
|
103
|
+
return importlib.resources.files(f"iconflow.resources.{package}")
|
|
104
|
+
except ModuleNotFoundError:
|
|
105
|
+
pass
|
|
106
|
+
# `files(None)` resolves to this module's own package rather than failing,
|
|
107
|
+
# so an unknown resource set has to be rejected before that call.
|
|
108
|
+
fallback = _NAMESPACE_FALLBACK.get(package)
|
|
109
|
+
try:
|
|
110
|
+
if fallback is None:
|
|
111
|
+
raise ModuleNotFoundError(package)
|
|
112
|
+
return importlib.resources.files(fallback)
|
|
113
|
+
except (ModuleNotFoundError, TypeError) as exc:
|
|
114
|
+
# An incomplete install, not a bug in the caller: say which resource set
|
|
115
|
+
# is missing and let the CLI report it as exit 2 rather than tracebacking.
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
f"packaged resource set {package!r} is missing from this install; "
|
|
118
|
+
"reinstall iconflow"
|
|
119
|
+
) from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resource(package: str, name: str):
|
|
123
|
+
"""Return one packaged resource path/Traversable by name.
|
|
124
|
+
|
|
125
|
+
``name`` is a resource-relative POSIX path. It is a library argument, not
|
|
126
|
+
user input, but rejecting escapes here keeps a future caller from turning
|
|
127
|
+
one into a read outside the resource set.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
parts = name.split("/")
|
|
131
|
+
if any(part in ("", ".", "..") for part in parts) or Path(name).is_absolute():
|
|
132
|
+
raise ValueError(f"invalid resource name: {name!r}")
|
|
133
|
+
subdir = RESOURCE_DIRS.get(package)
|
|
134
|
+
checkout = checkout_root()
|
|
135
|
+
if subdir is not None and checkout is not None:
|
|
136
|
+
source = checkout.joinpath(*subdir, *parts)
|
|
137
|
+
if source.is_file():
|
|
138
|
+
return source
|
|
139
|
+
return resource_root(package).joinpath(*parts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read(traversable) -> str:
|
|
143
|
+
return traversable.read_text(encoding="utf-8")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# licensing
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
SPDX_COMMENT = re.compile(r"<!--\s*SPDX-License-Identifier:.*?-->\s*", re.DOTALL)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def strip_spdx_comment(text: str) -> str:
|
|
154
|
+
"""Remove the licence header before a scaffold is copied into a project.
|
|
155
|
+
|
|
156
|
+
The dedication belongs on IconFlow's copy, not on the user's. Left in, it
|
|
157
|
+
rides `master.svg` all the way into the `favicon.svg` they serve in
|
|
158
|
+
production — an IconFlow URL embedded in their shipped asset, which is
|
|
159
|
+
exactly the attribution LICENSES.md section 1 promises never to require.
|
|
160
|
+
The CC0 grant is told to the person on the terminal instead.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
return SPDX_COMMENT.sub("", text, count=1).lstrip()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# The tier map from LICENSES.md, in the form an agent can act on. Kept here
|
|
170
|
+
# rather than parsed out of the Markdown so `iconflow license` answers
|
|
171
|
+
# identically from a wheel with no checkout.
|
|
172
|
+
LICENSE_TIERS: tuple[dict[str, str], ...] = (
|
|
173
|
+
{"tier": "0", "paths": "the icons you make with IconFlow",
|
|
174
|
+
"license": "yours — no conditions",
|
|
175
|
+
"what": "every master.svg, favicon, .ico, .icns, manifest, receipt and case "
|
|
176
|
+
"file you create"},
|
|
177
|
+
{"tier": "1", "paths": "iconflow/ scripts/ tests/", "license": "Apache-2.0",
|
|
178
|
+
"what": "the engine and tooling"},
|
|
179
|
+
{"tier": "1b", "paths": "templates/ (presets)", "license": "CC0-1.0",
|
|
180
|
+
"what": "technique scaffolds and every file IconFlow writes into your project"},
|
|
181
|
+
{"tier": "2", "paths": "docs/ casebook/ skills/", "license": "CC-BY-SA-4.0",
|
|
182
|
+
"what": "the written methodology"},
|
|
183
|
+
{"tier": "3a", "paths": "brand/ demo/ docs/assets/", "license": "CC-BY-4.0",
|
|
184
|
+
"what": "IconFlow's product mark and packaged imagery, plus its trademark"},
|
|
185
|
+
{"tier": "3b", "paths": "gallery/ showcase/ examples/ website/assets/",
|
|
186
|
+
"license": "CC-BY-NC-ND-4.0",
|
|
187
|
+
"what": "the published corpus: 137 archive studies and finished works"},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
OUTPUT_GUARANTEE: tuple[str, ...] = (
|
|
191
|
+
"No attribution required — you never have to credit IconFlow in your app.",
|
|
192
|
+
"Commercial use unrestricted — ship it, sell it, trademark it.",
|
|
193
|
+
"No copyleft and no share-alike reaches through the tool into your design.",
|
|
194
|
+
"The scaffolds behind `iconflow new` are CC0 public domain, so an icon you "
|
|
195
|
+
"evolve from one inherits nothing.",
|
|
196
|
+
"Reading the playbook and applying it creates no obligation: copyright covers "
|
|
197
|
+
"the prose, not the design methods it describes.",
|
|
198
|
+
"Any IconFlow boilerplate embodied in a file the tool generates for you — a "
|
|
199
|
+
"manifest, a head snippet, a receipt, the Review Lab HTML — is supplied under "
|
|
200
|
+
"CC0, so nothing the toolkit wrote attaches a condition to what it produced.",
|
|
201
|
+
"Not promised, because no licence can: that your icon is copyrightable, "
|
|
202
|
+
"registrable, or clear of someone else's mark. Those remain yours to check.",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
LICENSE_NOTES: tuple[str, ...] = (
|
|
206
|
+
"The one exception: `iconflow demo` copies IconFlow's OWN product mark so you "
|
|
207
|
+
"can watch the engine prove itself. Do not ship that as your identity.",
|
|
208
|
+
"Full map and boundary cases: LICENSES.md. Trademark: TRADEMARKS.md.",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def license_summary() -> dict:
|
|
213
|
+
"""The licensing answer an agent needs, in one structure."""
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"headline": "The icons you make with IconFlow are yours.",
|
|
217
|
+
"your_output": list(OUTPUT_GUARANTEE),
|
|
218
|
+
"tiers": [dict(tier) for tier in LICENSE_TIERS],
|
|
219
|
+
"notes": list(LICENSE_NOTES),
|
|
220
|
+
"documents": {"map": "LICENSES.md", "trademark": "TRADEMARKS.md",
|
|
221
|
+
"provenance": "docs/PROVENANCE.md"},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# docs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def doc_names() -> list[str]:
|
|
230
|
+
"""Every packaged reference document, without the ``.md`` suffix."""
|
|
231
|
+
|
|
232
|
+
root = resource_root("docs")
|
|
233
|
+
names = sorted(
|
|
234
|
+
entry.name[:-3]
|
|
235
|
+
for entry in root.iterdir()
|
|
236
|
+
if entry.name.endswith(".md") and entry.is_file()
|
|
237
|
+
)
|
|
238
|
+
return names
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve_doc(name: str) -> str:
|
|
242
|
+
"""Map a user-typed document name to its exact packaged name.
|
|
243
|
+
|
|
244
|
+
Accepts ``LEARNINGS``, ``learnings``, ``LEARNINGS.md``, and
|
|
245
|
+
``docs/LEARNINGS.md`` so an agent can quote whatever the prose it just read
|
|
246
|
+
happened to use.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
wanted = name.strip().replace("\\", "/").rsplit("/", 1)[-1]
|
|
250
|
+
if wanted.lower().endswith(".md"):
|
|
251
|
+
wanted = wanted[:-3]
|
|
252
|
+
available = doc_names()
|
|
253
|
+
for candidate in available:
|
|
254
|
+
if candidate.lower() == wanted.lower():
|
|
255
|
+
return candidate
|
|
256
|
+
close = [c for c in available if wanted.lower() in c.lower()]
|
|
257
|
+
hint = ", ".join(close or available)
|
|
258
|
+
raise ValueError(f"unknown document {name!r}; available: {hint}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def read_doc(name: str) -> str:
|
|
262
|
+
"""Return one packaged document's text, resolved leniently by name."""
|
|
263
|
+
|
|
264
|
+
return _read(resource("docs", f"{resolve_doc(name)}.md"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def doc_summary(name: str) -> str:
|
|
268
|
+
"""One-line description of a packaged document: its blockquote or subtitle.
|
|
269
|
+
|
|
270
|
+
Falls back to the empty string rather than guessing, so the listing never
|
|
271
|
+
invents a description a document does not carry.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
text = read_doc(name)
|
|
276
|
+
except (OSError, ValueError):
|
|
277
|
+
return ""
|
|
278
|
+
# Every document opens with an SPDX licence comment; it is provenance that
|
|
279
|
+
# should travel with a copied file, but it is not the document's subject.
|
|
280
|
+
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
281
|
+
paragraph: list[str] = []
|
|
282
|
+
for line in text.splitlines()[:24]:
|
|
283
|
+
stripped = line.strip()
|
|
284
|
+
if stripped.startswith(">"):
|
|
285
|
+
stripped = stripped[1:].strip()
|
|
286
|
+
if not paragraph:
|
|
287
|
+
if not stripped or _is_not_prose(stripped):
|
|
288
|
+
continue
|
|
289
|
+
paragraph.append(stripped)
|
|
290
|
+
continue
|
|
291
|
+
if not stripped:
|
|
292
|
+
break
|
|
293
|
+
paragraph.append(stripped)
|
|
294
|
+
return _first_sentence(" ".join(paragraph))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _is_not_prose(line: str) -> bool:
|
|
298
|
+
"""True for markdown furniture — headings, images, tables, list items, fences.
|
|
299
|
+
|
|
300
|
+
``**bold**`` opens a real sentence in several documents, so a bare ``*``
|
|
301
|
+
prefix is not enough to reject a line; only a list marker is.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
if line.startswith(("#", "!", "|", "```", ">")):
|
|
305
|
+
return True
|
|
306
|
+
return line[:2] in ("- ", "* ", "+ ")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _first_sentence(text: str, limit: int = 84) -> str:
|
|
310
|
+
"""Trim a paragraph to one readable line without cutting a word in half."""
|
|
311
|
+
|
|
312
|
+
cleaned = " ".join(text.replace("**", "").split())
|
|
313
|
+
if not cleaned:
|
|
314
|
+
return ""
|
|
315
|
+
stop = cleaned.find(". ")
|
|
316
|
+
if 0 < stop <= limit:
|
|
317
|
+
return cleaned[:stop]
|
|
318
|
+
if len(cleaned) <= limit:
|
|
319
|
+
return cleaned.rstrip(".")
|
|
320
|
+
head = cleaned[:limit].rsplit(" ", 1)[0].rstrip(",;:")
|
|
321
|
+
return f"{head}…"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def export_docs(out_dir: Path, names: list[str] | None = None) -> list[Path]:
|
|
325
|
+
"""Write packaged documents into ``out_dir`` and return what was written.
|
|
326
|
+
|
|
327
|
+
The images the documents reference come along, because an exemplar gallery
|
|
328
|
+
the reader cannot see is the difference between a distinctive mark and
|
|
329
|
+
another gradient tile. Names are resolved rather than trusted, so no caller
|
|
330
|
+
can steer a write outside ``out_dir``.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
out_dir = Path(out_dir)
|
|
334
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
resolved = [resolve_doc(name) for name in names] if names else doc_names()
|
|
336
|
+
written: list[Path] = []
|
|
337
|
+
for name in resolved:
|
|
338
|
+
target = out_dir / f"{name}.md"
|
|
339
|
+
_write_file(target, read_doc(name))
|
|
340
|
+
written.append(target)
|
|
341
|
+
licence = resource("docs", "LICENSE")
|
|
342
|
+
if licence.is_file():
|
|
343
|
+
_write_file(out_dir / "LICENSE", _read(licence))
|
|
344
|
+
written.append(out_dir / "LICENSE")
|
|
345
|
+
written.extend(_export_doc_assets(out_dir))
|
|
346
|
+
return written
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _export_doc_assets(out_dir: Path) -> list[Path]:
|
|
350
|
+
"""Copy `docs/assets/` beside the exported markdown so image links resolve."""
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
assets = resource_root("docs").joinpath("assets")
|
|
354
|
+
entries = sorted(assets.iterdir()) if assets.is_dir() else []
|
|
355
|
+
except (OSError, RuntimeError, ModuleNotFoundError):
|
|
356
|
+
return []
|
|
357
|
+
written: list[Path] = []
|
|
358
|
+
for entry in entries:
|
|
359
|
+
if not entry.is_file():
|
|
360
|
+
continue
|
|
361
|
+
target = out_dir / "assets" / entry.name
|
|
362
|
+
if target.is_symlink():
|
|
363
|
+
raise OSError(f"refusing to write through a symlink: {target}")
|
|
364
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
target.write_bytes(entry.read_bytes())
|
|
366
|
+
written.append(target)
|
|
367
|
+
return written
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _write_file(target: Path, text: str) -> None:
|
|
371
|
+
"""Write a deployed file, refusing to follow a symlink out of the target.
|
|
372
|
+
|
|
373
|
+
These destinations come from the caller (``--out``, ``--dir``), so a
|
|
374
|
+
pre-existing symlink at the path would otherwise let this overwrite a file
|
|
375
|
+
somewhere else entirely.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
if target.is_symlink():
|
|
379
|
+
raise OSError(f"refusing to write through a symlink: {target}")
|
|
380
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
target.write_text(text, encoding="utf-8")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# skill
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def skill_text() -> str:
|
|
389
|
+
"""The canonical ``SKILL.md`` this build carries."""
|
|
390
|
+
|
|
391
|
+
return _read(resource("skill", "SKILL.md"))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def claude_plugin_dirs(home: Path | None = None) -> list[Path]:
|
|
395
|
+
"""Installed Claude Code plugin copies of IconFlow, if there are any.
|
|
396
|
+
|
|
397
|
+
The plugin already supplies this skill. Writing a second copy into
|
|
398
|
+
``~/.claude/skills/`` would leave Claude Code holding two skills with the
|
|
399
|
+
same name, so the installer looks here before choosing its roots.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
base = Path.home() if home is None else Path(home)
|
|
403
|
+
cache = base / ".claude" / "plugins" / "cache"
|
|
404
|
+
if not cache.is_dir():
|
|
405
|
+
return []
|
|
406
|
+
return sorted(
|
|
407
|
+
path for path in cache.glob("*/iconflow") if path.is_dir()
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def default_skill_roots(home: Path | None = None) -> list[Path]:
|
|
412
|
+
"""The user-level ``skills/`` roots this installer writes into.
|
|
413
|
+
|
|
414
|
+
Drops the Claude Code root when its plugin already carries the skill; the
|
|
415
|
+
caller reports that so a skipped root is never silent.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
base = Path.home() if home is None else Path(home)
|
|
419
|
+
roots = [base.joinpath(*parts) for parts in SKILL_HOMES]
|
|
420
|
+
if claude_plugin_dirs(base):
|
|
421
|
+
claude_root = base.joinpath(*SKILL_HOMES[0])
|
|
422
|
+
roots = [root for root in roots if root != claude_root]
|
|
423
|
+
return roots
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def project_skill_roots(project: Path) -> list[Path]:
|
|
427
|
+
"""The same discovery roots, resolved inside one repository.
|
|
428
|
+
|
|
429
|
+
A project-local skill travels with the repository, so a teammate's agent
|
|
430
|
+
picks up the procedure by cloning rather than by installing anything.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
project = Path(project)
|
|
434
|
+
return [project.joinpath(*parts) for parts in SKILL_HOMES]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def legacy_skill_dirs(home: Path | None = None) -> list[Path]:
|
|
438
|
+
"""Deployment directories a previous IconFlow version created."""
|
|
439
|
+
|
|
440
|
+
base = Path.home() if home is None else Path(home)
|
|
441
|
+
return [base.joinpath(*parts, "iconflow") for parts in LEGACY_SKILL_HOMES]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def install_skill(roots: list[Path]) -> tuple[list[Path], list[Path]]:
|
|
445
|
+
"""Copy the packaged skill into each ``skills/`` root.
|
|
446
|
+
|
|
447
|
+
Returns ``(installed skill directories, files removed)``. Every file comes
|
|
448
|
+
from the package, so this works from an isolated wheel with no checkout
|
|
449
|
+
anywhere on the machine.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
# Read every file before touching a destination: a half-written skill
|
|
453
|
+
# directory would shadow a working one, and an empty one would look
|
|
454
|
+
# installed while teaching the agent nothing.
|
|
455
|
+
payload = {}
|
|
456
|
+
for name in SKILL_FILES:
|
|
457
|
+
source = resource("skill", name)
|
|
458
|
+
if not source.is_file():
|
|
459
|
+
raise OSError(
|
|
460
|
+
f"packaged skill file {name} is missing from this install; "
|
|
461
|
+
"reinstall iconflow"
|
|
462
|
+
)
|
|
463
|
+
payload[name] = _read(source)
|
|
464
|
+
|
|
465
|
+
installed: list[Path] = []
|
|
466
|
+
removed: list[Path] = []
|
|
467
|
+
for root in roots:
|
|
468
|
+
destination = Path(root).expanduser().resolve() / "iconflow"
|
|
469
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
for name, text in payload.items():
|
|
471
|
+
_write_file(destination.joinpath(*name.split("/")), text)
|
|
472
|
+
for stale in STALE_SKILL_FILES:
|
|
473
|
+
path = destination / stale
|
|
474
|
+
if path.is_file() and not path.is_symlink():
|
|
475
|
+
path.unlink()
|
|
476
|
+
removed.append(path)
|
|
477
|
+
installed.append(destination)
|
|
478
|
+
return installed, removed
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def remove_legacy_skills(home: Path | None = None) -> list[Path]:
|
|
482
|
+
"""Retire duplicate deployments from superseded discovery roots.
|
|
483
|
+
|
|
484
|
+
This deletes only files IconFlow itself wrote — the ones in
|
|
485
|
+
:data:`SKILL_FILES` and :data:`STALE_SKILL_FILES` — and then removes the
|
|
486
|
+
directory only if nothing else is left in it. Anything the user added or
|
|
487
|
+
edited by hand survives, and is reported back so the caller can say so.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
removed: list[Path] = []
|
|
491
|
+
for directory in legacy_skill_dirs(home):
|
|
492
|
+
# Only ever a path this module itself composed, never caller input.
|
|
493
|
+
if directory.name != "iconflow": # pragma: no cover - defensive
|
|
494
|
+
continue
|
|
495
|
+
# A symlinked deployment must be unlinked, never walked: rmtree refuses
|
|
496
|
+
# a symlink, and following it would delete whatever it points at.
|
|
497
|
+
if directory.is_symlink():
|
|
498
|
+
directory.unlink(missing_ok=True)
|
|
499
|
+
removed.append(directory)
|
|
500
|
+
continue
|
|
501
|
+
if not directory.is_dir():
|
|
502
|
+
continue
|
|
503
|
+
for name in (*SKILL_FILES, *STALE_SKILL_FILES):
|
|
504
|
+
path = directory.joinpath(*name.split("/"))
|
|
505
|
+
if path.is_file() and not path.is_symlink():
|
|
506
|
+
path.unlink()
|
|
507
|
+
_prune_empty_dirs(directory)
|
|
508
|
+
if not directory.exists():
|
|
509
|
+
removed.append(directory)
|
|
510
|
+
return removed
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _prune_empty_dirs(directory: Path) -> None:
|
|
514
|
+
"""Remove ``directory`` and its subdirectories, but only where empty."""
|
|
515
|
+
|
|
516
|
+
for child in sorted(directory.rglob("*"), reverse=True):
|
|
517
|
+
if child.is_dir() and not child.is_symlink() and not any(child.iterdir()):
|
|
518
|
+
child.rmdir()
|
|
519
|
+
with contextlib.suppress(OSError):
|
|
520
|
+
directory.rmdir()
|