turnloop 0.1.7__tar.gz → 0.1.8__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 (160) hide show
  1. {turnloop-0.1.7 → turnloop-0.1.8}/PKG-INFO +5 -5
  2. {turnloop-0.1.7 → turnloop-0.1.8}/README.md +4 -4
  3. {turnloop-0.1.7 → turnloop-0.1.8}/pyproject.toml +1 -1
  4. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_skills_install.py +189 -0
  5. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/__init__.py +1 -1
  6. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/cli.py +41 -14
  7. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/skills_install.py +45 -1
  8. {turnloop-0.1.7 → turnloop-0.1.8}/.github/workflows/ci.yml +0 -0
  9. {turnloop-0.1.7 → turnloop-0.1.8}/.gitignore +0 -0
  10. {turnloop-0.1.7 → turnloop-0.1.8}/.turnloop/.gitignore +0 -0
  11. {turnloop-0.1.7 → turnloop-0.1.8}/.turnloop/settings.local.json +0 -0
  12. {turnloop-0.1.7 → turnloop-0.1.8}/tests/conftest.py +0 -0
  13. {turnloop-0.1.7 → turnloop-0.1.8}/tests/fixtures/anthropic_tool_call.sse +0 -0
  14. {turnloop-0.1.7 → turnloop-0.1.8}/tests/fixtures/glm_tool_call.sse +0 -0
  15. {turnloop-0.1.7 → turnloop-0.1.8}/tests/fixtures/mcp_echo_server.py +0 -0
  16. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_bash.py +0 -0
  17. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_compaction.py +0 -0
  18. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_config.py +0 -0
  19. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_config_screen.py +0 -0
  20. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_configio.py +0 -0
  21. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_experiments.py +0 -0
  22. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_hooks_mcp_commands.py +0 -0
  23. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_loop.py +0 -0
  24. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_permissions.py +0 -0
  25. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_providers.py +0 -0
  26. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_system_prompt_layout.py +0 -0
  27. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_tools_files.py +0 -0
  28. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_tui.py +0 -0
  29. {turnloop-0.1.7 → turnloop-0.1.8}/tests/test_websearch.py +0 -0
  30. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/__main__.py +0 -0
  31. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/agent/__init__.py +0 -0
  32. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/agent/factory.py +0 -0
  33. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/agent/headless.py +0 -0
  34. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/agent/loop.py +0 -0
  35. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/agent/subagent.py +0 -0
  36. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/agent/system_prompt.py +0 -0
  37. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/commands/__init__.py +0 -0
  38. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/commands/dispatch.py +0 -0
  39. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/commands/loader.py +0 -0
  40. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/config.py +0 -0
  41. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/configio.py +0 -0
  42. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/context/__init__.py +0 -0
  43. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/context/budget.py +0 -0
  44. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/context/compaction.py +0 -0
  45. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/context/memory.py +0 -0
  46. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/core/__init__.py +0 -0
  47. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/core/events.py +0 -0
  48. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/core/ids.py +0 -0
  49. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/core/messages.py +0 -0
  50. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/core/tokens.py +0 -0
  51. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/diagnostics.py +0 -0
  52. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/errors.py +0 -0
  53. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/__init__.py +0 -0
  54. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  55. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  56. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/context-glm.yaml +0 -0
  57. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/context.yaml +0 -0
  58. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  59. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/groq-free.yaml +0 -0
  60. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  61. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  62. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/loop.yaml +0 -0
  63. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  64. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/reliability.yaml +0 -0
  65. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  66. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/configs/smoke.yaml +0 -0
  67. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/graders.py +0 -0
  68. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/report.py +0 -0
  69. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/runner.py +0 -0
  70. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/default.yaml +0 -0
  71. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  72. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  73. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  74. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  75. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  76. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  77. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  78. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  79. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  80. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  81. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  82. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  83. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  84. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  85. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  86. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  87. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  88. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  89. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  90. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  91. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  92. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  93. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  94. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  95. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  96. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  97. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  98. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  99. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  100. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  101. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  102. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  103. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  104. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  105. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  106. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  107. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  108. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  109. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  110. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  111. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  112. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/hooks/__init__.py +0 -0
  113. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/hooks/runner.py +0 -0
  114. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/mcp/__init__.py +0 -0
  115. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/mcp/adapter.py +0 -0
  116. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/mcp/client.py +0 -0
  117. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/permissions/__init__.py +0 -0
  118. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/permissions/engine.py +0 -0
  119. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/permissions/rules.py +0 -0
  120. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/__init__.py +0 -0
  121. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/anthropic.py +0 -0
  122. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/base.py +0 -0
  123. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/gemini.py +0 -0
  124. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/openai_compat.py +0 -0
  126. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/pricing.py +0 -0
  127. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/registry.py +0 -0
  128. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/providers/sse.py +0 -0
  129. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/sessions/__init__.py +0 -0
  130. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/sessions/models.py +0 -0
  131. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/sessions/store.py +0 -0
  132. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/__init__.py +0 -0
  133. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/ask.py +0 -0
  134. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/base.py +0 -0
  135. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/bash.py +0 -0
  136. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/builtin.py +0 -0
  137. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/edit.py +0 -0
  138. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/glob.py +0 -0
  139. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/grep.py +0 -0
  140. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/read.py +0 -0
  141. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/runner.py +0 -0
  142. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/shell.py +0 -0
  143. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/skill.py +0 -0
  144. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/task.py +0 -0
  145. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/textio.py +0 -0
  146. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/todo.py +0 -0
  147. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/webfetch.py +0 -0
  148. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/websearch.py +0 -0
  149. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tools/write.py +0 -0
  150. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/__init__.py +0 -0
  151. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/app.py +0 -0
  152. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/app.tcss +0 -0
  153. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/bridge.py +0 -0
  154. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/config_screen.py +0 -0
  155. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/mcp_screen.py +0 -0
  156. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/session_screen.py +0 -0
  157. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/widgets/__init__.py +0 -0
  158. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/widgets/permission.py +0 -0
  159. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/widgets/status.py +0 -0
  160. {turnloop-0.1.7 → turnloop-0.1.8}/turnloop/tui/widgets/transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turnloop
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
5
5
  Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
