@simplysm/sd-claude 13.0.67 → 13.0.68

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 (42) hide show
  1. package/README.md +5 -19
  2. package/claude/refs/sd-code-conventions.md +6 -0
  3. package/claude/refs/sd-migration.md +7 -0
  4. package/claude/rules/sd-claude-rules.md +2 -20
  5. package/claude/rules/sd-refs-linker.md +1 -0
  6. package/claude/skills/sd-api-name-review/SKILL.md +2 -1
  7. package/claude/skills/sd-brainstorm/SKILL.md +2 -1
  8. package/claude/skills/sd-check/SKILL.md +2 -1
  9. package/claude/skills/sd-commit/SKILL.md +2 -1
  10. package/claude/skills/sd-debug/SKILL.md +2 -1
  11. package/claude/skills/sd-discuss/SKILL.md +2 -1
  12. package/claude/skills/sd-email-analyze/SKILL.md +52 -0
  13. package/claude/skills/sd-email-analyze/email-analyzer.py +393 -0
  14. package/claude/skills/sd-explore/SKILL.md +2 -1
  15. package/claude/skills/sd-plan/SKILL.md +2 -1
  16. package/claude/skills/sd-plan-dev/SKILL.md +2 -1
  17. package/claude/skills/sd-readme/SKILL.md +2 -1
  18. package/claude/skills/sd-review/SKILL.md +2 -1
  19. package/claude/skills/sd-skill/SKILL.md +2 -1
  20. package/claude/skills/sd-tdd/SKILL.md +2 -1
  21. package/claude/skills/sd-use/SKILL.md +4 -1
  22. package/claude/skills/sd-worktree/SKILL.md +2 -1
  23. package/dist/commands/install.d.ts.map +1 -1
  24. package/dist/commands/install.js +0 -31
  25. package/dist/commands/install.js.map +1 -1
  26. package/dist/index.d.ts +0 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +0 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/sd-claude.js +8 -14
  31. package/dist/sd-claude.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/commands/install.ts +0 -40
  34. package/src/index.ts +0 -1
  35. package/src/sd-claude.ts +14 -23
  36. package/claude/skills/sd-eml-analyze/SKILL.md +0 -48
  37. package/claude/skills/sd-eml-analyze/eml-analyzer.py +0 -335
  38. package/dist/commands/npx.d.ts +0 -2
  39. package/dist/commands/npx.d.ts.map +0 -1
  40. package/dist/commands/npx.js +0 -16
  41. package/dist/commands/npx.js.map +0 -6
  42. package/src/commands/npx.ts +0 -19
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @simplysm/sd-claude
2
2
 
3
- Simplysm Claude Code CLI — asset installer and cross-platform npx wrapper. Automatically installs Claude Code assets (skills, agents, rules, refs, hooks) via `postinstall` when added as a dev dependency. Provides opinionated development workflows including TDD, systematic debugging, code review, planning, brainstorming, and git worktree management.
3
+ Simplysm Claude Code CLI — asset installer. Automatically installs Claude Code assets (skills, agents, rules, refs, hooks) via `postinstall` when added as a dev dependency. Provides opinionated development workflows including TDD, systematic debugging, code review, planning, brainstorming, and git worktree management.
4
4
 
5
5
  ## Installation
6
6
 
