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.
- {turnloop-0.1.7 → turnloop-0.1.9}/PKG-INFO +15 -7
- {turnloop-0.1.7 → turnloop-0.1.9}/README.md +14 -6
- {turnloop-0.1.7 → turnloop-0.1.9}/pyproject.toml +9 -1
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_skills_install.py +189 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/__init__.py +1 -1
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/system_prompt.py +5 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/cli.py +41 -14
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/skills_install.py +45 -1
- turnloop-0.1.7/.github/workflows/ci.yml +0 -36
- turnloop-0.1.7/.turnloop/.gitignore +0 -2
- turnloop-0.1.7/.turnloop/settings.local.json +0 -3
- {turnloop-0.1.7 → turnloop-0.1.9}/.gitignore +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/conftest.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/fixtures/anthropic_tool_call.sse +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/fixtures/glm_tool_call.sse +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/fixtures/mcp_echo_server.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_bash.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_compaction.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_config.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_config_screen.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_configio.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_experiments.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_hooks_mcp_commands.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_loop.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_permissions.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_providers.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_system_prompt_layout.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_tools_files.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_tui.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/tests/test_websearch.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/__main__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/factory.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/headless.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/loop.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/agent/subagent.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/commands/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/commands/dispatch.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/commands/loader.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/config.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/configio.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/budget.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/compaction.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/context/memory.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/events.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/ids.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/messages.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/core/tokens.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/diagnostics.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/errors.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/bakeoff.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/context-glm.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/context.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/groq-free.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/hard-glm.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/loop.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/reliability.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/configs/smoke.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/graders.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/report.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/runner.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/default.yaml +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/hooks/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/hooks/runner.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/mcp/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/mcp/adapter.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/mcp/client.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/permissions/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/permissions/engine.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/permissions/rules.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/anthropic.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/base.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/gemini.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/mock.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/openai_compat.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/pricing.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/registry.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/providers/sse.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/sessions/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/sessions/models.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/sessions/store.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/ask.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/base.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/bash.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/builtin.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/edit.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/glob.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/grep.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/read.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/runner.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/shell.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/skill.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/task.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/textio.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/todo.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/webfetch.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/websearch.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tools/write.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/app.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/app.tcss +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/bridge.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/config_screen.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/mcp_screen.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/session_screen.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/widgets/__init__.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/widgets/permission.py +0 -0
- {turnloop-0.1.7 → turnloop-0.1.9}/turnloop/tui/widgets/status.py +0 -0
- {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.
|
|
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,
|
|
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.
|
|
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 #
|
|
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` |
|
|
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
|
-
—
|
|
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.
|
|
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,
|
|
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.
|
|
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 #
|
|
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` |
|
|
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
|
-
—
|
|
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.
|
|
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
|
+
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
|
|
@@ -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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/models.py
RENAMED
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/services.py
RENAMED
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py
RENAMED
|
File without changes
|
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py
RENAMED
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/grep_edit/transform.py
RENAMED
|
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
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/regression_trap/validators.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{turnloop-0.1.7 → turnloop-0.1.9}/turnloop/experiments/suite/fixtures/rename/test_billing.py
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|