6
6
  Author: Pranesh
@@ -49,7 +49,7 @@ tl experiment run smoke # a measurement run, offline and free
49
49
 
50
50
  Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
51
51
  SDKs, no agent framework — the loop is the point, so the loop is written here.
52
- ~20,000 lines of Python, 338 tests, no network in the default test run.
52
+ ~20,000 lines of Python, 346 tests, no network in the default test run.
53
53
 
54
54
  ---
55
55
 
@@ -117,7 +117,7 @@ point.
117
117
  Verify:
118
118
 
119
119
  ```bash
120
- tl --version # turnloop 0.1.7
120
+ tl --version # turnloop 0.1.8
121
121
  tl doctor # every provider, key presence, shell, context budget
122
122
  ```
123
123
 
@@ -1023,7 +1023,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
1023
1023
  ## Testing
1024
1024
 
1025
1025
  ```bash
1026
- pytest # 338 tests, no network
1026
+ pytest # 346 tests, no network
1027
1027
  ruff check turnloop
1028
1028
  mypy turnloop
1029
1029
  ```
@@ -1037,7 +1037,7 @@ mypy turnloop
1037
1037
  | `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
1038
1038
  | `test_tui.py` | 25 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
1039
1039
  | `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
1040
- | `test_skills_install.py` | 19 | frontmatter validation, GitHub resolution, import subsets, drive-root refusal |
1040
+ | `test_skills_install.py` | 27 | frontmatter validation, GitHub resolution, path dedupe, EOF on every prompt, drive-root refusal |
1041
1041
  | `test_loop.py` | 23 | streaming, truncation, iteration cap |
1042
1042
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
1043
1043
  | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
@@ -20,7 +20,7 @@ tl experiment run smoke # a measurement run, offline and free
20
20
 
21
21
  Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
22
22
  SDKs, no agent framework — the loop is the point, so the loop is written here.