@@ -18,8 +18,7 @@ When installed as a dependency, the `postinstall` script (`scripts/postinstall.m
18
18
 
19
19
  1. Copies all `sd-*` assets (skills, agents, rules, refs, hooks, statusline) to the project's `.claude/` directory
20
20
  2. Configures `statusLine` in `.claude/settings.json` to use `sd-statusline.js`
21
- 3. Configures MCP servers in `.mcp.json` (context7 and playwright) using the cross-platform npx wrapper
22
- 4. Existing `sd-*` entries are replaced with the latest version on each install
21
+ 3. Existing `sd-*` entries are replaced with the latest version on each install
23
22
 
24
23
  The `prepack` script (`scripts/sync-claude-assets.mjs`) runs before `npm pack` / `npm publish` to sync assets from the project root `.claude/` directory into the package's `claude/` directory.
25
24
 
@@ -33,7 +32,6 @@ Updates also trigger reinstallation (`pnpm up @simplysm/sd-claude`).
33
32
  sd-claude.js # CLI entry point (bin)
34
33
  commands/
35
34
  install.js # Asset installation logic
36
- npx.js # Cross-platform npx wrapper
37
35
  scripts/
38
36
  postinstall.mjs # Thin wrapper — calls `sd-claude install`
39
37
  sync-claude-assets.mjs # Syncs assets from .claude/ before publish
@@ -78,22 +76,19 @@ Updates also trigger reinstallation (`pnpm up @simplysm/sd-claude`).
78
76
 
79
77
  ## CLI Commands
80
78
 
81
- The package provides an `sd-claude` binary with two subcommands:
79
+ The package provides an `sd-claude` binary:
82
80
 
83
81
  ```bash
84
82
  # Install Claude Code assets to the project's .claude/ directory
85
83
  sd-claude install
86
-
87
- # Cross-platform npx wrapper (uses npx.cmd on Windows, npx elsewhere)
88
- sd-claude npx -y @upstash/context7-mcp
89
84
  ```
90
85
 
91
- `sd-claude install` is called automatically by the `postinstall` script. `sd-claude npx` is used in `.mcp.json` to launch MCP servers cross-platform.
86
+ `sd-claude install` is called automatically by the `postinstall` script.
92
87
 
93
88
  ## Programmatic API
94
89
 
95
90
  ```typescript
96
- import { runInstall, runNpx } from "@simplysm/sd-claude";
91
+ import { runInstall } from "@simplysm/sd-claude";
97
92
  ```
98
93
 
99
94
  ### runInstall
@@ -107,15 +102,6 @@ Installs Claude Code assets to the project's `.claude/` directory. Performs:
107
102
  - Skips if running inside the simplysm monorepo itself
108
103
  - Cleans existing `sd-*` entries, then copies fresh versions from the package's `claude/` directory
109
104
  - Configures `statusLine` in `.claude/settings.json`
110
- - Configures MCP servers (context7, playwright) in `.mcp.json`
111
-
112
- ### runNpx
113
-
114
- ```typescript
115
- function runNpx(args: string[]): void
116
- ```
117
-
118
- Cross-platform npx wrapper. Spawns `npx.cmd` on Windows, `npx` on other platforms. Passes all arguments through and forwards the exit code.
119
105
 
120
106
  ## Skills
121
107
 
@@ -38,3 +38,9 @@ async function readFileAsync() { ... } // Async suffix prohibited
38
38
 
39
39
  - Not enforced — omit when code is self-explanatory
40
40
  - When written, use Korean
41
+
42
+ ## index.ts Export Pattern
43
+
44
+ - Large packages: `#region`/`#endregion` for sections + `//` for sub-groups
45
+ - Small packages (≤10 exports): `//` comments only
46
+ - Always `export *` (wildcard), never explicit `export type { ... } from "..."`
@@ -0,0 +1,7 @@
1
+ # Migration Rules
2
+
3
+ When porting/migrating code from another codebase (e.g., v12 Angular → v13 SolidJS):
4
+
5
+ 1. **Analyze every line**: Read the original source and all its dependencies (imports, base classes, etc.) line by line. Understand every feature, prop, and behavior. If a dependency cannot be found, ask the user.
6
+ 2. **Ask about every difference**: Any change from the original (API, pattern, design, omission, addition) must be asked to the user. Never decide silently.
7
+ 3. **Verify after completion**: Compare the result 1:1 with the original and report any omissions or differences to the user.
@@ -33,24 +33,6 @@ If a referenced file or document cannot be found, **stop immediately and ask the
33
33
  - When in doubt, **ask first** before proceeding.
34
34
  - Responses like "I'll create it myself" or "I'll add that as well" are strictly prohibited.
35
35
 
36
- ## Bug Workarounds
36
+ ## ⚠️ CRITICAL — NEVER SKIP
37
37
 
38
- ### AskUserQuestion UI Clipping CRITICAL
39
-
40
- The `AskUserQuestion` widget overlaps and hides the text above it in the UI.
41
-
42
- **Mitigation:** Before every `AskUserQuestion` call, output **5 blank lines** at the end of your text so the important content is pushed above the clipping zone.
43
-
44
- ```
45
- (your text here)⏎
46
-
47
-
48
-
49
-
50
-
51
- → AskUserQuestion tool call
52
- ```
53
-
54
- - This applies to EVERY `AskUserQuestion` call, no exceptions.
55
- - The blank lines MUST be in the same message, immediately before the tool call.
56
- - If you forget the blank lines, the user will not be able to read your preceding text.
38
+ **Before EVERY `AskUserQuestion` call, output `---` as the last line.** The widget clips text above it. No exceptions.
@@ -18,6 +18,7 @@ Before starting work, **Read** the relevant reference files from `.claude/refs/`
18
18
  | Using `@simplysm/*` package APIs | `.claude/refs/sd-simplysm-docs.md` |
19
19
  | Debugging, problem-solving, or planning approach | `.claude/refs/sd-workflow.md` |
20
20
  | @simplysm/service-\* 사용 시 | `.claude/refs/sd-service.md` |
21
+ | Migrating/porting code from another codebase | `.claude/refs/sd-migration.md` |
21
22
 
22
23
  ## v12 only (< 13)
23
24
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-api-name-review
3
- description: Use when reviewing a library or module's public API naming for consistency and industry standard alignment - function names, parameter names, option keys, enum values, type names
3
+ description: Public API naming consistency and industry standard alignment review
4
+ disable-model-invocation: true
4
5
  model: sonnet
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-brainstorm
3
- description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation."
3
+ description: Collaborative design exploration through Q&A before implementation
4
+ disable-model-invocation: true
4
5
  model: opus
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-check
3
- description: Use when verifying code quality via typecheck, lint, and tests - before deployment, PR creation, after code changes, or when type errors, lint violations, or test failures are suspected. Applies to whole project or specific paths.
3
+ description: Code quality verification via typecheck, lint, and tests
4
+ disable-model-invocation: true
4
5
  allowed-tools: Bash(npm run check), Bash(npm run typecheck), Bash(npm run lint --fix), Bash(npm run vitest)
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-commit
3
- description: Use when creating git commits — for staging and committing changed files with conventional commit messages, either for context-relevant files only (default) or all uncommitted changes at once (all mode).
3
+ description: Git commit with conventional commit messages
4
+ disable-model-invocation: true
4
5
  argument-hint: "[all]"
5
6
  allowed-tools: Bash(git status:*), Bash(git add:*), Bash(git commit:*)
6
7
  model: haiku
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-debug
3
- description: Use when encountering any bug, test failure, or unexpected behavior. Enforces root-cause investigation before proposing fixes.
3
+ description: Systematic root-cause debugging methodology
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Systematic Debugging
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-discuss
3
- description: "Use when evaluating code design decisions against industry standards and project conventions - class vs functional, pattern choices, architecture trade-offs, technology selection"
3
+ description: Evidence-based technical discussion with industry research
4
+ disable-model-invocation: true
4
5
  model: opus
5
6
  ---
6
7
 
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: sd-email-analyze
3
+ description: Email file (.eml/.msg) parsing and attachment extraction
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Email Analyzer
8
+
9
+ ## Overview
10
+
11
+ Python script that parses `.eml` and `.msg` (Outlook) email files. Extracts mail headers, body text, inline images, and attachments to disk. Content analysis of extracted files is delegated to Claude's Read tool and document-skills plugin.
12
+
13
+ ## When to Use
14
+
15
+ - User provides a `.eml` or `.msg` file to analyze or summarize
16
+ - Korean email content needs proper decoding
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ python .claude/skills/sd-email-analyze/email-analyzer.py <email_file_path>
22
+ ```
23
+
24
+ First run auto-installs: `extract-msg`.
25
+
26
+ ### After Running
27
+
28
+ 1. Read the markdown output (mail info, body text, file paths)
29
+ 2. **Inline images**: Use **Read** tool on each saved path to view
30
+ 3. **Attachments**: Use **Read** tool (PDF, images) or let **document-skills** plugin handle (XLSX, PPTX, DOCX)
31
+
32
+ ## Output
33
+
34
+ - `<email_stem>_files/` directory with all extracted files
35
+ - Markdown report to stdout:
36
+ 1. **Mail info table**: Subject, From, To, Cc, Date, counts
37
+ 2. **Body text**: Plain text (HTML stripped if no plain text)
38
+ 3. **Inline images**: Table with saved file paths
39
+ 4. **Attachments**: Table with saved file paths
40
+
41
+ ## Inline Image Handling
42
+
43
+ Two sources extracted:
44
+
45
+ 1. **CID images**: MIME parts with Content-ID (`cid:` references in HTML)
46
+ 2. **Data URI images**: Base64-encoded images in HTML (`data:image/...;base64,...`)
47
+
48
+ ## Common Mistakes
49
+
50
+ - **Wrong Python**: Ensure `python` points to Python 3.8+
51
+ - **Firewall blocking pip**: First run needs internet for `extract-msg` install
52
+ - **Forgetting inline images**: Always check "본문 삽입 이미지" section and Read each path
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """Email Analyzer - Parses EML/MSG files, extracts body/attachments/inline images to disk."""
3
+
4
+ import sys
5
+ import os
6
+ import io
7
+ import subprocess
8
+ import email
9
+ import html
10
+ import re
11
+ import base64
12
+ from email.policy import default as default_policy
13
+ from pathlib import Path
14
+
15
+ # stdout UTF-8 강제 (Windows 호환)
16
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
17
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
18
+
19
+
20
+ def ensure_packages():
21
+ """필요한 패키지 자동 설치."""
22
+ packages = {"extract-msg": "extract_msg"}
23
+ missing = []
24
+ for pip_name, import_name in packages.items():
25
+ try:
26
+ __import__(import_name)
27
+ except ImportError:
28
+ missing.append(pip_name)
29
+ if missing:
30
+ print(f"패키지 설치 중: {', '.join(missing)}...", file=sys.stderr)
31
+ subprocess.check_call(
32
+ [sys.executable, "-m", "pip", "install", "-q", *missing],
33
+ stdout=subprocess.DEVNULL,
34
+ stderr=subprocess.DEVNULL,
35
+ )
36
+
37
+
38
+ ensure_packages()
39
+
40
+ import extract_msg # noqa: E402
41
+
42
+
43
+ # ── Korean charset helpers ──────────────────────────────────────────
44
+
45
+ KOREAN_CHARSET_MAP = {
46
+ "ks_c_5601-1987": "cp949",
47
+ "ks_c_5601": "cp949",
48
+ "euc_kr": "cp949",
49
+ "euc-kr": "cp949",
50
+ }
51
+
52
+
53
+ def fix_charset(charset):
54
+ if charset is None:
55
+ return "utf-8"
56
+ return KOREAN_CHARSET_MAP.get(charset.lower(), charset)
57
+
58
+
59
+ # ── Email parsing ──────────────────────────────────────────────────
60
+
61
+
62
+ def parse_email(filepath):
63
+ """Parse email file (.eml or .msg) and return structured data."""
64
+ ext = Path(filepath).suffix.lower()
65
+ if ext == ".msg":
66
+ return _parse_msg(filepath)
67
+ return _parse_eml(filepath)
68
+
69
+
70
+ def _parse_eml(filepath):
71
+ with open(filepath, "rb") as f:
72
+ msg = email.message_from_binary_file(f, policy=default_policy)
73
+
74
+ headers = {
75
+ "subject": str(msg["Subject"] or ""),
76
+ "from": str(msg["From"] or ""),
77
+ "to": str(msg["To"] or ""),
78
+ "cc": str(msg["Cc"] or ""),
79
+ "date": str(msg["Date"] or ""),
80
+ }
81
+
82
+ body_plain = ""
83
+ body_html = ""
84
+
85
+ if msg.is_multipart():
86
+ for part in msg.walk():
87
+ ctype = part.get_content_type()
88
+ cdisp = part.get_content_disposition()
89
+ if cdisp == "attachment":
90
+ continue
91
+ if ctype == "text/plain" and not body_plain:
92
+ body_plain = _get_text_eml(part)
93
+ elif ctype == "text/html" and not body_html:
94
+ body_html = _get_text_eml(part)
95
+ else:
96
+ ctype = msg.get_content_type()
97
+ if ctype == "text/html":
98
+ body_html = _get_text_eml(msg)
99
+ else:
100
+ body_plain = _get_text_eml(msg)
101
+
102
+ attachments = []
103
+ inline_images = []
104
+ seen_cids = set()
105
+
106
+ for part in msg.walk():
107
+ payload = part.get_payload(decode=True)
108
+ if payload is None:
109
+ continue
110
+
111
+ content_id = (part.get("Content-ID") or "").strip("<> ")
112
+ ctype = part.get_content_type()
113
+ filename = part.get_filename()
114
+
115
+ # Inline image: Content-ID + image type
116
+ if content_id and ctype.startswith("image/"):
117
+ if content_id not in seen_cids:
118
+ seen_cids.add(content_id)
119
+ ext = _guess_image_ext(ctype, filename)
120
+ inline_images.append({
121
+ "content_id": content_id,
122
+ "filename": filename or f"inline_{len(inline_images) + 1}{ext}",
123
+ "content_type": ctype,
124
+ "size": len(payload),
125
+ "data": payload,
126
+ })
127
+ continue
128
+
129
+ # Regular attachment: has filename
130
+ if not filename:
131
+ continue
132
+ cdisp = part.get_content_disposition()
133
+ if cdisp not in ("attachment", "inline", None):
134
+ continue
135
+ attachments.append({
136
+ "filename": filename,
137
+ "content_type": ctype,
138
+ "size": len(payload),
139
+ "data": payload,
140
+ })
141
+
142
+ return headers, body_plain, body_html, attachments, inline_images
143
+
144
+
145
+ def _parse_msg(filepath):
146
+ msg = extract_msg.openMsg(filepath)
147
+ try:
148
+ headers = {
149
+ "subject": msg.subject or "",
150
+ "from": msg.sender or "",
151
+ "to": msg.to or "",
152
+ "cc": msg.cc or "",
153
+ "date": str(msg.date or ""),
154
+ }
155
+
156
+ body_plain = msg.body or ""
157
+ raw_html = getattr(msg, "htmlBody", None)
158
+ if isinstance(raw_html, bytes):
159
+ body_html = raw_html.decode("utf-8", errors="replace")
160
+ else:
161
+ body_html = raw_html or ""
162
+
163
+ attachments = []
164
+ inline_images = []
165
+
166
+ for att in (msg.attachments or []):
167
+ data = getattr(att, "data", None)
168
+ if data is None:
169
+ continue
170
+ if isinstance(data, str):
171
+ data = data.encode("utf-8")
172
+
173
+ filename = (
174
+ getattr(att, "longFilename", None)
175
+ or getattr(att, "shortFilename", None)
176
+ or getattr(att, "name", None)
177
+ or "unnamed"
178
+ )
179
+ mimetype = getattr(att, "mimetype", None) or "application/octet-stream"
180
+ cid = getattr(att, "contentId", None) or ""
181
+
182
+ entry = {
183
+ "filename": filename,
184
+ "content_type": mimetype,
185
+ "size": len(data),
186
+ "data": data,
187
+ }
188
+
189
+ if cid and mimetype.startswith("image/"):
190
+ entry["content_id"] = cid.strip("<> ")
191
+ inline_images.append(entry)
192
+ else:
193
+ attachments.append(entry)
194
+
195
+ return headers, body_plain, body_html, attachments, inline_images
196
+ finally:
197
+ msg.close()
198
+
199
+
200
+ def _get_text_eml(part):
201
+ try:
202
+ return part.get_content()
203
+ except Exception:
204
+ payload = part.get_payload(decode=True)
205
+ if not payload:
206
+ return ""
207
+ charset = fix_charset(part.get_content_charset())
208
+ return payload.decode(charset, errors="replace")
209
+
210
+
211
+ def _guess_image_ext(content_type, filename=None):
212
+ if filename:
213
+ ext = Path(filename).suffix
214
+ if ext:
215
+ return ext
216
+ mapping = {
217
+ "image/png": ".png",
218
+ "image/jpeg": ".jpg",
219
+ "image/gif": ".gif",
220
+ "image/bmp": ".bmp",
221
+ "image/webp": ".webp",
222
+ "image/svg+xml": ".svg",
223
+ }
224
+ return mapping.get(content_type, ".bin")
225
+
226
+
227
+ # ── File saving ────────────────────────────────────────────────────
228
+
229
+
230
+ def save_files(files, output_dir):
231
+ """Save files to output directory, handling name collisions. Returns list with saved_path."""
232
+ os.makedirs(output_dir, exist_ok=True)
233
+ result = []
234
+ for f in files:
235
+ filepath = os.path.join(output_dir, f["filename"])
236
+ if os.path.exists(filepath):
237
+ stem = Path(filepath).stem
238
+ ext = Path(filepath).suffix
239
+ n = 1
240
+ while os.path.exists(filepath):
241
+ filepath = os.path.join(output_dir, f"{stem}_{n}{ext}")
242
+ n += 1
243
+ with open(filepath, "wb") as fh:
244
+ fh.write(f["data"])
245
+ result.append({**f, "saved_path": filepath})
246
+ return result
247
+
248
+
249
+ def extract_data_uri_images(html_body, output_dir):
250
+ """Extract base64 data URI images embedded in HTML body."""
251
+ pattern = r'<img[^>]+src=["\']data:image/([^;]+);base64,([^"\']+)["\']'
252
+ matches = re.findall(pattern, html_body, re.IGNORECASE)
253
+ if not matches:
254
+ return []
255
+
256
+ os.makedirs(output_dir, exist_ok=True)
257
+ images = []
258
+ for i, (img_type, b64data) in enumerate(matches, 1):
259
+ try:
260
+ data = base64.b64decode(b64data)
261
+ ext_map = {"jpeg": ".jpg", "svg+xml": ".svg"}
262
+ ext = ext_map.get(img_type, f".{img_type}")
263
+ filename = f"datauri_{i}{ext}"
264
+ filepath = os.path.join(output_dir, filename)
265
+ with open(filepath, "wb") as f:
266
+ f.write(data)
267
+ images.append({
268
+ "filename": filename,
269
+ "content_type": f"image/{img_type}",
270
+ "size": len(data),
271
+ "saved_path": filepath,
272
+ })
273
+ except Exception:
274
+ continue
275
+ return images
276
+
277
+
278
+ # ── HTML stripping ──────────────────────────────────────────────────
279
+
280
+
281
+ def strip_html(text):
282
+ text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.I)
283
+ text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL | re.I)
284
+ text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
285
+ text = re.sub(r"</(?:p|div|tr|li)>", "\n", text, flags=re.I)
286
+ text = re.sub(r"<[^>]+>", "", text)
287
+ text = html.unescape(text)
288
+ text = re.sub(r"\n{3,}", "\n\n", text)
289
+ return text.strip()
290
+
291
+
292
+ # ── Size formatting ─────────────────────────────────────────────────
293
+
294
+
295
+ def fmt_size(n):
296
+ if n < 1024:
297
+ return f"{n} B"
298
+ if n < 1024 * 1024:
299
+ return f"{n / 1024:.1f} KB"
300
+ return f"{n / (1024 * 1024):.1f} MB"
301
+
302
+
303
+ # ── Markdown report ─────────────────────────────────────────────────
304
+
305
+
306
+ def build_report(filepath):
307
+ headers, body_plain, body_html, attachments, inline_images = parse_email(filepath)
308
+
309
+ out_dir = str(Path(filepath).parent / f"{Path(filepath).stem}_files")
310
+
311
+ # Save inline images
312
+ saved_inline = []
313
+ if inline_images:
314
+ saved_inline = save_files(inline_images, out_dir)
315
+
316
+ # Extract data URI images from HTML body
317
+ saved_datauri = []
318
+ if body_html:
319
+ saved_datauri = extract_data_uri_images(body_html, out_dir)
320
+
321
+ all_inline = saved_inline + saved_datauri
322
+
323
+ # Save attachments
324
+ saved_attachments = []
325
+ if attachments:
326
+ saved_attachments = save_files(attachments, out_dir)
327
+
328
+ out = []
329
+ out.append("# 이메일 분석서\n")
330
+ out.append(f"**원본 파일**: `{os.path.basename(filepath)}`\n")
331
+
332
+ # ── 메일 정보
333
+ out.append("## 메일 정보\n")
334
+ out.append("| 항목 | 내용 |")
335
+ out.append("|------|------|")
336
+ out.append(f"| **제목** | {headers['subject']} |")
337
+ out.append(f"| **보낸 사람** | {headers['from']} |")
338
+ out.append(f"| **받는 사람** | {headers['to']} |")
339
+ if headers["cc"]:
340
+ out.append(f"| **참조** | {headers['cc']} |")
341
+ out.append(f"| **날짜** | {headers['date']} |")
342
+ out.append(f"| **첨부파일** | {len(saved_attachments)}개 |")
343
+ if all_inline:
344
+ out.append(f"| **본문 이미지** | {len(all_inline)}개 |")
345
+ out.append("")
346
+
347
+ # ── 본문
348
+ out.append("## 본문 내용\n")
349
+ body = body_plain
350
+ if not body and body_html:
351
+ body = strip_html(body_html)
352
+ out.append(body.strip() if body else "_(본문 없음)_")
353
+ out.append("")
354
+
355
+ # ── 본문 삽입 이미지
356
+ if all_inline:
357
+ out.append("## 본문 삽입 이미지\n")
358
+ out.append("| # | 파일명 | 크기 | 저장 경로 |")
359
+ out.append("|---|--------|------|-----------|")
360
+ for i, img in enumerate(all_inline, 1):
361
+ out.append(f"| {i} | {img['filename']} | {fmt_size(img['size'])} | `{img['saved_path']}` |")
362
+ out.append("")
363
+
364
+ # ── 첨부파일
365
+ if saved_attachments:
366
+ out.append("## 첨부파일\n")
367
+ out.append("| # | 파일명 | 크기 | 저장 경로 |")
368
+ out.append("|---|--------|------|-----------|")
369
+ for i, a in enumerate(saved_attachments, 1):
370
+ out.append(f"| {i} | {a['filename']} | {fmt_size(a['size'])} | `{a['saved_path']}` |")
371
+ out.append("")
372
+
373
+ return "\n".join(out)
374
+
375
+
376
+ # ── Main ────────────────────────────────────────────────────────────
377
+
378
+ if __name__ == "__main__":
379
+ if len(sys.argv) < 2:
380
+ print("Usage: python email-analyzer.py <eml_or_msg_file>", file=sys.stderr)
381
+ sys.exit(1)
382
+
383
+ path = sys.argv[1]
384
+ if not os.path.isfile(path):
385
+ print(f"파일을 찾을 수 없습니다: {path}", file=sys.stderr)
386
+ sys.exit(1)
387
+
388
+ ext = Path(path).suffix.lower()
389
+ if ext not in (".eml", ".msg"):
390
+ print(f"지원하지 않는 형식: {ext} (.eml 또는 .msg만 지원)", file=sys.stderr)
391
+ sys.exit(1)
392
+
393
+ print(build_report(path))
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-explore
3
- description: Deeply analyze a codebase path by tracing execution paths, mapping architecture layers, understanding patterns, and documenting dependencies
3
+ description: Deep codebase analysis - execution paths, architecture, dependencies
4
+ disable-model-invocation: true
4
5
  model: sonnet
5
6
  context: fork
6
7
  allowed-tools: Read, Glob, Grep
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-plan
3
- description: "NEVER invoke directly from user requests. This skill is ONLY called as a follow-up step after sd-brainstorm has completed. If the user asks to plan, design, or implement something, use sd-brainstorm first."
3
+ description: Implementation plan creation from brainstorm designs
4
+ disable-model-invocation: true
4
5
  model: opus
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-plan-dev
3
- description: Use when executing implementation plans with independent tasks in the current session
3
+ description: Parallel execution of implementation plan tasks
4
+ disable-model-invocation: true
4
5
  model: opus
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sd-readme
3
- description: Use when updating a package README.md to reflect recent code changes, or when asked to sync README with current implementation, or when creating a new package README from scratch
3
+ description: Package README.md sync with current source code exports
4
+ disable-model-invocation: true
4
5
  argument-hint: "<package-name or path> (optional - omit to update all)"
5
6
  model: sonnet
6
7
  ---