pyquipu-cli 0.1.0__tar.gz
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.
- pyquipu_cli-0.1.0/.gitignore +75 -0
- pyquipu_cli-0.1.0/PKG-INFO +17 -0
- pyquipu_cli-0.1.0/pyproject.toml +41 -0
- pyquipu_cli-0.1.0/src/quipu/__init__.py +3 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/__init__.py +0 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/axon.py +103 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/cache.py +113 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/cache.stitcher.yaml +7 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/export.py +209 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/export.stitcher.yaml +10 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/helpers.py +86 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/helpers.stitcher.yaml +10 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/navigation.py +229 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/query.py +135 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/query.stitcher.yaml +3 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/remote.py +126 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/run.py +106 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/show.py +110 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/show.stitcher.yaml +2 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/ui.py +67 -0
- pyquipu_cli-0.1.0/src/quipu/cli/commands/workspace.py +90 -0
- pyquipu_cli-0.1.0/src/quipu/cli/config.py +38 -0
- pyquipu_cli-0.1.0/src/quipu/cli/config.stitcher.yaml +3 -0
- pyquipu_cli-0.1.0/src/quipu/cli/logger_config.py +43 -0
- pyquipu_cli-0.1.0/src/quipu/cli/logger_config.stitcher.yaml +5 -0
- pyquipu_cli-0.1.0/src/quipu/cli/main.py +44 -0
- pyquipu_cli-0.1.0/src/quipu/cli/rendering.py +28 -0
- pyquipu_cli-0.1.0/src/quipu/cli/rendering.stitcher.yaml +4 -0
- pyquipu_cli-0.1.0/src/quipu/cli/tui.css +42 -0
- pyquipu_cli-0.1.0/src/quipu/cli/tui.py +374 -0
- pyquipu_cli-0.1.0/src/quipu/cli/tui.stitcher.yaml +14 -0
- pyquipu_cli-0.1.0/src/quipu/cli/ui_utils.py +53 -0
- pyquipu_cli-0.1.0/src/quipu/cli/ui_utils.stitcher.yaml +17 -0
- pyquipu_cli-0.1.0/src/quipu/cli/view_model.py +123 -0
- pyquipu_cli-0.1.0/src/quipu/cli/view_model.stitcher.yaml +27 -0
- pyquipu_cli-0.1.0/stitcher.lock +1437 -0
- pyquipu_cli-0.1.0/tests/integration/conftest.py +3 -0
- pyquipu_cli-0.1.0/tests/integration/test_cache_commands.py +113 -0
- pyquipu_cli-0.1.0/tests/integration/test_cache_commands.stitcher.yaml +12 -0
- pyquipu_cli-0.1.0/tests/integration/test_cli_interaction.py +31 -0
- pyquipu_cli-0.1.0/tests/integration/test_cli_interaction.stitcher.yaml +2 -0
- pyquipu_cli-0.1.0/tests/integration/test_export_command.py +191 -0
- pyquipu_cli-0.1.0/tests/integration/test_export_command.stitcher.yaml +16 -0
- pyquipu_cli-0.1.0/tests/integration/test_navigation_commands.py +72 -0
- pyquipu_cli-0.1.0/tests/integration/test_query_commands.py +144 -0
- pyquipu_cli-0.1.0/tests/integration/test_query_commands.stitcher.yaml +4 -0
- pyquipu_cli-0.1.0/tests/integration/test_unfriendly_paths.py +153 -0
- pyquipu_cli-0.1.0/tests/integration/test_unfriendly_paths.stitcher.yaml +14 -0
- pyquipu_cli-0.1.0/tests/integration/test_workspace_commands.py +60 -0
- pyquipu_cli-0.1.0/tests/unit/test_tui_logic.py +66 -0
- pyquipu_cli-0.1.0/tests/unit/test_tui_logic.stitcher.yaml +8 -0
- pyquipu_cli-0.1.0/tests/unit/test_tui_reachability.py +69 -0
- pyquipu_cli-0.1.0/tests/unit/test_tui_reachability.stitcher.yaml +6 -0
- pyquipu_cli-0.1.0/tests/unit/test_view_model.py +194 -0
- pyquipu_cli-0.1.0/tests/unit/test_view_model.stitcher.yaml +18 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# tmpfiles
|
|
7
|
+
o.md
|
|
8
|
+
a.md
|
|
9
|
+
a
|
|
10
|
+
o
|
|
11
|
+
t
|
|
12
|
+
tmpfile
|
|
13
|
+
|
|
14
|
+
# Scaffoldings
|
|
15
|
+
context_builder.yml
|
|
16
|
+
context_builder.py
|
|
17
|
+
current_prompts
|
|
18
|
+
|
|
19
|
+
# Distribution / Packaging
|
|
20
|
+
.Python
|
|
21
|
+
build/
|
|
22
|
+
develop-eggs/
|
|
23
|
+
dist/
|
|
24
|
+
downloads/
|
|
25
|
+
eggs/
|
|
26
|
+
.eggs/
|
|
27
|
+
lib/
|
|
28
|
+
lib64/
|
|
29
|
+
parts/
|
|
30
|
+
sdist/
|
|
31
|
+
var/
|
|
32
|
+
wheels/
|
|
33
|
+
*.egg-info/
|
|
34
|
+
.installed.cfg
|
|
35
|
+
*.egg
|
|
36
|
+
|
|
37
|
+
# Virtual Environments
|
|
38
|
+
venv/
|
|
39
|
+
env/
|
|
40
|
+
.env
|
|
41
|
+
.venv/
|
|
42
|
+
|
|
43
|
+
# Testing
|
|
44
|
+
.tox/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
.cache
|
|
48
|
+
nosetests.xml
|
|
49
|
+
coverage.xml
|
|
50
|
+
*.cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
|
|
54
|
+
# IDEs
|
|
55
|
+
.idea/
|
|
56
|
+
.vscode/
|
|
57
|
+
*.swp
|
|
58
|
+
*.swo
|
|
59
|
+
|
|
60
|
+
# Project Specific
|
|
61
|
+
*.log
|
|
62
|
+
axon.log
|
|
63
|
+
# Ignore test vaults or scratchpads if created in root
|
|
64
|
+
vault/
|
|
65
|
+
temp/
|
|
66
|
+
scratch/
|
|
67
|
+
# --- Quipu Dev Infra ---
|
|
68
|
+
.envs/
|
|
69
|
+
.uv/
|
|
70
|
+
sandbox/
|
|
71
|
+
*.egg-info/
|
|
72
|
+
__pycache__/
|
|
73
|
+
.pytest_cache/
|
|
74
|
+
.coverage
|
|
75
|
+
htmlcov/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyquipu-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The command-line interface for Quipu.
|
|
5
|
+
Author-email: doucx <doucxldh@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pyquipu-application~=0.1.0
|
|
11
|
+
Requires-Dist: pyquipu-bus~=0.1.0
|
|
12
|
+
Requires-Dist: pyquipu-common~=0.1.0
|
|
13
|
+
Requires-Dist: pyquipu-engine~=0.1.0
|
|
14
|
+
Requires-Dist: pyquipu-interfaces~=0.1.0
|
|
15
|
+
Requires-Dist: pyquipu-runtime~=0.1.0
|
|
16
|
+
Requires-Dist: textual>=0.58.0
|
|
17
|
+
Requires-Dist: typer>=0.9.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyquipu-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [{ name="doucx", email="doucxldh@gmail.com" }]
|
|
9
|
+
description = "The command-line interface for Quipu."
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pyquipu-interfaces ~= 0.1.0",
|
|
18
|
+
"pyquipu-engine ~= 0.1.0",
|
|
19
|
+
"pyquipu-runtime ~= 0.1.0",
|
|
20
|
+
"pyquipu-common ~= 0.1.0",
|
|
21
|
+
"pyquipu-application ~= 0.1.0",
|
|
22
|
+
"pyquipu-bus ~= 0.1.0",
|
|
23
|
+
"typer >= 0.9.0",
|
|
24
|
+
"textual >= 0.58.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
pyquipu-interfaces = { workspace = true }
|
|
29
|
+
pyquipu-engine = { workspace = true }
|
|
30
|
+
pyquipu-runtime = { workspace = true }
|
|
31
|
+
pyquipu-common = { workspace = true }
|
|
32
|
+
pyquipu-application = { workspace = true }
|
|
33
|
+
pyquipu-bus = { workspace = true }
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
quipu = "quipu.cli.main:app"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/quipu"]
|
|
40
|
+
# Hatch 默认会自动包含 package_data,如果需要显式包含:
|
|
41
|
+
# include = ["/src/quipu/cli/*.css"]
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from quipu.bus import bus
|
|
9
|
+
|
|
10
|
+
from ..config import DEFAULT_ENTRY_FILE, DEFAULT_WORK_DIR
|
|
11
|
+
from ..logger_config import setup_logging
|
|
12
|
+
from ..ui_utils import confirmation_handler_for_executor
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(app: typer.Typer):
|
|
18
|
+
@app.command(name="axon", help="无状态执行 Plan 文件,绕过 Quipu 引擎。")
|
|
19
|
+
def axon_command(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
file: Annotated[
|
|
22
|
+
Optional[Path], typer.Argument(help="包含 Markdown 指令的文件路径。", resolve_path=True)
|
|
23
|
+
] = None,
|
|
24
|
+
work_dir: Annotated[
|
|
25
|
+
Path,
|
|
26
|
+
typer.Option(
|
|
27
|
+
"--work-dir", "-w", help="操作执行的根目录(工作区)", file_okay=False, dir_okay=True, resolve_path=True
|
|
28
|
+
),
|
|
29
|
+
] = DEFAULT_WORK_DIR,
|
|
30
|
+
parser_name: Annotated[str, typer.Option("--parser", "-p", help="选择解析器语法。默认为 'auto'。")] = "auto",
|
|
31
|
+
yolo: Annotated[
|
|
32
|
+
bool, typer.Option("--yolo", "-y", help="跳过所有确认步骤,直接执行 (You Only Look Once)。")
|
|
33
|
+
] = False,
|
|
34
|
+
list_acts: Annotated[bool, typer.Option("--list-acts", "-l", help="列出所有可用的操作指令及其说明。")] = False,
|
|
35
|
+
):
|
|
36
|
+
setup_logging()
|
|
37
|
+
|
|
38
|
+
# --list-acts 是一个独立的查询操作,应尽早处理并退出
|
|
39
|
+
if list_acts:
|
|
40
|
+
from quipu.application.controller import get_available_acts
|
|
41
|
+
|
|
42
|
+
bus.info("axon.listActs.ui.header")
|
|
43
|
+
acts = get_available_acts(work_dir)
|
|
44
|
+
for name in sorted(acts.keys()):
|
|
45
|
+
doc = acts[name]
|
|
46
|
+
clean_doc = inspect.cleandoc(doc) if doc else "暂无说明"
|
|
47
|
+
indented_doc = "\n".join(f" {line}" for line in clean_doc.splitlines())
|
|
48
|
+
item_header = bus.get("axon.listActs.ui.actItem", name=name)
|
|
49
|
+
bus.data(f"{item_header}\n{indented_doc}\n")
|
|
50
|
+
ctx.exit(0)
|
|
51
|
+
|
|
52
|
+
logger.debug(f"axon started with file={file}, work_dir={work_dir}, parser={parser_name}, yolo={yolo}")
|
|
53
|
+
|
|
54
|
+
# --- 1. 输入处理 (CLI 层职责) ---
|
|
55
|
+
content = ""
|
|
56
|
+
source_desc = ""
|
|
57
|
+
if file:
|
|
58
|
+
if not file.exists():
|
|
59
|
+
bus.error("common.error.fileNotFound", path=file)
|
|
60
|
+
ctx.exit(1)
|
|
61
|
+
content = file.read_text(encoding="utf-8")
|
|
62
|
+
source_desc = f"文件 ({file.name})"
|
|
63
|
+
elif not sys.stdin.isatty():
|
|
64
|
+
try:
|
|
65
|
+
stdin_content = sys.stdin.read()
|
|
66
|
+
if stdin_content:
|
|
67
|
+
content = stdin_content
|
|
68
|
+
source_desc = "STDIN (管道流)"
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
if not content and not file and DEFAULT_ENTRY_FILE.exists():
|
|
73
|
+
content = DEFAULT_ENTRY_FILE.read_text(encoding="utf-8")
|
|
74
|
+
source_desc = f"默认文件 ({DEFAULT_ENTRY_FILE.name})"
|
|
75
|
+
|
|
76
|
+
if not content.strip():
|
|
77
|
+
bus.warning("axon.warning.noInput")
|
|
78
|
+
ctx.exit(0)
|
|
79
|
+
|
|
80
|
+
# --- 2. 委托给应用层服务 (核心重构) ---
|
|
81
|
+
from quipu.application.controller import run_stateless_plan
|
|
82
|
+
|
|
83
|
+
logger.info(f"Axon 启动 | 源: {source_desc} | 工作区: {work_dir}")
|
|
84
|
+
|
|
85
|
+
result = run_stateless_plan(
|
|
86
|
+
content=content,
|
|
87
|
+
work_dir=work_dir,
|
|
88
|
+
parser_name=parser_name,
|
|
89
|
+
yolo=yolo,
|
|
90
|
+
confirmation_handler=confirmation_handler_for_executor,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# --- 3. 渲染结果 (CLI 层职责) ---
|
|
94
|
+
if result.message:
|
|
95
|
+
kwargs = result.msg_kwargs or {}
|
|
96
|
+
if result.exit_code == 2:
|
|
97
|
+
bus.warning(result.message, **kwargs)
|
|
98
|
+
elif not result.success:
|
|
99
|
+
bus.error(result.message, **kwargs)
|
|
100
|
+
else:
|
|
101
|
+
bus.success(result.message, **kwargs)
|
|
102
|
+
|
|
103
|
+
ctx.exit(result.exit_code)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from quipu.bus import bus
|
|
7
|
+
|
|
8
|
+
from ..config import DEFAULT_WORK_DIR
|
|
9
|
+
from ..logger_config import setup_logging
|
|
10
|
+
from .helpers import engine_context
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
cache_app = typer.Typer(name="cache", help="管理本地 SQLite 缓存。")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cache_app.command("sync")
|
|
18
|
+
def cache_sync(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
work_dir: Annotated[
|
|
21
|
+
Path,
|
|
22
|
+
typer.Option(
|
|
23
|
+
"--work-dir", "-w", help="操作执行的根目录(工作区)", file_okay=False, dir_okay=True, resolve_path=True
|
|
24
|
+
),
|
|
25
|
+
] = DEFAULT_WORK_DIR,
|
|
26
|
+
):
|
|
27
|
+
bus.info("cache.sync.info.hydrating")
|
|
28
|
+
try:
|
|
29
|
+
with engine_context(work_dir):
|
|
30
|
+
pass
|
|
31
|
+
bus.success("cache.sync.success")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error("数据同步失败", exc_info=True)
|
|
34
|
+
bus.error("cache.sync.error", error=str(e))
|
|
35
|
+
ctx.exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cache_app.command("rebuild")
|
|
39
|
+
def cache_rebuild(
|
|
40
|
+
ctx: typer.Context,
|
|
41
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="强制执行,跳过确认提示。")] = False,
|
|
42
|
+
work_dir: Annotated[
|
|
43
|
+
Path,
|
|
44
|
+
typer.Option(
|
|
45
|
+
"--work-dir", "-w", help="操作执行的根目录(工作区)", file_okay=False, dir_okay=True, resolve_path=True
|
|
46
|
+
),
|
|
47
|
+
] = DEFAULT_WORK_DIR,
|
|
48
|
+
):
|
|
49
|
+
setup_logging()
|
|
50
|
+
db_path = work_dir.resolve() / ".quipu" / "history.sqlite"
|
|
51
|
+
if not db_path.exists():
|
|
52
|
+
bus.warning("cache.rebuild.info.dbNotFound")
|
|
53
|
+
cache_sync(ctx, work_dir)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@cache_app.command("prune-refs")
|
|
57
|
+
def cache_prune_refs(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
work_dir: Annotated[
|
|
60
|
+
Path,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--work-dir", "-w", help="操作执行的根目录(工作区)", file_okay=False, dir_okay=True, resolve_path=True
|
|
63
|
+
),
|
|
64
|
+
] = DEFAULT_WORK_DIR,
|
|
65
|
+
):
|
|
66
|
+
setup_logging()
|
|
67
|
+
|
|
68
|
+
with engine_context(work_dir) as engine:
|
|
69
|
+
bus.info("cache.prune.info.scanning")
|
|
70
|
+
|
|
71
|
+
# 1. 获取所有本地 heads
|
|
72
|
+
local_heads = engine.git_db.get_all_ref_heads("refs/quipu/local/heads/")
|
|
73
|
+
if not local_heads:
|
|
74
|
+
bus.success("cache.prune.info.noRedundant")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
head_commits = {h[0] for h in local_heads}
|
|
78
|
+
|
|
79
|
+
# 2. 批量获取这些 commit 的内容以解析 parent
|
|
80
|
+
commits_content = engine.git_db.batch_cat_file(list(head_commits))
|
|
81
|
+
|
|
82
|
+
parents_of_heads = set()
|
|
83
|
+
for c_hash, content in commits_content.items():
|
|
84
|
+
text = content.decode("utf-8", errors="ignore")
|
|
85
|
+
for line in text.splitlines():
|
|
86
|
+
if line.startswith("parent "):
|
|
87
|
+
p_hash = line.split()[1]
|
|
88
|
+
parents_of_heads.add(p_hash)
|
|
89
|
+
elif line == "":
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
# 3. 计算交集:既是 Head 又是某个 Head 的 Parent -> 冗余
|
|
93
|
+
redundant_commits = head_commits.intersection(parents_of_heads)
|
|
94
|
+
|
|
95
|
+
if not redundant_commits:
|
|
96
|
+
bus.success("cache.prune.info.noRedundant")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# 4. 找出对应的 ref names 并删除
|
|
100
|
+
refs_to_delete = []
|
|
101
|
+
for c_hash, ref_name in local_heads:
|
|
102
|
+
if c_hash in redundant_commits:
|
|
103
|
+
refs_to_delete.append(ref_name)
|
|
104
|
+
|
|
105
|
+
bus.info("cache.prune.info.found", count=len(refs_to_delete), total=len(local_heads))
|
|
106
|
+
|
|
107
|
+
deleted_count = 0
|
|
108
|
+
for ref in refs_to_delete:
|
|
109
|
+
engine.git_db.delete_ref(ref)
|
|
110
|
+
deleted_count += 1
|
|
111
|
+
|
|
112
|
+
bus.success("cache.prune.success", count=deleted_count)
|
|
113
|
+
return
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Dict, List, Optional, Set
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
import yaml
|
|
9
|
+
from quipu.bus import bus
|
|
10
|
+
from quipu.engine.state_machine import Engine
|
|
11
|
+
from quipu.interfaces.models import QuipuNode
|
|
12
|
+
|
|
13
|
+
from ..config import DEFAULT_WORK_DIR
|
|
14
|
+
from ..ui_utils import prompt_for_confirmation
|
|
15
|
+
from .helpers import engine_context, filter_nodes, filter_reachable_nodes
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sanitize_summary(summary: str) -> str:
|
|
21
|
+
if not summary:
|
|
22
|
+
return "no-summary"
|
|
23
|
+
sanitized = re.sub(r"[\\/:#\[\]|]", "_", summary)
|
|
24
|
+
sanitized = re.sub(r"[\s_]+", "_", sanitized)
|
|
25
|
+
return sanitized[:60]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _generate_filename(node: QuipuNode) -> str:
|
|
29
|
+
ts = node.timestamp.strftime("%y%m%d-%H%M")
|
|
30
|
+
short_hash = node.commit_hash[:7]
|
|
31
|
+
sanitized_summary = _sanitize_summary(node.summary)
|
|
32
|
+
return f"{ts}-{short_hash}-{sanitized_summary}.md"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_frontmatter(node: QuipuNode) -> str:
|
|
36
|
+
data = {
|
|
37
|
+
"commit_hash": node.commit_hash,
|
|
38
|
+
"output_tree": node.output_tree,
|
|
39
|
+
"input_tree": node.input_tree,
|
|
40
|
+
"timestamp": node.timestamp.isoformat(),
|
|
41
|
+
"node_type": node.node_type,
|
|
42
|
+
}
|
|
43
|
+
if node.owner_id:
|
|
44
|
+
data["owner_id"] = node.owner_id
|
|
45
|
+
yaml_str = yaml.dump(data, Dumper=yaml.SafeDumper, default_flow_style=False, sort_keys=False)
|
|
46
|
+
return f"---\n{yaml_str}---"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _generate_navbar(
|
|
50
|
+
current_node: QuipuNode,
|
|
51
|
+
exported_hashes_set: Set[str],
|
|
52
|
+
filename_map: Dict[str, str],
|
|
53
|
+
hidden_link_types: Set[str],
|
|
54
|
+
) -> str:
|
|
55
|
+
nav_links = []
|
|
56
|
+
|
|
57
|
+
# 1. 总结节点 (↑)
|
|
58
|
+
if "summary" not in hidden_link_types:
|
|
59
|
+
ancestor = current_node.parent
|
|
60
|
+
while ancestor:
|
|
61
|
+
if ancestor.input_tree == ancestor.output_tree and ancestor.commit_hash in exported_hashes_set:
|
|
62
|
+
nav_links.append(f"> ↑ [总结节点]({filename_map[ancestor.commit_hash]})")
|
|
63
|
+
break
|
|
64
|
+
ancestor = ancestor.parent
|
|
65
|
+
|
|
66
|
+
# 2. 上一分支点 (↓)
|
|
67
|
+
if "branch" not in hidden_link_types:
|
|
68
|
+
ancestor = current_node.parent
|
|
69
|
+
found_branch_point = None
|
|
70
|
+
while ancestor:
|
|
71
|
+
if len(ancestor.children) > 1 and ancestor.commit_hash in exported_hashes_set:
|
|
72
|
+
found_branch_point = ancestor
|
|
73
|
+
break
|
|
74
|
+
ancestor = ancestor.parent
|
|
75
|
+
if (
|
|
76
|
+
found_branch_point
|
|
77
|
+
and current_node.parent
|
|
78
|
+
and found_branch_point.commit_hash != current_node.parent.commit_hash
|
|
79
|
+
):
|
|
80
|
+
nav_links.append(f"> ↓ [上一分支点]({filename_map[found_branch_point.commit_hash]})")
|
|
81
|
+
|
|
82
|
+
# 3. 父节点 (←)
|
|
83
|
+
if "parent" not in hidden_link_types:
|
|
84
|
+
if current_node.parent and current_node.parent.commit_hash in exported_hashes_set:
|
|
85
|
+
nav_links.append(f"> ← [父节点]({filename_map[current_node.parent.commit_hash]})")
|
|
86
|
+
|
|
87
|
+
# 4. 子节点 (→)
|
|
88
|
+
if "child" not in hidden_link_types:
|
|
89
|
+
for child in current_node.children:
|
|
90
|
+
if child.commit_hash in exported_hashes_set:
|
|
91
|
+
nav_links.append(f"> → [子节点]({filename_map[child.commit_hash]})")
|
|
92
|
+
|
|
93
|
+
if not nav_links:
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
return "\n\n" + "> [!nav] 节点导航\n" + "\n".join(nav_links)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _generate_file_content(
|
|
100
|
+
node: QuipuNode,
|
|
101
|
+
engine: Engine,
|
|
102
|
+
no_frontmatter: bool,
|
|
103
|
+
no_nav: bool,
|
|
104
|
+
exported_hashes_set: Set[str],
|
|
105
|
+
filename_map: Dict[str, str],
|
|
106
|
+
hidden_link_types: Set[str],
|
|
107
|
+
) -> str:
|
|
108
|
+
parts = []
|
|
109
|
+
if not no_frontmatter:
|
|
110
|
+
parts.append(_format_frontmatter(node))
|
|
111
|
+
|
|
112
|
+
public_content = engine.reader.get_node_content(node) or ""
|
|
113
|
+
parts.append("# content.md")
|
|
114
|
+
parts.append(public_content.strip())
|
|
115
|
+
|
|
116
|
+
private_content = engine.reader.get_private_data(node.commit_hash)
|
|
117
|
+
if private_content:
|
|
118
|
+
parts.append("# 开发者意图")
|
|
119
|
+
parts.append(private_content.strip())
|
|
120
|
+
|
|
121
|
+
content_str = "\n\n".join(parts)
|
|
122
|
+
|
|
123
|
+
if not no_nav:
|
|
124
|
+
navbar_str = _generate_navbar(node, exported_hashes_set, filename_map, hidden_link_types)
|
|
125
|
+
content_str += navbar_str
|
|
126
|
+
|
|
127
|
+
return content_str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def register(app: typer.Typer):
|
|
131
|
+
@app.command(name="export", help="将历史图谱中的节点导出为 Markdown 文件。")
|
|
132
|
+
def export_command(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
work_dir: Annotated[
|
|
135
|
+
Path, typer.Option("--work-dir", "-w", help="工作区根目录", resolve_path=True)
|
|
136
|
+
] = DEFAULT_WORK_DIR,
|
|
137
|
+
output_dir: Annotated[Path, typer.Option("--output-dir", "-o", help="导出目录", resolve_path=True)] = Path(
|
|
138
|
+
"./.quipu/export"
|
|
139
|
+
),
|
|
140
|
+
limit: Annotated[Optional[int], typer.Option("--limit", "-n", help="限制最新节点数量")] = None,
|
|
141
|
+
since: Annotated[Optional[str], typer.Option("--since", help="起始时间戳 (YYYY-MM-DD HH:MM)")] = None,
|
|
142
|
+
until: Annotated[Optional[str], typer.Option("--until", help="截止时间戳 (YYYY-MM-DD HH:MM)")] = None,
|
|
143
|
+
zip_output: Annotated[bool, typer.Option("--zip", help="压缩导出目录")] = False,
|
|
144
|
+
no_nav: Annotated[bool, typer.Option("--no-nav", help="禁用导航栏")] = False,
|
|
145
|
+
no_frontmatter: Annotated[bool, typer.Option("--no-frontmatter", help="禁用 Frontmatter")] = False,
|
|
146
|
+
hide_link_type: Annotated[
|
|
147
|
+
Optional[List[str]],
|
|
148
|
+
typer.Option(
|
|
149
|
+
"--hide-link-type", help="禁用特定类型的导航链接 (可多次使用: summary, branch, parent, child)"
|
|
150
|
+
),
|
|
151
|
+
] = None,
|
|
152
|
+
reachable_only: Annotated[
|
|
153
|
+
bool, typer.Option("--reachable-only", help="仅导出与当前工作区状态直接相关的节点。")
|
|
154
|
+
] = False,
|
|
155
|
+
):
|
|
156
|
+
hidden_types = set(hide_link_type) if hide_link_type else set()
|
|
157
|
+
|
|
158
|
+
with engine_context(work_dir) as engine:
|
|
159
|
+
if not engine.history_graph:
|
|
160
|
+
bus.info("export.info.emptyHistory")
|
|
161
|
+
ctx.exit(0)
|
|
162
|
+
|
|
163
|
+
nodes_to_process = sorted(engine.history_graph.values(), key=lambda n: n.timestamp, reverse=True)
|
|
164
|
+
|
|
165
|
+
if reachable_only:
|
|
166
|
+
nodes_to_process = filter_reachable_nodes(engine, nodes_to_process)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
# filter_nodes returns preserving input order (reverse chrono),
|
|
170
|
+
# but export expects chronological order for file generation/processing
|
|
171
|
+
filtered = filter_nodes(nodes_to_process, limit, since, until)
|
|
172
|
+
nodes_to_export = list(reversed(filtered))
|
|
173
|
+
except typer.BadParameter as e:
|
|
174
|
+
bus.error("export.error.badParam", error=str(e))
|
|
175
|
+
ctx.exit(1)
|
|
176
|
+
|
|
177
|
+
if not nodes_to_export:
|
|
178
|
+
bus.info("export.info.noMatchingNodes")
|
|
179
|
+
ctx.exit(0)
|
|
180
|
+
|
|
181
|
+
if output_dir.exists() and any(output_dir.iterdir()):
|
|
182
|
+
prompt = bus.get("export.prompt.overwrite", path=output_dir)
|
|
183
|
+
if not prompt_for_confirmation(prompt, default=False):
|
|
184
|
+
bus.warning("common.prompt.cancel")
|
|
185
|
+
raise typer.Abort()
|
|
186
|
+
shutil.rmtree(output_dir)
|
|
187
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
|
|
189
|
+
bus.info("export.info.starting", count=len(nodes_to_export), path=output_dir)
|
|
190
|
+
|
|
191
|
+
# 预计算文件名和节点集合以供导航栏使用
|
|
192
|
+
filename_map = {node.commit_hash: _generate_filename(node) for node in nodes_to_export}
|
|
193
|
+
exported_hashes_set = {node.commit_hash for node in nodes_to_export}
|
|
194
|
+
|
|
195
|
+
with typer.progressbar(nodes_to_export, label="导出进度") as progress:
|
|
196
|
+
for node in progress:
|
|
197
|
+
filename = filename_map[node.commit_hash]
|
|
198
|
+
content = _generate_file_content(
|
|
199
|
+
node, engine, no_frontmatter, no_nav, exported_hashes_set, filename_map, hidden_types
|
|
200
|
+
)
|
|
201
|
+
(output_dir / filename).write_text(content, encoding="utf-8")
|
|
202
|
+
|
|
203
|
+
if zip_output:
|
|
204
|
+
bus.info("export.info.zipping")
|
|
205
|
+
zip_path = shutil.make_archive(str(output_dir), "zip", output_dir)
|
|
206
|
+
shutil.rmtree(output_dir)
|
|
207
|
+
bus.success("export.success.zip", path=zip_path)
|
|
208
|
+
else:
|
|
209
|
+
bus.success("export.success.dir")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Generator, List, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from quipu.application.factory import create_engine
|
|
9
|
+
from quipu.bus import bus
|
|
10
|
+
from quipu.engine.state_machine import Engine
|
|
11
|
+
from quipu.interfaces.models import QuipuNode
|
|
12
|
+
|
|
13
|
+
from ..logger_config import setup_logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def engine_context(work_dir: Path) -> Generator[Engine, None, None]:
|
|
20
|
+
setup_logging()
|
|
21
|
+
engine = None
|
|
22
|
+
try:
|
|
23
|
+
engine = create_engine(work_dir)
|
|
24
|
+
yield engine
|
|
25
|
+
finally:
|
|
26
|
+
if engine:
|
|
27
|
+
engine.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_current_node(engine: Engine, graph: Dict[str, QuipuNode]) -> Optional[QuipuNode]:
|
|
31
|
+
current_hash = engine.git_db.get_tree_hash()
|
|
32
|
+
# 修复:直接从 graph 中通过 output_tree hash 查找
|
|
33
|
+
for node in graph.values():
|
|
34
|
+
if node.output_tree == current_hash:
|
|
35
|
+
return node
|
|
36
|
+
|
|
37
|
+
bus.warning("navigation.warning.workspaceDirty")
|
|
38
|
+
bus.info("navigation.info.saveHint")
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _execute_visit(ctx: typer.Context, engine: Engine, target_hash: str, msg_id: str, **kwargs):
|
|
43
|
+
bus.info(msg_id, **kwargs)
|
|
44
|
+
try:
|
|
45
|
+
engine.visit(target_hash)
|
|
46
|
+
bus.success("navigation.success.visit", short_hash=target_hash[:7])
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"导航操作失败 (目标哈希: {target_hash[:12]})", exc_info=True)
|
|
49
|
+
bus.error("navigation.error.generic", error=str(e))
|
|
50
|
+
ctx.exit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def filter_nodes(
|
|
54
|
+
nodes: List[QuipuNode], limit: Optional[int], since: Optional[str], until: Optional[str]
|
|
55
|
+
) -> List[QuipuNode]:
|
|
56
|
+
filtered = nodes
|
|
57
|
+
if since:
|
|
58
|
+
try:
|
|
59
|
+
since_dt = datetime.fromisoformat(since.replace(" ", "T"))
|
|
60
|
+
filtered = [n for n in filtered if n.timestamp >= since_dt]
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise typer.BadParameter("无效的 'since' 时间戳格式。请使用 'YYYY-MM-DD HH:MM'。")
|
|
63
|
+
if until:
|
|
64
|
+
try:
|
|
65
|
+
until_dt = datetime.fromisoformat(until.replace(" ", "T"))
|
|
66
|
+
filtered = [n for n in filtered if n.timestamp <= until_dt]
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise typer.BadParameter("无效的 'until' 时间戳格式。请使用 'YYYY-MM-DD HH:MM'。")
|
|
69
|
+
if limit is not None and limit > 0:
|
|
70
|
+
filtered = filtered[:limit]
|
|
71
|
+
return filtered
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def filter_reachable_nodes(engine: Engine, nodes: List[QuipuNode]) -> List[QuipuNode]:
|
|
75
|
+
current_node = _find_current_node(engine, engine.history_graph)
|
|
76
|
+
if not current_node:
|
|
77
|
+
# 如果工作区是脏的,无法确定起点,返回所有节点
|
|
78
|
+
return nodes
|
|
79
|
+
|
|
80
|
+
current_hash = current_node.output_tree
|
|
81
|
+
ancestors = engine.reader.get_ancestor_output_trees(current_hash)
|
|
82
|
+
descendants = engine.reader.get_descendant_output_trees(current_hash)
|
|
83
|
+
reachable_set = ancestors.union(descendants)
|
|
84
|
+
reachable_set.add(current_hash)
|
|
85
|
+
|
|
86
|
+
return [node for node in nodes if node.output_tree in reachable_set]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"_execute_visit": |-
|
|
2
|
+
辅助函数:执行 engine.visit 并处理结果
|
|
3
|
+
"_find_current_node": |-
|
|
4
|
+
在图中查找与当前工作区状态匹配的节点
|
|
5
|
+
"engine_context": |-
|
|
6
|
+
Context manager to set up logging, create, and automatically close a Quipu engine.
|
|
7
|
+
"filter_nodes": |-
|
|
8
|
+
根据时间戳和数量过滤节点列表。
|
|
9
|
+
"filter_reachable_nodes": |-
|
|
10
|
+
仅保留与当前工作区状态直接相关的节点(祖先和后代)。
|