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.
Files changed (55) hide show
  1. pyquipu_cli-0.1.0/.gitignore +75 -0
  2. pyquipu_cli-0.1.0/PKG-INFO +17 -0
  3. pyquipu_cli-0.1.0/pyproject.toml +41 -0
  4. pyquipu_cli-0.1.0/src/quipu/__init__.py +3 -0
  5. pyquipu_cli-0.1.0/src/quipu/cli/commands/__init__.py +0 -0
  6. pyquipu_cli-0.1.0/src/quipu/cli/commands/axon.py +103 -0
  7. pyquipu_cli-0.1.0/src/quipu/cli/commands/cache.py +113 -0
  8. pyquipu_cli-0.1.0/src/quipu/cli/commands/cache.stitcher.yaml +7 -0
  9. pyquipu_cli-0.1.0/src/quipu/cli/commands/export.py +209 -0
  10. pyquipu_cli-0.1.0/src/quipu/cli/commands/export.stitcher.yaml +10 -0
  11. pyquipu_cli-0.1.0/src/quipu/cli/commands/helpers.py +86 -0
  12. pyquipu_cli-0.1.0/src/quipu/cli/commands/helpers.stitcher.yaml +10 -0
  13. pyquipu_cli-0.1.0/src/quipu/cli/commands/navigation.py +229 -0
  14. pyquipu_cli-0.1.0/src/quipu/cli/commands/query.py +135 -0
  15. pyquipu_cli-0.1.0/src/quipu/cli/commands/query.stitcher.yaml +3 -0
  16. pyquipu_cli-0.1.0/src/quipu/cli/commands/remote.py +126 -0
  17. pyquipu_cli-0.1.0/src/quipu/cli/commands/run.py +106 -0
  18. pyquipu_cli-0.1.0/src/quipu/cli/commands/show.py +110 -0
  19. pyquipu_cli-0.1.0/src/quipu/cli/commands/show.stitcher.yaml +2 -0
  20. pyquipu_cli-0.1.0/src/quipu/cli/commands/ui.py +67 -0
  21. pyquipu_cli-0.1.0/src/quipu/cli/commands/workspace.py +90 -0
  22. pyquipu_cli-0.1.0/src/quipu/cli/config.py +38 -0
  23. pyquipu_cli-0.1.0/src/quipu/cli/config.stitcher.yaml +3 -0
  24. pyquipu_cli-0.1.0/src/quipu/cli/logger_config.py +43 -0
  25. pyquipu_cli-0.1.0/src/quipu/cli/logger_config.stitcher.yaml +5 -0
  26. pyquipu_cli-0.1.0/src/quipu/cli/main.py +44 -0
  27. pyquipu_cli-0.1.0/src/quipu/cli/rendering.py +28 -0
  28. pyquipu_cli-0.1.0/src/quipu/cli/rendering.stitcher.yaml +4 -0
  29. pyquipu_cli-0.1.0/src/quipu/cli/tui.css +42 -0
  30. pyquipu_cli-0.1.0/src/quipu/cli/tui.py +374 -0
  31. pyquipu_cli-0.1.0/src/quipu/cli/tui.stitcher.yaml +14 -0
  32. pyquipu_cli-0.1.0/src/quipu/cli/ui_utils.py +53 -0
  33. pyquipu_cli-0.1.0/src/quipu/cli/ui_utils.stitcher.yaml +17 -0
  34. pyquipu_cli-0.1.0/src/quipu/cli/view_model.py +123 -0
  35. pyquipu_cli-0.1.0/src/quipu/cli/view_model.stitcher.yaml +27 -0
  36. pyquipu_cli-0.1.0/stitcher.lock +1437 -0
  37. pyquipu_cli-0.1.0/tests/integration/conftest.py +3 -0
  38. pyquipu_cli-0.1.0/tests/integration/test_cache_commands.py +113 -0
  39. pyquipu_cli-0.1.0/tests/integration/test_cache_commands.stitcher.yaml +12 -0
  40. pyquipu_cli-0.1.0/tests/integration/test_cli_interaction.py +31 -0
  41. pyquipu_cli-0.1.0/tests/integration/test_cli_interaction.stitcher.yaml +2 -0
  42. pyquipu_cli-0.1.0/tests/integration/test_export_command.py +191 -0
  43. pyquipu_cli-0.1.0/tests/integration/test_export_command.stitcher.yaml +16 -0
  44. pyquipu_cli-0.1.0/tests/integration/test_navigation_commands.py +72 -0
  45. pyquipu_cli-0.1.0/tests/integration/test_query_commands.py +144 -0
  46. pyquipu_cli-0.1.0/tests/integration/test_query_commands.stitcher.yaml +4 -0
  47. pyquipu_cli-0.1.0/tests/integration/test_unfriendly_paths.py +153 -0
  48. pyquipu_cli-0.1.0/tests/integration/test_unfriendly_paths.stitcher.yaml +14 -0
  49. pyquipu_cli-0.1.0/tests/integration/test_workspace_commands.py +60 -0
  50. pyquipu_cli-0.1.0/tests/unit/test_tui_logic.py +66 -0
  51. pyquipu_cli-0.1.0/tests/unit/test_tui_logic.stitcher.yaml +8 -0
  52. pyquipu_cli-0.1.0/tests/unit/test_tui_reachability.py +69 -0
  53. pyquipu_cli-0.1.0/tests/unit/test_tui_reachability.stitcher.yaml +6 -0
  54. pyquipu_cli-0.1.0/tests/unit/test_view_model.py +194 -0
  55. 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"]
@@ -0,0 +1,3 @@
1
+ # This allows this package to coexist with other distribution packages
2
+ # that contribute to the 'quipu' namespace.
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
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,7 @@
1
+ "cache_prune_refs": |-
2
+ 清理 refs/quipu/local/heads/ 下的冗余引用。
3
+ 只保留分支末端 (Leaves),删除中间节点的引用。
4
+ "cache_rebuild": |-
5
+ 强制全量重建 SQLite 缓存。
6
+ "cache_sync": |-
7
+ 将 Git 历史增量同步到 SQLite 缓存。
@@ -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,10 @@
1
+ "_format_frontmatter": |-
2
+ 生成 YAML Frontmatter 字符串。
3
+ "_generate_file_content": |-
4
+ 构建单个 Markdown 文件的完整内容。
5
+ "_generate_filename": |-
6
+ 根据规范生成文件名。
7
+ "_generate_navbar": |-
8
+ 生成导航栏 Markdown 字符串。
9
+ "_sanitize_summary": |-
10
+ 净化摘要以用作安全的文件名部分。
@@ -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
+ 仅保留与当前工作区状态直接相关的节点(祖先和后代)。