foxclaw 0.1.2 → 0.1.4

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 (368) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/.github/workflows/release.yml +5 -0
  6. package/python/CHANGELOG.md +100 -0
  7. package/python/CHANGELOG_CN.md +100 -0
  8. package/python/README.md +17 -2
  9. package/python/README_CN.md +38 -20
  10. package/python/bench/bm25_audit.py +1 -14
  11. package/python/bench/bm25_regression.py +0 -1
  12. package/python/bench/bm25_stress.py +0 -4
  13. package/python/bench/cd_audit.py +0 -23
  14. package/python/bench/chaos.py +6 -8
  15. package/python/bench/e2e_real_models.py +0 -1
  16. package/python/bench/llm_retry_test.py +2 -9
  17. package/python/bench/load_test.py +5 -8
  18. package/python/bench/vector_ivf_upgrade.py +1 -7
  19. package/python/docs/CONTENT_GUARD.md +189 -0
  20. package/python/docs/CONTENT_GUARD_CN.md +189 -0
  21. package/python/docs/RELEASE.md +78 -37
  22. package/python/docs/RUN_CN.md +2 -2
  23. package/python/evals/governance/cases.py +0 -37
  24. package/python/evals/governance/run_evals.py +5 -37
  25. package/python/examples/governance_middleware_demo.py +1 -6
  26. package/python/examples/langgraph_governance_demo.py +7 -11
  27. package/python/foxclaw/__init__.py +74 -152
  28. package/python/foxclaw/agents/base.py +82 -136
  29. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  30. package/python/foxclaw/agents/impl/finance.py +4 -21
  31. package/python/foxclaw/agents/impl/office.py +7 -24
  32. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  33. package/python/foxclaw/agents/mixins/_context.py +0 -23
  34. package/python/foxclaw/agents/mixins/augment.py +10 -16
  35. package/python/foxclaw/agents/mixins/llm.py +7 -6
  36. package/python/foxclaw/agents/mixins/memory.py +23 -3
  37. package/python/foxclaw/agents/mixins/preference.py +58 -8
  38. package/python/foxclaw/agents/mixins/prompt.py +59 -12
  39. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  40. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  41. package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
  42. package/python/foxclaw/api/__init__.py +18 -77
  43. package/python/foxclaw/api/_state.py +43 -0
  44. package/python/foxclaw/api/bootstrap.py +114 -0
  45. package/python/foxclaw/api/middleware.py +5 -28
  46. package/python/foxclaw/api/routes/ahs.py +60 -73
  47. package/python/foxclaw/api/routes/attachments.py +5 -6
  48. package/python/foxclaw/api/routes/browser.py +7 -11
  49. package/python/foxclaw/api/routes/domain.py +18 -26
  50. package/python/foxclaw/api/routes/knowledge.py +3 -21
  51. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  52. package/python/foxclaw/api/routes/memory.py +50 -12
  53. package/python/foxclaw/api/routes/orchestration.py +3 -5
  54. package/python/foxclaw/api/routes/recovery.py +4 -3
  55. package/python/foxclaw/api/routes/review.py +2 -11
  56. package/python/foxclaw/api/routes/search.py +3 -7
  57. package/python/foxclaw/api/routes/system.py +33 -49
  58. package/python/foxclaw/api/routes/task.py +31 -49
  59. package/python/foxclaw/api/routes/ws.py +2 -6
  60. package/python/foxclaw/api/spine.py +1 -4
  61. package/python/foxclaw/behavior/__init__.py +20 -0
  62. package/python/foxclaw/behavior/behavior.yaml +62 -0
  63. package/python/foxclaw/behavior/spec.py +287 -0
  64. package/python/foxclaw/browser/action/action_executor.py +1 -5
  65. package/python/foxclaw/browser/action/action_types.py +0 -1
  66. package/python/foxclaw/browser/action/url_policy.py +1 -5
  67. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  68. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  69. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  70. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  71. package/python/foxclaw/browser/service.py +7 -11
  72. package/python/foxclaw/capability/embedding_service.py +4 -6
  73. package/python/foxclaw/channels/__init__.py +9 -11
  74. package/python/foxclaw/channels/discord.py +4 -13
  75. package/python/foxclaw/channels/dispatcher.py +2 -27
  76. package/python/foxclaw/channels/feishu.py +10 -20
  77. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  78. package/python/foxclaw/channels/signing.py +9 -9
  79. package/python/foxclaw/channels/telegram.py +3 -4
  80. package/python/foxclaw/channels/weixin.py +7 -17
  81. package/python/foxclaw/cli.py +42 -13
  82. package/python/foxclaw/cognitive/reasoner.py +9 -10
  83. package/python/foxclaw/cognitive/state.py +18 -24
  84. package/python/foxclaw/core/audit_chain.py +1 -3
  85. package/python/foxclaw/core/audit_sink.py +3 -3
  86. package/python/foxclaw/core/compaction.py +8 -26
  87. package/python/foxclaw/core/config.py +2 -2
  88. package/python/foxclaw/core/confirm_nonce.py +6 -12
  89. package/python/foxclaw/core/content_guard.py +341 -0
  90. package/python/foxclaw/core/envfile.py +2 -2
  91. package/python/foxclaw/core/failure_policy.py +17 -26
  92. package/python/foxclaw/core/gateway.py +6 -10
  93. package/python/foxclaw/core/governance_audit.py +3 -7
  94. package/python/foxclaw/core/idempotency.py +15 -22
  95. package/python/foxclaw/core/injection_guard.py +10 -29
  96. package/python/foxclaw/core/memory_adapter.py +53 -60
  97. package/python/foxclaw/core/metrics.py +4 -5
  98. package/python/foxclaw/core/model_router.py +74 -150
  99. package/python/foxclaw/core/paths.py +1 -1
  100. package/python/foxclaw/core/recovery.py +21 -34
  101. package/python/foxclaw/core/scheduler.py +23 -26
  102. package/python/foxclaw/core/secrets.py +0 -3
  103. package/python/foxclaw/core/sensitive_write.py +283 -0
  104. package/python/foxclaw/core/sessions.py +4 -6
  105. package/python/foxclaw/core/settings.py +16 -32
  106. package/python/foxclaw/core/shared_write.py +11 -23
  107. package/python/foxclaw/core/steering_queue.py +12 -11
  108. package/python/foxclaw/core/system_bus.py +1 -1
  109. package/python/foxclaw/core/thought_chain.py +30 -32
  110. package/python/foxclaw/core/token_meter.py +9 -13
  111. package/python/foxclaw/core/tools.py +4 -12
  112. package/python/foxclaw/core/tracing.py +25 -40
  113. package/python/foxclaw/core/truth_pipeline.py +5 -9
  114. package/python/foxclaw/core/update_check.py +149 -0
  115. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  116. package/python/foxclaw/core/verify.py +6 -14
  117. package/python/foxclaw/core/web_search.py +4 -28
  118. package/python/foxclaw/finance/evidence.py +14 -18
  119. package/python/foxclaw/finance/fx.py +1 -3
  120. package/python/foxclaw/finance/graph.py +9 -18
  121. package/python/foxclaw/finance/models.py +20 -24
  122. package/python/foxclaw/finance/nlparse.py +12 -72
  123. package/python/foxclaw/finance/policy.py +8 -36
  124. package/python/foxclaw/finance/reasoner.py +3 -11
  125. package/python/foxclaw/finance/reasoning.py +13 -34
  126. package/python/foxclaw/finance/reconcile.py +5 -10
  127. package/python/foxclaw/finance/runtime.py +19 -46
  128. package/python/foxclaw/governance/middleware.py +13 -21
  129. package/python/foxclaw/harness/_base.py +2 -8
  130. package/python/foxclaw/harness/_policy.py +19 -38
  131. package/python/foxclaw/harness/drift.py +1 -13
  132. package/python/foxclaw/harness/event_bus.py +2 -5
  133. package/python/foxclaw/harness/harness.py +9 -97
  134. package/python/foxclaw/harness/loop_detection.py +11 -18
  135. package/python/foxclaw/itinerary/amap_client.py +2 -5
  136. package/python/foxclaw/itinerary/models.py +7 -7
  137. package/python/foxclaw/itinerary/reasoner.py +1 -6
  138. package/python/foxclaw/itinerary/reasoning.py +15 -21
  139. package/python/foxclaw/itinerary/runtime.py +8 -12
  140. package/python/foxclaw/kernel/capabilities.py +5 -6
  141. package/python/foxclaw/kernel/policy_gate.py +4 -10
  142. package/python/foxclaw/kernel/process_manager.py +17 -36
  143. package/python/foxclaw/kernel/scheduler.py +3 -9
  144. package/python/foxclaw/kernel/sentinel.py +13 -23
  145. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  146. package/python/foxclaw/kernel/spine.py +9 -9
  147. package/python/foxclaw/knowledge/__init__.py +4 -20
  148. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  149. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  150. package/python/foxclaw/learning/models.py +13 -13
  151. package/python/foxclaw/learning/reasoner.py +1 -4
  152. package/python/foxclaw/learning/runtime.py +19 -31
  153. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  154. package/python/foxclaw/mcp_server.py +2 -3
  155. package/python/foxclaw/orchestration/__init__.py +16 -33
  156. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  157. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  158. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  159. package/python/foxclaw/planning/llm_planner.py +3 -3
  160. package/python/foxclaw/planning/planner.py +6 -11
  161. package/python/foxclaw/search/agent_port.py +7 -11
  162. package/python/foxclaw/search/evidence.py +0 -4
  163. package/python/foxclaw/search/internal_search.py +3 -5
  164. package/python/foxclaw/search/models.py +11 -13
  165. package/python/foxclaw/search/planner.py +3 -3
  166. package/python/foxclaw/search/retriever.py +1 -1
  167. package/python/foxclaw/search/runtime.py +1 -8
  168. package/python/foxclaw/skills/__init__.py +46 -100
  169. package/python/foxclaw/skills/archive.py +111 -0
  170. package/python/foxclaw/skills/fetch.py +226 -0
  171. package/python/foxclaw/skills/hooks_spec.py +21 -25
  172. package/python/foxclaw/skills/install_cmd.py +216 -0
  173. package/python/foxclaw/skills/lifecycle.py +16 -42
  174. package/python/foxclaw/skills/markdown_skill.py +10 -9
  175. package/python/foxclaw/skills/mcp_client.py +5 -11
  176. package/python/foxclaw/skills/mcp_store.py +0 -3
  177. package/python/foxclaw/skills/permission.py +13 -48
  178. package/python/foxclaw/skills/pipeline.py +21 -53
  179. package/python/foxclaw/skills/policy.py +4 -10
  180. package/python/foxclaw/skills/review.py +3 -3
  181. package/python/frontend/deck/auth.js +0 -7
  182. package/python/frontend/deck/chat_model_picker.js +0 -54
  183. package/python/frontend/deck/domain.js +0 -20
  184. package/python/frontend/deck/main.js +0 -144
  185. package/python/frontend/deck/markdown.js +0 -4
  186. package/python/frontend/deck/settings.js +1 -18
  187. package/python/frontend/deck/state.js +0 -1
  188. package/python/frontend/deck/util.js +0 -2
  189. package/python/frontend/deck/visuals.js +0 -22
  190. package/python/frontend/planet/planet.js +18 -201
  191. package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
  192. package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
  193. package/python/frontend/v3/index.html +3 -3
  194. package/python/frontend/widget_runtime.js +0 -43
  195. package/python/frontend/widgets.js +0 -57
  196. package/python/frontend_src/index.html +1 -1
  197. package/python/frontend_src/package-lock.json +2 -2
  198. package/python/frontend_src/package.json +1 -1
  199. package/python/frontend_src/src/App.vue +3 -7
  200. package/python/frontend_src/src/api.js +0 -7
  201. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  202. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  203. package/python/frontend_src/src/components/Inspector.vue +0 -6
  204. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  205. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  206. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  207. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  208. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  209. package/python/frontend_src/src/components/Stage.vue +71 -25
  210. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  211. package/python/frontend_src/src/components/TopBar.vue +38 -10
  212. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  213. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  214. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  215. package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
  216. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  217. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  218. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  219. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  220. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  221. package/python/frontend_src/src/lib/fmt.js +0 -4
  222. package/python/frontend_src/src/lib/i18n.js +53 -0
  223. package/python/frontend_src/src/lib/locales/en.js +173 -0
  224. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  225. package/python/frontend_src/src/lib/md.js +0 -14
  226. package/python/frontend_src/src/main.js +2 -0
  227. package/python/frontend_src/src/stores/app.js +17 -32
  228. package/python/frontend_src/src/stores/chat.js +0 -34
  229. package/python/frontend_src/src/stores/model.js +0 -8
  230. package/python/frontend_src/src/stores/panels.js +16 -44
  231. package/python/frontend_src/src/stores/profile.js +31 -0
  232. package/python/frontend_src/src/stores/system.js +25 -0
  233. package/python/frontend_src/vite.config.js +0 -7
  234. package/python/pyproject.toml +17 -3
  235. package/python/requirements.txt +311 -0
  236. package/python/tests/conftest.py +0 -22
  237. package/python/tests/run_all.py +4 -66
  238. package/python/tests/test_agent_lifecycle.py +4 -38
  239. package/python/tests/test_agent_rules.py +81 -0
  240. package/python/tests/test_ahs_p2.py +4 -25
  241. package/python/tests/test_anthropic_tools.py +1 -15
  242. package/python/tests/test_api.py +19 -9
  243. package/python/tests/test_api_auth.py +1 -5
  244. package/python/tests/test_attachments.py +9 -25
  245. package/python/tests/test_audit_chain.py +2 -7
  246. package/python/tests/test_audit_sink.py +0 -6
  247. package/python/tests/test_behavior_spec.py +198 -0
  248. package/python/tests/test_browser.py +2 -6
  249. package/python/tests/test_browser_request_guard.py +0 -4
  250. package/python/tests/test_capabilities.py +0 -6
  251. package/python/tests/test_channel_signing.py +2 -9
  252. package/python/tests/test_channels.py +1 -11
  253. package/python/tests/test_config_layering.py +0 -4
  254. package/python/tests/test_confirm_nonce.py +1 -12
  255. package/python/tests/test_conflict_dismiss.py +143 -0
  256. package/python/tests/test_content_guard.py +177 -0
  257. package/python/tests/test_context_compaction_wiring.py +1 -7
  258. package/python/tests/test_dispatch.py +0 -6
  259. package/python/tests/test_domain_evals.py +11 -80
  260. package/python/tests/test_domain_import.py +0 -6
  261. package/python/tests/test_domain_reasoners.py +0 -14
  262. package/python/tests/test_e2e.py +0 -17
  263. package/python/tests/test_emb_cache.py +7 -17
  264. package/python/tests/test_embedding_singleton.py +1 -3
  265. package/python/tests/test_evals_smoke.py +0 -24
  266. package/python/tests/test_event_spine.py +10 -18
  267. package/python/tests/test_evidence_semantic.py +1 -14
  268. package/python/tests/test_evidence_sse.py +3 -6
  269. package/python/tests/test_file_ingest.py +0 -11
  270. package/python/tests/test_finance.py +0 -9
  271. package/python/tests/test_finance_ledger.py +5 -12
  272. package/python/tests/test_finance_persist.py +8 -12
  273. package/python/tests/test_gateway.py +3 -22
  274. package/python/tests/test_governance.py +5 -52
  275. package/python/tests/test_governance_audit.py +4 -39
  276. package/python/tests/test_governance_middleware.py +1 -7
  277. package/python/tests/test_hkc.py +6 -24
  278. package/python/tests/test_hkc_index_build_manager.py +0 -33
  279. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  280. package/python/tests/test_hkc_search_event_sync.py +3 -54
  281. package/python/tests/test_import_path_guard.py +0 -6
  282. package/python/tests/test_injection_guard.py +18 -36
  283. package/python/tests/test_integration.py +5 -29
  284. package/python/tests/test_itinerary.py +0 -3
  285. package/python/tests/test_kee_semantic.py +0 -5
  286. package/python/tests/test_kernel.py +1 -11
  287. package/python/tests/test_learning.py +3 -12
  288. package/python/tests/test_llm_admin.py +4 -19
  289. package/python/tests/test_llm_planner.py +1 -23
  290. package/python/tests/test_markdown_skills.py +3 -14
  291. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  292. package/python/tests/test_mcp_connectors.py +3 -15
  293. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  294. package/python/tests/test_mcp_no_memory.py +3 -3
  295. package/python/tests/test_mcp_policy_gate.py +0 -1
  296. package/python/tests/test_mcp_server.py +6 -9
  297. package/python/tests/test_memory_recall.py +1 -4
  298. package/python/tests/test_metrics.py +0 -8
  299. package/python/tests/test_multi_chain.py +1 -13
  300. package/python/tests/test_multimodal.py +5 -18
  301. package/python/tests/test_negotiation.py +3 -13
  302. package/python/tests/test_orchestration.py +1 -11
  303. package/python/tests/test_orchestration_decoupled.py +0 -6
  304. package/python/tests/test_p3_p5_hardening.py +3 -17
  305. package/python/tests/test_parallel_dispatch.py +4 -12
  306. package/python/tests/test_pipeline.py +17 -58
  307. package/python/tests/test_plan_visible.py +4 -4
  308. package/python/tests/test_planner.py +0 -7
  309. package/python/tests/test_policy_gate.py +2 -3
  310. package/python/tests/test_profile.py +67 -0
  311. package/python/tests/test_readme_claims.py +163 -0
  312. package/python/tests/test_reasoner.py +2 -17
  313. package/python/tests/test_release_check.py +2 -13
  314. package/python/tests/test_resume_checkpoint.py +7 -15
  315. package/python/tests/test_routing.py +2 -12
  316. package/python/tests/test_scheduler.py +7 -10
  317. package/python/tests/test_search.py +1 -11
  318. package/python/tests/test_secrets.py +4 -10
  319. package/python/tests/test_semantic.py +1 -10
  320. package/python/tests/test_sensitive_write.py +181 -0
  321. package/python/tests/test_sessions.py +3 -4
  322. package/python/tests/test_settings.py +2 -18
  323. package/python/tests/test_shadow_learning.py +3 -43
  324. package/python/tests/test_shared_write.py +0 -7
  325. package/python/tests/test_skill_taint_gate.py +1 -10
  326. package/python/tests/test_skills.py +2 -18
  327. package/python/tests/test_skills_fetch.py +282 -0
  328. package/python/tests/test_stats_and_events.py +2 -5
  329. package/python/tests/test_streaming.py +2 -7
  330. package/python/tests/test_supervisor_pipeline.py +4 -5
  331. package/python/tests/test_supervisor_triage.py +4 -10
  332. package/python/tests/test_taint.py +2 -7
  333. package/python/tests/test_task_stream.py +1 -12
  334. package/python/tests/test_thinking_sse.py +0 -1
  335. package/python/tests/test_tool_center.py +1 -17
  336. package/python/tests/test_tool_parallel.py +2 -2
  337. package/python/tests/test_tool_params.py +1 -5
  338. package/python/tests/test_tool_use.py +0 -13
  339. package/python/tests/test_toplevel_concurrency.py +3 -4
  340. package/python/tests/test_tracing.py +7 -41
  341. package/python/tests/test_truth_crystallizer.py +4 -6
  342. package/python/tests/test_truth_flow.py +2 -7
  343. package/python/tests/test_update_check.py +95 -0
  344. package/python/tests/test_verify_evidence.py +8 -21
  345. package/python/tests/test_version_sync.py +35 -6
  346. package/python/tests/test_web_trigger.py +0 -17
  347. package/python/tools/build_content_lexicon.py +218 -0
  348. package/python/tools/bundle_web.py +98 -0
  349. package/python/tools/check_faiss_type.py +0 -1
  350. package/python/tools/check_log.ps1 +0 -8
  351. package/python/tools/mypy_baseline.txt +12 -4
  352. package/python/tools/mypy_ratchet.py +52 -6
  353. package/python/tools/pack.py +0 -5
  354. package/python/tools/recheck_conflicts.py +156 -0
  355. package/python/tools/release_check.py +24 -15
  356. package/python/tools/restart_foxclaw.ps1 +5 -8
  357. package/python/tools/shared_write_audit.py +1 -15
  358. package/python/tools/size_check.py +1 -1
  359. package/python/tools/strip_comments.py +228 -0
  360. package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
  361. package/python/vendor/hkc/hkc_api/schemas.py +11 -0
  362. package/python/vendor/hkc/hkc_kee/kee.py +124 -3
  363. package/python/vendor/hkc/hkc_sdk/base.py +22 -0
  364. package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
  365. package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
  366. package/python/foxclaw/api/app.py +0 -123
  367. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  368. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -0,0 +1,111 @@
