@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.
- package/README.md +5 -19
- package/claude/refs/sd-code-conventions.md +6 -0
- package/claude/refs/sd-migration.md +7 -0
- package/claude/rules/sd-claude-rules.md +2 -20
- package/claude/rules/sd-refs-linker.md +1 -0
- package/claude/skills/sd-api-name-review/SKILL.md +2 -1
- package/claude/skills/sd-brainstorm/SKILL.md +2 -1
- package/claude/skills/sd-check/SKILL.md +2 -1
- package/claude/skills/sd-commit/SKILL.md +2 -1
- package/claude/skills/sd-debug/SKILL.md +2 -1
- package/claude/skills/sd-discuss/SKILL.md +2 -1
- package/claude/skills/sd-email-analyze/SKILL.md +52 -0
- package/claude/skills/sd-email-analyze/email-analyzer.py +393 -0
- package/claude/skills/sd-explore/SKILL.md +2 -1
- package/claude/skills/sd-plan/SKILL.md +2 -1
- package/claude/skills/sd-plan-dev/SKILL.md +2 -1
- package/claude/skills/sd-readme/SKILL.md +2 -1
- package/claude/skills/sd-review/SKILL.md +2 -1
- package/claude/skills/sd-skill/SKILL.md +2 -1
- package/claude/skills/sd-tdd/SKILL.md +2 -1
- package/claude/skills/sd-use/SKILL.md +4 -1
- package/claude/skills/sd-worktree/SKILL.md +2 -1
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +0 -31
- package/dist/commands/install.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/sd-claude.js +8 -14
- package/dist/sd-claude.js.map +1 -1
- package/package.json +2 -2
- package/src/commands/install.ts +0 -40
- package/src/index.ts +0 -1
- package/src/sd-claude.ts +14 -23
- package/claude/skills/sd-eml-analyze/SKILL.md +0 -48
- package/claude/skills/sd-eml-analyze/eml-analyzer.py +0 -335
- package/dist/commands/npx.d.ts +0 -2
- package/dist/commands/npx.d.ts.map +0 -1
- package/dist/commands/npx.js +0 -16
- package/dist/commands/npx.js.map +0 -6
- 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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
##
|
|
36
|
+
## ⚠️ CRITICAL — NEVER SKIP
|
|
37
37
|
|
|
38
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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-readme
|
|
3
|
-
description:
|
|
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
|
---
|