ziplex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. ziplex/__init__.py +6 -0
  2. ziplex/checkpoint.py +163 -0
  3. ziplex/cli.py +430 -0
  4. ziplex/confidence.py +131 -0
  5. ziplex/config.py +111 -0
  6. ziplex/corrector.py +192 -0
  7. ziplex/edits.py +88 -0
  8. ziplex/extract/__init__.py +0 -0
  9. ziplex/extract/code/__init__.py +0 -0
  10. ziplex/extract/code/compressor.py +99 -0
  11. ziplex/extract/code/extractor.py +221 -0
  12. ziplex/extract/code/languages.py +744 -0
  13. ziplex/extract/code/parser.py +15 -0
  14. ziplex/extract/text/__init__.py +0 -0
  15. ziplex/extract/text/json.py +48 -0
  16. ziplex/extract/text/markdown.py +201 -0
  17. ziplex/extract/text/registry.py +23 -0
  18. ziplex/extract/text/txt.py +62 -0
  19. ziplex/file/__init__.py +0 -0
  20. ziplex/file/collector.py +120 -0
  21. ziplex/file/relationship.py +383 -0
  22. ziplex/file/scanner.py +139 -0
  23. ziplex/file/selector.py +92 -0
  24. ziplex/file/textutil.py +32 -0
  25. ziplex/freshness.py +157 -0
  26. ziplex/gui/__init__.py +0 -0
  27. ziplex/gui/gui_server.py +598 -0
  28. ziplex/gui/index.html +28 -0
  29. ziplex/gui/js/app.js +261 -0
  30. ziplex/gui/js/graph.js +371 -0
  31. ziplex/gui/js/i18n.js +327 -0
  32. ziplex/gui/js/pack.js +337 -0
  33. ziplex/gui/js/pages/files.js +105 -0
  34. ziplex/gui/js/pages/landing.js +280 -0
  35. ziplex/gui/js/pages/options.js +122 -0
  36. ziplex/gui/js/pages/overview.js +43 -0
  37. ziplex/gui/js/pages/relationships.js +87 -0
  38. ziplex/gui/js/pages/search.js +55 -0
  39. ziplex/gui/js/router.js +72 -0
  40. ziplex/gui/pack_service.py +784 -0
  41. ziplex/gui/style.css +475 -0
  42. ziplex/gui/watcher.py +214 -0
  43. ziplex/llm.py +321 -0
  44. ziplex/mcp_server.py +52 -0
  45. ziplex/packager.py +616 -0
  46. ziplex/query_service.py +229 -0
  47. ziplex/search.py +91 -0
  48. ziplex/settings.py +125 -0
  49. ziplex/skill_export.py +212 -0
  50. ziplex/summarizer.py +191 -0
  51. ziplex/tech_stack.py +315 -0
  52. ziplex/text_references.py +98 -0
  53. ziplex/tokenizer.py +148 -0
  54. ziplex-0.1.0.dist-info/METADATA +261 -0
  55. ziplex-0.1.0.dist-info/RECORD +59 -0
  56. ziplex-0.1.0.dist-info/WHEEL +5 -0
  57. ziplex-0.1.0.dist-info/entry_points.txt +4 -0
  58. ziplex-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. ziplex-0.1.0.dist-info/top_level.txt +1 -0