1
+ """技能包归档读取:zip / tar.gz 统一「按名读」,不落盘、不 extractall。
2
+
3
+ `markdown_skill.extract_skill_package` 只认 zip,而 ClawHub 发 zip、GitHub 发 tar.gz。
4
+ 两者共用同一套规则:拒路径穿越、限解压总量(防 zip bomb)、挑**最浅**的 SKILL.md
5
+ (GitHub 仓库里它常埋在 `skills/<name>/` 多级子目录下)。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import tarfile
12
+ import zipfile
13
+
14
+ MAX_TOTAL = 8 * 1024 * 1024
15
+
16
+ _CODE_EXT = (".py", ".sh", ".bash", ".ps1", ".js", ".mjs", ".cjs", ".ts",
17
+ ".rb", ".pl", ".bat", ".cmd", ".exe")
18
+
19
+
20
+ class PackageError(ValueError):
21
+ """包不可用:格式坏 / 缺 SKILL.md / 超体积。"""
22
+
23
+
24
+ def safe_name(name: str) -> bool:
25
+ """拒绝对路径、盘符与 `..` 段。按名读虽不落盘,但脏名字会流进日志与提示词。"""
26
+ n = (name or "").replace("\\", "/")
27
+ while n.startswith("./"):
28
+ n = n[2:]
29
+ if not n or n.startswith("/") or ".." in n.split("/"):
30
+ return False
31
+ return not (len(n) > 1 and n[1] == ":")
32
+
33
+
34
+ def _shallowest(names: list[str], base: str, subpath: str = "") -> str | None:
35
+ """挑最浅的同名文件(全小写比较)。subpath 非空时只在其下找。"""
36
+ want = f"/{subpath.strip('/')}/" if subpath.strip("/") else ""
37
+ cands = [
38
+ n for n in names
39
+ if safe_name(n)
40
+ and n.rsplit("/", 1)[-1].lower() == base
41
+ and (not want or want in f"/{n}")
42
+ ]
43
+ if not cands:
44
+ return None
45
+ return min(cands, key=lambda n: (n.count("/"), len(n)))
46
+
47
+
48
+ def pick_skill_md(names: list[str], subpath: str = "") -> str | None:
49
+ """挑最浅的 SKILL.md:ClawHub 在根,GitHub 仓库常在 skills/<name>/ 下。"""
50
+ return _shallowest(names, "skill.md", subpath)
51
+
52
+
53
+ def has_code(files: list[str]) -> bool:
54
+ """包内是否带可执行脚本 / 独立的 scripts/ 目录。"""
55
+ for n in files:
56
+ low = n.lower()
57
+ if low.endswith(_CODE_EXT) or "/scripts/" in f"/{low}":
58
+ return True
59
+ return False
60
+
61
+
62
+ def read_package(data: bytes, subpath: str = "", max_total: int = MAX_TOTAL) -> dict:
63
+ """解一个技能包,返回 {files, skill_md, meta}。"""
64
+ if not data:
65
+ raise PackageError("空包")
66
+ if data[:2] == b"PK":
67
+ names, read = _open_zip(data, max_total)
68
+ else:
69
+ names, read = _open_tar(data, max_total)
70
+ md = pick_skill_md(names, subpath)
71
+ if md is None:
72
+ where = f"(子路径 {subpath} 下)" if subpath else ""
73
+ raise PackageError(f"包内未找到 SKILL.md{where}")
74
+ meta: dict = {}
75
+ meta_name = _shallowest(names, "_meta.json")
76
+ if meta_name:
77
+ try:
78
+ meta = json.loads(read(meta_name).decode("utf-8", "replace")) or {}
79
+ except Exception:
80
+ meta = {}
81
+ return {"files": names, "skill_md": read(md).decode("utf-8", "replace"), "meta": meta}
82
+
83
+
84
+ def _open_zip(data: bytes, max_total: int):
85
+ try:
86
+ zf = zipfile.ZipFile(io.BytesIO(data))
87
+ except Exception as e:
88
+ raise PackageError(f"zip 解析失败: {e}") from None
89
+ infos = [i for i in zf.infolist() if not i.is_dir()]
90
+ total = sum(i.file_size for i in infos)
91
+ if total > max_total:
92
+ raise PackageError(f"解压体积超上限({total} > {max_total})")
93
+ return [i.filename for i in infos], zf.read
94
+
95
+
96
+ def _open_tar(data: bytes, max_total: int):
97
+ try:
98
+ tf = tarfile.open(fileobj=io.BytesIO(data), mode="r:*")
99
+ members = [m for m in tf.getmembers() if m.isfile()]
100
+ except Exception as e:
101
+ raise PackageError(f"既不是 zip 也不是 tar 包: {e}") from None
102
+ total = sum(m.size for m in members)
103
+ if total > max_total:
104
+ raise PackageError(f"解压体积超上限({total} > {max_total})")
105
+ by_name = {m.name: m for m in members}
106
+
107
+ def read(name: str) -> bytes:
108
+ fp = tf.extractfile(by_name[name])
109
+ return fp.read() if fp else b""
110
+
111
+ return [m.name for m in members], read
@@ -0,0 +1,226 @@
1
+ """技能来源解析与下载:ClawHub / GitHub / http(s) 包地址 / 本地路径。
2
+
3
+ `foxclaw skills install <ref>` 的统一入口。ref 形式:
4
+
5
+ @owner/slug ClawHub 作用域包(与 `openclaw skills install` 同源)
6
+ owner/slug 同上,省略 @
7
+ github:owner/repo[/sub][@ref] GitHub 仓库里的 SKILL.md(默认 HEAD)
8
+ https://...zip|tar.gz|.md 直接给包地址
9
+ ./skill.zip | ./SKILL.md 本地包 / 文件 / 目录
10
+
11
+ 安全口径:ClawHub 在**下载前**先查 moderation,`isMalwareBlocked` 直接拒绝;
12
+ `isSuspicious` 只作告警交给调用方确认。包内带可执行脚本时标 `has_code` ——
13
+ foxclaw 的 SKILL.md 是纯提示词,不执行包内代码,这点必须让调用方说清楚。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from .archive import PackageError, has_code, read_package
27
+
28
+ DEFAULT_REGISTRY = "https://clawhub.ai"
29
+ MAX_BYTES = 10 * 1024 * 1024
30
+ UA = "foxclaw-skills/1 (+https://github.com/snowfoxHQ/FoxClaw)"
31
+ _ARCHIVE_EXT = (".zip", ".tar.gz", ".tgz", ".tar")
32
+ _LOCAL_ID = re.compile(r"^@?([A-Za-z0-9][\w.-]*)/([\w.-]+)$")
33
+
34
+
35
+ class HttpError(PackageError):
36
+ """远端返回非 2xx。status 保留下来供调用方区分 404(不存在)与其他错误。"""
37
+
38
+ def __init__(self, status: int, url: str, detail: str):
39
+ self.status = status
40
+ super().__init__(f"{url} 返回 {status}: {detail or '(无响应体)'}")
41
+
42
+
43
+ class MalwareBlocked(PackageError):
44
+ """来源方已判定为恶意,任何 --yes 都不放行。"""
45
+
46
+
47
+ @dataclass
48
+ class SkillPackage:
49
+ skill_md: str
50
+ source: str
51
+ origin: str
52
+ files: list = field(default_factory=list)
53
+ meta: dict = field(default_factory=dict)
54
+ version: str = ""
55
+ moderation: dict = field(default_factory=dict)
56
+
57
+ @property
58
+ def has_code(self) -> bool:
59
+ return has_code(self.files)
60
+
61
+ @property
62
+ def suspicious(self) -> bool:
63
+ return bool(self.moderation.get("isSuspicious"))
64
+
65
+
66
+ def registry() -> str:
67
+ """ClawHub 地址。内网镜像走 FOXCLAW_CLAWHUB_REGISTRY,别改代码。"""
68
+ return (os.environ.get("FOXCLAW_CLAWHUB_REGISTRY", "").strip()
69
+ or DEFAULT_REGISTRY).rstrip("/")
70
+
71
+
72
+ def _http_get(url: str, timeout: float = 25.0,
73
+ max_bytes: int = MAX_BYTES) -> tuple[bytes, str]:
74
+ """GET 一个 URL,返回 (bytes, content-type)。超限/4xx 都抛 PackageError。"""
75
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
78
+ ctype = (resp.headers.get("Content-Type") or "").split(";")[0].lower()
79
+ chunks, total = [], 0
80
+ while True:
81
+ buf = resp.read(65536)
82
+ if not buf:
83
+ break
84
+ total += len(buf)
85
+ if total > max_bytes:
86
+ raise PackageError(f"下载超过上限({max_bytes} 字节): {url}")
87
+ chunks.append(buf)
88
+ return b"".join(chunks), ctype
89
+ except urllib.error.HTTPError as e:
90
+ detail = e.read(300).decode("utf-8", "replace").strip()
91
+ raise HttpError(e.code, url, detail) from None
92
+ except urllib.error.URLError as e:
93
+ raise PackageError(f"下载失败 {url}: {e.reason}") from None
94
+
95
+
96
+ def _json_get(url: str, timeout: float = 25.0) -> dict:
97
+ raw, _ = _http_get(url, timeout)
98
+ try:
99
+ got = json.loads(raw.decode("utf-8", "replace"))
100
+ except Exception:
101
+ raise PackageError(f"{url} 返回的不是 JSON") from None
102
+ return got if isinstance(got, dict) else {}
103
+
104
+
105
+ def fetch(ref: str, timeout: float = 25.0) -> SkillPackage:
106
+ """把一个 ref 解析成 SkillPackage(已下载并解出 SKILL.md)。"""
107
+ r = (ref or "").strip()
108
+ if not r:
109
+ raise PackageError("缺少 skill 来源")
110
+ low = r.lower()
111
+ if low.startswith(("http://", "https://")):
112
+ return _from_url(r, timeout)
113
+ if low.startswith("github:"):
114
+ return _from_github(r[len("github:"):], timeout)
115
+ local = Path(os.path.expanduser(r))
116
+ if local.exists():
117
+ return _from_local(local)
118
+ m = _LOCAL_ID.match(r)
119
+ if m:
120
+ return _from_clawhub(m.group(1), m.group(2), timeout)
121
+ raise PackageError(
122
+ "无法识别的来源。支持:@owner/slug(ClawHub)、github:owner/repo[/子路径][@ref]、"
123
+ "http(s):// 包地址、本地 .zip/.tar.gz/SKILL.md/目录")
124
+
125
+
126
+ def _from_clawhub(owner: str, slug: str, timeout: float) -> SkillPackage:
127
+ """ClawHub:先查详情(拿版本 + 审核状态),再下载 zip。
128
+
129
+ 先查详情而不是直接下载,是为了在落盘前拦住 `isMalwareBlocked` —— openclaw 自己
130
+ 也把 moderation 摆在安装前的核验位置,我们不能比它更松。
131
+ """
132
+ base = registry()
133
+ q_slug = urllib.parse.quote(slug, safe="")
134
+ q_owner = urllib.parse.quote(owner, safe="")
135
+ try:
136
+ detail = _json_get(f"{base}/api/v1/skills/{q_slug}?ownerHandle={q_owner}", timeout)
137
+ except HttpError as e:
138
+ if e.status == 404:
139
+ raise PackageError(f"ClawHub 上找不到 @{owner}/{slug}") from None
140
+ raise
141
+ mod = detail.get("moderation") or {}
142
+ if mod.get("isMalwareBlocked"):
143
+ reason = mod.get("summary") or "来源方未给说明"
144
+ raise MalwareBlocked(f"@{owner}/{slug} 已被 ClawHub 判为恶意并拦截:{reason}")
145
+ version = str((detail.get("latestVersion") or {}).get("version") or "")
146
+ data, ctype = _http_get(
147
+ f"{base}/api/v1/download?slug={q_slug}&ownerHandle={q_owner}", timeout)
148
+ if data[:1] == b"{" or "json" in ctype:
149
+ try:
150
+ handoff = json.loads(data.decode("utf-8", "replace")) or {}
151
+ except Exception:
152
+ handoff = {}
153
+ archive = str(handoff.get("archiveUrl") or "")
154
+ if not archive:
155
+ raise PackageError(f"@{owner}/{slug} 无托管版本,且返回里没有可下载地址")
156
+ data, _ = _http_get(archive, timeout)
157
+ pkg = read_package(data)
158
+ return SkillPackage(
159
+ skill_md=pkg["skill_md"], source="clawhub",
160
+ origin=f"@{owner}/{slug}" + (f"@{version}" if version else ""),
161
+ files=pkg["files"], meta=pkg["meta"], version=version, moderation=mod)
162
+
163
+
164
+ def _from_github(spec: str, timeout: float) -> SkillPackage:
165
+ """github:owner/repo[/子路径][@ref] → codeload 的 tar.gz(不占 GitHub API 配额)。"""
166
+ body, _, ref = spec.partition("@")
167
+ parts = [p for p in body.split("/") if p]
168
+ if len(parts) < 2:
169
+ raise PackageError("github: 形式应为 github:owner/repo[/子路径][@ref]")
170
+ owner, repo = parts[0], parts[1]
171
+ sub = "/".join(parts[2:])
172
+ url = f"https://codeload.github.com/{owner}/{repo}/tar.gz/{ref or 'HEAD'}"
173
+ data, _ = _http_get(url, timeout)
174
+ pkg = read_package(data, subpath=sub)
175
+ origin = f"github:{owner}/{repo}" + (f"/{sub}" if sub else "") + (f"@{ref}" if ref else "")
176
+ return SkillPackage(skill_md=pkg["skill_md"], source="github", origin=origin,
177
+ files=pkg["files"], meta=pkg["meta"])
178
+
179
+
180
+ def _from_url(url: str, timeout: float) -> SkillPackage:
181
+ """直链:归档当包解,markdown/纯文本当单文件 SKILL.md。"""
182
+ data, ctype = _http_get(url, timeout)
183
+ path = urllib.parse.urlparse(url).path.lower()
184
+ name = path.rsplit("/", 1)[-1] or "SKILL.md"
185
+ textish = path.endswith((".md", ".markdown")) or ctype in ("text/markdown", "text/plain")
186
+ is_archive = data[:2] == b"PK" or data[:2] == b"\x1f\x8b" or path.endswith(_ARCHIVE_EXT)
187
+ if textish and not is_archive:
188
+ text = data.decode("utf-8", "replace")
189
+ return SkillPackage(skill_md=text, source="url", origin=url, files=[name])
190
+ pkg = read_package(data)
191
+ return SkillPackage(skill_md=pkg["skill_md"], source="url", origin=url,
192
+ files=pkg["files"], meta=pkg["meta"])
193
+
194
+
195
+ def _from_local(path: Path) -> SkillPackage:
196
+ """本地:目录(找 SKILL.md)、单 .md、或 .zip/.tar.gz 包。"""
197
+ origin = str(path)
198
+ if path.is_dir():
199
+ direct = path / "SKILL.md"
200
+ if direct.is_file():
201
+ text = direct.read_text(encoding="utf-8", errors="replace")
202
+ meta = _read_meta(path / "_meta.json")
203
+ else:
204
+ cands = sorted(path.glob("*/SKILL.md")) + sorted(path.glob("*/*/SKILL.md"))
205
+ if len(cands) != 1:
206
+ raise PackageError(
207
+ f"{path} 里没找到唯一的 SKILL.md(直接放 SKILL.md,或只有一个子目录带它)")
208
+ text = cands[0].read_text(encoding="utf-8", errors="replace")
209
+ meta = _read_meta(cands[0].parent / "_meta.json")
210
+ files = [p.relative_to(path).as_posix() for p in path.rglob("*") if p.is_file()]
211
+ return SkillPackage(skill_md=text, source="local", origin=origin,
212
+ files=files, meta=meta)
213
+ if path.suffix.lower() in (".md", ".markdown"):
214
+ return SkillPackage(skill_md=path.read_text(encoding="utf-8", errors="replace"),
215
+ source="local", origin=origin, files=[path.name])
216
+ pkg = read_package(path.read_bytes())
217
+ return SkillPackage(skill_md=pkg["skill_md"], source="local", origin=origin,
218
+ files=pkg["files"], meta=pkg["meta"])
219
+
220
+
221
+ def _read_meta(p: Path) -> dict:
222
+ try:
223
+ got = json.loads(p.read_text(encoding="utf-8"))
224
+ return got if isinstance(got, dict) else {}
225
+ except Exception:
226
+ return {}
@@ -13,60 +13,56 @@ Foxclaw — 标准生命周期钩子点 (Lifecycle Hooks Specification)
13
13
  """
14
14
  from __future__ import annotations
15
15
 
16
+ from typing import Literal, cast
17
+
16
18
 
17
- # ── 标准钩子点(生命周期顺序)──────────────────────────────
18
19
  class Hooks:
19
20
  """标准钩子点常量。用 Hooks.BEFORE_TOOL_CALL 而非裸字符串,避免拼写错误。"""
20
21
 
21
- # 会话级
22
- SESSION_START = "session_start" # 一次任务开始
23
- AGENT_START = "agent_start" # Agent 开始处理(分诊后)
24
- AGENT_END = "agent_end" # Agent 处理完成
25
- SESSION_END = "session_end" # 一次任务结束
22
+ SESSION_START = "session_start"
23
+ AGENT_START = "agent_start"
24
+ AGENT_END = "agent_end"
25
+ SESSION_END = "session_end"
26
26
 
27
- # 工具/skill 级
28
- BEFORE_TOOL_CALL = "before_tool_call" # 调用前(可阻断/可改参数)
29
- AFTER_TOOL_CALL = "after_tool_call" # 调用成功后
30
- ON_ERROR = "on_error" # 调用失败
31
- ON_LOOP = "on_loop" # 检测到死循环
27
+ BEFORE_TOOL_CALL = "before_tool_call"
28
+ AFTER_TOOL_CALL = "after_tool_call"
29
+ ON_ERROR = "on_error"
30
+ ON_LOOP = "on_loop"
32
31
 
33
- # 消息级
34
- BEFORE_MESSAGE_WRITE = "before_message_write" # 写入/发送消息前(可改写)
32
+ BEFORE_MESSAGE_WRITE = "before_message_write"
35
33
 
36
34
 
37
- # 所有标准钩子点(供校验)
38
35
  ALL_HOOKS = frozenset({
39
36
  Hooks.SESSION_START, Hooks.AGENT_START, Hooks.AGENT_END, Hooks.SESSION_END,
40
37
  Hooks.BEFORE_TOOL_CALL, Hooks.AFTER_TOOL_CALL, Hooks.ON_ERROR, Hooks.ON_LOOP,
41
38
  Hooks.BEFORE_MESSAGE_WRITE,
42
39
  })
43
40
 
44
- # 拥有阻断权的钩子(只有这些返回 {block:True} 才生效)
45
41
  BLOCKING_HOOKS = frozenset({Hooks.BEFORE_TOOL_CALL})
46
42
 
47
- # 可注入 additionalContext 的钩子(E2:非阻断,但其返回的 additional_context 会被并入推理上下文)。
48
- # agent_start = 一次 Agent 任务的推理开始点,对应范式 pre_reasoning —— 推理前注入上下文的落点。
49
43
  CONTEXT_HOOKS = frozenset({Hooks.AGENT_START})
50
44
 
51
- # 工具级钩子(按 skill_id 做 matcher 过滤)
52
45
  TOOL_SCOPED_HOOKS = frozenset({
53
46
  Hooks.BEFORE_TOOL_CALL, Hooks.AFTER_TOOL_CALL, Hooks.ON_ERROR, Hooks.ON_LOOP,
54
47
  })
55
48
 
56
- # 向后兼容 + 语义别名 → 标准名
57
49
  ALIAS = {
58
50
  "pre_skill": Hooks.BEFORE_TOOL_CALL,
59
51
  "post_skill": Hooks.AFTER_TOOL_CALL,
60
- # E2:推理层/会话层语义名(对齐范式 pre_reasoning / Stop),规整到已有的会话级钩子点——
61
- # 顺带**复活**此前定义却从未被 fire 的 agent_start / agent_end 孤儿钩子。
62
- "pre_reasoning": Hooks.AGENT_START, # 一次 Agent 任务推理开始前(可注入 additionalContext)
63
- "task_complete": Hooks.AGENT_END, # 一次 Agent 任务结束后(只读观测/上报,对应范式 Stop)
52
+ "pre_reasoning": Hooks.AGENT_START,
53
+ "task_complete": Hooks.AGENT_END,
64
54
  }
65
55
 
66
56
 
67
- def normalize_hook(event: str) -> str:
57
+ HookEvent = Literal["before_tool_call", "after_tool_call", "on_error", "on_loop",
58
+ "session_start", "agent_start", "agent_end", "session_end",
59
+ "before_message_write",
60
+ "pre_skill", "post_skill", "pre_reasoning", "task_complete"]
61
+
62
+
63
+ def normalize_hook(event: str) -> HookEvent:
68
64
  """把旧钩子名规整为标准名;已是标准名则原样返回。"""
69
- return ALIAS.get(event, event)
65
+ return cast(HookEvent, ALIAS.get(event, event))
70
66
 
71
67
 
72
68
  def is_valid_hook(event: str) -> bool:
@@ -0,0 +1,216 @@
1
+ """`foxclaw skills install|list|remove` 的实现。
2
+
3
+ 为什么不放在 cli.py:那里只该有参数解析与分发(体积门 15KB,且它已承载 serve/config)。
4
+ 装技能的真正复杂度在「服务在跑没跑」这一个分叉上:
5
+
6
+ * **在跑** → 必须打 /api/ahs/md-skills。直接改 markdown_skills.json 会被运行中的
7
+ 注册表下一次 `save()` 用内存快照整份覆盖,用户的安装无声消失(极难查)。
8
+ * **没跑** → 直接写同一个 JSON,格式与 MarkdownStore 完全一致,下次启动即加载。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from pathlib import Path
19
+ from typing import Any, cast
20
+
21
+
22
+ def _data_dir() -> Path:
23
+ """与 `foxclaw serve` 同源:FOXCLAW_DATA_DIR,否则 cwd 下的 foxclaw_data。"""
24
+ raw = os.environ.get("FOXCLAW_DATA_DIR", "").strip()
25
+ return Path(raw or "./foxclaw_data").resolve()
26
+
27
+
28
+ def _api_base() -> str:
29
+ """本地服务地址:FOXCLAW_API 可覆盖,否则回环 + FOXCLAW_PORT。"""
30
+ raw = os.environ.get("FOXCLAW_API", "").strip()
31
+ if raw:
32
+ return raw.rstrip("/")
33
+ port = os.environ.get("FOXCLAW_PORT", "").strip() or "8000"
34
+ return f"http://127.0.0.1:{port}"
35
+
36
+
37
+ def _api_alive(base: str) -> bool:
38
+ try:
39
+ with urllib.request.urlopen(f"{base}/api/system/info", timeout=0.8) as r:
40
+ return 200 <= r.status < 300
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ def _api_json(base: str, method: str, path: str, payload: dict | None = None) -> dict:
46
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
47
+ headers = {"Content-Type": "application/json"}
48
+ key = os.environ.get("FOXCLAW_API_KEY", "").strip()
49
+ if key:
50
+ headers["X-API-Key"] = key
51
+ req = urllib.request.Request(f"{base}{path}", data=body, method=method, headers=headers)
52
+ try:
53
+ with urllib.request.urlopen(req, timeout=15) as r:
54
+ return json.loads(r.read().decode("utf-8", "replace") or "{}")
55
+ except urllib.error.HTTPError as e:
56
+ detail = e.read(400).decode("utf-8", "replace").strip()
57
+ raise RuntimeError(f"{method} {path} → {e.code}: {detail}") from None
58
+ except urllib.error.URLError as e:
59
+ raise RuntimeError(f"连不上服务: {e.reason}") from None
60
+
61
+
62
+ def _confirm() -> bool:
63
+ """非 TTY(管道/CI)一律不认,必须显式 --yes。"""
64
+ if not sys.stdin.isatty():
65
+ return False
66
+ try:
67
+ ans = input("安装后它会注入你 AI 的提示词、影响其行为。继续?[y/N] ")
68
+ except (EOFError, KeyboardInterrupt):
69
+ return False
70
+ return ans.strip().lower() in ("y", "yes")
71
+
72
+
73
+ def _persist(skill, text: str) -> tuple[bool, str, bool]:
74
+ """落地一个指令技能。返回 (是否新建, 位置说明, 是否已热加载)。"""
75
+ base = _api_base()
76
+ if _api_alive(base):
77
+ got = _api_json(base, "POST", "/api/ahs/md-skills",
78
+ {"content": text, "id": skill.id, "acknowledged": True})
79
+ return bool(got.get("created")), base, True
80
+ from .markdown_skill import MarkdownSkillRegistry
81
+ from .markdown_store import MarkdownStore
82
+
83
+ reg = MarkdownSkillRegistry()
84
+ store = MarkdownStore(_data_dir())
85
+ store.load(reg)
86
+ created = reg.get(skill.id) is None
87
+ reg.register(skill)
88
+ store.save(reg)
89
+ return created, str(_data_dir() / "markdown_skills.json"), False
90
+
91
+
92
+ def install(args) -> int:
93
+ from .fetch import MalwareBlocked, PackageError, fetch
94
+ from .markdown_skill import parse_skill_md
95
+
96
+ try:
97
+ pkg = fetch(args.ref)
98
+ except MalwareBlocked as e:
99
+ print(f"拒绝安装:{e}", file=sys.stderr)
100
+ return 3
101
+ except PackageError as e:
102
+ print(f"获取失败:{e}", file=sys.stderr)
103
+ return 2
104
+ want_id = (args.id or "").strip() or str(pkg.meta.get("slug") or "")
105
+ try:
106
+ skill = parse_skill_md(pkg.skill_md, id=want_id or None)
107
+ except ValueError as e:
108
+ print(f"SKILL.md 解析失败:{e}", file=sys.stderr)
109
+ return 2
110
+ if pkg.version and not skill.version:
111
+ skill.version = pkg.version
112
+ if not args.json:
113
+ ver = f" v{skill.version}" if skill.version else ""
114
+ print(f"来源 {pkg.source} · {pkg.origin}")
115
+ print(f"技能 {skill.name}(id={skill.id}){ver}")
116
+ print(f"描述 {skill.description[:100]}")
117
+ if pkg.has_code:
118
+ print("注意 包内带可执行脚本。foxclaw 只注入 SKILL.md、不运行这些脚本;"
119
+ "若正文要求 agent 去跑它们,该技能不会按预期工作。")
120
+ if pkg.suspicious:
121
+ print("警告 ClawHub 标记该技能可疑,请先读正文再决定。")
122
+ if not args.yes and not _confirm():
123
+ print("已取消:未确认。确认后加 --yes(或在本机终端里交互确认)。")
124
+ return 1
125
+ try:
126
+ created, where, hot = _persist(skill, pkg.skill_md)
127
+ except RuntimeError as e:
128
+ print(f"写入失败:{e}", file=sys.stderr)
129
+ return 4
130
+ if args.json:
131
+ print(json.dumps({"ok": True, "created": created, "skill": skill.to_meta(),
132
+ "source": pkg.source, "origin": pkg.origin,
133
+ "has_code": pkg.has_code, "where": where,
134
+ "hot_loaded": hot}, ensure_ascii=False))
135
+ return 0
136
+ print(f"{'已安装' if created else '已更新'}「{skill.name}」→ {where}")
137
+ if not hot:
138
+ print("提示:服务若正在运行,重启一次才会加载;不想重启就先起服务再装。")
139
+ return 0
140
+
141
+
142
+ def list_skills(args) -> int:
143
+ base = _api_base()
144
+ hot = _api_alive(base)
145
+ try:
146
+ if hot:
147
+ rows = _api_json(base, "GET", "/api/ahs/md-skills").get("skills") or []
148
+ else:
149
+ from .markdown_skill import MarkdownSkillRegistry
150
+ from .markdown_store import MarkdownStore
151
+
152
+ reg = MarkdownSkillRegistry()
153
+ MarkdownStore(_data_dir()).load(reg)
154
+ rows = reg.catalog()
155
+ except RuntimeError as e:
156
+ print(f"读取失败:{e}", file=sys.stderr)
157
+ return 4
158
+ if args.json:
159
+ print(json.dumps({"source": "api" if hot else "file", "skills": rows},
160
+ ensure_ascii=False))
161
+ return 0
162
+ if not rows:
163
+ print("还没有安装指令技能。`foxclaw skills install @owner/slug` 装一个。")
164
+ return 0
165
+ for s in rows:
166
+ ver = f" v{s.get('version')}" if s.get("version") else ""
167
+ off = "" if s.get("enabled", True) else " [已停用]"
168
+ blocked = " [被封杀]" if s.get("blocked") else ""
169
+ print(f"{str(s.get('id', '')).ljust(26)} {str(s.get('source', '?')):8}{ver}{off}{blocked}")
170
+ print(f" {str(s.get('description', ''))[:90]}")
171
+ return 0
172
+
173
+
174
+ def remove(args) -> int:
175
+ sid = (args.id or "").strip()
176
+ if not sid:
177
+ print("要卸载哪个?`foxclaw skills list` 看 id。", file=sys.stderr)
178
+ return 2
179
+ base = _api_base()
180
+ if _api_alive(base):
181
+ try:
182
+ _api_json(base, "DELETE", f"/api/ahs/md-skills/{urllib.parse.quote(sid, safe='')}")
183
+ except RuntimeError as e:
184
+ print(f"卸载失败:{e}", file=sys.stderr)
185
+ return 4
186
+ print(f"已卸载 {sid}(服务已热更新)")
187
+ return 0
188
+ from .markdown_skill import MarkdownSkillRegistry
189
+ from .markdown_store import MarkdownStore
190
+
191
+ reg = MarkdownSkillRegistry()
192
+ store = MarkdownStore(_data_dir())
193
+ store.load(reg)
194
+ if not reg.unregister(sid):
195
+ print(f"没找到 id={sid} 的指令技能(`foxclaw skills list` 看现有)", file=sys.stderr)
196
+ return 1
197
+ store.save(reg)
198
+ print(f"已卸载 {sid} → {_data_dir() / 'markdown_skills.json'}")
199
+ return 0
200
+
201
+
202
+ def run(args) -> int:
203
+ for stream in (sys.stdout, sys.stderr):
204
+ try:
205
+ cast(Any, stream).reconfigure(errors="replace")
206
+ except Exception:
207
+ pass
208
+ action = getattr(args, "skills_action", None)
209
+ if action == "install":
210
+ return install(args)
211
+ if action == "list":
212
+ return list_skills(args)
213
+ if action == "remove":
214
+ return remove(args)
215
+ print("用法:`foxclaw skills install <ref>` / `list` / `remove <id>`")
216
+ return 2