23
- ~20,000 lines of Python, 338 tests, no network in the default test run.
23
+ ~20,000 lines of Python, 346 tests, no network in the default test run.
24
24
 
25
25
  ---
26
26
 
@@ -88,7 +88,7 @@ point.
88
88
  Verify:
89
89
 
90
90
  ```bash
91
- tl --version # turnloop 0.1.7
91
+ tl --version # turnloop 0.1.8
92
92
  tl doctor # every provider, key presence, shell, context budget
93
93
  ```
94
94
 
@@ -994,7 +994,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
994
994
  ## Testing
995
995
 
996
996
  ```bash
997
- pytest # 338 tests, no network
997
+ pytest # 346 tests, no network
998
998
  ruff check turnloop
999
999
  mypy turnloop
1000
1000
  ```
@@ -1008,7 +1008,7 @@ mypy turnloop
1008
1008
  | `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
1009
1009
  | `test_tui.py` | 25 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
1010
1010
  | `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
1011
- | `test_skills_install.py` | 19 | frontmatter validation, GitHub resolution, import subsets, drive-root refusal |
1011
+ | `test_skills_install.py` | 27 | frontmatter validation, GitHub resolution, path dedupe, EOF on every prompt, drive-root refusal |
1012
1012
  | `test_loop.py` | 23 | streaming, truncation, iteration cap |
1013
1013
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
1014
1014
  | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "turnloop"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -8,17 +8,21 @@ the same technique httpx's own docs recommend for offline tests.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import argparse
11
12
  import json
12
13
  from pathlib import Path
13
14
 
14
15
  import httpx
15
16
  import pytest
16
17
 
18
+ import turnloop.skills_install as skills_install
19
+ from turnloop.cli import _cmd_skills_add, _cmd_skills_import, _cmd_skills_remove
17
20
  from turnloop.commands.loader import load_skills
18
21
  from turnloop.config import default_settings, load_settings
19
22
  from turnloop.errors import ConfigError
