jfox-cli 0.7.0__tar.gz → 0.7.2__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 (162) hide show
  1. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/CHANGELOG.md +18 -0
  2. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/CLAUDE.md +12 -7
  3. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/PKG-INFO +1 -1
  4. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/__init__.py +1 -1
  5. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/cli.py +22 -2
  6. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/model_downloader.py +75 -18
  7. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/note.py +35 -9
  8. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/performance.py +3 -3
  9. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/pyproject.toml +1 -1
  10. jfox_cli-0.7.2/tests/unit/test_atomic_write.py +165 -0
  11. jfox_cli-0.7.2/tests/unit/test_content_file.py +73 -0
  12. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_model_downloader.py +24 -0
  13. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/uv.lock +1 -1
  14. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.claude/settings.local.json +0 -0
  15. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.claude/skills/ci/SKILL.md +0 -0
  16. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.claude/skills/release/SKILL.md +0 -0
  17. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.claude/skills/release/release_helper.py +0 -0
  18. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.githooks/pre-push +0 -0
  19. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.github/workflows/integration-test.yml +0 -0
  20. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.github/workflows/publish.yml +0 -0
  21. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.gitignore +0 -0
  22. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/.python-version +0 -0
  23. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/AGENTS.md +0 -0
  24. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/DEVELOPMENT_PLAN.md +0 -0
  25. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/README.md +0 -0
  26. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/SESSION.md +0 -0
  27. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/SESSION_SUMMARY.md +0 -0
  28. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/installation.md +0 -0
  29. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-11-bulk-import-bm25-fix.md +0 -0
  30. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-11-edit-command.md +0 -0
  31. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-11-unify-format-option.md +0 -0
  32. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-ci-coverage-optimization.md +0 -0
  33. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-edit-content-file.md +0 -0
  34. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-fix-index-rebuild-clear.md +0 -0
  35. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-fix-index-verify-id-mismatch.md +0 -0
  36. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-fix-jfox-health-skill-kb-param.md +0 -0
  37. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-index-kb-param.md +0 -0
  38. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-lazy-import-perf.md +0 -0
  39. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-12-skill-redesign.md +0 -0
  40. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-13-add-content-file.md +0 -0
  41. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-13-bulk-import-vectorstore-init.md +0 -0
  42. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-13-ingest-log-command.md +0 -0
  43. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-13-ingest-skill-sync.md +0 -0
  44. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-13-suppress-third-party-logging.md +0 -0
  45. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-13-sync-skills-with-cli.md +0 -0
  46. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-14-sync-docs-daemon-show.md +0 -0
  47. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-15-gpu-embedding.md +0 -0
  48. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-15-make-daemon-deps-required.md +0 -0
  49. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-15-session-summary-confirmation.md +0 -0
  50. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-16-fix-windows-daemon-console-window.md +0 -0
  51. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-18-daemon-timeout-and-index-rebuild.md +0 -0
  52. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-22-release-skill.md +0 -0
  53. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-26-fix-daemon-deprecation-warnings.md +0 -0
  54. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-27-intranet-model-download.md +0 -0
  55. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-28-tag-filtering.md +0 -0
  56. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-04-29-daemon-stop-fix.md +0 -0
  57. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-05-03-jfox-check.md +0 -0
  58. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-05-03-list-notes-skip-summary.md +0 -0
  59. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-05-03-log-level-fix.md +0 -0
  60. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/plans/2026-05-04-list-notes-index.md +0 -0
  61. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
  62. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-12-skill-redesign-design.md +0 -0
  63. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-13-pr-auto-code-review-design.md +0 -0
  64. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-14-show-command-design.md +0 -0
  65. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-15-gpu-embedding-design.md +0 -0
  66. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-15-session-summary-confirmation-design.md +0 -0
  67. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-21-release-skill-design.md +0 -0
  68. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-26-fix-daemon-deprecation-warnings-design.md +0 -0
  69. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-27-intranet-model-download-design.md +0 -0
  70. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-28-tag-filtering-design.md +0 -0
  71. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-29-daemon-stop-fix-design.md +0 -0
  72. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-04-30-jfox-kb-env-var-design.md +0 -0
  73. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-05-03-jfox-check-design.md +0 -0
  74. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-05-03-list-notes-skip-summary-design.md +0 -0
  75. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-05-03-log-level-fix-design.md +0 -0
  76. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/superpowers/specs/2026-05-04-list-notes-index-design.md +0 -0
  77. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/docs/troubleshooting.md +0 -0
  78. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jessica-jones-static-cable.md +0 -0
  79. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/__main__.py +0 -0
  80. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/bm25_index.py +0 -0
  81. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/config.py +0 -0
  82. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/daemon/__init__.py +0 -0
  83. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/daemon/__main__.py +0 -0
  84. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/daemon/client.py +0 -0
  85. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/daemon/process.py +0 -0
  86. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/daemon/server.py +0 -0
  87. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/embedding_backend.py +0 -0
  88. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/formatters.py +0 -0
  89. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/git_extractor.py +0 -0
  90. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/global_config.py +0 -0
  91. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/graph.py +0 -0
  92. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/indexer.py +0 -0
  93. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/kb_manager.py +0 -0
  94. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/models.py +0 -0
  95. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/note_index.py +0 -0
  96. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/search_engine.py +0 -0
  97. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/template.py +0 -0
  98. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/template_cli.py +0 -0
  99. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/jfox/vector_store.py +0 -0
  100. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/pytest.ini +0 -0
  101. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/run_full_test.ps1 +0 -0
  102. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/scripts/download-model-intranet.sh +0 -0
  103. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/README.md +0 -0
  104. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/claude-code/jfox-common/SKILL.md +0 -0
  105. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/claude-code/jfox-ingest/SKILL.md +0 -0
  106. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
  107. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
  108. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/claude-code/jfox-session-summary/SKILL.md +0 -0
  109. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/kimi-cli/jfox-common/SKILL.md +0 -0
  110. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/kimi-cli/jfox-ingest/SKILL.md +0 -0
  111. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/kimi-cli/jfox-organize/SKILL.md +0 -0
  112. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/kimi-cli/jfox-search/SKILL.md +0 -0
  113. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/skills-recommend/kimi-cli/jfox-session-summary/SKILL.md +0 -0
  114. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/COVERAGE_PLAN.md +0 -0
  115. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/MIGRATION.md +0 -0
  116. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/TESTS.md +0 -0
  117. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/conftest.py +0 -0
  118. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/integration/__init__.py +0 -0
  119. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/integration/test_backlinks.py +0 -0
  120. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/integration/test_model_download.py +0 -0
  121. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/integration/test_tag_filter_cli.py +0 -0
  122. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/performance/__init__.py +0 -0
  123. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/performance/test_performance.py +0 -0
  124. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_advanced_features.py +0 -0
  125. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_cli_format.py +0 -0
  126. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_config_set_unit.py +0 -0
  127. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_config_unit.py +0 -0
  128. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_core_workflow.py +0 -0
  129. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_embedding_device.py +0 -0
  130. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_hybrid_search.py +0 -0
  131. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_integration.py +0 -0
  132. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_kb_current.py +0 -0
  133. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/test_suggest_links.py +0 -0
  134. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/__init__.py +0 -0
  135. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_bm25_batch.py +0 -0
  136. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_check.py +0 -0
  137. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_daemon_process.py +0 -0
  138. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_edit.py +0 -0
  139. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_format_unify.py +0 -0
  140. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_formatters.py +0 -0
  141. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_git_extractor.py +0 -0
  142. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_global_config.py +0 -0
  143. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_index_kb_param.py +0 -0
  144. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_indexer_clear_before_rebuild.py +0 -0
  145. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_indexer_verify.py +0 -0
  146. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_kb_manager.py +0 -0
  147. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_lazy_import.py +0 -0
  148. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_list_notes_skip.py +0 -0
  149. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_logging_config.py +0 -0
  150. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_note_index.py +0 -0
  151. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_release_helper.py +0 -0
  152. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_show.py +0 -0
  153. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_tag_filter.py +0 -0
  154. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_template.py +0 -0
  155. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_template_cli.py +0 -0
  156. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_use_kb_env_var.py +0 -0
  157. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/unit/test_vector_store_clear.py +0 -0
  158. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/utils/__init__.py +0 -0
  159. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/utils/assertions.py +0 -0
  160. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/utils/jfox_cli.py +0 -0
  161. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/utils/note_generator.py +0 -0
  162. {jfox_cli-0.7.0 → jfox_cli-0.7.2}/tests/utils/temp_kb.py +0 -0
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to jfox-cli will be documented in this file.
4
4
 
