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.
- ziplex/__init__.py +6 -0
- ziplex/checkpoint.py +163 -0
- ziplex/cli.py +430 -0
- ziplex/confidence.py +131 -0
- ziplex/config.py +111 -0
- ziplex/corrector.py +192 -0
- ziplex/edits.py +88 -0
- ziplex/extract/__init__.py +0 -0
- ziplex/extract/code/__init__.py +0 -0
- ziplex/extract/code/compressor.py +99 -0
- ziplex/extract/code/extractor.py +221 -0
- ziplex/extract/code/languages.py +744 -0
- ziplex/extract/code/parser.py +15 -0
- ziplex/extract/text/__init__.py +0 -0
- ziplex/extract/text/json.py +48 -0
- ziplex/extract/text/markdown.py +201 -0
- ziplex/extract/text/registry.py +23 -0
- ziplex/extract/text/txt.py +62 -0
- ziplex/file/__init__.py +0 -0
- ziplex/file/collector.py +120 -0
- ziplex/file/relationship.py +383 -0
- ziplex/file/scanner.py +139 -0
- ziplex/file/selector.py +92 -0
- ziplex/file/textutil.py +32 -0
- ziplex/freshness.py +157 -0
- ziplex/gui/__init__.py +0 -0
- ziplex/gui/gui_server.py +598 -0
- ziplex/gui/index.html +28 -0
- ziplex/gui/js/app.js +261 -0
- ziplex/gui/js/graph.js +371 -0
- ziplex/gui/js/i18n.js +327 -0
- ziplex/gui/js/pack.js +337 -0
- ziplex/gui/js/pages/files.js +105 -0
- ziplex/gui/js/pages/landing.js +280 -0
- ziplex/gui/js/pages/options.js +122 -0
- ziplex/gui/js/pages/overview.js +43 -0
- ziplex/gui/js/pages/relationships.js +87 -0
- ziplex/gui/js/pages/search.js +55 -0
- ziplex/gui/js/router.js +72 -0
- ziplex/gui/pack_service.py +784 -0
- ziplex/gui/style.css +475 -0
- ziplex/gui/watcher.py +214 -0
- ziplex/llm.py +321 -0
- ziplex/mcp_server.py +52 -0
- ziplex/packager.py +616 -0
- ziplex/query_service.py +229 -0
- ziplex/search.py +91 -0
- ziplex/settings.py +125 -0
- ziplex/skill_export.py +212 -0
- ziplex/summarizer.py +191 -0
- ziplex/tech_stack.py +315 -0
- ziplex/text_references.py +98 -0
- ziplex/tokenizer.py +148 -0
- ziplex-0.1.0.dist-info/METADATA +261 -0
- ziplex-0.1.0.dist-info/RECORD +59 -0
- ziplex-0.1.0.dist-info/WHEEL +5 -0
- ziplex-0.1.0.dist-info/entry_points.txt +4 -0
- ziplex-0.1.0.dist-info/licenses/LICENSE +21 -0
- ziplex-0.1.0.dist-info/top_level.txt +1 -0
ziplex/__init__.py
ADDED
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()
|