20
23
  from turnloop.skills_install import (
21
24
  SkillInstallError,
25
+ SkillSource,
22
26
  fetch_skill_sources,
23
27
  find_claude_code_candidates,
24
28
  import_selected,
@@ -332,3 +336,188 @@ def test_mark_import_asked_is_idempotent(tmp_path, monkeypatch, project):
332
336
 
333
337
  on_disk = json.loads(fake_user_path.read_text(encoding="utf-8"))
334
338
  assert on_disk == {"skills_import_asked": True}
339
+
340
+
341
+ # --------------------------------------------------------------------------
342
+ # a repo shipping the same skill under two paths must not collide silently
343
+ # --------------------------------------------------------------------------
344
+
345
+
346
+ def _ponytail_tree() -> dict:
347
+ """The real layout from the bug report: 6 skills, each under both
348
+ `.openclaw/skills/<name>/` (a different harness's convention) and the
349
+ repo's own `skills/<name>/` -- 12 paths, 6 distinct skills."""
350
+ names = ["ponytail-audit", "ponytail-debt", "ponytail-gain", "ponytail-help",
351
+ "ponytail-review", "ponytail"]
352
+ paths = [f".openclaw/skills/{n}/SKILL.md" for n in names]
353
+ paths += [f"skills/{n}/SKILL.md" for n in names]
354
+ return {"tree": [{"path": p, "type": "blob"} for p in paths]}
355
+
356
+
357
+ async def test_fetch_repo_dedupes_the_same_skill_shipped_under_two_paths():
358
+ tree = _ponytail_tree()
359
+
360
+ async def handler(request: httpx.Request) -> httpx.Response:
361
+ url = str(request.url)
362
+ if "git/trees" in url:
363
+ return httpx.Response(200, json=tree)
364
+ if url.startswith("https://api.github.com/repos/o/r"):
365
+ return httpx.Response(200, json={"default_branch": "main"})
366
+ return httpx.Response(200, text=VALID_SKILL)
367
+
368
+ async with mock_client(handler) as client:
369
+ sources = await fetch_skill_sources("o/r", client)
370
+
371
+ names = [s.name for s in sources]
372
+ assert len(names) == len(set(names)) == 6 # 12 paths in, 6 unique targets out
373
+ # the shallower `skills/<name>/` copy wins over `.openclaw/skills/<name>/`
374
+ assert all("/skills/" in s.raw_url and "/.openclaw/" not in s.raw_url for s in sources)
375
+
376
+
377
+ async def test_fetch_repo_raises_on_a_genuine_equal_depth_collision():
378
+ """Two paths at the same depth resolving to the same name is a real
379
+ ambiguity (which one is "the" skill?), not a canonical-copy pick -- must
380
+ be reported, never resolved by guessing."""
381
+ tree = {"tree": [
382
+ {"path": "docs/x/SKILL.md", "type": "blob"},
383
+ {"path": "examples/x/SKILL.md", "type": "blob"},
384
+ ]}
385
+
386
+ async def handler(request: httpx.Request) -> httpx.Response:
387
+ url = str(request.url)
388
+ if "git/trees" in url:
389
+ return httpx.Response(200, json=tree)
390
+ return httpx.Response(200, json={"default_branch": "main"})
391
+
392
+ async with mock_client(handler) as client:
393
+ with pytest.raises(SkillInstallError) as exc_info:
394
+ await fetch_skill_sources("o/r", client)
395
+
396
+ assert "docs/x/SKILL.md" in str(exc_info.value)
397
+ assert "examples/x/SKILL.md" in str(exc_info.value)
398
+
399
+
400
+ def test_installing_the_deduped_set_never_writes_the_same_target_twice(project):
401
+ tree = _ponytail_tree()
402
+ paths = sorted(item["path"] for item in tree["tree"])
403
+ winners = skills_install._dedupe_skill_paths(paths)
404
+
405
+ assert len(winners) == 6
406
+ for path in winners:
407
+ name = skills_install._name_from_path(path)
408
+ content = f"---\nname: {name}\ndescription: a {name} skill\n---\nbody\n"
409
+ install_skill(content, False, project, name_hint=name)
410
+
411
+ loaded = load_skills(project)
412
+ assert len(loaded) == 6 # no target overwritten a second time within the run
413
+
414
+
415
+ # --------------------------------------------------------------------------
416
+ # --yes on a collection installs all of them without touching stdin
417
+ # --------------------------------------------------------------------------
418
+
419
+
420
+ def _no_input_allowed(monkeypatch):
421
+ """Fails the test immediately if any prompt reaches real `input()`."""
422
+
423
+ def _boom(*_args, **_kwargs):
424
+ raise AssertionError("input() must not be called")
425
+
426
+ monkeypatch.setattr("builtins.input", _boom)
427
+
428
+
429
+ def test_yes_installs_every_source_in_a_collection_without_prompting(settings, monkeypatch):
430
+ _no_input_allowed(monkeypatch)
431
+ sources = [
432
+ SkillSource(name=n, raw_url=f"https://example/{n}/SKILL.md",
433
+ content=f"---\nname: {n}\ndescription: skill {n}\n---\nbody\n")
434
+ for n in ("one", "two", "three")
435
+ ]
436
+
437
+ async def fake_fetch(_source, _client):
438
+ return sources
439
+
440
+ monkeypatch.setattr(skills_install, "fetch_skill_sources", fake_fetch)
441
+
442
+ args = argparse.Namespace(source="o/r", user=False, yes=True)
443
+ rc = _cmd_skills_add(settings, args, settings.project_root)
444
+
445
+ assert rc == 0
446
+ assert set(load_skills(settings.project_root)) == {"one", "two", "three"}
447
+
448
+
449
+ # --------------------------------------------------------------------------
450
+ # every prompt exits cleanly on EOF (closed/piped stdin) instead of raising
451
+ # --------------------------------------------------------------------------
452
+
453
+
454
+ def _raise_eof(monkeypatch):
455
+ def _boom(*_args, **_kwargs):
456
+ raise EOFError()
457
+
458
+ monkeypatch.setattr("builtins.input", _boom)
459
+
460
+
461
+ def test_collection_selector_prompt_exits_cleanly_on_eof(settings, monkeypatch, capsys):
462
+ _raise_eof(monkeypatch)
463
+ sources = [
464
+ SkillSource(name=n, raw_url=f"https://example/{n}/SKILL.md",
465
+ content=f"---\nname: {n}\ndescription: skill {n}\n---\nbody\n")
466
+ for n in ("one", "two")
467
+ ]
468
+
469
+ async def fake_fetch(_source, _client):
470
+ return sources
471
+
472
+ monkeypatch.setattr(skills_install, "fetch_skill_sources", fake_fetch)
473
+
474
+ args = argparse.Namespace(source="o/r", user=False, yes=False)
475
+ with pytest.raises(SystemExit) as exc_info:
476
+ _cmd_skills_add(settings, args, settings.project_root)
477
+
478
+ assert exc_info.value.code == 1
479
+ assert "no input available" in capsys.readouterr().err
480
+
481
+
482
+ def test_single_skill_confirmation_prompt_exits_cleanly_on_eof(settings, monkeypatch, capsys):
483
+ _raise_eof(monkeypatch)
484
+ source = SkillSource(name="one", raw_url="https://example/one/SKILL.md", content=VALID_SKILL)
485
+
486
+ async def fake_fetch(_source, _client):
487
+ return [source]
488
+
489
+ monkeypatch.setattr(skills_install, "fetch_skill_sources", fake_fetch)
490
+
491
+ args = argparse.Namespace(source="o/r", user=False, yes=False)
492
+ with pytest.raises(SystemExit) as exc_info:
493
+ _cmd_skills_add(settings, args, settings.project_root)
494
+
495
+ assert exc_info.value.code == 1
496
+ assert "no input available" in capsys.readouterr().err
497
+
498
+
499
+ def test_remove_confirmation_prompt_exits_cleanly_on_eof(settings, monkeypatch, capsys):
500
+ install_skill(VALID_SKILL, False, settings.project_root, name_hint="demo")
501
+ _raise_eof(monkeypatch)
502
+
503
+ args = argparse.Namespace(name="demo", user=False, yes=False)
504
+ with pytest.raises(SystemExit) as exc_info:
505
+ _cmd_skills_remove(settings, args)
506
+
507
+ assert exc_info.value.code == 1
508
+ assert "no input available" in capsys.readouterr().err
509
+
510
+
511
+ def test_import_selector_prompt_exits_cleanly_on_eof(settings, monkeypatch, capsys, tmp_path):
512
+ claude_dir = tmp_path / "claude-skills"
513
+ _write_claude_skill(claude_dir, "not-yet")
514
+ monkeypatch.setattr("turnloop.skills_install.CLAUDE_CODE_SKILLS_DIR", claude_dir)
515
+ monkeypatch.setattr("turnloop.configio.user_settings_path", lambda: tmp_path / "home" / "settings.json")
516
+ _raise_eof(monkeypatch)
517
+
518
+ args = argparse.Namespace(user=False, all=False)
519
+ with pytest.raises(SystemExit) as exc_info:
520
+ _cmd_skills_import(settings, args, settings.project_root)
521
+
522
+ assert exc_info.value.code == 1
523
+ assert "no input available" in capsys.readouterr().err
@@ -1,3 +1,3 @@
1
1
  """turnloop — a local-first agentic coding harness."""
2
2
 
3
- __version__ = "0.1.7"
3
+ __version__ = "0.1.8"
@@ -255,6 +255,22 @@ def _print_skill_target(settings: Settings, user_level: bool, cwd: Path) -> None
255
255
  f"current directory ({cwd}) -- run `tl config` to see why")