ziplex/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Ziplex: local project -> aif.json, a token-reduced context format an AI
2
+ reads instead of raw files. See the repo root CLAUDE.md for the full
3
+ pipeline overview.
4
+ """
5
+
6
+ __version__ = "0.1.0"
ziplex/checkpoint.py ADDED
@@ -0,0 +1,163 @@
1
+ """pack()'s checkpoint/resume system, split out of packager.py so that module
2
+ is left owning just the pipeline itself, not also this: saving/loading/
3
+ deleting a checkpoint file, deciding what to do when an LLM call has
4
+ exhausted its own retries (retry / answer manually / checkpoint and exit),
5
+ and the same question for a checkpoint found at the start of a run (resume
6
+ it / discard it and start over).
7
+
8
+ Every function here takes an `interactive` flag rather than calling
9
+ input()/print() unconditionally, so a non-interactive `pack` (CI, scripted
10
+ use, `--auto-correct`) degrades to a safe default -- checkpoint-and-exit for
11
+ a failing LLM call, always-resume for a found checkpoint -- instead of
12
+ EOFError-ing against closed stdin.
13
+ """
14
+
15
+ import hashlib
16
+ import json
17
+ from pathlib import Path
18
+
19
+ from .file.textutil import relative_key as _rel_key
20
+
21
+ CHECKPOINT_DIR = Path(__file__).parent.parent.parent / "checkpoint"
22
+
23
+
24
+ def _checkpoint_path(root_path: str) -> Path:
25
+ """checkpoint/<basename>-<hash>.json -- the hash suffix (first 8 hex
26
+ chars of sha256 of the resolved absolute path) keeps this filename
27
+ collision-proof across two different projects that happen to share a
28
+ folder name (e.g. C:\\clients\\acme\\backend and C:\\clients\\other\\
29
+ backend). A basename-only filename doesn't: the second project's pack
30
+ would find and silently auto-resume the first project's leftover
31
+ checkpoint (non-interactive callers -- the GUI, always -- never even
32
+ get a resume-vs-discard prompt to catch it), splicing one project's
33
+ file summaries/signatures into the other's output.
34
+
35
+ Safe to key on the raw absolute path here specifically because
36
+ checkpoint.json is purely local, ephemeral tool bookkeeping -- deleted
37
+ on a successful pack, never committed to the target project's own
38
+ repo the way aif.json/detail.json/cache.json are (see README's Team
39
+ use section) -- so there's no cross-machine-portability requirement
40
+ the way there is for freshness.py's own mitigation of the same class
41
+ of bug in the *committed* cache (which can't use a path fingerprint
42
+ for exactly that reason).
43
+
44
+ One consequence: a checkpoint already on disk under the pre-fix
45
+ basename-only naming becomes unreachable once this ships -- accepted
46
+ as a safe degrade (a pack resumes from scratch instead of picking up
47
+ a stale in-flight checkpoint) rather than a real loss, since a
48
+ checkpoint only ever holds re-derivable extraction state, never a
49
+ project's actual source.
50
+ """
51
+ resolved = str(Path(root_path).resolve())
52
+ digest = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:8]
53
+ return CHECKPOINT_DIR / f"{Path(root_path).name}-{digest}.json"
54
+
55
+
56
+ def save_checkpoint(root_path: str, data: dict) -> None:
57
+ CHECKPOINT_DIR.mkdir(exist_ok=True)
58
+ path = _checkpoint_path(root_path)
59
+ with open(path, "w", encoding="utf-8") as f:
60
+ json.dump(data, f, ensure_ascii=False, indent=2)
61
+ print(f"\n πŸ’Ύ 체크포인트 μ €μž₯됨: {path}")
62
+
63
+
64
+ def load_checkpoint(root_path: str) -> dict | None:
65
+ path = _checkpoint_path(root_path)
66
+ if not path.exists():
67
+ return None
68
+ with open(path, "r", encoding="utf-8") as f:
69
+ return json.load(f)
70
+
71
+
72
+ def delete_checkpoint(root_path: str) -> None:
73
+ path = _checkpoint_path(root_path)
74
+ if path.exists():
75
+ path.unlink()
76
+
77
+
78
+ def build_snapshot(root: Path, files_data: dict, rules: list = None, prompt: str = "") -> dict:
79
+ """The shape handle_llm_failure() checkpoints on a failure -- everything
80
+ pack() has produced so far, keyed by relative name (matching what
81
+ unpack_snapshot() below expects to restore from), so a resumed run can
82
+ skip straight past whatever already succeeded.
83
+ """
84
+ return {
85
+ "project": {"name": root.name, "prompt": prompt},
86
+ "rules": rules or [],
87
+ "files_data": {
88
+ _rel_key(fp, root): d
89
+ for fp, d in files_data.items()
90
+ }
91
+ }
92
+
93
+
94
+ def unpack_snapshot(checkpoint: dict | None) -> tuple[list, str, dict]:
95
+ """The read-side counterpart to build_snapshot() -- pulls (rules, prompt,
96
+ files_data) back out of a loaded checkpoint, so a caller restoring from
97
+ one never has to know this shape's exact keys itself. Keeps the shape
98
+ defined in exactly one place: a future change to build_snapshot()'s keys
99
+ has to change this function right alongside it, in the same file,
100
+ instead of silently drifting from a raw dict-indexing read site
101
+ somewhere else that build_snapshot() has no visibility into.
102
+
103
+ checkpoint=None (nothing to resume, e.g. load_checkpoint() found
104
+ nothing) returns the same empty defaults an absent checkpoint already
105
+ implied before this function existed.
106
+ """
107
+ if not checkpoint:
108
+ return [], "", {}
109
+ rules = checkpoint.get("rules", [])
110
+ prompt = checkpoint.get("project", {}).get("prompt", "")
111
+ files_data = checkpoint.get("files_data", {})
112
+ return rules, prompt, files_data
113
+
114
+
115
+ def handle_llm_failure(
116
+ name: str, field: str, current_aif: dict, root_path: str, interactive: bool = True
117
+ ) -> str | None:
118
+ """On an LLM call that's exhausted its own internal retries: ask the user
119
+ what to do (retry / type a value / checkpoint and exit), or -- when
120
+ interactive=False, e.g. under `pack --auto-correct` -- skip the prompt
121
+ entirely and behave as if "checkpoint and exit" was chosen. That keeps a
122
+ non-interactive `pack` call from blocking on input() forever (or crashing
123
+ with EOFError, which is what used to happen here under a closed stdin);
124
+ the caller gets a clean {} back and the checkpoint is there to resume
125
+ from once the LLM is behaving again.
126
+ """
127
+ print(f"\n ⚠️ {name} {field} 생성 μ‹€νŒ¨")
128
+
129
+ if not interactive:
130
+ print(" πŸ’Ύ λΉ„λŒ€ν™”ν˜• λͺ¨λ“œ β†’ 체크포인트 μ €μž₯ ν›„ 쀑단")
131
+ save_checkpoint(root_path, current_aif)
132
+ return "EXIT"
133
+
134
+ print(" [1] μž¬μ‹œλ„")
135
+ print(" [2] 직접 μž…λ ₯")
136
+ print(" [3] μ €μž₯ ν›„ μ’…λ£Œ")
137
+ choice = input(" 선택: ").strip()
138
+
139
+ if choice == "1":
140
+ return None
141
+ elif choice == "2":
142
+ return input(f" {field} 직접 μž…λ ₯: ").strip()
143
+ elif choice == "3":
144
+ save_checkpoint(root_path, current_aif)
145
+ return "EXIT"
146
+
147
+ return None
148
+
149
+
150
+ def resume_checkpoint_choice(interactive: bool) -> bool:
151
+ """Returns True to resume from a found checkpoint, False to discard it and
152
+ start over. Non-interactive callers always resume -- silently discarding
153
+ prior progress is a worse default than continuing it, and there's no
154
+ terminal to ask.
155
+ """
156
+ if not interactive:
157
+ return True
158
+
159
+ print(f"\n πŸ“‚ 체크포인트 발견")
160
+ print(" [1] μ΄μ–΄μ„œ μ§„ν–‰")
161
+ print(" [2] μ²˜μŒλΆ€ν„° μ‹œμž‘")
162
+ choice = input(" 선택: ").strip()
163
+ return choice != "2"
ziplex/cli.py ADDED
@@ -0,0 +1,430 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .extract.code.extractor import extract_signatures, extract_dependencies, extract_api, debug_tree
7
+ from .extract.code.compressor import compress_file
8
+ from .file.collector import collect_files, print_tree as print_file_tree
9
+ from .file.scanner import scan_files
10
+ from .file.textutil import relative_key as _rel_key
11
+ from .text_references import find_text_references_for_file
12
+ from .tokenizer import analyze_tokens, analyze_tokens_with_compression
13
+ from .file.selector import select_files, review_dangerous_files
14
+ from .llm import analyze_rules, analyze_prompt
15
+ from .summarizer import generate_summaries
16
+ from .packager import pack, save_aif
17
+ from .corrector import correct_aif
18
+ from .edits import finalize_aif
19
+ from .file.relationship import build_tree, print_tree as print_dependency_tree
20
+ from .search import search_files, read_detail_range
21
+ from .freshness import check_freshness
22
+ from .skill_export import export_skill
23
+ from .config import init_config, CONFIG_FILENAME, collection_kwargs as _collection_kwargs, collect_and_scan as _collect_and_scan
24
+
25
+
26
+ def _split_patterns(value: str | None) -> list[str] | None:
27
+ """Splits a --include/--ignore CLI value on "," into a pattern list,
28
+ stripping whitespace around each one -- "src/**/*.py, *.md" (a space
29
+ after the comma, a natural way to type the list) would otherwise leave
30
+ " *.md" as a literal leading-space pattern that pathspec matches nothing
31
+ against, silently dropping every intended file with no error. None stays
32
+ None (no flag given), matching collect_files()'s own "no filter" default.
33
+ """
34
+ if not value:
35
+ return None
36
+ return [p.strip() for p in value.split(",") if p.strip()]
37
+
38
+
39
+ def _check_max_tokens(aif_tokens: dict, max_tokens: int, model: str) -> tuple[bool, int | None]:
40
+ """CI-guard check for `pack --max-tokens`: does the packed payload
41
+ (aif.json's actual per-file token cost, tokenizer.py's "compressed"
42
+ figure -- see analyze_tokens_with_payload) fit under a budget?
43
+
44
+ Returns (passed, actual_count). actual_count is None (and passed is
45
+ always False) when `model` isn't a key in aif_tokens (aif["tokens"],
46
+ keyed by tokenizer.MODEL_ENCODINGS) -- lets the caller tell "over
47
+ budget" apart from "typo'd --max-tokens-model" instead of one silently
48
+ reading as the other.
49
+
50
+ Pure and side-effect-free on purpose (no printing, no sys.exit) so it's
51
+ directly testable -- main() owns turning the result into CLI output and
52
+ an exit code.
53
+ """
54
+ model_data = aif_tokens.get(model)
55
+ if model_data is None:
56
+ return False, None
57
+ actual = model_data["compressed"]
58
+ return actual <= max_tokens, actual
59
+
60
+
61
+ def main():
62
+ # Windows consoles default to the system locale's codepage (e.g. cp949 on
63
+ # Korean Windows), not UTF-8 -- printing an emoji then raises
64
+ # UnicodeEncodeError before pack() gets anywhere. Force UTF-8 on real
65
+ # process stdout/stderr; guarded with hasattr since a piped/captured
66
+ # stream (tests, some CI runners) may not support reconfigure() at all.
67
+ # Lives in main() itself (not just the `if __name__ == "__main__":` guard
68
+ # below) so the `ziplex` console-script entry point -- which imports this
69
+ # module and calls main() directly, never executing as __main__ -- gets
70
+ # the same fix; a bare `python cli.py` run still hits it too since main()
71
+ # is the very next thing __main__ calls either way.
72
+ if hasattr(sys.stdout, "reconfigure"):
73
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
74
+ if hasattr(sys.stderr, "reconfigure"):
75
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
76
+
77
+ parser = argparse.ArgumentParser(description="Ziplex")
78
+ sub = parser.add_subparsers(dest="command")
79
+
80
+ c = sub.add_parser("compress", help="μ½”λ“œ μ••μΆ•")
81
+ c.add_argument("file", help="파일 경둜")
82
+
83
+ s = sub.add_parser("signatures", help="μ‹œκ·Έλ‹ˆμ²˜ μΆ”μΆœ")
84
+ s.add_argument("file", help="파일 경둜")
85
+
86
+ d = sub.add_parser("dependencies", help="μ˜μ‘΄μ„± μΆ”μΆœ")
87
+ d.add_argument("file", help="파일 경둜")
88
+
89
+ a = sub.add_parser("api", help="API μΆ”μΆœ")
90
+ a.add_argument("file", help="파일 경둜")
91
+
92
+ db = sub.add_parser("debug", help="트리 ꡬ쑰 좜λ ₯")
93
+ db.add_argument("file", help="파일 경둜")
94
+
95
+ col = sub.add_parser("collect", help="파일 μˆ˜μ§‘")
96
+ col.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
97
+
98
+ tok = sub.add_parser("tokens", help="토큰 μΉ΄μš΄νŒ…")
99
+ tok.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
100
+
101
+ sel = sub.add_parser("select", help="파일 선택")
102
+ sel.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
103
+
104
+ an = sub.add_parser("analyze", help="LLM 뢄석")
105
+ an.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
106
+
107
+ p = sub.add_parser("pack", help="ν”„λ‘œμ νŠΈ νŒ¨ν‚Ή")
108
+ p.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
109
+ p.add_argument("--output", "-o", default=None, help="좜λ ₯ 파일 경둜 (κΈ°λ³Έκ°’: result/<ν”„λ‘œμ νŠΈ 폴더λͺ…>.json)")
110
+ p.add_argument("--auto", action="store_true", help="파일 μžλ™ 선택 (전체 μ•ˆμ „ 파일 포함)")
111
+ p.add_argument("--auto-correct", action="store_true", help="LLM κ²°κ³Ό μžλ™ 승인 (λŒ€ν™”ν˜• 보정 κ±΄λ„ˆλœ€)")
112
+ p.add_argument("--no-cache", action="store_true", help="λ³€κ²½ μ—†λŠ” νŒŒμΌλ„ μš”μ•½μ„ λ‹€μ‹œ 생성 (이전 pack μž¬μ‚¬μš© 끄기)")
113
+ p.add_argument("--no-llm", action="store_true",
114
+ help="LLM 호좜 없이 ꡬ쑰 정보(μ‹œκ·Έλ‹ˆμ²˜/μ˜μ‘΄μ„±)만으둜 νŒ¨ν‚Ή -- GEMINI_API_KEY λΆˆν•„μš”, rules/AI κ°€μ΄λ“œλŠ” μƒμ„±λ˜μ§€ μ•ŠμŒ")
115
+ p.add_argument("--include", default=None, help="포함할 glob νŒ¨ν„΄, μ‰Όν‘œλ‘œ ꡬ뢄 (예: 'src/**/*.py,*.md') -- .ziplex.json의 include에 좔가됨")
116
+ p.add_argument("--ignore", default=None, help="μΆ”κ°€λ‘œ μ œμ™Έν•  glob νŒ¨ν„΄, μ‰Όν‘œλ‘œ ꡬ뢄 -- .ziplex.json의 ignore에 좔가됨")
117
+ p.add_argument("--max-tokens", type=int, default=None, metavar="N",
118
+ help="νŒ¨ν‚Ήλœ aif.json의 νŒŒμΌλ³„ payloadκ°€ N 토큰을 λ„˜μœΌλ©΄ μ’…λ£Œ μ½”λ“œ 1둜 μ‹€νŒ¨ -- CIμ—μ„œ μ»¨ν…μŠ€νŠΈ μ˜ˆμ‚° 초과λ₯Ό λ§‰λŠ” μš©λ„")
119
+ p.add_argument("--max-tokens-model", default="GPT-4o", metavar="MODEL",
120
+ help="--max-tokens νŒλ‹¨ κΈ°μ€€ λͺ¨λΈ (κΈ°λ³Έκ°’: GPT-4o, tokenizer.MODEL_ENCODINGS의 ν‚€ 쀑 ν•˜λ‚˜)")
121
+
122
+ tr = sub.add_parser("tree", help="μ˜μ‘΄μ„± 트리 좜λ ₯")
123
+ tr.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
124
+
125
+ se = sub.add_parser("search", help="ν”„λ‘œμ νŠΈ 전체 검색 (μ •κ·œμ‹)")
126
+ se.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
127
+ se.add_argument("pattern", help="검색할 μ •κ·œμ‹ νŒ¨ν„΄")
128
+ se.add_argument("--context", "-C", type=int, default=0, help="맀치 μ•žλ’€λ‘œ 보여쀄 쀄 수")
129
+ se.add_argument("--ignore-case", "-i", action="store_true", help="λŒ€μ†Œλ¬Έμž λ¬΄μ‹œ")
130
+
131
+ de = sub.add_parser("detail", help="detail.jsonμ—μ„œ 파일 μΌλΆ€λ§Œ 읽기")
132
+ de.add_argument("detail_path", help="<name>.detail.json 경둜")
133
+ de.add_argument("file", help="detail.json μ•ˆμ˜ 파일 ν‚€")
134
+ de.add_argument("--start", type=int, default=None, help="μ‹œμž‘ 쀄 번호 (1-based)")
135
+ de.add_argument("--end", type=int, default=None, help="끝 쀄 번호 (1-based, 포함)")
136
+
137
+ fr = sub.add_parser("freshness", help="aif.json이 μ΅œμ‹  μƒνƒœμΈμ§€ 확인 (ν•΄μ‹œ 비ꡐ, LLM 호좜 μ—†μŒ)")
138
+ fr.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
139
+ fr.add_argument("cache_path", help="<name>.cache.json 경둜")
140
+
141
+ sk = sub.add_parser("skill", help="aif.json을 Claude Agent Skill둜 내보내기 (.claude/skills/, MCP μ„œλ²„ 없이도 인식됨)")
142
+ sk.add_argument("aif_path", help="aif.json 경둜")
143
+ sk.add_argument("--output", "-o", default=None, help="좜λ ₯ 디렉터리 (κΈ°λ³Έκ°’: .claude/skills/<ν”„λ‘œμ νŠΈλͺ…>/)")
144
+
145
+ ini = sub.add_parser("init", help="ν”„λ‘œμ νŠΈμ— .ziplex.json μ„€μ • 파일 생성 (include/ignore νŒ¨ν„΄)")
146
+ ini.add_argument("path", help="ν”„λ‘œμ νŠΈ 폴더 경둜")
147
+
148
+ args = parser.parse_args()
149
+
150
+ if args.command == "compress":
151
+ print(compress_file(args.file))
152
+
153
+ elif args.command == "signatures":
154
+ sigs = extract_signatures(args.file)
155
+ for sig in sigs:
156
+ print(f" {sig}")
157
+
158
+ elif args.command == "dependencies":
159
+ deps = extract_dependencies(args.file)
160
+ for dep in deps:
161
+ print(f" {dep}")
162
+
163
+ elif args.command == "api":
164
+ apis = extract_api(args.file)
165
+ for api in apis:
166
+ print(f" {api}")
167
+
168
+ elif args.command == "debug":
169
+ debug_tree(args.file)
170
+
171
+ elif args.command == "collect":
172
+ files = collect_files(args.path, **_collection_kwargs(args.path))
173
+ scan_result = scan_files(files)
174
+
175
+ print(f"\nπŸ“ μˆ˜μ§‘λœ 파일: {len(files)}개")
176
+ print_file_tree(files, args.path)
177
+
178
+ if scan_result["dangerous"]:
179
+ print(f"\n⚠️ 민감 파일 감지: {len(scan_result['dangerous'])}개")
180
+ for d in scan_result["dangerous"]:
181
+ print(f" ❌ {d['file']} -- {d.get('reason') or '민감 μ •λ³΄λ‘œ 좔정됨'}")
182
+
183
+ print(f"\nβœ… μ•ˆμ „ν•œ 파일: {len(scan_result['safe'])}개")
184
+
185
+ elif args.command == "tokens":
186
+ safe_files = _collect_and_scan(args.path)["safe"]
187
+
188
+ results, _ = analyze_tokens_with_compression(safe_files)
189
+ print(f"\nπŸ“Š 토큰 뢄석 ({len(safe_files)}개 파일)\n")
190
+ for model, data in results.items():
191
+ print(f"{model}")
192
+ print(f" μ••μΆ• μ „: {data['original']:,} / {data['max']:,} {data['original_bar']}")
193
+ print(f" μ••μΆ• ν›„: {data['compressed']:,} / {data['max']:,} {data['compressed_bar']}")
194
+ print(f" 절감: {data['saved']:,} 토큰 ({data['saved_pct']}% κ°μ†Œ)\n")
195
+
196
+ elif args.command == "select":
197
+ scan_result = _collect_and_scan(args.path)
198
+ safe_files = scan_result["safe"]
199
+ dangerous = scan_result["dangerous"]
200
+
201
+ if dangerous:
202
+ # always interactive (this whole subcommand is the interactive
203
+ # picker), so always offered the chance to include one anyway --
204
+ # same review file/selector.review_dangerous_files() gives
205
+ # pack()'s own picker path.
206
+ included_anyway = review_dangerous_files(dangerous, args.path)
207
+ safe_files = safe_files + included_anyway
208
+ excluded = [d["file"] for d in dangerous if d["file"] not in included_anyway]
209
+ if excluded:
210
+ print(f"\n⚠️ 민감 파일 μ œμ™Έλ¨: {len(excluded)}개")
211
+ for f in excluded:
212
+ print(f" ❌ {f}")
213
+
214
+ selected = select_files(safe_files, args.path)
215
+
216
+ elif args.command == "analyze":
217
+ # 1. Collect files
218
+ safe_files = _collect_and_scan(args.path)["safe"]
219
+
220
+ print(f"\nπŸ“ 뢄석 λŒ€μƒ: {len(safe_files)}개 파일\n")
221
+
222
+ # 2. Per-file analysis -- delegates to summarizer.generate_summaries(),
223
+ # the same batched/threaded/retry-once-then-placeholder path pack()
224
+ # itself uses for its own per-file summaries, instead of a bespoke
225
+ # one-call-per-file loop with its own separate failure placeholder
226
+ # ("뢄석 μ‹€νŒ¨" vs summarizer.SUMMARY_FAILED_PLACEHOLDER). A future fix to
227
+ # batching/retry/placeholder handling there (see CLAUDE.md's
228
+ # `summarizer.py` bullet) now applies here too, instead of silently
229
+ # missing this command the way a hand-rolled duplicate would.
230
+ pending = {}
231
+ signatures_map = {}
232
+ for file in safe_files:
233
+ sigs = extract_signatures(file)
234
+ deps = extract_dependencies(file)
235
+
236
+ if not sigs and not deps:
237
+ continue
238
+
239
+ pending[file] = {"signatures": sigs, "dependencies": deps}
240
+ signatures_map[file] = sigs
241
+
242
+ print(f" πŸ” {len(pending)}개 파일 뢄석 쀑...")
243
+ summaries = generate_summaries(pending, Path(args.path))
244
+
245
+ # 3. Extract rules
246
+ print(f"\n πŸ“‹ μ½”λ”© λ£° μΆ”μΆœ 쀑...")
247
+ rules_response = analyze_rules(signatures_map)
248
+ try:
249
+ rules_data = json.loads(rules_response)
250
+ except json.JSONDecodeError:
251
+ rules_data = {"rules": []}
252
+
253
+ # 4. Generate prompt
254
+ print(f" ✍️ AI κ°€μ΄λ“œ 생성 쀑...\n")
255
+ prompt_response = analyze_prompt(
256
+ project_name=Path(args.path).name,
257
+ architecture=[],
258
+ rules=rules_data["rules"]
259
+ )
260
+ try:
261
+ prompt_data = json.loads(prompt_response)
262
+ except json.JSONDecodeError:
263
+ prompt_data = {"prompt": "생성 μ‹€νŒ¨"}
264
+
265
+ # 5. Print results
266
+ print("=" * 50)
267
+ print("πŸ“„ νŒŒμΌλ³„ Summary")
268
+ print("=" * 50)
269
+ for file, summary in summaries.items():
270
+ print(f" {Path(file).name}: {summary}")
271
+
272
+ print("\n" + "=" * 50)
273
+ print("πŸ“‹ μ½”λ”© λ£°")
274
+ print("=" * 50)
275
+ for rule in rules_data["rules"]:
276
+ print(f" - {rule}")
277
+
278
+ print("\n" + "=" * 50)
279
+ print("✍️ AI κ°€μ΄λ“œ")
280
+ print("=" * 50)
281
+ print(f" {prompt_data['prompt']}")
282
+
283
+ elif args.command == "pack":
284
+ # --auto-correct also means no terminal to prompt if an LLM call
285
+ # keeps failing inside pack() itself (see handle_llm_failure).
286
+ aif = pack(
287
+ args.path, auto=args.auto, interactive=not args.auto_correct, use_cache=not args.no_cache,
288
+ use_llm=not args.no_llm,
289
+ include=_split_patterns(args.include),
290
+ ignore=_split_patterns(args.ignore),
291
+ )
292
+ if aif:
293
+ if args.auto_correct:
294
+ aif = finalize_aif(aif) # skip interactive review, still build relationships
295
+ else:
296
+ aif = correct_aif(aif) # interactive correct + build relationships
297
+ save_aif(aif, args.output)
298
+
299
+ print("\n" + "=" * 50)
300
+ print("πŸ“„ νŒŒμΌλ³„ Summary")
301
+ print("=" * 50)
302
+ for name, data in aif["files"].items():
303
+ if data["summary"]:
304
+ print(f" {name}: {data['summary']}")
305
+
306
+ print("\n" + "=" * 50)
307
+ print("πŸ“‹ μ½”λ”© λ£°")
308
+ print("=" * 50)
309
+ for rule in aif["rules"]:
310
+ print(f" - {rule}")
311
+
312
+ print("\n" + "=" * 50)
313
+ print("✍️ AI κ°€μ΄λ“œ")
314
+ print("=" * 50)
315
+ print(f" {aif['project']['prompt']}")
316
+
317
+ print("\n" + "=" * 50)
318
+ print("πŸ“Š 토큰 뢄석")
319
+ print("=" * 50)
320
+ for model, data in aif["tokens"].items():
321
+ print(f" {model}: {data['original']} β†’ {data['compressed']} ({data['saved_pct']}% 절감)")
322
+
323
+ if args.max_tokens is not None:
324
+ passed, actual = _check_max_tokens(aif["tokens"], args.max_tokens, args.max_tokens_model)
325
+ if actual is None:
326
+ print(f"\n⚠️ --max-tokens-model '{args.max_tokens_model}'은 μ•Œ 수 μ—†λŠ” λͺ¨λΈμž…λ‹ˆλ‹€"
327
+ f" (μ‚¬μš© κ°€λŠ₯: {', '.join(aif['tokens'].keys())})")
328
+ sys.exit(1)
329
+ elif not passed:
330
+ print(f"\n❌ 토큰 μ˜ˆμ‚° 초과: {args.max_tokens_model} κΈ°μ€€ {actual:,} > {args.max_tokens:,} (--max-tokens)")
331
+ sys.exit(1)
332
+ else:
333
+ print(f"\nβœ… 토큰 μ˜ˆμ‚° 톡과: {args.max_tokens_model} κΈ°μ€€ {actual:,} ≀ {args.max_tokens:,}")
334
+ elif args.max_tokens is not None:
335
+ # pack() returned {} -- a checkpoint-and-exit on a repeated LLM
336
+ # failure, or a cancelled/empty run -- so there's no aif["tokens"]
337
+ # for the guard above to even check. Left unhandled, this whole
338
+ # block (nested inside `if aif:`) never ran at all and main()
339
+ # exited 0 by default: exactly the scenario --max-tokens exists
340
+ # to catch (a CI pipeline silently passing despite pack() never
341
+ # actually completing), so an incomplete pack must fail loudly
342
+ # here too when the guard was requested. No message/exit change
343
+ # at all when --max-tokens wasn't passed -- unchanged from before.
344
+ print("\n❌ pack이 μ™„λ£Œλ˜μ§€ μ•Šμ•„ --max-tokens 검사λ₯Ό μˆ˜ν–‰ν•  수 μ—†μŠ΅λ‹ˆλ‹€"
345
+ " (체크포인트 μ €μž₯ ν›„ μ€‘λ‹¨λ˜μ—ˆμ„ 수 μžˆμŠ΅λ‹ˆλ‹€)")
346
+ sys.exit(1)
347
+
348
+ elif args.command == "tree":
349
+ safe_files = _collect_and_scan(args.path)["safe"]
350
+
351
+ # Keyed by relative_key(), not the raw file_path collect_files()
352
+ # returns -- matching packager.py's own convention (and required
353
+ # for the text-reference matching below: find_text_references_for_
354
+ # file() returns entries straight from this same relative-key list,
355
+ # which only resolve correctly against a stem_map built from those
356
+ # same keys; see resolve_dependency()'s exact-key-match branch).
357
+ all_names = [_rel_key(fp, args.path) for fp in safe_files]
358
+ files_data = {}
359
+ for file_path in safe_files:
360
+ name = _rel_key(file_path, args.path)
361
+ deps = extract_dependencies(file_path) + find_text_references_for_file(file_path, name, all_names)
362
+ files_data[name] = {"dependencies": deps}
363
+
364
+ tree = build_tree(files_data)
365
+ print_dependency_tree(tree)
366
+
367
+ elif args.command == "search":
368
+ safe_files = _collect_and_scan(args.path)["safe"]
369
+
370
+ try:
371
+ matches = search_files(
372
+ safe_files, args.path, args.pattern,
373
+ context_lines=args.context, ignore_case=args.ignore_case
374
+ )
375
+ except ValueError as e:
376
+ print(f"⚠️ {e}")
377
+ return
378
+
379
+ if not matches:
380
+ print("검색 κ²°κ³Ό μ—†μŒ")
381
+ for m in matches:
382
+ print(f"\n{m.file}:{m.line_number}")
383
+ for line in m.context_before:
384
+ print(f" {line}")
385
+ print(f" β†’ {m.line}")
386
+ for line in m.context_after:
387
+ print(f" {line}")
388
+
389
+ elif args.command == "detail":
390
+ with open(args.detail_path, "r", encoding="utf-8") as f:
391
+ detail = json.load(f)
392
+
393
+ entry = detail.get(args.file)
394
+ if entry is None:
395
+ print(f"⚠️ '{args.file}'λŠ” {args.detail_path}에 μ—†μŠ΅λ‹ˆλ‹€")
396
+ return
397
+
398
+ print(read_detail_range(entry.get("compressed", ""), args.start, args.end))
399
+
400
+ elif args.command == "freshness":
401
+ with open(args.cache_path, "r", encoding="utf-8") as f:
402
+ manifest = json.load(f)
403
+
404
+ safe_files = _collect_and_scan(args.path)["safe"]
405
+ report = check_freshness(safe_files, args.path, manifest)
406
+
407
+ if not report.is_stale:
408
+ print("βœ… μ΅œμ‹  μƒνƒœ β€” λ³€κ²½λœ 파일 μ—†μŒ")
409
+ else:
410
+ print("⚠️ aif.json이 μ˜€λž˜λμŠ΅λ‹ˆλ‹€")
411
+ if report.changed:
412
+ print(f" 변경됨 ({len(report.changed)}): {', '.join(report.changed)}")
413
+ if report.added:
414
+ print(f" 좔가됨 ({len(report.added)}): {', '.join(report.added)}")
415
+ if report.removed:
416
+ print(f" μ‚­μ œλ¨ ({len(report.removed)}): {', '.join(report.removed)}")
417
+
418
+ elif args.command == "skill":
419
+ target = export_skill(args.aif_path, args.output)
420
+ print(f"βœ… Skill 내보내기 μ™„λ£Œ: {target}")
421
+ print(" Claude Codeκ°€ μžλ™μœΌλ‘œ μΈμ‹ν•˜λ €λ©΄ ν”„λ‘œμ νŠΈ 루트의 .claude/skills/ μ•„λž˜μ— μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€.")
422
+
423
+ elif args.command == "init":
424
+ existed = (Path(args.path) / CONFIG_FILENAME).exists()
425
+ target = init_config(args.path)
426
+ print(f"βœ… .ziplex.json {'이미 있음' if existed else '생성됨'}: {target}")
427
+ print(' μ˜ˆμ‹œ: {"include": ["src/**/*.py"], "ignore": ["**/*.generated.*"]}')
428
+
429
+ if __name__ == "__main__":
430
+ main()