turnloop 0.1.7__tar.gz → 0.1.9__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.9}/PKG-INFO +15 -7
  2. {turnloop-0.1.7 → turnloop-0.1.9}/README.md +14 -6
  3. {turnloop-0.1.7 → turnloop-0.1.9}/pyproject.toml +9 -1
  4. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_skills_install.py +189 -0
  5. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/__init__.py +1 -1
  6. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/system_prompt.py +5 -0
  7. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/cli.py +41 -14
  8. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/skills_install.py +45 -1
  9. turnloop-0.1.7/.github/workflows/ci.yml +0 -36
  10. turnloop-0.1.7/.turnloop/.gitignore +0 -2
  11. turnloop-0.1.7/.turnloop/settings.local.json +0 -3
  12. {turnloop-0.1.7 → turnloop-0.1.9}/.gitignore +0 -0
  13. {turnloop-0.1.7 → turnloop-0.1.9}/tests/conftest.py +0 -0
  14. {turnloop-0.1.7 → turnloop-0.1.9}/tests/fixtures/anthropic_tool_call.sse +0 -0
  15. {turnloop-0.1.7 → turnloop-0.1.9}/tests/fixtures/glm_tool_call.sse +0 -0
  16. {turnloop-0.1.7 → turnloop-0.1.9}/tests/fixtures/mcp_echo_server.py +0 -0
  17. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_bash.py +0 -0
  18. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_compaction.py +0 -0
  19. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_config.py +0 -0
  20. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_config_screen.py +0 -0
  21. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_configio.py +0 -0
  22. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_experiments.py +0 -0
  23. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_hooks_mcp_commands.py +0 -0
  24. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_loop.py +0 -0
  25. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_permissions.py +0 -0
  26. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_providers.py +0 -0
  27. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_system_prompt_layout.py +0 -0
  28. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_tools_files.py +0 -0
  29. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_tui.py +0 -0
  30. {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_websearch.py +0 -0
  31. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/__main__.py +0 -0
  32. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/__init__.py +0 -0
  33. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/factory.py +0 -0
  34. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/headless.py +0 -0
  35. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/loop.py +0 -0
  36. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/subagent.py +0 -0
  37. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/commands/__init__.py +0 -0
  38. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/commands/dispatch.py +0 -0
  39. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/commands/loader.py +0 -0
  40. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/config.py +0 -0
  41. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/configio.py +0 -0
  42. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/__init__.py +0 -0
  43. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/budget.py +0 -0
  44. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/compaction.py +0 -0
  45. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/memory.py +0 -0
  46. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/__init__.py +0 -0
  47. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/events.py +0 -0
  48. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/ids.py +0 -0
  49. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/messages.py +0 -0
  50. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/tokens.py +0 -0
  51. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/diagnostics.py +0 -0
  52. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/errors.py +0 -0
  53. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/__init__.py +0 -0
  54. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  55. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  56. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/context-glm.yaml +0 -0
  57. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/context.yaml +0 -0
  58. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  59. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/groq-free.yaml +0 -0
  60. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  61. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  62. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/loop.yaml +0 -0
  63. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  64. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/reliability.yaml +0 -0
  65. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  66. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/smoke.yaml +0 -0
  67. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/graders.py +0 -0
  68. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/report.py +0 -0
  69. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/runner.py +0 -0
  70. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/default.yaml +0 -0
  71. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  72. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  73. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  74. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  75. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  76. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  77. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  78. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  79. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  80. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  81. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  82. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  83. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  84. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  85. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  86. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  87. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  88. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  89. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  90. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  91. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  92. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  93. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  94. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  95. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  96. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  97. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  98. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  99. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  100. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  101. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  102. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  103. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  104. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  105. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  106. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  107. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  108. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  109. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  110. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  111. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  112. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/hooks/__init__.py +0 -0
  113. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/hooks/runner.py +0 -0
  114. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/mcp/__init__.py +0 -0
  115. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/mcp/adapter.py +0 -0
  116. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/mcp/client.py +0 -0
  117. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/permissions/__init__.py +0 -0
  118. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/permissions/engine.py +0 -0
  119. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/permissions/rules.py +0 -0
  120. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/__init__.py +0 -0
  121. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/anthropic.py +0 -0
  122. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/base.py +0 -0
  123. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/gemini.py +0 -0
  124. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/openai_compat.py +0 -0
  126. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/pricing.py +0 -0
  127. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/registry.py +0 -0
  128. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/sse.py +0 -0
  129. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/sessions/__init__.py +0 -0
  130. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/sessions/models.py +0 -0
  131. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/sessions/store.py +0 -0
  132. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/__init__.py +0 -0
  133. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/ask.py +0 -0
  134. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/base.py +0 -0
  135. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/bash.py +0 -0
  136. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/builtin.py +0 -0
  137. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/edit.py +0 -0
  138. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/glob.py +0 -0
  139. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/grep.py +0 -0
  140. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/read.py +0 -0
  141. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/runner.py +0 -0
  142. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/shell.py +0 -0
  143. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/skill.py +0 -0
  144. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/task.py +0 -0
  145. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/textio.py +0 -0
  146. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/todo.py +0 -0
  147. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/webfetch.py +0 -0
  148. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/websearch.py +0 -0
  149. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/write.py +0 -0
  150. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/__init__.py +0 -0
  151. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/app.py +0 -0
  152. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/app.tcss +0 -0
  153. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/bridge.py +0 -0
  154. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/config_screen.py +0 -0
  155. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/mcp_screen.py +0 -0
  156. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/session_screen.py +0 -0
  157. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/widgets/__init__.py +0 -0
  158. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/widgets/permission.py +0 -0
  159. {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/widgets/status.py +0 -0
  160. {turnloop-0.1.7 → turnloop-0.1.9}/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.9
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.9
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 |
@@ -1092,11 +1092,19 @@ in the system prompt and load their body on demand, which on a 65k window is the
1092
1092
  difference between having skills and not.
1093
1093
 
1094
1094
  The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
1095
- — 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1095
+ — 432 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1096
1096
  the model cannot configure the tool it is running inside: asked to add an MCP server
1097
1097
  it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
1098
1098
  `examples/` — both real outputs from live runs. With it, both go to the right place
1099
- first try. It includes the literal YAML frontmatter a skill needs, since a skill with
1099
+ first try.
1100
+
1101
+ It also names `tl skills add` explicitly and tells the model *not* to run the install
1102
+ scripts skill repos publish for other agents. That paragraph was added after watching
1103
+ three separate sessions try `/plugin marketplace add` as a shell command, then
1104
+ `clawhub install`, then `git clone` plus `npm install` plus `npm test` — twenty-six
1105
+ messages ending in "the ponytail skill has been installed", when nothing had been put
1106
+ anywhere turnloop looks. The same request now costs 11,090 tokens and answers
1107
+ `tl skills add DietrichGebert/ponytail`. It includes the literal YAML frontmatter a skill needs, since a skill with
1100
1108
  markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
1101
1109
  now also reports anything found on disk but rejected, with the reason and path — see
1102
1110
  [Bugs worth reading about](#bugs-worth-reading-about).
@@ -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.9
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 |
@@ -1063,11 +1063,19 @@ in the system prompt and load their body on demand, which on a 65k window is the
1063
1063
  difference between having skills and not.
1064
1064
 
1065
1065
  The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
1066
- — 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1066
+ — 432 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1067
1067
  the model cannot configure the tool it is running inside: asked to add an MCP server
1068
1068
  it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
1069
1069
  `examples/` — both real outputs from live runs. With it, both go to the right place
1070
- first try. It includes the literal YAML frontmatter a skill needs, since a skill with
1070
+ first try.
1071
+
1072
+ It also names `tl skills add` explicitly and tells the model *not* to run the install
1073
+ scripts skill repos publish for other agents. That paragraph was added after watching
1074
+ three separate sessions try `/plugin marketplace add` as a shell command, then
1075
+ `clawhub install`, then `git clone` plus `npm install` plus `npm test` — twenty-six
1076
+ messages ending in "the ponytail skill has been installed", when nothing had been put
1077
+ anywhere turnloop looks. The same request now costs 11,090 tokens and answers
1078
+ `tl skills add DietrichGebert/ponytail`. It includes the literal YAML frontmatter a skill needs, since a skill with
1071
1079
  markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
1072
1080
  now also reports anything found on disk but rejected, with the reason and path — see
1073
1081
  [Bugs worth reading about](#bugs-worth-reading-about).
@@ -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.9"
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"
@@ -54,6 +54,14 @@ Homepage = "https://github.com/Pranesh-2005/turnloop"
54
54
  [tool.hatch.build.targets.wheel]
55
55
  packages = ["turnloop"]
56
56
 
57
+ # Without an explicit include, the sdist sweeps up whatever happens to be sitting
58
+ # in the working tree. A stray `git clone` left behind by a failed skill install
59
+ # put 157 of 317 entries — 4.2 MB of an unrelated repository — into the 0.1.8
60
+ # sdist before this was pinned down. An allowlist fails closed: new junk is not
61
+ # published, and a genuinely new top-level path has to be added here on purpose.
62
+ [tool.hatch.build.targets.sdist]
63
+ include = ["turnloop", "tests", "README.md", "pyproject.toml", "LICENSE"]
64
+
57
65
  [tool.ruff]
58
66
  line-length = 100
59
67
  target-version = "py311"
@@ -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.9"
@@ -114,6 +114,11 @@ Frontmatter is YAML, not markdown, and needs `description` or it is dropped \
114
114
  name: my-skill
115
115
  description: one line the model uses to decide when to load this
116
116
  ---
117
+ Installing one is the user's job, not yours: `tl skills add <owner/repo>` \
118
+ fetches and validates a SKILL.md, and `tl skills import` adopts skills already \
119
+ on the machine. Skill repos publish install scripts for other agents \
120
+ (`/plugin ...`, `clawhub`, `npm install`) — none of those put anything where \
121
+ turnloop looks, so never run them. Point the user at `tl skills add` instead.
117
122
  """
118
123
 
119
124
  PLAN_MODE = """\
@@ -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
@@ -1,36 +0,0 @@
1
- name: ci
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
-
8
- jobs:
9
- test:
10
- strategy:
11
- fail-fast: false
12
- matrix:
13
- # Windows is not optional here: shell resolution and process-tree kill are
14
- # platform-specific and are exactly where this project's bugs live.
15
- os: [ubuntu-latest, windows-latest]
16
- python: ["3.11", "3.13"]
17
- runs-on: ${{ matrix.os }}
18
- steps:
19
- - uses: actions/checkout@v5
20
-
21
- - uses: actions/setup-python@v6
22
- with:
23
- python-version: ${{ matrix.python }}
24
-
25
- - run: pip install -e ".[dev]"
26
-
27
- - name: Tests
28
- # Network is blocked at the transport layer by conftest, so `not live` is
29
- # belt and braces rather than the only guard.
30
- run: pytest -m "not live"
31
-
32
- - name: Lint
33
- run: ruff check turnloop tests
34
-
35
- - name: Types
36
- run: mypy turnloop
@@ -1,2 +0,0 @@
1
- sessions/
2
- settings.local.json
@@ -1,3 +0,0 @@
1
- {
2
- "provider": "blaxel"
3
- }
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