{
"command": "ls -la /Users/warrenthompson/.claude/skills/profile-gen; readlink /Users/warrenthompson/.claude/skills/profile-gen; echo \"---\"; find /Users/warrenthompson/Source/ProfileGenSkill -type f -not -path \"*/.git/*\" | head -60",
"description": "Check symlink and list skill files"
}drwxr-xr-x@ - warrenthompson 11 Sep 23:59 .claude drwxr-xr-x@ - warrenthompson 12 Sep 00:44 .git drwxr-xr-x@ - warrenthompson 10 Sep 16:50 .profiles-assets drwxr-xr-x@ - warrenthompson 11 Sep 20:29 .pytest_cache drwxr-xr-x@ - warrenthompson 10 Sep 13:44 .venv drwxr-xr-x@ - warrenthompson 11 Sep 23:31 assets drwxr-xr-x@ - warrenthompson 10 Sep 16:50 profiles drwxr-xr-x@ - warrenthompson 12 Sep 00:44 references drwxr-xr-x@ - warrenthompson 12 Sep 00:43 scripts drwxr-xr-x@ - warrenthompson 11 Sep 23:31 templates drwxr-xr-x@ - warrenthompson 12 Sep 10:04 tests .rw-r--r--@ 88 warrenthompson 11 Sep 22:53 .gitignore .rw-r--r--@ 7.2k warrenthompson 11 Sep 23:31 CLAUDE.md .rw-r--r--@ 8.5k warrenthompson 11 Sep 22:53 README.md .rw-r--r--@ 28 warrenthompson 10 Sep 13:44 requirements.txt .rw-r--r--@ 20k warrenthompson 12 Sep 00:33 SKILL.md /Users/warrenthompson/Source/ProfileGenSkill --- /Users/warrenthompson/Source/ProfileGenSkill/requirements.txt /Users/warrenthompson/Source/ProfileGenSkill/README.md /Users/warrenthompson/Source/ProfileGenSkill/.gitignore /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md /Users/warrenthompson/Source/ProfileGenSkill/CLAUDE.md /Users/warrenthompson/Source/ProfileGenSkill/.pytest_cache/CACHEDIR.TAG /Users/warrenthompson/Source/ProfileGenSkill/.pytest_cache/README.md /Users/warrenthompson/Source/ProfileGenSkill/.pytest_cache/.gitignore /Users/warrenthompson/Source/ProfileGenSkill/references/backends.md /Users/warrenthompson/Source/ProfileGenSkill/references/voices.md /Users/warrenthompson/Source/ProfileGenSkill/references/comfyui.md /Users/warrenthompson/Source/ProfileGenSkill/references/profile-schema.md /Users/warrenthompson/Source/ProfileGenSkill/references/comfyui-workflow-authoring.md /Users/warrenthompson/Source/ProfileGenSkill/references/prompting.md /Users/warrenthompson/Source/ProfileGenSkill/references/terminal-display.md /Users/warrenthompson/Source/ProfileGenSkill/tests/test_gif.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_session_intro.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_grok_backend.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_prompt.py /Users/warrenthompson/Source/ProfileGenSkill/tests/mock_comfyui_server.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_termimg.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_frontmatter.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_show_profile.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_termstate.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_comfyui_inventory.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_storage.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_toggle_display.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_check_config_node_verification.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_render.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_comfyui_inject.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_e2e_mock.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_grok_cli_backend.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_hud.py /Users/warrenthompson/Source/ProfileGenSkill/tests/test_generate_image_square_crop.py /Users/warrenthompson/Source/ProfileGenSkill/.claude/settings.json /Users/warrenthompson/Source/ProfileGenSkill/.venv/pyvenv.cfg /Users/warrenthompson/Source/ProfileGenSkill/.venv/.gitignore /Users/warrenthompson/Source/ProfileGenSkill/scripts/session_intro.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/check_config.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/toggle_display.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/inspect_comfyui.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/generate_image.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/show_profile.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/write_profile.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/make_gif.py /Users/warrenthompson/Source/ProfileGenSkill/templates/profile.standalone.md.j2 /Users/warrenthompson/Source/ProfileGenSkill/templates/profile.embedded.md.j2 /Users/warrenthompson/Source/ProfileGenSkill/assets/profile.schema.json /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/comfyui_inventory.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/config.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/frontmatter.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/discovery.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/render.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/termimg.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/gif.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/__init__.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/http.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/storage.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/hud.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/prompt.py
{
"command": "ls -la /Users/warrenthompson/Source/ProfileGenSkill/scripts/ /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/ 2>/dev/null",
"description": "List scripts dirs"
}/Users/warrenthompson/Source/ProfileGenSkill/scripts/: drwxr-xr-x@ - warrenthompson 12 Sep 10:33 __pycache__ drwxr-xr-x@ - warrenthompson 12 Sep 10:13 hud drwxr-xr-x@ - warrenthompson 12 Sep 10:00 profilegen .rw-r--r--@ 4.9k warrenthompson 11 Sep 10:54 check_config.py .rw-r--r--@ 6.5k warrenthompson 11 Sep 15:01 generate_image.py .rw-r--r--@ 1.7k warrenthompson 10 Sep 14:05 inspect_comfyui.py .rw-r--r--@ 5.8k warrenthompson 11 Sep 15:47 make_gif.py .rw-r--r--@ 8.8k warrenthompson 12 Sep 00:43 session_intro.py .rw-r--r--@ 10.0k warrenthompson 12 Sep 10:33 show_profile.py .rw-r--r--@ 4.1k warrenthompson 11 Sep 23:31 toggle_display.py .rw-r--r--@ 7.3k warrenthompson 11 Sep 23:31 write_profile.py /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/: drwxr-xr-x@ - warrenthompson 12 Sep 10:33 __pycache__ drwxr-xr-x@ - warrenthompson 11 Sep 20:28 backends .rw-r--r--@ 76 warrenthompson 10 Sep 12:32 __init__.py .rw-r--r--@ 6.4k warrenthompson 11 Sep 10:53 comfyui_inventory.py .rw-r--r--@ 3.1k warrenthompson 10 Sep 14:12 config.py .rw-r--r--@ 2.0k warrenthompson 11 Sep 22:53 discovery.py .rw-r--r--@ 7.3k warrenthompson 11 Sep 23:31 frontmatter.py .rw-r--r--@ 4.0k warrenthompson 11 Sep 15:46 gif.py .rw-r--r--@ 2.2k warrenthompson 10 Sep 12:35 http.py .rw-r--r--@ 16k warrenthompson 12 Sep 10:33 hud.py .rw-r--r--@ 7.4k warrenthompson 10 Sep 21:55 prompt.py .rw-r--r--@ 3.6k warrenthompson 10 Sep 12:35 render.py .rw-r--r--@ 10k warrenthompson 10 Sep 17:05 storage.py .rw-r--r--@ 6.6k warrenthompson 11 Sep 22:53 termimg.py .rw-r--r--@ 4.7k warrenthompson 11 Sep 22:53 termstate.py
{
"file_path": "/Users/warrenthompson/Source/ProfileGenSkill/scripts/show_profile.py"
}1 #!/usr/bin/env python3
2 """CLI: display a profile-gen persona -- its picture and/or name -- in the user's terminal.
3
4 Two ways to point it at a persona:
5
6 --root ROOT
7 Default mode. Every persona currently auto-loaded via ROOT/CLAUDE.md's profile-gen marker
8 blocks (the same blocks write_profile.py --output claude-md/claude-md-ref maintains).
9
10 --profile PATH [--root ROOT]
11 Exactly one profile's markdown file, skipping CLAUDE.md discovery -- used for the immediate
12 preview right after profile-gen writes a persona. --root still matters: `image` fields are
13 recorded relative to the project root (see references/profile-schema.md), not to the
14 markdown file's own directory.
15
16 Three ways to actually draw it, chosen by --mode (default `auto`):
17
18 hud A small always-on-top, click-through overlay window pinned to a corner of the terminal
19 window, showing the picture (animated, for a GIF persona) and the name. macOS only.
20 This is the default because it's the only option that consumes no terminal rows,
21 touches no user configuration, and doesn't depend on terminal image protocols -- so
22 tmux is irrelevant to it. See profilegen/hud.py and scripts/hud/PersonaHUD.swift.
23 inline An inline image painted into the text grid via termimg.py (iTerm2/WezTerm, Kitty,
24 sixel). Works in a plain terminal, but inside tmux it degrades to a clipped sliver --
25 tmux tracks only text cells and loses the image on the next repaint.
26 state iTerm2's badge (the name) and background image (the picture). Survives tmux, but it
27 works by overwriting *user-owned* iTerm2 session settings: anyone with a configured
28 background image loses it, and clearing sets empty rather than restoring theirs. Never
29 chosen by `auto` for that reason -- it's opt-in only.
30 auto `hud` where supported, else `inline`.
31 off Draw nothing. Useful with --clear.
32
33 --clear stops the overlay and clears any badge/background left behind.
34
35 When stdout is not a tty -- an agent's shell tool, whose output is captured for the model rather
36 than shown to the user -- `state` mode writes its escape sequences directly to the active tmux
37 pane's device instead, so the persona still reaches the real screen (see
38 profilegen/termstate.py). In that same case a one-line JSON summary of what was done is printed
39 to stdout for the calling program; when a human is watching a real terminal, nothing is printed.
40
41 Never errors out over a missing/malformed persona or an unsupported terminal -- a persona failing
42 to display is not worth interrupting a session over, especially from a SessionStart hook. Exits 0
43 in every "nothing to show" case.
44
45 Each persona's own `display.image`/`display.name` preference decides what's shown, defaulting to
46 true/true. --show-image/--no-image and --show-name/--no-name override that for this invocation
47 only, without touching the persona's stored preference (see toggle_display.py to change it).
48
49 `display.autostart` is a separate question from those: it decides whether a persona appears *on
50 its own* at session start, not what gets drawn when it does. It's only consulted under
51 --autostart-only, which is what a SessionStart hook passes -- so a persona can be set to stay
52 quiet until asked for by `/display-profile` while still displaying normally when it is.
53 """
54 from __future__ import annotations
55
56 import argparse
57 import json
58 import sys
59 from pathlib import Path
60
61 sys.path.insert(0, str(Path(__file__).resolve().parent))
62
63 from profilegen import discovery, frontmatter, hud, termimg, termstate # noqa: E402
64
65
66 def _load_markdown_fields(markdown_path: Path) -> dict | None:
67 try:
68 text = markdown_path.read_text(encoding="utf-8")
69 except OSError:
70 return None
71 return frontmatter.extract_frontmatter(text)
72
73
74 def _resolve_image_path(image: object, root: Path) -> Path | None:
75 if not image or not isinstance(image, str):
76 return None
77 path = Path(image)
78 return path if path.is_absolute() else root / path
79
80
81 def _display_one(fields: dict, root: Path, args: argparse.Namespace, mode: str) -> dict:
82 display = fields.get("display") if isinstance(fields.get("display"), dict) else {}
83 show_image = args.show_image if args.show_image is not None else display.get("image", True)
84 show_name = args.show_name if args.show_name is not None else display.get("name", True)
85
86 name = fields.get("name")
87 image_path = _resolve_image_path(fields.get("image"), root)
88 result = {
89 "name": name,
90 "mode": mode,
91 "hud": False,
92 "badge": False,
93 "background": False,
94 "inline": False,
95 }
96
97 if mode == "hud":
98 result["hud"] = hud.launch(
99 str(image_path) if (show_image and image_path and image_path.is_file()) else None,
100 str(name) if (show_name and name) else None,
101 avatar=args.avatar,
102 avatar_min=args.avatar_min,
103 corner=args.corner,
104 margin=args.margin,
105 )
106 return result
107
108 if mode == "state":
109 tty = termstate.resolve_target_tty()
110 if show_name and name:
111 result["badge"] = termstate.set_badge(str(name), tty)
112 if show_image and image_path is not None and image_path.is_file():
113 result["background"] = termstate.set_background_image(str(image_path), tty)
114 return result
115
116 if mode == "inline":
117 if show_image and image_path is not None:
118 result["inline"] = termimg.display_image(image_path, width_pct=args.width_pct)
119 if show_name and name:
120 print(f"\033[1m{name}\033[0m")
121 result["badge"] = False
122 return result
123
124
125 def _print_reports(reports: list[dict]) -> None:
126 """One JSON object per line on stdout, for a calling program.
127
128 The leading newline matters: escape sequences written to stdout (the fallback when there's no
129 separate pane device to target) don't end in one, so without it the first report would be
130 glued onto the tail of a sequence and wouldn't parse as a line of JSON.
131 """
132 if not reports:
133 return
134 sys.stdout.write("\n")
135 for report in reports:
136 print(json.dumps(report))
137
138
139 def _clear(args: argparse.Namespace) -> dict:
140 tty = termstate.resolve_target_tty()
141 return {
142 "stopped_hud": hud.stop(),
143 "cleared_badge": termstate.clear_badge(tty),
144 "cleared_background": termstate.clear_background_image(tty),
145 }
146
147
148 def main() -> None:
149 parser = argparse.ArgumentParser(description=__doc__)
150 parser.add_argument(
151 "--root", default=".", help="project root personas are resolved against (default: cwd)"
152 )
153 parser.add_argument(
154 "--profile", help="display exactly this one profile markdown file, skipping CLAUDE.md discovery"
155 )
156 parser.add_argument("--slug", help="only display the persona with this slug")
157 parser.add_argument(
158 "--mode",
159 choices=["auto", "hud", "state", "inline", "off"],
160 default="auto",
161 help="how to draw: overlay window (hud), in-grid image (inline), "
162 "iTerm2 badge+background (state -- overwrites user settings), or auto",
163 )
164 parser.add_argument("--clear", action="store_true", help="stop the overlay / clear badge+background")
165 parser.add_argument(
166 "--autostart-only",
167 action="store_true",
168 help="skip personas whose display.autostart is false (what a SessionStart hook uses)",
169 )
170 parser.add_argument(
171 "--avatar", type=int, default=hud.DEFAULT_AVATAR,
172 help="hud avatar's largest size in points; it shrinks to fit narrower tmux panes/windows",
173 )
174 parser.add_argument(
175 "--avatar-min", type=int, default=hud.DEFAULT_AVATAR_MIN,
176 help="hud avatar's smallest size in points, however narrow the pane gets",
177 )
178 parser.add_argument(
179 "--corner", choices=["tr", "tl", "br", "bl"], default=hud.DEFAULT_CORNER,
180 help="hud corner of the terminal window (default: top-right)",
181 )
182 parser.add_argument("--margin", type=int, default=hud.DEFAULT_MARGIN, help="hud inset from that corner")
183 parser.add_argument("--width-pct", type=int, default=10, help="inline image width as %% of terminal width")
184 parser.add_argument("--show-image", dest="show_image", action="store_true", default=None)
185 parser.add_argument("--no-image", dest="show_image", action="store_false")
186 parser.add_argument("--show-name", dest="show_name", action="store_true", default=None)
187 parser.add_argument("--no-name", dest="show_name", action="store_false")
188 args = parser.parse_args()
189
190 machine_readable = not sys.stdout.isatty()
191
192 if args.clear:
193 report = _clear(args)
194 if machine_readable:
195 _print_reports([report])
196 return
197
198 mode = args.mode
199 if mode == "auto":
200 # never auto-select `state`: it works by overwriting user-owned iTerm2 settings.
201 mode = "hud" if hud.is_supported() else "inline"
202 if mode == "off":
203 return
204
205 root = Path(args.root).resolve()
206 reports = []
207
208 def _autostart_allows(fields: dict) -> bool:
209 if not args.autostart_only:
210 return True
211 display = fields.get("display") if isinstance(fields.get("display"), dict) else {}
212 return bool(display.get("autostart", True))
213
214 if args.profile:
215 fields = _load_markdown_fields(Path(args.profile))
216 if fields and _autostart_allows(fields):
217 reports.append(_display_one(fields, root, args, mode))
218 else:
219 for persona in discovery.discover_personas(root):
220 if args.slug and persona.slug != args.slug:
221 continue
222 if not _autostart_allows(persona.fields):
223 continue
224 reports.append(_display_one(persona.fields, root, args, mode))
225
226 if machine_readable:
227 _print_reports(reports)
228
229
230 if __name__ == "__main__":
231 main()
232 {
"file_path": "/Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/storage.py"
}1 """Path policy and idempotent file edits for profile-gen output.
2
3 Two independent choices drive where things land:
4
5 --output claude-md | claude-md-ref | file
6 --assets tracked | gitignored
7
8 +---------------+------------+-----------------------------------+-------------------------+
9 | output | assets | markdown | asset dir |
10 +---------------+------------+-----------------------------------+-------------------------+
11 | file | tracked | profiles/<slug>/<slug>.md | profiles/<slug>/ |
12 | file | gitignored | .profiles-assets/<slug>/<slug>.md | .profiles-assets/<slug>/ |
13 | claude-md-ref | tracked | profiles/<slug>/<slug>.md | profiles/<slug>/ |
14 | claude-md-ref | gitignored | .claude/persona/persona.md | .claude/persona/ |
15 | claude-md | tracked | ./CLAUDE.md (full block) | profiles/<slug>/ |
16 | claude-md | gitignored | ./CLAUDE.md (full block) | .profiles-assets/<slug>/ |
17 +---------------+------------+-----------------------------------+-------------------------+
18
19 ``file`` and ``claude-md-ref`` (when tracked) co-locate the persona's own markdown with its
20 asset directory.
21
22 ``claude-md-ref`` additionally keeps a one-line ``@<relative path>`` import for the persona in
23 the project's real ``CLAUDE.md`` (Claude Code's own file-import syntax -- which only resolves a
24 specific file, not a bare directory), replaced in place on regeneration -- so a private persona
25 is still automatically discovered by anyone opening the project, without its content ever being
26 inlined into a tracked file.
27
28 ``claude-md-ref`` + ``gitignored`` (the recommended combo for a private/NSFW persona) uses a
29 **fixed, generic path** -- ``.claude/persona/persona.md`` -- instead of one derived from the
30 persona's name, matching the hand-rolled convention this was modeled on (a project's `CLAUDE.md`
31 importing a fixed `@.claude/PERSONA.md` while that file's own content, not its path, carries the
32 identity). The tracked `@`-import string is therefore always the same literal text no matter who
33 the persona is -- nothing about their identity leaks into a tracked file. This means only **one**
34 private/gitignored persona is addressable this way per project at a time; regenerating with a
35 different name overwrites the previous one at that same fixed path. Images alongside
36 ``persona.md`` (``persona.png``, ``persona.gif``, an auto-built ComfyUI ``workflow.json``, etc.)
37 are only ever referenced from within ``persona.md`` itself (never from the tracked CLAUDE.md
38 directly), so their names don't matter for privacy -- generic names are used for tidiness only.
39
40 A user wanting multiple simultaneous private personas, or a persona meant to be shared/committed
41 as-is, should use ``--assets tracked`` (visible, slug-named, supports any number of personas) or
42 ``--output claude-md`` (fully inlined, appropriate when the content itself is meant to be
43 committed).
44 """
45 from __future__ import annotations
46
47 import os
48 import re
49 from pathlib import Path
50
51 _GITIGNORE_PATTERN = ".profiles-assets/"
52
53 _PRIVATE_GITIGNORE_PATTERN = ".claude/persona/"
54 _PRIVATE_MARKER_KEY = "persona"
55
56 _SLUG_INVALID_RE = re.compile(r"[^a-z0-9]+")
57
58 _STANDALONE_MD_OUTPUTS = ("file", "claude-md-ref")
59
60
61 def slugify(name: str) -> str:
62 slug = _SLUG_INVALID_RE.sub("-", name.strip().lower()).strip("-")
63 return slug or "profile"
64
65
66 def _is_private_reference(output: str, assets: str) -> bool:
67 return output == "claude-md-ref" and assets == "gitignored"
68
69
70 def gitignore_pattern_for(output: str, assets: str) -> str:
71 """The .gitignore line that should be ensured for this output/assets combo."""
72 return _PRIVATE_GITIGNORE_PATTERN if _is_private_reference(output, assets) else _GITIGNORE_PATTERN
73
74
75 def plan_paths(root: str | Path, slug: str, output: str, assets: str) -> dict:
76 """Return the final markdown_path/asset_dir (and marker_key) for the given output/assets
77 combo.
78
79 ``claude-md-ref`` + ``gitignored`` is special-cased to the fixed
80 ``.claude/persona/persona.md`` path (see module docstring) -- every other combo derives its
81 path from ``slug`` as usual.
82
83 ``marker_key`` is what the CLAUDE.md marker comment / ``@`` import path uses to identify
84 this persona: the human-readable ``slug`` normally, or the fixed string ``"persona"`` for the
85 private combo above.
86 """
87 root = Path(root)
88
89 if _is_private_reference(output, assets):
90 asset_dir = root / ".claude" / "persona"
91 markdown_path = asset_dir / "persona.md"
92 return {
93 "markdown_path": str(markdown_path),
94 "asset_dir": str(asset_dir),
95 "marker_key": _PRIVATE_MARKER_KEY,
96 }
97
98 if assets == "tracked":
99 asset_dir = root / "profiles" / slug
100 elif assets == "gitignored":
101 asset_dir = root / ".profiles-assets" / slug
102 else:
103 raise ValueError(f"unknown assets policy: {assets!r}")
104
105 if output == "claude-md":
106 markdown_path = root / "CLAUDE.md"
107 elif output in _STANDALONE_MD_OUTPUTS:
108 markdown_path = asset_dir / f"{slug}.md"
109 else:
110 raise ValueError(f"unknown output policy: {output!r}")
111
112 return {
113 "markdown_path": str(markdown_path),
114 "asset_dir": str(asset_dir),
115 "marker_key": slug,
116 }
117
118
119 def ensure_gitignore(root: str | Path, pattern: str = _GITIGNORE_PATTERN) -> bool:
120 """Idempotently ensure ``pattern`` is present as a line in ``root/.gitignore``.
121
122 Returns True if the file was created or modified, False if the pattern
123 was already present.
124 """
125 root = Path(root)
126 gitignore_path = root / ".gitignore"
127
128 if not gitignore_path.exists():
129 gitignore_path.write_text(pattern + "\n", encoding="utf-8")
130 return True
131
132 existing = gitignore_path.read_text(encoding="utf-8")
133 lines = existing.splitlines()
134 if pattern in lines:
135 return False
136
137 if existing and not existing.endswith("\n"):
138 existing += "\n"
139 gitignore_path.write_text(existing + pattern + "\n", encoding="utf-8")
140 return True
141
142
143 def _marker_block_re(slug: str) -> "re.Pattern[str]":
144 start = re.escape(f"<!-- profile-gen:start slug={slug} -->")
145 end = re.escape(f"<!-- profile-gen:end slug={slug} -->")
146 return re.compile(rf"{start}.*?{end}\n?", re.DOTALL)
147
148
149 def _replace_or_append_block(existing: str, slug: str, block: str) -> str:
150 """Return ``existing`` with the profile-gen block for ``slug`` replaced in place if
151 present, or appended (with a blank-line separator) otherwise.
152 """
153 block = block if block.endswith("\n") else block + "\n"
154 block_re = _marker_block_re(slug)
155
156 if block_re.search(existing):
157 return block_re.sub(block, existing, count=1)
158 if existing.strip():
159 sep = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
160 return existing + sep + block
161 return block
162
163
164 def write_markdown_output(
165 root: str | Path, output: str, assets: str, slug: str, rendered_md: str
166 ) -> str:
167 """Write ``rendered_md`` to the location dictated by ``output``/``assets``.
168
169 ``assets`` is required to disambiguate the tracked vs. gitignored layouts for
170 ``file``/``claude-md-ref`` -- see ``plan_paths``, which this function delegates to for the
171 exact path.
172
173 For ``output`` in (``file``, ``claude-md-ref``), ``rendered_md`` is the persona's full
174 standalone content; the containing directory is created and the file is overwritten.
175
176 For ``output == "claude-md"``, ``rendered_md`` must be the
177 ``<!-- profile-gen:start slug=... --> ... <!-- profile-gen:end ... -->`` block. If a block
178 with this slug already exists in CLAUDE.md, it is replaced in place; otherwise the block is
179 appended (with a blank-line separator).
180
181 Returns the path written to, as a string.
182 """
183 root = Path(root)
184 paths = plan_paths(root, slug, output, assets)
185 markdown_path = Path(paths["markdown_path"])
186
187 if output in _STANDALONE_MD_OUTPUTS:
188 markdown_path.parent.mkdir(parents=True, exist_ok=True)
189 markdown_path.write_text(rendered_md, encoding="utf-8")
190 return str(markdown_path)
191
192 if output == "claude-md":
193 existing = markdown_path.read_text(encoding="utf-8") if markdown_path.exists() else ""
194 new_content = _replace_or_append_block(existing, paths["marker_key"], rendered_md)
195 markdown_path.write_text(new_content, encoding="utf-8")
196 return str(markdown_path)
197
198 raise ValueError(f"unknown output policy: {output!r}")
199
200
201 def write_claude_md_reference(root: str | Path, marker_key: str, target_path: str | Path) -> str:
202 """Ensure a one-line ``@<relative path>`` import keyed by ``marker_key`` is present in
203 ``root/CLAUDE.md`` (Claude Code's own file-import syntax), replacing any previous
204 reference under the same key in place. Used by ``output == "claude-md-ref"`` so a persona's
205 own markdown is never inlined into a tracked file -- only a tiny, content-free pointer to it
206 is.
207
208 ``marker_key`` should be ``plan_paths(...)["marker_key"]`` for this persona -- the fixed
209 string ``"persona"`` for the private (gitignored) case, so the persona's identity doesn't
210 leak into this tracked reference string either.
211
212 Returns the CLAUDE.md path written to, as a string.
213 """
214 root = Path(root)
215 claude_md_path = root / "CLAUDE.md"
216 target = Path(target_path)
217
218 try:
219 rel = target.relative_to(root)
220 except ValueError:
221 rel = Path(os.path.relpath(target, root))
222
223 block = (
224 f"<!-- profile-gen:start slug={marker_key} -->\n"
225 f"@{rel.as_posix()}\n"
226 f"<!-- profile-gen:end slug={marker_key} -->\n"
227 )
228
229 existing = claude_md_path.read_text(encoding="utf-8") if claude_md_path.exists() else ""
230 new_content = _replace_or_append_block(existing, marker_key, block)
231 claude_md_path.write_text(new_content, encoding="utf-8")
232 return str(claude_md_path)
233 {
"file_path": "/Users/warrenthompson/Source/ProfileGenSkill/scripts/write_profile.py"
}1 #!/usr/bin/env python3
2 """CLI: plan output paths, or render+write a profile-gen profile to disk.
3
4 --root is REQUIRED and must be an absolute path to the user's actual project directory --
5 never omit it or rely on a default. This script's own location (under the skill's install
6 directory) has nothing to do with --root; passing the wrong directory here is what causes a
7 persona to be written inside the skill's installation instead of the target project.
8
9 Two modes:
10
11 --plan-only --name NAME --root ROOT [--output ...] [--assets ...]
12 Prints {"markdown_path": ..., "asset_dir": ...} and writes nothing.
13 Called before image/gif generation so those scripts know exactly
14 where to write their output files.
15
16 --fields-file FIELDS.json --root ROOT [--output ...] [--assets ...]
17 Loads the full profile fields, renders the markdown, writes it out,
18 ensures .gitignore is updated when --assets gitignored, and (for
19 --output claude-md-ref) keeps a one-line @-import for the persona in
20 the project's CLAUDE.md. Prints {"markdown_path", "image_path",
21 "gitignore_updated", "claude_md_updated", "claude_md_path"}
22 on success, or {"error": "..."} (nonzero exit) on failure.
23
24 --output modes:
25 file standalone markdown, co-located with its assets (tracked or gitignored per
26 --assets); nothing else touched.
27 claude-md-ref same standalone markdown as `file`, PLUS a one-line `@<relative path>` import
28 kept in CLAUDE.md (replaced in place on regeneration) so a private
29 (gitignored) persona is still auto-discovered without inlining its content
30 into a tracked file. Recommended default for personal/NSFW personas. When
31 combined with --assets gitignored, both the on-disk path and the tracked
32 @-import string are the fixed, generic .claude/persona/persona.md -- never
33 derived from the persona's name -- so identity never leaks into a tracked
34 file (matches the hand-rolled convention this was modeled on). Only one such
35 private persona is addressable per project at a time; regenerating with a
36 different name overwrites it.
37 claude-md the full rendered profile is embedded directly in CLAUDE.md -- appropriate
38 when the persona itself is meant to be shared/committed as-is.
39
40 Fields file required keys: name, slug, image, nsfw, generation.
41 Optional keys: voice, personality, display (an {"image": bool, "name": bool, "autostart": bool}
42 object -- image/name control whether show_profile.py shows this persona's picture/name at all,
43 autostart whether a SessionStart hook shows it automatically, which is a separate question: a
44 persona can be available via /display-profile without appearing on its own. All default to true
45 when omitted, filled in here before rendering so every written profile has explicit values, even
46 one predating a field).
47
48 There is a single `image` field -- it holds whichever file is the profile's final picture, a
49 static PNG or an animated GIF, never both. `generation.gif_mode` says which: null for a plain
50 static image, "native"/"synthetic" when `image` is an animated GIF.
51 """
52 from __future__ import annotations
53
54 import argparse
55 import json
56 import sys
57 from pathlib import Path
58
59 sys.path.insert(0, str(Path(__file__).resolve().parent))
60
61 from profilegen import render, storage # noqa: E402
62
63 REQUIRED_FIELD_KEYS = ("name", "slug", "image", "nsfw", "generation")
64
65
66 def _fail(message: str) -> None:
67 print(json.dumps({"error": message}))
68 sys.exit(1)
69
70
71 def _plan_only(args: argparse.Namespace) -> None:
72 slug = storage.slugify(args.name)
73 paths = storage.plan_paths(args.root, slug, args.output, args.assets)
74 print(json.dumps(paths))
75
76
77 def _write(args: argparse.Namespace) -> None:
78 try:
79 raw = Path(args.fields_file).read_text(encoding="utf-8")
80 except OSError as exc:
81 _fail(f"could not read fields file: {exc}")
82 return
83
84 try:
85 fields = json.loads(raw)
86 except json.JSONDecodeError as exc:
87 _fail(f"fields file is not valid JSON: {exc}")
88 return
89
90 missing = [key for key in REQUIRED_FIELD_KEYS if key not in fields]
91 if missing:
92 _fail(f"fields file missing required keys: {', '.join(missing)}")
93 return
94
95 slug = fields.get("slug") or storage.slugify(fields["name"])
96 display = fields.get("display") if isinstance(fields.get("display"), dict) else {}
97 fields["display"] = {
98 "image": display.get("image", True),
99 "name": display.get("name", True),
100 "autostart": display.get("autostart", True),
101 }
102
103 try:
104 if args.output == "claude-md":
105 rendered = render.render_embedded(fields)
106 else:
107 rendered = render.render_standalone(fields)
108 except FileNotFoundError as exc:
109 _fail(f"template not found: {exc}")
110 return
111
112 try:
113 markdown_path = storage.write_markdown_output(
114 args.root, args.output, args.assets, slug, rendered
115 )
116 except (OSError, ValueError) as exc:
117 _fail(f"could not write markdown output: {exc}")
118 return
119
120 claude_md_path = None
121 if args.output == "claude-md-ref":
122 marker_key = storage.plan_paths(args.root, slug, args.output, args.assets)["marker_key"]
123 try:
124 claude_md_path = storage.write_claude_md_reference(args.root, marker_key, markdown_path)
125 except OSError as exc:
126 _fail(f"could not write CLAUDE.md reference: {exc}")
127 return
128 elif args.output == "claude-md":
129 claude_md_path = markdown_path
130
131 gitignore_updated = False
132 if args.assets == "gitignored":
133 pattern = storage.gitignore_pattern_for(args.output, args.assets)
134 gitignore_updated = storage.ensure_gitignore(args.root, pattern=pattern)
135
136 print(
137 json.dumps(
138 {
139 "markdown_path": markdown_path,
140 "image_path": fields.get("image"),
141 "gitignore_updated": gitignore_updated,
142 "claude_md_updated": args.output in ("claude-md", "claude-md-ref"),
143 "claude_md_path": claude_md_path,
144 }
145 )
146 )
147
148
149 def main() -> None:
150 parser = argparse.ArgumentParser(description=__doc__)
151 parser.add_argument("--plan-only", action="store_true")
152 parser.add_argument("--fields-file")
153 parser.add_argument("--name", help="required with --plan-only")
154 parser.add_argument(
155 "--root",
156 required=True,
157 help="absolute path to the user's actual project directory (never this skill's own "
158 "install directory)",
159 )
160 parser.add_argument(
161 "--output", choices=["claude-md", "claude-md-ref", "file"], default="claude-md-ref"
162 )
163 parser.add_argument(
164 "--assets", choices=["tracked", "gitignored"], default="tracked"
165 )
166 args = parser.parse_args()
167
168 if args.plan_only:
169 if not args.name:
170 _fail("--plan-only requires --name")
171 return
172 _plan_only(args)
173 return
174
175 if not args.fields_file:
176 _fail("--fields-file is required unless --plan-only is given")
177 return
178 _write(args)
179
180
181 if __name__ == "__main__":
182 main()
183 {
"file_path": "/Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/discovery.py"
}1 """Shared persona discovery for show_profile.py / toggle_display.py. 2 3 Finds every persona currently auto-loaded via a project's CLAUDE.md profile-gen marker blocks, 4 alongside the actual markdown file each one's content (and thus its `display:` block) lives in -- 5 a standalone `persona.md`/`profiles/<slug>/<slug>.md` for `claude-md-ref`, or CLAUDE.md itself for 6 a fully-embedded (`claude-md`) persona. `embedded=True` on the latter tells a caller that wants to 7 edit the `display:` block that it must confine the edit to that persona's own marker-delimited 8 region within CLAUDE.md (see `frontmatter.block_span`) -- CLAUDE.md can hold more than one 9 embedded persona, so an unscoped edit could touch the wrong one's block. 10 """ 11 from __future__ import annotations 12 13 from dataclasses import dataclass 14 from pathlib import Path 15 16 from . import frontmatter 17 18 19 @dataclass 20 class DiscoveredPersona: 21 slug: str 22 fields: dict 23 markdown_path: Path 24 embedded: bool 25 26 27 def _load_markdown_fields(markdown_path: Path) -> dict | None: 28 try: 29 text = markdown_path.read_text(encoding="utf-8") 30 except OSError: 31 return None 32 return frontmatter.extract_frontmatter(text) 33 34 35 def discover_personas(root: Path) -> list[DiscoveredPersona]: 36 claude_md = root / "CLAUDE.md" 37 if not claude_md.is_file(): 38 return [] 39 try: 40 text = claude_md.read_text(encoding="utf-8") 41 except OSError: 42 return [] 43 44 personas: list[DiscoveredPersona] = [] 45 for slug in frontmatter.find_marker_slugs(text): 46 ref = frontmatter.extract_ref_import(text, slug) 47 if ref: 48 target = root / ref 49 fields = _load_markdown_fields(target) 50 if fields: 51 personas.append(DiscoveredPersona(slug, fields, target, embedded=False)) 52 continue 53 embedded_fields = frontmatter.extract_embedded_block(text, slug) 54 if embedded_fields: 55 personas.append(DiscoveredPersona(slug, embedded_fields, claude_md, embedded=True)) 56 return personas 57
{
"command": "grep -n \"find_marker_slugs\" -A 15 /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/frontmatter.py | head -30; echo \"=== SCHEMA ===\"; cat -n /Users/warrenthompson/Source/ProfileGenSkill/assets/profile.schema.json",
"description": "Check marker slug ordering and schema"
}74:def find_marker_slugs(claude_md_text: str) -> list[str]:
75- """All profile-gen marker-block slugs present in a CLAUDE.md, in file order."""
76- return _MARKER_RE.findall(claude_md_text)
77-
78-
79-def _block(claude_md_text: str, slug: str) -> str | None:
80- start = f"<!-- profile-gen:start slug={slug} -->"
81- end = f"<!-- profile-gen:end slug={slug} -->"
82- if start not in claude_md_text:
83- return None
84- return claude_md_text.split(start, 1)[1].split(end, 1)[0]
85-
86-
87-def extract_ref_import(claude_md_text: str, slug: str) -> str | None:
88- """The ``@<path>`` import line inside a claude-md-ref marker block for ``slug``, or None."""
89- block = _block(claude_md_text, slug)
=== SCHEMA ===
1 {
2 "$schema": "https://json-schema.org/draft/2020-12/schema",
3 "$id": "https://profile-gen.local/profile.schema.json",
4 "title": "profile-gen profile",
5 "type": "object",
6 "required": ["schema_version", "name", "slug", "image", "nsfw", "generation"],
7 "properties": {
8 "schema_version": { "const": 1 },
9 "name": { "type": "string", "minLength": 1 },
10 "slug": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
11 "image": { "type": "string", "minLength": 1 },
12 "voice": { "type": ["string", "null"] },
13 "personality": { "type": ["string", "null"] },
14 "nsfw": { "type": "boolean" },
15 "display": {
16 "type": "object",
17 "properties": {
18 "image": { "type": "boolean" },
19 "name": { "type": "boolean" },
20 "autostart": { "type": "boolean" }
21 },
22 "additionalProperties": false
23 },
24 "generation": {
25 "type": "object",
26 "required": ["backend", "model", "prompt", "seed", "gif_mode", "created_at"],
27 "properties": {
28 "backend": { "type": "string", "enum": ["chatgpt", "grok", "grok-cli", "comfyui", "mock"] },
29 "model": { "type": "string" },
30 "prompt": { "type": "string" },
31 "negative_prompt": { "type": ["string", "null"] },
32 "seed": { "type": ["integer", "null"] },
33 "gif_mode": { "type": ["string", "null"], "enum": ["native", "synthetic", null] },
34 "created_at": { "type": "string", "format": "date-time" }
35 }
36 }
37 },
38 "additionalProperties": false
39 }{
"command": "grep -n \"personality\\|backstory\\|history\\|background\\|maxLength\" /Users/warrenthompson/Source/ProfileGenSkill/references/profile-schema.md | head -40",
"description": "Search schema doc for persona fields"
}15:| `personality` | no | string or `null` | Free-form prose description of the persona's personality/register. | 108:loaded automatically, without any of its content (name, personality, prompts, NSFW flag) ever
{
"command": "cat -n /Users/warrenthompson/Source/ProfileGenSkill/scripts/toggle_display.py; echo \"=== GENIMG ===\"; grep -n \"seed\\|workflow\\|add_argument\" /Users/warrenthompson/Source/ProfileGenSkill/scripts/generate_image.py",
"description": "Read toggle_display and generate_image flags"
} 1 #!/usr/bin/env python3
2 """CLI: flip a persona's stored display.image/display.name preference in place.
3
4 Discovers personas the same way show_profile.py does (via <root>/CLAUDE.md's profile-gen marker
5 blocks) and edits whichever markdown file that persona's content actually lives in --
6 persona.md/profiles/<slug>/<slug>.md for claude-md-ref, or the right slug's own marker-delimited
7 region inside CLAUDE.md itself for a fully-embedded (claude-md) persona. Only the display: block
8 is touched; nothing else in the file is regenerated or reformatted.
9
10 At least one of --image/--name/--autostart is required. --image/--name control what is drawn;
11 --autostart controls whether a SessionStart hook shows this persona on its own, which is a
12 separate question -- a persona can stay quiet at startup while still displaying when asked for.
13 A persona written before a flag existed simply has it appended to its display: block. Prints one JSON object per persona changed:
14 {"slug", "markdown_path", "display": {"image": bool, "name": bool}}, or {"error": "..."}
15 (nonzero exit) if nothing was found to change.
16 """
17 from __future__ import annotations
18
19 import argparse
20 import json
21 import sys
22 from pathlib import Path
23
24 sys.path.insert(0, str(Path(__file__).resolve().parent))
25
26 from profilegen import discovery, frontmatter # noqa: E402
27
28
29 def _fail(message: str) -> None:
30 print(json.dumps({"error": message}))
31 sys.exit(1)
32
33
34 def main() -> None:
35 parser = argparse.ArgumentParser(description=__doc__)
36 parser.add_argument(
37 "--root", default=".", help="project root to discover personas under (default: cwd)"
38 )
39 parser.add_argument(
40 "--slug", help="only touch the persona with this slug (default: all discovered personas)"
41 )
42 parser.add_argument("--image", choices=["on", "off"])
43 parser.add_argument("--name", choices=["on", "off"])
44 parser.add_argument(
45 "--autostart",
46 choices=["on", "off"],
47 help="whether a SessionStart hook shows this persona automatically",
48 )
49 args = parser.parse_args()
50
51 if args.image is None and args.name is None and args.autostart is None:
52 _fail("nothing to do: pass --image, --name and/or --autostart as on|off")
53 return
54
55 root = Path(args.root).resolve()
56 personas = discovery.discover_personas(root)
57 if args.slug:
58 personas = [p for p in personas if p.slug == args.slug]
59
60 if not personas:
61 _fail("no persona found (is CLAUDE.md missing a profile-gen block, or --slug wrong?)")
62 return
63
64 image = (args.image == "on") if args.image else None
65 name = (args.name == "on") if args.name else None
66 autostart = (args.autostart == "on") if args.autostart else None
67
68 results = []
69 for persona in personas:
70 try:
71 text = persona.markdown_path.read_text(encoding="utf-8")
72 except OSError as e:
73 _fail(f"could not read {persona.markdown_path}: {e}")
74 return
75
76 region = None
77 if persona.embedded:
78 region = frontmatter.block_span(text, persona.slug)
79 if region is None:
80 _fail(f"could not locate marker block for slug {persona.slug}")
81 return
82
83 try:
84 new_text = frontmatter.set_display_flags(
85 text, image=image, name=name, autostart=autostart, region=region
86 )
87 except ValueError as e:
88 _fail(f"{persona.markdown_path}: {e}")
89 return
90
91 persona.markdown_path.write_text(new_text, encoding="utf-8")
92
93 if persona.embedded:
94 updated_fields = frontmatter.extract_embedded_block(new_text, persona.slug)
95 else:
96 updated_fields = frontmatter.extract_frontmatter(new_text)
97
98 results.append(
99 {
100 "slug": persona.slug,
101 "markdown_path": str(persona.markdown_path),
102 "display": (updated_fields or {}).get("display", {}),
103 }
104 )
105
106 for result in results:
107 print(json.dumps(result))
108
109
110 if __name__ == "__main__":
111 main()
=== GENIMG ===
21:Prints one JSON object to stdout: {"path", "backend", "model", "seed", "width", "height",
88: parser.add_argument("--backend", required=True, choices=["chatgpt", "grok", "grok-cli", "comfyui", "mock"])
89: parser.add_argument("--prompt-file", required=True)
90: parser.add_argument("--negative-file")
91: parser.add_argument("--nsfw", action="store_true")
92: parser.add_argument("--out", required=True)
93: parser.add_argument("--seed", type=int)
94: parser.add_argument("--width", type=int, default=1024)
95: parser.add_argument("--height", type=int, default=1024)
96: parser.add_argument("--workflow", help="override comfyui_workflow path (e.g. a freshly authored one)")
97: parser.add_argument("--style-preset", help="optional short style clause, e.g. 'anime style'")
104: cfg = config.resolve(args.backend, {"comfyui_workflow": args.workflow})
120: seed=args.seed,
143: "seed": result.seed,{
"command": "grep -rn \"profile.schema.json\\|jsonschema\" /Users/warrenthompson/Source/ProfileGenSkill --include=*.py --include=*.md --include=*.txt --include=*.json -l | grep -v \".venv\\|.git/\"; echo \"=== ref grep ===\"; grep -rn \"placeholder\\|no-image\\|text-only\\|later\\|re-run\" /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md /Users/warrenthompson/Source/ProfileGenSkill/references/profile-schema.md | head -20",
"description": "Check schema validation usage and placeholder docs"
}(eval):1: no matches found: --include=*.py === ref grep === /Users/warrenthompson/Source/ProfileGenSkill/references/profile-schema.md:24:| `image` | yes | boolean | Show the profile picture inline in a supporting terminal (iTerm2/WezTerm, Kitty, or sixel via `img2sixel`) when `show_profile.py` runs. Set at generation time via `--show-image`/`--no-image`; `true` by default. | /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md:4:argument-hint: "[description hints] [--backend chatgpt|grok|grok-cli|comfyui] [--nsfw] [--gif] [--voice NAME] [--output claude-md-ref|claude-md|file] [--assets tracked|gitignored] [--name NAME] [--no-image] [--no-name]" /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md:37:`--no-image`, `--no-name`. Anything not recognized as a flag is a description hint. /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md:39:`--no-image`/`--no-name` control this persona's stored `display.image`/`display.name` /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md:244:if step 8 was skipped. Set `display.image`/`display.name` from step 1's `--no-image`/`--no-name` /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md:281:either, wire it in now rather than telling them to do it themselves later. /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md:287:block was replaced vs. newly appended (re-running with the same name/slug always replaces in
{
"command": "grep -n \"autostart\\|session_intro\\|--profile\\|--slug\\|--mode\" /Users/warrenthompson/Source/ProfileGenSkill/references/terminal-display.md",
"description": "Grep terminal-display doc"
}13:| `--mode` | What it does | Where it works |
41:`display.autostart` answers a different question: not *what* to draw, but whether this persona appears **on its own** at session start. It's consulted only under `--autostart-only`, which is what the `SessionStart` hook passes — so `autostart: false` keeps a persona fully displayable via `/display-profile` while stopping it appearing unprompted. Useful when several projects each have a persona and only some should announce themselves. Defaults to `true`, so personas written before the field keep appearing rather than silently stopping.
45:- `--profile <markdown_path> --root <PROJECT_ROOT>` — shows exactly one profile, used right after `write_profile.py` for an immediate preview (SKILL.md step 10).
46:- `--root <PROJECT_ROOT>` (no `--profile`) — discovers every persona currently auto-loaded via `<PROJECT_ROOT>/CLAUDE.md`'s `profile-gen` marker blocks and shows each one. This is the mode for a `SessionStart` hook (below), since it needs no argument beyond the project root to find whatever persona(s) are active in that project.
82:`show_profile.py --profile ...` (SKILL.md step 10) only previews a persona for the rest of the session it was just generated in. To have a persona show automatically at the start of *every* future session in a project — the point of this feature, since the whole reason to want it is "I forget who I'm talking to" — add a `SessionStart` hook to the project's `.claude/settings.json`:
103:This hook needs no `--profile` argument — plain `--root` mode discovers whichever persona(s) are referenced from that project's `CLAUDE.md` on its own. Use the `update-config` skill to actually apply a settings.json change like this one, rather than hand-editing it.
107:`scripts/session_intro.py` is a separate `SessionStart` hook script that makes Claude actually introduce itself at the top of a session — name, personality, and a one-sentence gesture at what's recently been touched. That signal is the most-recently-modified files under the project root (by mtime), read fresh on every session start — not the auto-memory index (`MEMORY.md`). An earlier version used the memory index and it produced a persona that confidently narrated a "most recently we've been working on..." story pulled from stale memory, unrelated to what was actually current; memory is written once and organized by topic, not chronology (see the memory system's own guidance on this), so it can describe work from weeks ago as if it were live. File mtimes can't be stale that way. `git log` is a fallback only, for a project where file recency isn't a useful signal. The prompt also explicitly tells Claude not to invent a narrative about *why* a file changed — just to notice *that* it did, and stay honest if it isn't sure. It's independent of the `hud`/`inline`/`state` display above (it prints text/context, not an image), so the two can be wired in together or separately.
117: "command": "python3 <SKILL_DIR>/scripts/session_intro.py --root \"$CLAUDE_PROJECT_DIR\""
138:python3 scripts/toggle_display.py --root <PROJECT_ROOT> [--slug SLUG] [--image on|off] [--name on|off]
141:It uses the same `<PROJECT_ROOT>/CLAUDE.md` discovery as `show_profile.py` (via the shared `profilegen.discovery` module), so it finds whichever persona(s) are currently referenced there — pass `--slug` to target just one if more than one is discovered. It edits whichever file that persona's content actually lives in: the standalone `persona.md`/`profiles/<slug>/<slug>.md` for `claude-md-ref`, or that persona's own marker-delimited region inside `CLAUDE.md` itself for a fully-embedded (`claude-md`) persona — `frontmatter.block_span()` confines the edit so a different embedded persona's block is never touched. Prints one JSON object per persona changed: `{"slug", "markdown_path", "display": {...}}`, or `{"error": "..."}` (nonzero exit) if no persona was found.{
"command": "cat -n /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/config.py; grep -n \"workflow\" /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/backends/comfyui.py | head -30",
"description": "Read config and comfyui workflow handling"
} 1 """Config resolution: CLI flag > env var > ./.claude/profile-gen.json > ~/.claude/profile-gen.json."""
2 from __future__ import annotations
3
4 import json
5 import os
6 from pathlib import Path
7 from typing import Any
8
9 # key -> env var name
10 _KEYS = {
11 "openai_api_key": "OPENAI_API_KEY",
12 "xai_api_key": "XAI_API_KEY",
13 "comfyui_url": "COMFYUI_URL",
14 "comfyui_workflow": "COMFYUI_WORKFLOW",
15 "comfyui_gif_workflow": "COMFYUI_GIF_WORKFLOW",
16 "comfyui_timeout_s": "COMFYUI_TIMEOUT_S",
17 "comfyui_client_id": "COMFYUI_CLIENT_ID",
18 "openai_base_url": "OPENAI_BASE_URL",
19 "openai_image_model": "OPENAI_IMAGE_MODEL",
20 "xai_image_model": "XAI_IMAGE_MODEL",
21 "grok_cli_bin": "GROK_CLI_BIN",
22 "grok_cli_workdir": "GROK_CLI_WORKDIR",
23 "grok_cli_timeout_s": "GROK_CLI_TIMEOUT_S",
24 "default_backend": "PROFILEGEN_DEFAULT_BACKEND",
25 "default_voice": "PROFILEGEN_DEFAULT_VOICE",
26 "style_preset": "PROFILEGEN_STYLE_PRESET",
27 }
28
29 _INT_KEYS = {"comfyui_timeout_s", "grok_cli_timeout_s"}
30
31
32 class ConfigError(Exception):
33 """Raised when a config file is malformed or otherwise unusable."""
34
35
36 def _load_json_file(path: Path) -> dict[str, Any]:
37 if not path.exists():
38 return {}
39 try:
40 text = path.read_text(encoding="utf-8")
41 except OSError as e:
42 raise ConfigError(f"Could not read config file {path}: {e}") from e
43 try:
44 data = json.loads(text)
45 except json.JSONDecodeError as e:
46 raise ConfigError(f"Malformed JSON in config file {path}: {e}") from e
47 if not isinstance(data, dict):
48 raise ConfigError(f"Config file {path} must contain a JSON object")
49 return data
50
51
52 def _coerce(key: str, value: Any) -> Any:
53 if value is None:
54 return None
55 if key in _INT_KEYS:
56 try:
57 return int(value)
58 except (TypeError, ValueError):
59 return value
60 return value
61
62
63 def resolve(backend: str, cli_overrides: dict[str, Any] | None = None) -> dict[str, Any]:
64 """Merge config with precedence: cli_overrides > env vars > ./.claude/profile-gen.json
65 > ~/.claude/profile-gen.json. Returns a dict with all known keys (missing ones as None),
66 plus a ``backend`` key set to the requested backend name.
67 """
68 cli_overrides = cli_overrides or {}
69
70 home_cfg = _load_json_file(Path.home() / ".claude" / "profile-gen.json")
71 local_cfg = _load_json_file(Path.cwd() / ".claude" / "profile-gen.json")
72
73 result: dict[str, Any] = {"backend": backend}
74 for key in _KEYS:
75 value = None
76 if key in home_cfg:
77 value = home_cfg[key]
78 if key in local_cfg:
79 value = local_cfg[key]
80 env_name = _KEYS[key]
81 if env_name in os.environ:
82 value = os.environ[env_name]
83 if key in cli_overrides and cli_overrides[key] is not None:
84 value = cli_overrides[key]
85 result[key] = _coerce(key, value)
86
87 # pass through any extra CLI overrides not in the known key set (e.g. one-off flags)
88 for key, value in cli_overrides.items():
89 if key not in result and value is not None:
90 result[key] = value
91
92 return result
1:"""ComfyUI backend: fully generic, driven by a user-supplied API-format workflow JSON.
41:def _find_by_title(workflow: dict, titles: set[str]) -> Optional[str]:
42: for node_id, node in workflow.items():
48:def _find_sampler(workflow: dict) -> Optional[str]:
49: for node_id, node in workflow.items():
55:def _resolve_link_source(workflow: dict, node_id: Optional[str], input_key: str) -> Optional[str]:
58: node = workflow.get(node_id)
67:def _find_latent_node(workflow: dict) -> Optional[str]:
68: node_id = _find_by_title(workflow, {_LATENT_TITLE})
71: for node_id, node in workflow.items():
80:def _find_output_node(workflow: dict) -> Optional[str]:
81: for node_id, node in workflow.items():
98:def inject(workflow: dict, spec: PromptSpec) -> tuple[dict, dict]:
99: """Mutate a copy of ``workflow`` with the given PromptSpec. Returns (workflow, resolved)
102: wf = json.loads(json.dumps(workflow)) # deep copy
150:def _load_workflow(path: str) -> dict:
153: raise BackendError(f"ComfyUI workflow file not found: {path}")
157: raise BackendError(f"Invalid JSON in ComfyUI workflow file {path}: {e}") from e
165: self.supports_native_gif = bool(cfg.get("comfyui_gif_workflow"))
171: workflow_path = cfg.get("comfyui_workflow")
172: if not workflow_path:
173: errors.append("comfyui_workflow is not set (env COMFYUI_WORKFLOW)")
175: p = Path(workflow_path)
177: errors.append(f"comfyui_workflow file does not exist: {workflow_path}")
182: errors.append(f"comfyui_workflow is not valid JSON: {e}")
195: def _run_workflow(self, workflow_path: str, spec: PromptSpec, output_key: str) -> ImageResult:
200: if not workflow_path:
201: raise BackendError("comfyui workflow is not set")
204: workflow = _load_workflow(workflow_path)
205: injected, resolved = inject(workflow, spec){
"command": "grep -rn \"jsonschema\\|profile.schema.json\" /Users/warrenthompson/Source/ProfileGenSkill/scripts /Users/warrenthompson/Source/ProfileGenSkill/SKILL.md /Users/warrenthompson/Source/ProfileGenSkill/references /Users/warrenthompson/Source/ProfileGenSkill/requirements.txt 2>/dev/null | head; echo \"=== seed ===\"; grep -n \"seed\" /Users/warrenthompson/Source/ProfileGenSkill/scripts/profilegen/backends/comfyui.py | head -20",
"description": "Check schema validation usage and seed handling"
}/Users/warrenthompson/Source/ProfileGenSkill/references/profile-schema.md:3:Canonical definition: `assets/profile.schema.json`. This document explains each field in
=== seed ===
14:Seed is set on every node that has a "seed" or "noise_seed" input, to
15:``spec.seed`` (or a random 32-bit int if unset) -- the used value is reported in
16:``resolved["seed_used"]`` so it's consistent across all nodes touched.
34:_SEED_KEYS = ("seed", "noise_seed")
100: where resolved is {positive_node, negative_node, seed_nodes, latent_node, seed_used}.
130: seed_used = spec.seed if spec.seed is not None else random.getrandbits(32)
131: seed_nodes: list[str] = []
134: for seed_key in _SEED_KEYS:
135: if seed_key in inputs:
136: inputs[seed_key] = seed_used
137: seed_nodes.append(node_id)
142: "seed_nodes": seed_nodes,
145: "seed_used": seed_used,
252: seed=resolved["seed_used"],{
"command": "sed -n '1,40p' /Users/warrenthompson/Source/ProfileGenSkill/references/profile-schema.md",
"description": "Read profile schema doc top"
}# Profile schema reference Canonical definition: `assets/profile.schema.json`. This document explains each field in plain language and shows one example of each render mode. ## Fields | Field | Required | Type | Notes | |---|---|---|---| | `schema_version` | yes | `1` (const) | Bump only if the schema shape changes. | | `name` | yes | string | Human-readable display name, e.g. `"Ada Sterling"`. | | `slug` | yes | string | `^[a-z0-9]+(-[a-z0-9]+)*$` — derived from `name` via `storage.slugify()`. Used in file/dir names and as the CLAUDE.md marker-block key. | | `image` | yes | string (path) | Path to the profile's single picture, relative to the repo root — a static PNG, or an animated GIF if one was generated (see `generation.gif_mode`). There is only ever one image field; a GIF replaces the PNG here rather than sitting alongside it. | | `voice` | no | string or `null` | Opaque voice-name reference (e.g. a Kokoro TTS voice like `af_jessica`). Metadata only — no audio is synthesized. See `references/voices.md`. | | `personality` | no | string or `null` | Free-form prose description of the persona's personality/register. | | `nsfw` | yes | boolean | Whether this profile was generated in NSFW mode. Threads through to `generation.prompt`/`generation.negative_prompt` and the backend call — see `references/prompting.md`. | | `display` | yes | object | Whether `scripts/show_profile.py` shows this persona's picture/name inline in the terminal. See below. | | `generation` | yes | object | Provenance of how the image was produced. See below. | ### `display` object | Field | Required | Type | Notes | |---|---|---|---| | `image` | yes | boolean | Show the profile picture inline in a supporting terminal (iTerm2/WezTerm, Kitty, or sixel via `img2sixel`) when `show_profile.py` runs. Set at generation time via `--show-image`/`--no-image`; `true` by default. | | `name` | yes | boolean | Print the persona's name when `show_profile.py` runs. Set via `--show-name`/`--no-name`; `true` by default. | | `autostart` | yes | boolean | Whether a `SessionStart` hook shows this persona *on its own*. Separate from `image`/`name`, which say what gets drawn once it is shown — `autostart: false` keeps a persona fully available via `/display-profile` while stopping it appearing unprompted. Only consulted under `show_profile.py --autostart-only` (what the hook passes); `true` by default. | Written by every profile-gen run from this point forward. A profile written before this field existed simply lacks it — `show_profile.py` treats a missing `display` (or a missing `image`/ `name` key within it) as `true`, so older profiles keep behaving as if both were on. See `references/terminal-display.md` for how `show_profile.py` uses this. ### `generation` object | Field | Required | Type | Notes | |---|---|---|---| | `backend` | yes | `"chatgpt"` \| `"grok"` \| `"grok-cli"` \| `"comfyui"` \| `"mock"` | Which backend produced the image. | | `model` | yes | string | Backend-reported model identifier (e.g. `gpt-image-1`, `grok-2-image`). | | `prompt` | yes | string | Final positive prompt sent to the backend (including the NSFW clause, if any). | | `negative_prompt` | no | string or `null` | Final negative prompt, if the backend supports one (ComfyUI). `null` for backends without a negative-prompt concept. |
{
"command": "grep -n \"personality\\|def \\|autostart\\|--root\\|add_argument\" /Users/warrenthompson/Source/ProfileGenSkill/scripts/session_intro.py | head -30",
"description": "Scan session_intro.py"
}52:def _personality_section(markdown_path: Path) -> str | None:
60: if line.strip().lower() == "## personality":
74:def _recent_files_activity(root: Path, count: int = FILE_COUNT) -> str | None:
113:def _git_activity(root: Path, count: int = 8) -> str | None:
129:def _recent_activity(root: Path) -> tuple[str | None, str]:
139:def build_context(name: str | None, personality: str | None, activity: str | None, source: str) -> str | None:
143: if personality:
144: persona_bit += f" Personality: {personality}"
178:def _first_activity_teaser(activity: str, source: str) -> str | None:
189:def build_system_message(name: str | None, activity: str | None, source: str) -> str | None:
204:def main() -> int:
206: parser.add_argument("--root", required=True, help="project root to inspect")
214: personality = None
218: personality = _personality_section(persona.markdown_path)
220: context = build_context(name, personality, activity, source){
"command": "sed -n '204,235p' /Users/warrenthompson/Source/ProfileGenSkill/scripts/session_intro.py",
"description": "Read session_intro main"
}def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", required=True, help="project root to inspect")
args = parser.parse_args()
root = Path(args.root)
personas = discovery.discover_personas(root)
activity, source = _recent_activity(root)
name = None
personality = None
if personas:
persona = personas[0]
name = persona.fields.get("name") or persona.slug
personality = _personality_section(persona.markdown_path)
context = build_context(name, personality, activity, source)
system_message = build_system_message(name, activity, source)
output = {}
if context:
output["hookSpecificOutput"] = {
"hookEventName": "SessionStart",
"additionalContext": context,
}
if system_message:
output["systemMessage"] = system_message
if output:
print(json.dumps(output))
return 0