256
256
 
257
257
 
258
+ def _prompt(text: str) -> str:
259
+ """`input()`, but closed/piped stdin exits cleanly instead of a traceback.
260
+
261
+ Every confirmation in the skills commands goes through this. Piping stdin
262
+ from `/dev/null` (or any non-interactive invocation -- CI, a script) hits
263
+ EOF the instant `input()` reads, which is a normal thing for a CLI to face
264
+ and not worth a stack trace over.
265
+ """
266
+ try:
267
+ return input(text)
268
+ except EOFError:
269
+ print("\nskills: no input available (stdin is closed) -- rerun with --yes "
270
+ "for non-interactive use", file=sys.stderr)
271
+ raise SystemExit(1) from None
272
+
273
+
258
274
  _SKILL_TRUST_NOTICE = (
259
275
  "Its body will be loaded into the model's context whenever the model decides "
260
276
  "it applies -- this is the same class of trust decision as adding an MCP "
@@ -291,19 +307,30 @@ def _cmd_skills_add(settings: Settings, args: argparse.Namespace, cwd: Path) ->
291
307
  print(f"{args.source} contains {len(sources)} skills:")
292
308
  for i, s in enumerate(sources, 1):
293
309
  print(f" {i}. {s.name}")
294
- choice = input("install which? (numbers comma-separated, or 'all'): ").strip()
295
- if choice.lower() == "all":
310
+ if args.yes:
311
+ # `--yes` means "don't make me answer prompts", and the collection
312
+ # selector is a prompt like any other -- treating it differently would
313
+ # mean `--yes` still hangs (now fixed to error) waiting on stdin that
314
+ # was never going to arrive in a script/CI invocation. "all" is also
315
+ # the reading that makes `--yes` actually do what it says on the tin;
316
+ # each SKILL.md still goes through validate_skill_content below, so a
317
+ # broken one is skipped rather than silently written.
318
+ print(f"--yes: installing all {len(sources)}")
296
319
  chosen = sources
297
320
  else:
298
- try:
299
- picked = {int(x) for x in choice.split(",") if x.strip()}
300
- except ValueError:
301
- print("skills add: not a valid selection", file=sys.stderr)
302
- return 2
303
- chosen = [s for i, s in enumerate(sources, 1) if i in picked]
304
- if not chosen:
305
- print("nothing selected")
306
- return 0
321
+ choice = _prompt("install which? (numbers comma-separated, or 'all'): ").strip()
322
+ if choice.lower() == "all":
323
+ chosen = sources
324
+ else:
325
+ try:
326
+ picked = {int(x) for x in choice.split(",") if x.strip()}
327
+ except ValueError:
328
+ print("skills add: not a valid selection", file=sys.stderr)
329
+ return 2
330
+ chosen = [s for i, s in enumerate(sources, 1) if i in picked]
331
+ if not chosen:
332
+ print("nothing selected")
333
+ return 0
307
334
  else:
308
335
  chosen = sources
309
336
 
@@ -320,7 +347,7 @@ def _cmd_skills_add(settings: Settings, args: argparse.Namespace, cwd: Path) ->
320
347
  print(f"description: {description}")
321
348
  print(_SKILL_TRUST_NOTICE)
322
349
  if not args.yes:
323
- if input("install this skill? [y/N] ").strip().lower() not in ("y", "yes"):
350
+ if _prompt("install this skill? [y/N] ").strip().lower() not in ("y", "yes"):
324
351
  print(f"skipped {name}")
325
352
  continue
326
353
 
@@ -353,7 +380,7 @@ def _cmd_skills_remove(settings: Settings, args: argparse.Namespace) -> int:
353
380
 
354
381
  scope = "user" if args.user else "project"
355
382
  if not args.yes:
356
- confirm = input(f"remove skill '{args.name}' ({scope})? [y/N] ").strip().lower()
383
+ confirm = _prompt(f"remove skill '{args.name}' ({scope})? [y/N] ").strip().lower()
357
384
  if confirm not in ("y", "yes"):
358
385
  print("cancelled")
359
386
  return 0
@@ -389,7 +416,7 @@ def _cmd_skills_import(settings: Settings, args: argparse.Namespace, cwd: Path)
389
416
  if args.all:
390
417
  chosen = candidates
391
418
  else:
392
- choice = input("import which? (numbers comma-separated, 'all', or blank to skip): ").strip()
419
+ choice = _prompt("import which? (numbers comma-separated, 'all', or blank to skip): ").strip()
393
420
  if not choice:
394
421
  print("nothing imported. Run `tl skills import` again anytime.")
395
422
  return 0
@@ -95,6 +95,7 @@ async def fetch_skill_sources(source: str, client: httpx.AsyncClient) -> list[Sk
95
95
  paths = await _list_skill_md_paths(client, owner, name, branch)
96
96
  if not paths:
97
97
  raise SkillInstallError(f"no SKILL.md found in {owner}/{name}")
98
+ paths = _dedupe_skill_paths(paths) # before fetching -- no point downloading a copy we'll discard
98
99
  sources = []
99
100
  for path in paths:
100
101
  raw_url = f"https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}"
@@ -140,7 +141,15 @@ async def _default_branch(client: httpx.AsyncClient, owner: str, name: str) -> s
140
141
  async def _list_skill_md_paths(
141
142
  client: httpx.AsyncClient, owner: str, name: str, branch: str
142
143
  ) -> list[str]:
143
- """Every `SKILL.md` in the repo -- root or nested one directory deep, both."""
144
+ """Every `SKILL.md` in the repo, at any depth.
145
+
146
+ `recursive=1` on the git trees API walks the whole tree, not just root and
147
+ one level down -- that's how a real repo's `.openclaw/skills/<n>/SKILL.md`
148
+ (three levels deep) turns up alongside `skills/<n>/SKILL.md`. The old
149
+ docstring here claimed "root or nested one directory deep, both", which
150
+ was never what the code did; fixed to describe the actual (and wanted)
151
+ behavior instead of narrowing the code to match a wrong claim.
152
+ """
144
153
  response = await client.get(
145
154
  f"https://api.github.com/repos/{owner}/{name}/git/trees/{branch}",
146
155
  params={"recursive": "1"},
@@ -156,6 +165,41 @@ async def _list_skill_md_paths(
156
165
  )
157
166
 
158
167
 
168
+ def _dedupe_skill_paths(paths: list[str]) -> list[str]:
169
+ """One winning path per install name -- never two paths writing the same target.
170
+
171
+ A repo can ship the same skill twice: once at `skills/<name>/SKILL.md` for
172
+ itself, once at `.openclaw/skills/<name>/SKILL.md` (or any other harness's
173
+ dot-directory) so that harness's own loader picks it up too. Both resolve
174
+ to install name `<name>` (`_name_from_path` only looks at the parent dir),
175
+ so installing "all" would write the same target twice, second write
176
+ silently winning with no sign a collision happened.
177
+
178
+ Shallower path wins: fewer directory components before `SKILL.md` reads as
179
+ "closer to the repo's own root", which is a reasonable proxy for "the
180
+ canonical copy" for any repo, not just one with a `.openclaw/` layout. A
181
+ tie at equal depth is a genuine ambiguity -- e.g. `docs/x/SKILL.md` vs
182
+ `examples/x/SKILL.md` -- and gets refused rather than resolved by
183
+ guessing; the caller can still install either directly by raw URL.
184
+ """
185
+ by_name: dict[str, list[str]] = {}
186
+ for p in paths:
187
+ by_name.setdefault(_name_from_path(p), []).append(p)
188
+
189
+ winners = []
190
+ for name, candidates in by_name.items():
191
+ shallowest = min(p.count("/") for p in candidates)
192
+ tied = [p for p in candidates if p.count("/") == shallowest]
193
+ if len(tied) > 1:
194
+ raise SkillInstallError(
195
+ f"'{name}' has {len(tied)} equally-plausible SKILL.md paths and "
196
+ f"cannot be resolved automatically: {', '.join(sorted(tied))}. "
197
+ "Install one directly by its raw URL instead."
198
+ )
199
+ winners.append(tied[0])
200
+ return sorted(winners)
201
+
202
+
159
203
  def _name_from_path(path_or_url: str) -> str:
160
204
  """The install name: the SKILL.md's parent directory, or 'skill' at repo root."""
161
205
  path = urlparse(path_or_url).path if "://" in path_or_url else path_or_url
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes