oh-my-design-cli 1.6.1 → 1.6.2

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.
Files changed (40) hide show
  1. package/README.ko.md +12 -0
  2. package/README.md +12 -0
  3. package/data/reference-fingerprints.json +979 -402
  4. package/dist/bin/oh-my-design.js +4 -3
  5. package/dist/bin/oh-my-design.js.map +1 -1
  6. package/dist/{install-skills-UKEVE3KT.js → install-skills-6QFSN5BN.js} +98 -34
  7. package/dist/install-skills-6QFSN5BN.js.map +1 -0
  8. package/package.json +2 -1
  9. package/skills/claude-design/SKILL.md +385 -0
  10. package/skills/claude-design/references/claude-design-flow.md +425 -0
  11. package/skills/claude-design/references/codebase-analysis.md +373 -0
  12. package/skills/claude-design/scripts/analyze_codebase.py +1369 -0
  13. package/skills/claude-design/scripts/clickable_link.sh +48 -0
  14. package/skills/claude-design/scripts/collect_source.py +178 -0
  15. package/skills/claude-design/scripts/drive_claude_design.cjs +378 -0
  16. package/skills/claude-design/scripts/gather_references.py +437 -0
  17. package/web/references/bunjang/DESIGN.md +1 -1
  18. package/web/references/classting/DESIGN.md +251 -0
  19. package/web/references/coinone/DESIGN.md +218 -0
  20. package/web/references/devsisters/DESIGN.md +253 -0
  21. package/web/references/drnow/DESIGN.md +331 -0
  22. package/web/references/flo/DESIGN.md +306 -0
  23. package/web/references/fugle/DESIGN.md +250 -0
  24. package/web/references/gogolook/DESIGN.md +5 -0
  25. package/web/references/grip/DESIGN.md +250 -0
  26. package/web/references/hogangnono/DESIGN.md +308 -0
  27. package/web/references/hyundaicard/DESIGN.md +5 -0
  28. package/web/references/jkopay/DESIGN.md +249 -0
  29. package/web/references/jobkorea/DESIGN.md +310 -0
  30. package/web/references/krafton/DESIGN.md +230 -0
  31. package/web/references/laftel/DESIGN.md +253 -0
  32. package/web/references/lezhin/DESIGN.md +301 -0
  33. package/web/references/momoshop/DESIGN.md +279 -0
  34. package/web/references/mustit/DESIGN.md +282 -0
  35. package/web/references/payco/DESIGN.md +227 -0
  36. package/web/references/piccollage/DESIGN.md +277 -0
  37. package/web/references/riiid/DESIGN.md +228 -0
  38. package/web/references/trenbe/DESIGN.md +252 -0
  39. package/web/references/voicetube/DESIGN.md +227 -0
  40. package/dist/install-skills-UKEVE3KT.js.map +0 -1
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env python3
2
+ """Gather local brand/visual reference assets (and explicit URL references) for the claude-design skill.
3
+
4
+ Walks a root directory (default: current working directory) up to a bounded depth, pruning
5
+ noisy/excluded directories in place, and collects files whose extension matches a set of
6
+ VISUAL image asset types. By default DOCUMENT files (.pdf, .hwp/.hwpx, .doc/.docx, .ppt/.pptx,
7
+ .xls/.xlsx) are EXCLUDED — they are document templates, not brand visuals — unless --include-docs
8
+ is passed.
9
+
10
+ Ranking (v2): brand-likelihood FIRST, then modification time (newest first). Filenames that look
11
+ like real brand assets (logo, wordmark, symbol, icon, brand, og, hero, cover, favicon, app-icon)
12
+ score high and float to the top; everything else falls back to mtime. Results are then size-filtered
13
+ and capped at a maximum count. This fixes the prior bug where large PDF business-plan templates
14
+ outranked the real brand assets (logo.svg / og.png) that Claude Design actually wants.
15
+
16
+ Explicit URL references can be passed (repeatable) and are echoed back alongside the discovered files.
17
+
18
+ Designed to be robust: never crashes on a bad/unreadable path (PermissionError, FileNotFoundError,
19
+ OSError are all swallowed gracefully). stdlib only.
20
+
21
+ CLI:
22
+ python3 gather_references.py [--root DIR] [--max N] [--max-mb FLOAT] [--url URL ...]
23
+ [--ext .png,.jpg,...] [--exclude GLOB ...] [--depth N] [--include-docs] [--json]
24
+
25
+ Output:
26
+ With --json: a JSON object
27
+ {"root": ..., "files": [{"path","type","bytes","mtime_iso"}], "urls": [...],
28
+ "truncated": bool, "ranked_by": ..., "note": ...}
29
+ Without --json: a short human-readable summary.
30
+ """
31
+
32
+ import argparse
33
+ import fnmatch
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ from datetime import datetime, timezone
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Defaults (mirrored in the SKILL.md file contract)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ DEFAULT_MAX_FILES = 12
45
+ DEFAULT_MAX_MB = 8.0
46
+ DEFAULT_DEPTH = 4
47
+
48
+ # v2: VISUAL-ONLY by default. Brand visuals, not document templates.
49
+ DEFAULT_EXTENSIONS = [
50
+ ".png", ".jpg", ".jpeg", ".webp", ".svg", ".avif", ".gif",
51
+ ]
52
+
53
+ # Document extensions are never visual brand assets. Excluded from the default
54
+ # scan; only included when --include-docs is passed (they are then *added* to the
55
+ # active extension set, alongside whatever visual/--ext set is in effect).
56
+ DOC_EXTENSIONS = [
57
+ ".pdf",
58
+ ".hwp", ".hwpx",
59
+ ".doc", ".docx",
60
+ ".ppt", ".pptx",
61
+ ".xls", ".xlsx",
62
+ ]
63
+
64
+ DEFAULT_EXCLUDED_DIRS = [
65
+ "node_modules", ".git", "dist", "build", ".next", ".nuxt", "out",
66
+ "venv", ".venv", "__pycache__", ".cache", "coverage", "target",
67
+ "Pods", ".gradle", ".idea", ".DS_Store",
68
+ ]
69
+
70
+ # v2: brand-likelihood ranking. A filename whose basename contains one of these
71
+ # tokens (as a whole token, delimited by start/end of name or any non-alphanumeric
72
+ # separator) is treated as a likely real brand asset and floated to the top of the
73
+ # results, ahead of generic screenshots / incidental images.
74
+ #
75
+ # Token boundaries matter: the short "og" token (Open Graph: og.png / og-image.png)
76
+ # must NOT match as a substring inside ordinary words like dog/frog/blog/logout/
77
+ # progress/recognition/cognac/biography, and "logo" must not match "logout". The
78
+ # leading group consumes one separator (or start-of-string); the trailing boundary
79
+ # is a zero-width lookahead so adjacent tokens (e.g. "icon-logo") both still match.
80
+ BRAND_KEYWORD_PATTERN = re.compile(
81
+ r"(?:^|[^a-z0-9])"
82
+ r"(?:logo|wordmark|symbol|icon|brand|hero|cover|favicon|app[-_]?icon|og[-_]?image|og)"
83
+ r"(?=[^a-z0-9]|$)",
84
+ re.IGNORECASE,
85
+ )
86
+
87
+ RANKED_BY = "brand-likelihood, then mtime-desc"
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Helpers
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def _normalize_extensions(raw):
95
+ """Turn a comma-separated extension string into a lowercased set with leading dots.
96
+
97
+ Accepts forms like ".png,jpg, .JPEG" and yields {".png", ".jpg", ".jpeg"}.
98
+ Returns None to signal "fall back to defaults" when nothing usable is parsed.
99
+ """
100
+ if raw is None:
101
+ return None
102
+ exts = set()
103
+ for part in str(raw).split(","):
104
+ part = part.strip().lower()
105
+ if not part:
106
+ continue
107
+ if not part.startswith("."):
108
+ part = "." + part
109
+ exts.add(part)
110
+ return exts or None
111
+
112
+
113
+ def _ext_of(path):
114
+ """Lowercased file extension including the leading dot (e.g. '.png'), or '' if none."""
115
+ return os.path.splitext(path)[1].lower()
116
+
117
+
118
+ def _type_of(path):
119
+ """Lowercased extension WITHOUT the leading dot (e.g. 'png'), or '' if none."""
120
+ return _ext_of(path).lstrip(".")
121
+
122
+
123
+ def _mtime_iso(epoch):
124
+ """Convert an epoch float to an ISO-8601 UTC string; never raises."""
125
+ try:
126
+ return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat()
127
+ except (OverflowError, OSError, ValueError):
128
+ return ""
129
+
130
+
131
+ def _is_excluded_dir(name, exclude_globs):
132
+ """True if a directory basename matches any exclude glob (or default excluded set)."""
133
+ for pattern in exclude_globs:
134
+ if fnmatch.fnmatch(name, pattern):
135
+ return True
136
+ return False
137
+
138
+
139
+ def _brand_score(path):
140
+ """Heuristic brand-likelihood score for a file path.
141
+
142
+ Higher = more likely to be a real brand asset Claude Design should reference.
143
+ Scored on the *filename* (the basename), case-insensitively, against
144
+ BRAND_KEYWORD_PATTERN (which matches brand tokens only at token boundaries, so
145
+ "og" matches og.png/og-image but never dog/blog/logout). Returns 1 if any brand
146
+ token is present, else 0.
147
+
148
+ This is intentionally a coarse binary signal: brand-likelihood is the PRIMARY
149
+ sort key, with mtime as the tie-breaker, so we only need to separate "looks
150
+ like a brand asset" from "everything else". Never raises.
151
+ """
152
+ try:
153
+ name = os.path.basename(path)
154
+ except Exception:
155
+ return 0
156
+ return 1 if BRAND_KEYWORD_PATTERN.search(name) else 0
157
+
158
+
159
+ def gather(root, max_files, max_mb, extensions, exclude_globs, depth):
160
+ """Walk `root` up to `depth` levels, collecting matching files.
161
+
162
+ Returns (files, truncated, note) where:
163
+ - files: list of dicts {path, type, bytes, mtime_iso}, ranked by brand-likelihood
164
+ first then mtime-desc, size-filtered, and capped.
165
+ - truncated: True if matching files were dropped because of the cap. A negative
166
+ `max_files` means "keep everything", so it is never truncated.
167
+ - note: optional human note (e.g. about an unreadable root); '' otherwise.
168
+ """
169
+ note = ""
170
+ matched = [] # list of (brand_score, mtime, path, size)
171
+
172
+ try:
173
+ abs_root = os.path.abspath(root)
174
+ except (OSError, ValueError):
175
+ return [], False, "Could not resolve root path: %r" % (root,)
176
+
177
+ if not os.path.exists(abs_root):
178
+ return [], False, "Root path does not exist: %s" % abs_root
179
+ if not os.path.isdir(abs_root):
180
+ return [], False, "Root path is not a directory: %s" % abs_root
181
+
182
+ max_bytes = None
183
+ if max_mb is not None and max_mb >= 0:
184
+ max_bytes = int(max_mb * 1024 * 1024)
185
+
186
+ root_depth = abs_root.rstrip(os.sep).count(os.sep)
187
+
188
+ try:
189
+ # os.walk does NOT follow directory symlinks by default, so symlink loops cannot hang us.
190
+ walker = os.walk(abs_root, topdown=True, onerror=lambda err: None)
191
+ for current_dir, dirnames, filenames in walker:
192
+ # Enforce bounded depth by pruning descent once we exceed the limit.
193
+ try:
194
+ current_depth = current_dir.rstrip(os.sep).count(os.sep) - root_depth
195
+ except Exception:
196
+ current_depth = 0
197
+ if current_depth >= depth:
198
+ # Do not descend any deeper.
199
+ dirnames[:] = []
200
+ else:
201
+ # Prune excluded directories IN PLACE so os.walk skips them entirely.
202
+ dirnames[:] = [
203
+ d for d in dirnames if not _is_excluded_dir(d, exclude_globs)
204
+ ]
205
+
206
+ for fname in filenames:
207
+ if _ext_of(fname) not in extensions:
208
+ continue
209
+ fpath = os.path.join(current_dir, fname)
210
+ try:
211
+ st = os.stat(fpath)
212
+ except (PermissionError, FileNotFoundError, OSError):
213
+ # Unreadable entry or broken symlink; skip gracefully.
214
+ continue
215
+ # Skip anything that isn't a regular file (e.g. a directory named "foo.png").
216
+ if not os.path.isfile(fpath):
217
+ continue
218
+ size = st.st_size
219
+ if max_bytes is not None and size > max_bytes:
220
+ continue
221
+ matched.append((_brand_score(fpath), st.st_mtime, fpath, size))
222
+ except (PermissionError, FileNotFoundError, OSError) as exc:
223
+ note = "Walk interrupted: %s" % exc
224
+
225
+ # v2 ranking: brand-likelihood FIRST (high score first), then newest first.
226
+ matched.sort(key=lambda item: (item[0], item[1]), reverse=True)
227
+
228
+ if max_files >= 0:
229
+ truncated = len(matched) > max_files
230
+ kept = matched[:max_files]
231
+ else:
232
+ # Negative cap means "keep everything"; nothing is dropped.
233
+ truncated = False
234
+ kept = matched
235
+
236
+ files = []
237
+ for _score, mtime, fpath, size in kept:
238
+ files.append({
239
+ "path": fpath,
240
+ "type": _type_of(fpath),
241
+ "bytes": size,
242
+ "mtime_iso": _mtime_iso(mtime),
243
+ })
244
+
245
+ return files, truncated, note
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # CLI
250
+ # ---------------------------------------------------------------------------
251
+
252
+ def build_parser():
253
+ parser = argparse.ArgumentParser(
254
+ prog="gather_references.py",
255
+ description=(
256
+ "Scan a directory for brand/visual reference assets and collect explicit URL "
257
+ "references for the claude-design skill. Visual-only by default; documents "
258
+ "(.pdf/.hwp/.doc/.ppt/.xls) are excluded unless --include-docs is given. "
259
+ "Results are ranked by brand-likelihood first, then by modification time."
260
+ ),
261
+ )
262
+ parser.add_argument(
263
+ "--root",
264
+ default=os.getcwd(),
265
+ help="Directory to scan (default: current working directory).",
266
+ )
267
+ parser.add_argument(
268
+ "--max",
269
+ dest="max_files",
270
+ type=int,
271
+ default=DEFAULT_MAX_FILES,
272
+ help="Maximum number of files to return (default: %d)." % DEFAULT_MAX_FILES,
273
+ )
274
+ parser.add_argument(
275
+ "--max-mb",
276
+ dest="max_mb",
277
+ type=float,
278
+ default=DEFAULT_MAX_MB,
279
+ help="Skip files larger than this many megabytes (default: %s)." % DEFAULT_MAX_MB,
280
+ )
281
+ parser.add_argument(
282
+ "--url",
283
+ dest="urls",
284
+ action="append",
285
+ default=[],
286
+ metavar="URL",
287
+ help="Explicit URL reference (repeatable).",
288
+ )
289
+ parser.add_argument(
290
+ "--ext",
291
+ dest="ext",
292
+ default=None,
293
+ metavar=".png,.jpg,...",
294
+ help=(
295
+ "Comma-separated list of extensions to match. Overrides the visual-only "
296
+ "default (%s). If --include-docs is also passed, document extensions are "
297
+ "added on top of this set." % ",".join(DEFAULT_EXTENSIONS)
298
+ ),
299
+ )
300
+ parser.add_argument(
301
+ "--include-docs",
302
+ dest="include_docs",
303
+ action="store_true",
304
+ help=(
305
+ "Also include document files (%s). These are EXCLUDED by default because "
306
+ "they are document templates, not brand visuals." % ",".join(DOC_EXTENSIONS)
307
+ ),
308
+ )
309
+ parser.add_argument(
310
+ "--exclude",
311
+ dest="exclude",
312
+ action="append",
313
+ default=[],
314
+ metavar="GLOB",
315
+ help="Additional directory-name glob(s) to exclude (repeatable).",
316
+ )
317
+ parser.add_argument(
318
+ "--depth",
319
+ type=int,
320
+ default=DEFAULT_DEPTH,
321
+ help="Maximum directory depth to descend (default: %d)." % DEFAULT_DEPTH,
322
+ )
323
+ parser.add_argument(
324
+ "--json",
325
+ action="store_true",
326
+ help="Emit a JSON object instead of a human-readable summary.",
327
+ )
328
+ return parser
329
+
330
+
331
+ def _human_size(num_bytes):
332
+ """Render a byte count as a compact human-readable string."""
333
+ try:
334
+ value = float(num_bytes)
335
+ except (TypeError, ValueError):
336
+ return "?"
337
+ for unit in ("B", "KB", "MB", "GB", "TB"):
338
+ if value < 1024.0 or unit == "TB":
339
+ if unit == "B":
340
+ return "%d %s" % (int(value), unit)
341
+ return "%.1f %s" % (value, unit)
342
+ value /= 1024.0
343
+ return "%.1f TB" % value
344
+
345
+
346
+ def main(argv=None):
347
+ parser = build_parser()
348
+ args = parser.parse_args(argv)
349
+
350
+ # Resolve the active extension set:
351
+ # - --ext (if given) replaces the visual-only default;
352
+ # - otherwise use the visual-only default;
353
+ # - --include-docs ADDS document extensions on top of whichever set is active.
354
+ extensions = _normalize_extensions(args.ext)
355
+ if extensions is None:
356
+ extensions = set(DEFAULT_EXTENSIONS)
357
+ if args.include_docs:
358
+ extensions = set(extensions) | set(DOC_EXTENSIONS)
359
+
360
+ exclude_globs = list(DEFAULT_EXCLUDED_DIRS) + list(args.exclude or [])
361
+
362
+ depth = args.depth if args.depth is not None and args.depth >= 0 else DEFAULT_DEPTH
363
+
364
+ files, truncated, note = gather(
365
+ root=args.root,
366
+ max_files=args.max_files,
367
+ max_mb=args.max_mb,
368
+ extensions=extensions,
369
+ exclude_globs=exclude_globs,
370
+ depth=depth,
371
+ )
372
+
373
+ urls = list(args.urls or [])
374
+
375
+ try:
376
+ abs_root = os.path.abspath(args.root)
377
+ except (OSError, ValueError):
378
+ abs_root = args.root
379
+
380
+ if args.json:
381
+ payload = {
382
+ "root": abs_root,
383
+ "files": files,
384
+ "urls": urls,
385
+ "truncated": truncated,
386
+ "ranked_by": RANKED_BY,
387
+ "note": note,
388
+ }
389
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
390
+ return 0
391
+
392
+ # Human-readable summary.
393
+ lines = []
394
+ lines.append("Reference scan root: %s" % abs_root)
395
+ lines.append(
396
+ "Found %d file(s) (cap=%d, max-mb=%s, depth=%d, docs=%s)%s"
397
+ % (
398
+ len(files),
399
+ args.max_files,
400
+ args.max_mb,
401
+ depth,
402
+ "included" if args.include_docs else "excluded",
403
+ " [truncated: more matched than shown]" if truncated else "",
404
+ )
405
+ )
406
+ lines.append("Ranked by: %s" % RANKED_BY)
407
+ if files:
408
+ lines.append("")
409
+ for entry in files:
410
+ lines.append(
411
+ " - %s [%s, %s, %s]"
412
+ % (
413
+ entry["path"],
414
+ entry["type"] or "?",
415
+ _human_size(entry["bytes"]),
416
+ entry["mtime_iso"] or "?",
417
+ )
418
+ )
419
+ else:
420
+ lines.append(" (no matching brand/visual assets found)")
421
+
422
+ if urls:
423
+ lines.append("")
424
+ lines.append("URL references (%d):" % len(urls))
425
+ for url in urls:
426
+ lines.append(" - %s" % url)
427
+
428
+ if note:
429
+ lines.append("")
430
+ lines.append("Note: %s" % note)
431
+
432
+ print("\n".join(lines))
433
+ return 0
434
+
435
+
436
+ if __name__ == "__main__":
437
+ sys.exit(main())
@@ -8,7 +8,7 @@ homepage: "https://m.bunjang.co.kr"
8
8
  primary_color: "#d80c18"
9
9
  logo:
10
10
  type: favicon
11
- slug: "https://www.google.com/s2/favicons?domain=bunjang.co.kr&sz=256"
11
+ slug: "https://static.bunjang.co.kr/web/ui/favicon.ico"
12
12
  verified: "2026-05-14"
13
13
  omd: "0.1"
14
14
  ---