5
+ ## [0.7.2] - 2026-05-07
6
+
7
+ ### Fixes
8
+ - **note**: atomic write to prevent 0-byte note files (#201)
9
+ - strip frontmatter from --content-file input to prevent duplication (#200)
10
+
11
+ ### Changes
12
+ - update CLAUDE.md to reflect current codebase state (#203)
13
+
14
+ [0.7.2]: https://github.com/zhuxixi/jfox/compare/v0.7.1...v0.7.2
15
+
16
+ ## [0.7.1] - 2026-05-05
17
+
18
+ ### Fixes
19
+ - dynamically detect model weight file format
20
+
21
+ [0.7.1]: https://github.com/zhuxixi/jfox/compare/v0.7.0...v0.7.1
22
+
5
23
  ## [0.7.0] - 2026-05-05
6
24
 
7
25
  ### Features
@@ -56,18 +56,21 @@ Notes are Markdown files with YAML frontmatter stored under `~/.zettelkasten/<kb
56
56
 
57
57
  | Module | Role |
58
58
  |--------|------|
59
- | `cli.py` | All CLI commands (~2500 lines). Commands follow pattern: `@app.command()` → `_xxx_impl()` helper for reuse |
59
+ | `cli.py` | All CLI commands (~2900 lines). Commands follow pattern: `@app.command()` → `_xxx_impl()` helper for reuse |
60
60
  | `config.py` | `ZKConfig` + `use_kb()` context manager for multi-KB switching |
61
61
  | `global_config.py` | `GlobalConfigManager` managing `~/.zk_config.json` |
62
62
  | `kb_manager.py` | Knowledge base lifecycle (create, rename, remove) |
63
63
  | `formatters.py` | Output formats: JSON, CSV, YAML, Table, Paths |
64
+ | `git_extractor.py` | Git 仓库数据提取器(ingest 功能) |
65
+ | `model_downloader.py` | Embedding 模型下载与缓存管理 |
66
+ | `note_index.py` | 笔记索引管理(文件名↔ID 映射) |
64
67
  | `indexer.py` | File monitoring (watchdog) + incremental indexing |
65
68
  | `note.py` | Markdown file CRUD with YAML frontmatter |
66
69
  | `models.py` | `Note` data model with frontmatter serialization |
67
70
  | `search_engine.py` | `HybridSearchEngine` with `SearchMode` enum, RRF fusion |
68
71
  | `bm25_index.py` | BM25 keyword search index |
69
72
  | `embedding_backend.py` | Sentence-transformers embedding backend(支持 daemon 代理) |
70
- | `daemon/` | Embedding 模型 HTTP 守护进程,`jfox daemon start/stop/status` |
73
+ | `daemon/` | Embedding 模型 HTTP 守护进程 (`server.py`/`client.py`/`process.py`),`jfox daemon start/stop/status` |
71
74
  | `vector_store.py` | ChromaDB vector store for semantic search |
72
75
  | `graph.py` | NetworkX knowledge graph from links/backlinks |
73
76
  | `template.py` / `template_cli.py` | Jinja2 template system for structured note creation |
@@ -107,11 +110,11 @@ Notes are Markdown files with YAML frontmatter stored under `~/.zettelkasten/<kb
107
110
  - **Model caching**: Session-level model cache in conftest.py to avoid 30-60s reload per test
108
111
  - **Test markers**: `slow`, `performance`, `integration`, `embedding`, `workflow`, `bulk`
109
112
  - **Run single-process** to avoid ChromaDB/model loading conflicts
110
- - **Test directory reorganization in progress**:
111
- - `tests/unit/` — Pure logic unit tests (formatters, config, kb_manager, template)
112
- - `tests/integration/` — Cross-module integration tests (backlinks)
113
+ - **Test directory reorganization mostly complete**:
114
+ - `tests/unit/` — Pure logic unit tests (25 files)
115
+ - `tests/integration/` — Cross-module integration tests (backlinks, tag filter, model download)
113
116
  - `tests/performance/` — Performance benchmarks
114
- - Root-level `test_*_unit.py` files also exist (partial migration)
117
+ - Root-level `test_config_unit.py` and `test_config_set_unit.py` remain (no longer duplicated, test different things)
115
118
  - **pytest.ini**: `timeout=120`, `--strict-markers`, `-ra` (show all test summary)
116
119
 
117
120
  ## CI (GitHub Actions)
@@ -122,6 +125,8 @@ Four jobs in `.github/workflows/integration-test.yml`:
122
125
  - **Full** (manual): All tests, all OS, all Python versions
123
126
  - **Coverage** (after fast): Runs coverage on fast tests, uploads HTML/XML artifacts
124
127
 
128
+ **Release** workflow in `.github/workflows/publish.yml`: publishes to PyPI on GitHub release publication.
129
+
125
130
  ## Windows Notes
126
131
 
127
132
  - `robocopy` flags get misinterpreted by bash — use `cmd.exe /c "robocopy source dest /E"`
@@ -135,4 +140,4 @@ Four jobs in `.github/workflows/integration-test.yml`:
135
140
  ## Gotchas
136
141
 
137
142
  - `pytest.ini` `addopts` includes `-v`, so `pytest tests/` already runs verbose — adding `-v` manually is redundant
138
- - Test directory migration is partial: root-level `test_*_unit.py` files duplicate `tests/unit/` tests
143
+ - Test directory migration mostly complete; root-level `test_config_unit.py` and `test_config_set_unit.py` remain but test different things from `tests/unit/`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jfox-cli
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: JFox - Zettelkasten 知识管理 CLI 工具
5
5
  Project-URL: Homepage, https://github.com/zhuxixi/jfox
6
6
  Project-URL: Repository, https://github.com/zhuxixi/jfox
@@ -1,5 +1,5 @@
1
1
  """JFox - Zettelkasten 知识管理工具"""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.2"
4
4
  __author__ = "User"
5
5
  __email__ = "user@example.com"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import logging
5
+ import re
5
6
  import sys
6
7
  import warnings
7
8
  from datetime import datetime
@@ -1146,8 +1147,25 @@ def delete(
1146
1147
  raise typer.Exit(1)
1147
1148
 
1148
1149
 
1150
+ def _strip_frontmatter(raw: str) -> str:
1151
+ """如果内容包含 YAML frontmatter,则剥离 frontmatter 和标题行,只返回正文"""
1152
+ # 去除 UTF-8 BOM
1153
+ if raw.startswith(""):
1154
+ raw = raw[1:]
1155
+ match = re.match(r"^---\n.*?\n---\n+(.*)", raw, re.DOTALL)
1156
+ if not match:
1157
+ return raw
1158
+ body = match.group(1).strip()
1159
+ # 去除 jfox 生成的标题行(# 后跟空格和非空内容,空行结尾)
1160
+ body = re.sub(r"^#[ \t]+\S.*\n*", "", body).strip()
1161
+ return body
1162
+
1163
+
1149
1164
  def _read_content_file(content_file: str) -> str:
1150
- """从文件或 stdin 读取内容(--content-file 共用逻辑)"""
1165
+ """从文件或 stdin 读取内容(--content-file 共用逻辑)
1166
+
1167
+ 如果文件包含 YAML frontmatter(如 jfox 笔记文件),自动剥离只保留正文。
1168
+ """
1151
1169
  if content_file == "-":
1152
1170
  import sys
1153
1171
 
@@ -1159,12 +1177,14 @@ def _read_content_file(content_file: str) -> str:
1159
1177
  if not p.is_file():
1160
1178
  raise ValueError(f"路径不是文件: {content_file}")
1161
1179
  try:
1162
- return p.read_text(encoding="utf-8")
1180
+ raw = p.read_text(encoding="utf-8")
1163
1181
  except PermissionError:
1164
1182
  raise ValueError(f"无权限读取文件: {content_file}")
1165
1183
  except UnicodeDecodeError:
1166
1184
  raise ValueError(f"文件编码错误(需要 UTF-8): {content_file}")
1167
1185
 
1186
+ return _strip_frontmatter(raw)
1187
+
1168
1188
 
1169
1189
  def _edit_impl(
1170
1190
  note_id: str,
@@ -18,9 +18,14 @@ _HF_MIRROR = "https://hf-mirror.com"
18
18
  _TIMEOUT_HF_HUB = 60
19
19
  _TIMEOUT_CURL = 120
20
20
 
21
- # 需要下载的文件列表(按重要性排序)
22
- _REQUIRED_FILES = [
21
+ # 权重文件候选列表(按优先级排序:safetensors 优先,PyTorch 回退)
22
+ _WEIGHT_FILE_CANDIDATES = [
23
23
  "model.safetensors",
24
+ "pytorch_model.bin",
25
+ ]
26
+
27
+ # 非权重必需文件列表
28
+ _REQUIRED_FILES = [
24
29
  "config.json",
25
30
  "tokenizer.json",
26
31
  "tokenizer_config.json",
@@ -89,12 +94,13 @@ class ModelDownloader:
89
94
  snapshots_dir = self._model_cache / "snapshots"
90
95
  if not snapshots_dir.exists():
91
96
  return False
92
- # 检查至少有一个 snapshot 且包含 model.safetensors
97
+ # 检查至少有一个 snapshot 且包含权重文件
93
98
  try:
94
99
  for snapshot in snapshots_dir.iterdir():
95
100
  if snapshot.is_dir():
96
- if (snapshot / "model.safetensors").exists():
97
- return True
101
+ for candidate in _WEIGHT_FILE_CANDIDATES:
102
+ if (snapshot / candidate).exists():
103
+ return True
98
104
  except OSError:
99
105
  logger.warning(f"无法遍历缓存目录: {snapshots_dir}")
100
106
  return False
@@ -113,15 +119,29 @@ class ModelDownloader:
113
119
  env_backup = os.environ.get("HF_ENDPOINT")
114
120
  os.environ["HF_ENDPOINT"] = endpoint
115
121
 
116
- # 下载核心文件
117
- hf_hub_download(
118
- repo_id=self.model_name,
119
- filename="model.safetensors",
120
- cache_dir=str(self._hf_hub_cache),
121
- local_files_only=False,
122
- )
122
+ # 按优先级尝试下载权重文件
123
+ weight_downloaded = False
124
+ for candidate in _WEIGHT_FILE_CANDIDATES:
125
+ try:
126
+ hf_hub_download(
127
+ repo_id=self.model_name,
128
+ filename=candidate,
129
+ cache_dir=str(self._hf_hub_cache),
130
+ local_files_only=False,
131
+ )
132
+ weight_downloaded = True
133
+ logger.debug(f"权重文件 {candidate} 下载成功")
134
+ break
135
+ except Exception as e:
136
+ logger.debug(f"权重文件 {candidate} 尝试失败 ({e}),尝试下一个")
137
+ continue
138
+
139
+ if not weight_downloaded:
140
+ logger.warning("所有权重文件候选均下载失败")
141
+ return False
142
+
123
143
  # 尝试下载其他必要文件(不失败)
124
- for fname in _REQUIRED_FILES[1:]:
144
+ for fname in _REQUIRED_FILES:
125
145
  try:
126
146
  hf_hub_download(
127
147
  repo_id=self.model_name,
@@ -159,6 +179,46 @@ class ModelDownloader:
159
179
  tmp_path = Path(tmpdir)
160
180
  downloaded = []
161
181
 
182
+ # 按优先级尝试下载权重文件
183
+ weight_downloaded = False
184
+ for candidate in _WEIGHT_FILE_CANDIDATES:
185
+ url = f"{base_url}/{candidate}"
186
+ dest = tmp_path / candidate
187
+ logger.info(f"试用权重文件 {candidate}...")
188
+ try:
189
+ result = subprocess.run(
190
+ [
191
+ "curl",
192
+ "-L",
193
+ "-f",
194
+ "-s",
195
+ "-S",
196
+ "--connect-timeout",
197
+ "10",
198
+ "--max-time",
199
+ str(_TIMEOUT_CURL),
200
+ "-o",
201
+ str(dest),
202
+ url,
203
+ ],
204
+ capture_output=True,
205
+ text=True,
206
+ timeout=_TIMEOUT_CURL + 5,
207
+ )
208
+ if result.returncode == 0 and dest.exists() and dest.stat().st_size > 0:
209
+ downloaded.append(candidate)
210
+ weight_downloaded = True
211
+ break
212
+ else:
213
+ logger.debug(f"{candidate} 下载失败或为空,跳过")
214
+ except (OSError, subprocess.TimeoutExpired) as e:
215
+ logger.debug(f"{candidate} 下载异常: {e}")
216
+
217
+ if not weight_downloaded:
218
+ logger.error("所有权重文件候选下载失败,步骤 3 未完成")
219
+ return False
220
+
221
+ # 下载非权重必需文件
162
222
  for fname in _REQUIRED_FILES:
163
223
  url = f"{base_url}/{fname}"
164
224
  dest = tmp_path / fname
@@ -190,10 +250,6 @@ class ModelDownloader:
190
250
  except (OSError, subprocess.TimeoutExpired) as e:
191
251
  logger.debug(f"{fname} 下载异常: {e}")
192
252
 
193
- if "model.safetensors" not in downloaded:
194
- logger.error("model.safetensors 下载失败,步骤 3 未完成")
195
- return False
196
-
197
253
  # 按 HF 缓存目录结构放置
198
254
  import hashlib
199
255
 
@@ -215,10 +271,11 @@ class ModelDownloader:
215
271
 
216
272
  def get_manual_instructions(self) -> str:
217
273
  """获取手动下载说明"""
274
+ candidates = " / ".join(_WEIGHT_FILE_CANDIDATES)
218
275
  return (
219
276
  f"自动下载失败。请手动下载模型:\n"
220
277
  f" 1. 访问 {_HF_MIRROR}/{self.model_name}\n"
221
- f" 2. 下载 model.safetensors 和 config.json\n"
278
+ f" 2. 下载权重文件({candidates})和 config.json\n"
222
279
  f" 3. 放置到 {self._model_cache}/snapshots/\n"
223
280
  f" 或运行: bash scripts/download-model-intranet.sh"
224
281
  )
@@ -1,6 +1,8 @@
1
1
  """笔记 CRUD 操作"""
2
2
 
3
3
  import logging
4
+ import os
5
+ import tempfile
4
6
  from datetime import datetime
5
7
  from pathlib import Path
6
8
  from typing import Any, Dict, List, Optional
@@ -59,15 +61,41 @@ def create_note(
59
61
  return note
60
62
 
61
63
 
64
+ def _atomic_write(filepath: Path, content: str) -> None:
65
+ """原子写入:先写临时文件再原子替换,防止崩溃产生空文件"""
66
+ filepath.parent.mkdir(parents=True, exist_ok=True)
67
+ tmp_fd = -1
68
+ tmp_path = ""
69
+ try:
70
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=filepath.parent, suffix=".tmp")
71
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
72
+ tmp_fd = -1 # fd 已移交 fdopen 管理
73
+ f.write(content)
74
+ # 保留目标文件权限(如已存在)
75
+ if filepath.exists():
76
+ try:
77
+ os.chmod(tmp_path, filepath.stat().st_mode)
78
+ except OSError:
79
+ pass
80
+ os.replace(tmp_path, filepath)
81
+ except BaseException:
82
+ if tmp_fd >= 0:
83
+ try:
84
+ os.close(tmp_fd)
85
+ except OSError:
86
+ pass
87
+ if tmp_path:
88
+ try:
89
+ os.unlink(tmp_path)
90
+ except OSError:
91
+ pass
92
+ raise
93
+
94
+
62
95
  def save_note(note: Note, add_to_index: bool = True) -> bool:
63
96
  """保存笔记到文件"""
64
97
  try:
65
- # 确保目录存在
66
- note.filepath.parent.mkdir(parents=True, exist_ok=True)
67
-
68
- # 写入文件
69
- with open(note.filepath, "w", encoding="utf-8") as f:
70
- f.write(note.to_markdown())
98
+ _atomic_write(note.filepath, note.to_markdown())
71
99
 
72
100
  logger.info(f"Saved note to {note.filepath}")
73
101
 
@@ -265,9 +293,7 @@ def update_note(note_obj: Note, add_to_index: bool = True) -> bool:
265
293
  note_obj.updated = datetime.now()
266
294
 
267
295
  # 写入新文件(filepath 属性根据当前字段生成)
268
- note_obj.filepath.parent.mkdir(parents=True, exist_ok=True)
269
- with open(note_obj.filepath, "w", encoding="utf-8") as f:
270
- f.write(note_obj.to_markdown())
296
+ _atomic_write(note_obj.filepath, note_obj.to_markdown())
271
297
 
272
298
  # 如果文件路径变了(标题修改导致重命名),删除旧文件
273
299
  if old_filepath != note_obj.filepath and old_filepath.exists():
@@ -9,6 +9,8 @@ import time
9
9
  from functools import wraps
10
10
  from typing import Any, Callable, List, Optional
11
11
 
12
+ from .note import _atomic_write
13
+
12
14
  logger = logging.getLogger(__name__)
13
15
 
14
16
 
@@ -237,9 +239,7 @@ def bulk_import_notes(
237
239
  # 批量保存(不索引)
238
240
  for note in notes:
239
241
  try:
240
- note.filepath.parent.mkdir(parents=True, exist_ok=True)
241
- with open(note.filepath, "w", encoding="utf-8") as f:
242
- f.write(note.to_markdown())
242
+ _atomic_write(note.filepath, note.to_markdown())
243
243
  imported += 1
244
244
  except Exception as e:
245
245
  logger.warning(f"Failed to save note: {e}")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jfox-cli"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "JFox - Zettelkasten 知识管理 CLI 工具"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,165 @@
1
+ """
2
+ 测试类型: 单元测试
3
+ 目标模块: jfox.note (_atomic_write 函数)
4
+ 预估耗时: < 1秒
5
+ 依赖要求: 无外部依赖
6
+ """
7
+
8
+ import os
9
+ import tempfile
10
+ from unittest.mock import patch
11
+
12
+ import pytest
13
+
14
+ pytestmark = [pytest.mark.unit, pytest.mark.fast]
15
+
16
+ from jfox.config import ZKConfig
17
+ from jfox.models import NoteType
18
+ from jfox.note import (
19
+ _atomic_write,
20
+ create_note,
21
+ find_note_file,
22
+ save_note,
23
+ update_note,
24
+ )
25
+
26
+
27
+ class TestAtomicWrite:
28
+ """测试 _atomic_write 原子写入"""
29
+
30
+ def test_normal_write(self, tmp_path):
31
+ """正常写入:文件内容正确"""
32
+ filepath = tmp_path / "test.md"
33
+ _atomic_write(filepath, "hello world")
34
+ assert filepath.read_text(encoding="utf-8") == "hello world"
35
+
36
+ def test_creates_parent_dir(self, tmp_path):
37
+ """目标目录不存在时自动创建"""
38
+ filepath = tmp_path / "sub" / "dir" / "test.md"
39
+ _atomic_write(filepath, "content")
40
+ assert filepath.read_text(encoding="utf-8") == "content"
41
+
42
+ def test_overwrites_existing(self, tmp_path):
43
+ """覆盖已有文件时内容正确"""
44
+ filepath = tmp_path / "test.md"
45
+ filepath.write_text("old content", encoding="utf-8")
46
+ _atomic_write(filepath, "new content")
47
+ assert filepath.read_text(encoding="utf-8") == "new content"
48
+
49
+ def test_no_tmp_file_on_write_failure(self, tmp_path):
50
+ """写入失败时不留临时文件,原文件不受影响"""
51
+ filepath = tmp_path / "test.md"
52
+ filepath.write_text("original", encoding="utf-8")
53
+
54
+ # 模拟写入过程中异常
55
+ with patch("os.replace", side_effect=OSError("disk full")):
56
+ with pytest.raises(OSError, match="disk full"):
57
+ _atomic_write(filepath, "new content")
58
+
59
+ # 原文件不受影响
60
+ assert filepath.read_text(encoding="utf-8") == "original"
61
+ # 无 .tmp 残留
62
+ tmp_files = list(tmp_path.glob("*.tmp"))
63
+ assert len(tmp_files) == 0
64
+
65
+ def test_no_empty_file_on_crash(self, tmp_path):
66
+ """崩溃后不会产生 0 字节目标文件"""
67
+ filepath = tmp_path / "test.md"
68
+ filepath.write_text("original", encoding="utf-8")
69
+
70
+ original_mkstemp = tempfile.mkstemp
71
+
72
+ def failing_mkstemp(**kwargs):
73
+ fd, path = original_mkstemp(**kwargs)
74
+ os.write(fd, b"partial")
75
+ os.close(fd)
76
+ raise KeyboardInterrupt
77
+
78
+ with patch("tempfile.mkstemp", side_effect=failing_mkstemp):
79
+ with pytest.raises(KeyboardInterrupt):
80
+ _atomic_write(filepath, "new content")
81
+
82
+ # 目标文件不受影响
83
+ assert filepath.read_text(encoding="utf-8") == "original"
84
+
85
+
86
+ class TestUpdateNoteAtomic:
87
+ """测试 update_note 使用原子写入"""
88
+
89
+ def _make_config(self, tmp_path):
90
+ cfg = ZKConfig(base_dir=tmp_path)
91
+ cfg.ensure_dirs()
92
+ return cfg
93
+
94
+ @patch("jfox.note.config")
95
+ @patch("jfox.config.config")
96
+ def test_update_note_no_zero_byte_on_failure(
97
+ self, mock_global_config, mock_note_config, tmp_path
98
+ ):
99
+ """update_note 写入失败时不留 0 字节文件"""
100
+ cfg = self._make_config(tmp_path)
101
+ mock_global_config.notes_dir = cfg.notes_dir
102
+ mock_note_config.notes_dir = cfg.notes_dir
103
+
104
+ n = create_note("original", title="Test", note_type=NoteType.PERMANENT)
105
+ save_note(n, add_to_index=False)
106
+
107
+ # 修改内容
108
+ n.content = "modified"
109
+
110
+ # 模拟 _atomic_write 失败
111
+ with patch("jfox.note._atomic_write", side_effect=RuntimeError("boom")):
112
+ result = update_note(n, add_to_index=False)
113
+
114
+ assert result is False
115
+ # 磁盘上原文件不变
116
+ old_file = find_note_file(cfg, n.id)
117
+ assert old_file is not None
118
+ assert "original" in old_file.read_text(encoding="utf-8")
119
+
120
+
121
+ class TestSaveNoteAtomic:
122
+ """测试 save_note 使用原子写入"""
123
+
124
+ def _make_config(self, tmp_path):
125
+ cfg = ZKConfig(base_dir=tmp_path)
126
+ cfg.ensure_dirs()
127
+ return cfg
128
+
129
+ @patch("jfox.note.config")
130
+ def test_save_note_uses_atomic_write(self, mock_config, tmp_path):
131
+ """save_note 应通过 _atomic_write 写入"""
132
+ cfg = self._make_config(tmp_path)
133
+ mock_config.notes_dir = cfg.notes_dir
134
+
135
+ n = create_note("test content", title="Test", note_type=NoteType.FLEETING)
136
+
137
+ with patch("jfox.note._atomic_write", wraps=_atomic_write) as mock_aw:
138
+ save_note(n, add_to_index=False)
139
+ mock_aw.assert_called_once()
140
+
141
+ # 验证文件内容正确
142
+ assert n.filepath.exists()
143
+ content = n.filepath.read_text(encoding="utf-8")
144
+ assert "test content" in content
145
+
146
+ @patch("jfox.note.config")
147
+ def test_save_note_no_zero_byte_on_failure(self, mock_config, tmp_path):
148
+ """save_note 写入失败时不留 0 字节文件"""
149
+ cfg = self._make_config(tmp_path)
150
+ mock_config.notes_dir = cfg.notes_dir
151
+
152
+ n = create_note("content", title="Test", note_type=NoteType.FLEETING)
153
+ n.set_filepath(cfg.notes_dir / "fleeting" / "test.md")
154
+
155
+ # 先创建一个有内容的文件
156
+ n.filepath.parent.mkdir(parents=True, exist_ok=True)
157
+ n.filepath.write_text("original content", encoding="utf-8")
158
+
159
+ # 模拟 _atomic_write 失败
160
+ with patch("jfox.note._atomic_write", side_effect=RuntimeError("boom")):
161
+ result = save_note(n, add_to_index=False)
162
+
163
+ assert result is False
164
+ # 原文件不受影响(不会被截断为 0 字节)
165
+ assert n.filepath.read_text(encoding="utf-8") == "original content"
@@ -0,0 +1,73 @@
1
+ """测试 _read_content_file 对含 frontmatter 文件的处理"""
2
+
3
+ import io
4
+ import tempfile
5
+
6
+ import pytest
7
+
8
+ from jfox.cli import _read_content_file
9
+
10
+
11
+ class TestReadContentFile:
12
+ """_read_content_file 的单元测试"""
13
+
14
+ def test_plain_content_unchanged(self):
15
+ """纯文本内容应原样返回"""
16
+ with tempfile.NamedTemporaryFile(
17
+ mode="w", suffix=".md", delete=False, encoding="utf-8"
18
+ ) as f:
19
+ f.write("Hello world")
20
+ f.flush()
21
+ result = _read_content_file(f.name)
22
+ assert result == "Hello world"
23
+
24
+ def test_content_with_frontmatter_stripped(self):
25
+ """含 frontmatter 的文件应只返回正文"""
26
+ raw = "---\nid: '123'\ntitle: test\n---\n\n# test\n\nBody text\n"
27
+ with tempfile.NamedTemporaryFile(
28
+ mode="w", suffix=".md", delete=False, encoding="utf-8"
29
+ ) as f:
30
+ f.write(raw)
31
+ f.flush()
32
+ result = _read_content_file(f.name)
33
+ assert "---" not in result
34
+ assert "Body text" in result
35
+
36
+ def test_content_with_frontmatter_no_title(self):
37
+ """含 frontmatter 但无标题行的文件"""
38
+ raw = "---\nid: '123'\ntitle: test\n---\n\nJust body text\n"
39
+ with tempfile.NamedTemporaryFile(
40
+ mode="w", suffix=".md", delete=False, encoding="utf-8"
41
+ ) as f:
42
+ f.write(raw)
43
+ f.flush()
44
+ result = _read_content_file(f.name)
45
+ assert "---" not in result
46
+ assert "Just body text" in result
47
+
48
+ def test_stdin_passthrough(self):
49
+ """stdin 模式('-')不应做处理"""
50
+ import sys
51
+
52
+ old_stdin = sys.stdin
53
+ try:
54
+ sys.stdin = io.StringIO("---\nid: x\n---\nbody")
55
+ result = _read_content_file("-")
56
+ finally:
57
+ sys.stdin = old_stdin
58
+ assert "---" in result
59
+
60
+ def test_file_not_found(self):
61
+ """不存在的文件应抛异常"""
62
+ with pytest.raises(ValueError, match="文件不存在"):
63
+ _read_content_file("/nonexistent/file.md")
64
+
65
+ def test_bom_file_stripped(self):
66
+ """含 UTF-8 BOM 的 frontmatter 文件应正确剥离"""
67
+ raw = "---\nid: '123'\ntitle: test\n---\n\nBody with BOM\n"
68
+ with tempfile.NamedTemporaryFile(mode="wb", suffix=".md", delete=False) as f:
69
+ f.write(raw.encode("utf-8-sig"))
70
+ f.flush()
71
+ result = _read_content_file(f.name)
72
+ assert "---" not in result
73
+ assert "Body with BOM" in result
@@ -101,6 +101,30 @@ class TestModelDownloader:
101
101
  result = downloader._try_curl_download()
102
102
  assert result is False
103
103
 
104
+ def test_check_cached_with_pytorch_bin(self, downloader):
105
+ """pytorch_model.bin 格式的缓存也能正常识别"""
106
+ snapshot = downloader._model_cache / "snapshots" / "abc123"
107
+ snapshot.mkdir(parents=True)
108
+ (snapshot / "pytorch_model.bin").write_text("fake")
109
+ assert downloader._check_cached() is True
110
+
111
+ def test_try_hf_hub_download_fallback_to_pytorch(self, downloader):
112
+ """model.safetensors 不存在时回退到 pytorch_model.bin"""
113
+ call_count = 0
114
+
115
+ def hf_hub_side_effect(*args, **kwargs):
116
+ nonlocal call_count
117
+ call_count += 1
118
+ if call_count == 1: # model.safetensors 失败
119
+ raise Exception("not found")
120
+ # pytorch_model.bin + 其他文件均成功
121
+ return str(downloader._model_cache / "snapshots" / "abc")
122
+
123
+ with patch("huggingface_hub.hf_hub_download") as mock_download:
124
+ mock_download.side_effect = hf_hub_side_effect
125
+ result = downloader._try_hf_hub_download()
126
+ assert result is True
127
+
104
128
  def test_cleanup_partial(self, downloader):
105
129
  """验证部分下载残留被清理(通过 TemporaryDirectory 自动实现)"""
106
130
  with patch("jfox.model_downloader.shutil.which", return_value="curl"):
@@ -867,7 +867,7 @@ wheels = [
867
867
 
868
868
  [[package]]
869
869
  name = "jfox-cli"
870
- version = "0.7.0"
870
+ version = "0.7.2"
871
871
  source = { editable = "." }
872
872
  dependencies = [
873
873
  { name = "chromadb" },