thwip-cli 1.1.2__tar.gz → 1.1.3__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.
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/.github/workflows/publish.yml +15 -5
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/PKG-INFO +2 -2
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/README.md +1 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/pyproject.toml +1 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_cli.py +17 -1
- thwip_cli-1.1.3/tests/test_config.py +21 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_tools.py +21 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/__init__.py +1 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/claude_agent.py +27 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/google_agent.py +28 -7
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/openai_agent.py +23 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/cli.py +91 -24
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/config.py +31 -8
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/session.py +17 -4
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/theme.py +1 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/__init__.py +15 -9
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/file_editor.py +24 -2
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/terminal.py +4 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/uv.lock +1 -1
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/.gitignore +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/LICENSE +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/install.sh +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_agents.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_detector.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_session.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/__main__.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/base.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/detector.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/limits.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/utils.py +0 -0
|
@@ -4,17 +4,16 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
tags:
|
|
6
6
|
- 'v*'
|
|
7
|
-
workflow_dispatch:
|
|
8
7
|
|
|
9
8
|
jobs:
|
|
10
9
|
test:
|
|
11
10
|
runs-on: ubuntu-latest
|
|
12
11
|
steps:
|
|
13
12
|
- name: Checkout code
|
|
14
|
-
uses: actions/checkout@
|
|
13
|
+
uses: actions/checkout@v6
|
|
15
14
|
|
|
16
15
|
- name: Install uv
|
|
17
|
-
uses: astral-sh/setup-uv@
|
|
16
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
18
17
|
|
|
19
18
|
- name: Set up Python
|
|
20
19
|
run: uv python install 3.13
|
|
@@ -33,14 +32,25 @@ jobs:
|
|
|
33
32
|
|
|
34
33
|
steps:
|
|
35
34
|
- name: Checkout code
|
|
36
|
-
uses: actions/checkout@
|
|
35
|
+
uses: actions/checkout@v6
|
|
37
36
|
|
|
38
37
|
- name: Install uv
|
|
39
|
-
uses: astral-sh/setup-uv@
|
|
38
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
39
|
+
|
|
40
|
+
- name: Verify tag matches package version
|
|
41
|
+
run: |
|
|
42
|
+
PACKAGE_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
43
|
+
test "${GITHUB_REF_NAME}" = "v${PACKAGE_VERSION}" || {
|
|
44
|
+
echo "Tag ${GITHUB_REF_NAME} does not match package version v${PACKAGE_VERSION}."
|
|
45
|
+
exit 1
|
|
46
|
+
}
|
|
40
47
|
|
|
41
48
|
- name: Build package
|
|
42
49
|
run: uv build
|
|
43
50
|
|
|
51
|
+
- name: Verify package metadata
|
|
52
|
+
run: uvx twine check dist/*
|
|
53
|
+
|
|
44
54
|
- name: Publish to PyPI
|
|
45
55
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
56
|
with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
|
|
5
5
|
Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
|
|
@@ -39,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
39
39
|
|
|
40
40
|
> Universal Coding Agent Multiplexer
|
|
41
41
|
>
|
|
42
|
-
>
|
|
42
|
+
> Discover supported local AI tools, connect configured model providers in one terminal interface, and switch providers while preserving completed text conversation history.
|
|
43
43
|
|
|
44
44
|
---
|
|
45
45
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Universal Coding Agent Multiplexer
|
|
4
4
|
>
|
|
5
|
-
>
|
|
5
|
+
> Discover supported local AI tools, connect configured model providers in one terminal interface, and switch providers while preserving completed text conversation history.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from thwip.agents.base import AgentDone, Capability, TextDelta, ToolUseStart
|
|
10
|
-
from thwip.cli import ThwipCLI
|
|
10
|
+
from thwip.cli import SafeFileHistory, ThwipCLI
|
|
11
11
|
from thwip.session import Session
|
|
12
12
|
from thwip.tools import ToolManager
|
|
13
13
|
|
|
@@ -44,6 +44,10 @@ class ToolCallingAgent:
|
|
|
44
44
|
yield ToolUseStart(tool_id="read-1", tool_name="read_file", args={"file_path": "note.txt"})
|
|
45
45
|
yield AgentDone()
|
|
46
46
|
else:
|
|
47
|
+
assert messages[-2]["role"] == "assistant"
|
|
48
|
+
assert messages[-2]["tool_calls"][0]["id"] == "read-1"
|
|
49
|
+
assert messages[-1]["role"] == "tool"
|
|
50
|
+
assert messages[-1]["tool_call_id"] == "read-1"
|
|
47
51
|
assert "known result" in messages[-1]["content"]
|
|
48
52
|
yield TextDelta(content="Used the tool result.")
|
|
49
53
|
yield AgentDone()
|
|
@@ -94,3 +98,15 @@ def test_inline_api_key_is_rejected():
|
|
|
94
98
|
|
|
95
99
|
assert cli.config.keys == {}
|
|
96
100
|
assert cli.config.key_sources == {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_inline_api_key_is_not_stored_in_prompt_history(tmp_path):
|
|
104
|
+
history_path = tmp_path / "history.txt"
|
|
105
|
+
history = SafeFileHistory(str(history_path))
|
|
106
|
+
|
|
107
|
+
history.store_string("/key openai secret-value")
|
|
108
|
+
history.store_string("/key openai")
|
|
109
|
+
|
|
110
|
+
contents = history_path.read_text()
|
|
111
|
+
assert "secret-value" not in contents
|
|
112
|
+
assert "/key openai" in contents
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Regression tests for secure configuration persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from thwip.config import ThwipConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_save_does_not_persist_environment_or_discovered_keys(tmp_path, monkeypatch):
|
|
11
|
+
monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
|
|
12
|
+
config = ThwipConfig(
|
|
13
|
+
keys={"openai": "from-env", "anthropic": "explicit"},
|
|
14
|
+
key_sources={"openai": "env:OPENAI_API_KEY", "anthropic": "config.toml"},
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
config.save()
|
|
18
|
+
|
|
19
|
+
data = tomllib.loads((tmp_path / "config.toml").read_text())
|
|
20
|
+
assert data["keys"] == {"anthropic": "explicit"}
|
|
21
|
+
assert (tmp_path / "config.toml").stat().st_mode & 0o777 == 0o600
|
|
@@ -6,7 +6,10 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import tempfile
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
9
11
|
from thwip.tools import ToolManager
|
|
12
|
+
from thwip.tools.terminal import TerminalRunner
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
def test_file_editor_operations():
|
|
@@ -49,3 +52,21 @@ def test_file_editor_blocks_paths_outside_workspace(tmp_path):
|
|
|
49
52
|
assert "outside the project workspace" in manager.file_editor.read_file(str(outside))
|
|
50
53
|
assert "outside the project workspace" in manager.file_editor.write_file("../outside.txt", "changed")
|
|
51
54
|
assert outside.read_text() == "private"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_tool_output_is_bounded(tmp_path):
|
|
58
|
+
manager = ToolManager(project_path=tmp_path)
|
|
59
|
+
(tmp_path / "large.txt").write_text("x" * 25_000)
|
|
60
|
+
|
|
61
|
+
result = manager.execute_tool("read_file", {"file_path": "large.txt", "max_lines": 1})
|
|
62
|
+
|
|
63
|
+
assert len(result) <= 20_100
|
|
64
|
+
assert "Output truncated" in result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_async_command_timeout_terminates_process(tmp_path):
|
|
69
|
+
runner = TerminalRunner(tmp_path)
|
|
70
|
+
result = await runner.run_command_async("sleep 5", timeout=0.01)
|
|
71
|
+
|
|
72
|
+
assert "timed out" in result
|
|
@@ -313,11 +313,37 @@ class ClaudeAgent(BaseAgent):
|
|
|
313
313
|
if tools:
|
|
314
314
|
stream = False
|
|
315
315
|
|
|
316
|
+
# Convert the portable OpenAI-style tool transcript into Anthropic blocks.
|
|
317
|
+
anthropic_messages: list[dict[str, Any]] = []
|
|
318
|
+
for message in messages:
|
|
319
|
+
if message.get("tool_calls"):
|
|
320
|
+
blocks: list[dict[str, Any]] = []
|
|
321
|
+
if message.get("content"):
|
|
322
|
+
blocks.append({"type": "text", "text": message["content"]})
|
|
323
|
+
for call in message["tool_calls"]:
|
|
324
|
+
function = call["function"]
|
|
325
|
+
arguments = function.get("arguments", {})
|
|
326
|
+
blocks.append({
|
|
327
|
+
"type": "tool_use", "id": call["id"], "name": function["name"],
|
|
328
|
+
"input": json.loads(arguments) if isinstance(arguments, str) else arguments,
|
|
329
|
+
})
|
|
330
|
+
anthropic_messages.append({"role": "assistant", "content": blocks})
|
|
331
|
+
elif message.get("role") == "tool":
|
|
332
|
+
anthropic_messages.append({
|
|
333
|
+
"role": "user",
|
|
334
|
+
"content": [{
|
|
335
|
+
"type": "tool_result", "tool_use_id": message["tool_call_id"],
|
|
336
|
+
"content": message.get("content", ""),
|
|
337
|
+
}],
|
|
338
|
+
})
|
|
339
|
+
else:
|
|
340
|
+
anthropic_messages.append(message)
|
|
341
|
+
|
|
316
342
|
# Build request kwargs
|
|
317
343
|
model_info = self.get_model_info(model)
|
|
318
344
|
kwargs: dict[str, Any] = {
|
|
319
345
|
"model": model,
|
|
320
|
-
"messages":
|
|
346
|
+
"messages": anthropic_messages,
|
|
321
347
|
"max_tokens": model_info.max_output if model_info else 16_384,
|
|
322
348
|
}
|
|
323
349
|
|
|
@@ -286,22 +286,43 @@ class GoogleAgent(BaseAgent):
|
|
|
286
286
|
client = self._ensure_client()
|
|
287
287
|
model = model or self.get_default_model()
|
|
288
288
|
|
|
289
|
+
from google.genai import types
|
|
290
|
+
|
|
289
291
|
# Convert messages to Gemini format
|
|
290
292
|
gemini_contents = []
|
|
291
293
|
for msg in messages:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
"
|
|
295
|
-
|
|
296
|
-
|
|
294
|
+
if msg.get("tool_calls"):
|
|
295
|
+
parts = []
|
|
296
|
+
if msg.get("content"):
|
|
297
|
+
parts.append(types.Part.from_text(text=msg["content"]))
|
|
298
|
+
for call in msg["tool_calls"]:
|
|
299
|
+
function = call["function"]
|
|
300
|
+
arguments = function.get("arguments", {})
|
|
301
|
+
if isinstance(arguments, str):
|
|
302
|
+
arguments = json.loads(arguments)
|
|
303
|
+
parts.append(types.Part.from_function_call(
|
|
304
|
+
name=function["name"], args=arguments,
|
|
305
|
+
))
|
|
306
|
+
gemini_contents.append(types.Content(role="model", parts=parts))
|
|
307
|
+
elif msg.get("role") == "tool":
|
|
308
|
+
gemini_contents.append(types.Content(
|
|
309
|
+
role="user",
|
|
310
|
+
parts=[types.Part.from_function_response(
|
|
311
|
+
name=msg.get("name", "tool"),
|
|
312
|
+
response={"result": msg.get("content", "")},
|
|
313
|
+
)],
|
|
314
|
+
))
|
|
315
|
+
else:
|
|
316
|
+
role = "user" if msg["role"] == "user" else "model"
|
|
317
|
+
gemini_contents.append(types.Content(
|
|
318
|
+
role=role, parts=[types.Part.from_text(text=msg["content"])],
|
|
319
|
+
))
|
|
297
320
|
|
|
298
321
|
config: dict[str, Any] = {}
|
|
299
322
|
if system_prompt:
|
|
300
323
|
config["system_instruction"] = system_prompt
|
|
301
324
|
|
|
302
325
|
try:
|
|
303
|
-
from google.genai import types
|
|
304
|
-
|
|
305
326
|
if tools:
|
|
306
327
|
declarations = []
|
|
307
328
|
for tool in tools:
|
|
@@ -362,9 +362,31 @@ class OpenAIAgent(BaseAgent):
|
|
|
362
362
|
"description": function.get("description", ""),
|
|
363
363
|
"parameters": function.get("parameters", {}),
|
|
364
364
|
})
|
|
365
|
+
response_input: list[dict[str, Any]] = []
|
|
366
|
+
for message in api_messages:
|
|
367
|
+
if message.get("tool_calls"):
|
|
368
|
+
if message.get("content"):
|
|
369
|
+
response_input.append({"role": "assistant", "content": message["content"]})
|
|
370
|
+
for call in message["tool_calls"]:
|
|
371
|
+
function = call["function"]
|
|
372
|
+
arguments = function.get("arguments", {})
|
|
373
|
+
response_input.append({
|
|
374
|
+
"type": "function_call", "call_id": call["id"],
|
|
375
|
+
"name": function["name"],
|
|
376
|
+
"arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
|
|
377
|
+
})
|
|
378
|
+
elif message.get("role") == "tool":
|
|
379
|
+
response_input.append({
|
|
380
|
+
"type": "function_call_output",
|
|
381
|
+
"call_id": message["tool_call_id"],
|
|
382
|
+
"output": message.get("content", ""),
|
|
383
|
+
})
|
|
384
|
+
else:
|
|
385
|
+
response_input.append(message)
|
|
386
|
+
|
|
365
387
|
response_kwargs: dict[str, Any] = {
|
|
366
388
|
"model": model,
|
|
367
|
-
"input":
|
|
389
|
+
"input": response_input,
|
|
368
390
|
"max_output_tokens": model_info.max_output if model_info else 16_384,
|
|
369
391
|
}
|
|
370
392
|
if response_tools:
|
|
@@ -4,7 +4,7 @@ Main interactive terminal CLI for thwip.
|
|
|
4
4
|
Universal coding agent multiplexer:
|
|
5
5
|
- Dynamic UI adapting to agent capabilities
|
|
6
6
|
- Seamless mid-conversation agent switching with context preservation
|
|
7
|
-
-
|
|
7
|
+
- Detection of supported installed tools and available credentials
|
|
8
8
|
- Quota / rate limit exhaustion failover
|
|
9
9
|
- Rich live markdown and tool execution
|
|
10
10
|
"""
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import getpass
|
|
16
16
|
import os
|
|
17
|
+
import re
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
20
|
from prompt_toolkit import PromptSession
|
|
@@ -59,6 +60,15 @@ from thwip.theme import (
|
|
|
59
60
|
from thwip.tools import ToolManager
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
class SafeFileHistory(FileHistory):
|
|
64
|
+
"""Keep inline API-key commands out of persistent prompt history."""
|
|
65
|
+
|
|
66
|
+
def store_string(self, string: str) -> None:
|
|
67
|
+
if re.match(r"^\s*/(?:key|auth|config|k)\s+\S+\s+\S+", string, re.IGNORECASE):
|
|
68
|
+
return
|
|
69
|
+
super().store_string(string)
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
class ThwipCLI:
|
|
63
73
|
"""The interactive terminal CLI REPL."""
|
|
64
74
|
|
|
@@ -127,9 +137,15 @@ class ThwipCLI:
|
|
|
127
137
|
)
|
|
128
138
|
|
|
129
139
|
# Setup prompt session
|
|
130
|
-
|
|
140
|
+
history_path = get_config_dir() / "history.txt"
|
|
141
|
+
history_path.touch(mode=0o600, exist_ok=True)
|
|
142
|
+
try:
|
|
143
|
+
history_path.chmod(0o600)
|
|
144
|
+
except OSError:
|
|
145
|
+
pass
|
|
146
|
+
history_file = str(history_path)
|
|
131
147
|
prompt_session: PromptSession = PromptSession(
|
|
132
|
-
history=
|
|
148
|
+
history=SafeFileHistory(history_file),
|
|
133
149
|
completer=ThwipCompleter([a.name for a in self.registry.list_agents()]),
|
|
134
150
|
key_bindings=create_keybindings(),
|
|
135
151
|
)
|
|
@@ -177,7 +193,7 @@ class ThwipCLI:
|
|
|
177
193
|
|
|
178
194
|
async def handle_command(self, cmd_line: str) -> str | None:
|
|
179
195
|
"""Handle slash commands."""
|
|
180
|
-
parts = cmd_line.split(
|
|
196
|
+
parts = cmd_line.split(maxsplit=2)
|
|
181
197
|
cmd = parts[0].lower()
|
|
182
198
|
arg1 = parts[1] if len(parts) > 1 else ""
|
|
183
199
|
arg2 = parts[2] if len(parts) > 2 else ""
|
|
@@ -236,10 +252,20 @@ class ThwipCLI:
|
|
|
236
252
|
elif sub == "load":
|
|
237
253
|
loaded = Session.load(arg2)
|
|
238
254
|
if loaded:
|
|
239
|
-
|
|
255
|
+
project = Path(loaded.project_path).expanduser().resolve()
|
|
256
|
+
if not project.is_dir():
|
|
257
|
+
print_error(f"Saved project directory '{loaded.project_path}' no longer exists.")
|
|
258
|
+
return None
|
|
240
259
|
agent = self.registry.get_agent(loaded.current_agent)
|
|
241
|
-
if agent:
|
|
242
|
-
|
|
260
|
+
if not agent:
|
|
261
|
+
print_error(f"Saved agent '{loaded.current_agent}' is not available.")
|
|
262
|
+
return None
|
|
263
|
+
if not agent.get_model_info(loaded.current_model):
|
|
264
|
+
loaded.current_model = agent.get_default_model()
|
|
265
|
+
print_warning("The saved model is unavailable. Using the provider default.")
|
|
266
|
+
self.session = loaded
|
|
267
|
+
self.current_agent = agent
|
|
268
|
+
self.tool_manager = ToolManager(str(project))
|
|
243
269
|
print_success(f"Loaded session '{loaded.name}' with {len(loaded.messages)} messages.")
|
|
244
270
|
else:
|
|
245
271
|
print_error(f"Session '{arg2}' not found.")
|
|
@@ -283,8 +309,8 @@ class ThwipCLI:
|
|
|
283
309
|
("edit_file", "Filesystem", "Structured string replacement in local files", "Active"),
|
|
284
310
|
("write_file", "Filesystem", "Create or overwrite full files", "Active"),
|
|
285
311
|
("list_dir", "Filesystem", "List directory tree and metadata", "Active"),
|
|
286
|
-
("run_command", "Terminal", "Execute
|
|
287
|
-
("run_code", "
|
|
312
|
+
("run_command", "Terminal", "Execute confirmed shell commands in the project", "Active"),
|
|
313
|
+
("run_code", "Execution", "Run confirmed Python snippets with local user permissions", "Active"),
|
|
288
314
|
("git_status", "Version Control", "Inspect repository changes and staging", "Active"),
|
|
289
315
|
("git_diff", "Version Control", "View unified unstaged/staged diffs", "Active"),
|
|
290
316
|
]
|
|
@@ -415,7 +441,10 @@ class ThwipCLI:
|
|
|
415
441
|
f" Or add it to ~/.thwip/config.toml"
|
|
416
442
|
)
|
|
417
443
|
else:
|
|
418
|
-
print_success(
|
|
444
|
+
print_success(
|
|
445
|
+
f"Now chatting with {new_agent.display_name} ({chosen_model}). "
|
|
446
|
+
"Portable text history preserved."
|
|
447
|
+
)
|
|
419
448
|
|
|
420
449
|
def cmd_show_agents(self) -> None:
|
|
421
450
|
"""Show table of all detected agents."""
|
|
@@ -690,6 +719,8 @@ class ThwipCLI:
|
|
|
690
719
|
if p.is_dir():
|
|
691
720
|
self.session.project_path = str(p)
|
|
692
721
|
self.tool_manager = ToolManager(str(p))
|
|
722
|
+
self.config.project = str(p)
|
|
723
|
+
self.config.save()
|
|
693
724
|
print_success(f"Project path changed to {p}")
|
|
694
725
|
else:
|
|
695
726
|
print_error(f"Directory '{new_path}' does not exist.")
|
|
@@ -728,9 +759,9 @@ class ThwipCLI:
|
|
|
728
759
|
Text.from_markup(
|
|
729
760
|
f"[bold yellow]Missing API Key for {self.current_agent.display_name}[/bold yellow]\n\n"
|
|
730
761
|
f"An API key is required to send requests to [bold]{self.session.current_model}[/bold].\n\n"
|
|
731
|
-
f"
|
|
732
|
-
f"
|
|
733
|
-
f"
|
|
762
|
+
f" - Configure key now: [bold cyan]/key {self.current_agent.name}[/bold cyan]\n"
|
|
763
|
+
f" - Or set in shell: [bold white]export {key_name}=your_key_here[/bold white]\n"
|
|
764
|
+
f" - Or switch agent: [bold cyan]/switch[/bold cyan]"
|
|
734
765
|
),
|
|
735
766
|
title="Setup Required",
|
|
736
767
|
border_style="yellow",
|
|
@@ -819,7 +850,8 @@ class ThwipCLI:
|
|
|
819
850
|
if limit_hit or not tool_requests:
|
|
820
851
|
break
|
|
821
852
|
|
|
822
|
-
|
|
853
|
+
tool_call_messages = []
|
|
854
|
+
tool_result_messages = []
|
|
823
855
|
for request in tool_requests:
|
|
824
856
|
display_args = {key: value for key, value in request.args.items() if key != "content"}
|
|
825
857
|
console.print(
|
|
@@ -835,15 +867,25 @@ class ThwipCLI:
|
|
|
835
867
|
if approved
|
|
836
868
|
else "Denied by user."
|
|
837
869
|
)
|
|
838
|
-
|
|
839
|
-
|
|
870
|
+
result_preview = Text(" Result: ", style="green")
|
|
871
|
+
result_preview.append(output[:500], style="dim")
|
|
872
|
+
console.print(result_preview)
|
|
873
|
+
tool_call_messages.append({
|
|
874
|
+
"id": request.tool_id,
|
|
875
|
+
"type": "function",
|
|
876
|
+
"function": {"name": request.tool_name, "arguments": request.args},
|
|
877
|
+
})
|
|
878
|
+
tool_result_messages.append({
|
|
879
|
+
"role": "tool",
|
|
880
|
+
"tool_call_id": request.tool_id,
|
|
881
|
+
"name": request.tool_name,
|
|
882
|
+
"content": output,
|
|
883
|
+
})
|
|
840
884
|
|
|
841
|
-
assistant_note = round_text or "I requested one or more workspace tools."
|
|
842
|
-
working_messages.append({"role": "assistant", "content": assistant_note})
|
|
843
885
|
working_messages.append({
|
|
844
|
-
"role": "
|
|
845
|
-
"content": "Workspace tool results follow. Continue the task using these results.\n\n" + "\n\n".join(results),
|
|
886
|
+
"role": "assistant", "content": round_text, "tool_calls": tool_call_messages,
|
|
846
887
|
})
|
|
888
|
+
working_messages.extend(tool_result_messages)
|
|
847
889
|
else:
|
|
848
890
|
print_warning("Stopped after 8 consecutive tool rounds to prevent an infinite loop.")
|
|
849
891
|
|
|
@@ -855,18 +897,41 @@ class ThwipCLI:
|
|
|
855
897
|
company=self.current_agent.company,
|
|
856
898
|
tokens=total_tokens,
|
|
857
899
|
)
|
|
900
|
+
if getattr(self.config, "auto_save", False):
|
|
901
|
+
if self.session.name == "new-session":
|
|
902
|
+
self.session.name = f"session-{self.session.id}"
|
|
903
|
+
self.session.save()
|
|
858
904
|
|
|
859
905
|
async def handle_limit_failover(self, event: LimitHit) -> None:
|
|
860
906
|
"""Handle rate limit or quota exhaustion with auto-suggested failover."""
|
|
907
|
+
if not self.config.fallback.enabled:
|
|
908
|
+
print_warning("Provider fallback is disabled in configuration.")
|
|
909
|
+
return
|
|
910
|
+
|
|
861
911
|
# Find ready alternatives
|
|
862
912
|
ready = self.registry.get_ready_agents()
|
|
863
913
|
alternatives = []
|
|
864
|
-
|
|
865
|
-
|
|
914
|
+
seen: set[str] = set()
|
|
915
|
+
candidates: list[tuple[BaseAgent, str]] = []
|
|
916
|
+
for target in self.config.fallback.chain:
|
|
917
|
+
agent_name, separator, model = target.partition("/")
|
|
918
|
+
agent = self.registry.get_agent(agent_name)
|
|
919
|
+
if not agent or agent.name == self.current_agent.name or agent not in ready:
|
|
920
|
+
continue
|
|
921
|
+
chosen_model = model if separator and agent.get_model_info(model) else agent.get_default_model()
|
|
922
|
+
candidates.append((agent, chosen_model))
|
|
923
|
+
seen.add(agent.name)
|
|
924
|
+
candidates.extend(
|
|
925
|
+
(agent, agent.get_default_model())
|
|
926
|
+
for agent in ready
|
|
927
|
+
if agent.name != self.current_agent.name and agent.name not in seen
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
for a, chosen_model in candidates:
|
|
866
931
|
alternatives.append({
|
|
867
932
|
"agent": a.display_name,
|
|
868
933
|
"company": a.company,
|
|
869
|
-
"model":
|
|
934
|
+
"model": chosen_model,
|
|
870
935
|
"capabilities": [c.value for c in a.capabilities],
|
|
871
936
|
})
|
|
872
937
|
|
|
@@ -883,7 +948,9 @@ class ThwipCLI:
|
|
|
883
948
|
print_warning("No other configured agents found. Please add an API key or start Ollama.")
|
|
884
949
|
return
|
|
885
950
|
|
|
886
|
-
choice =
|
|
951
|
+
choice = "1" if self.config.limits.auto_switch else input(
|
|
952
|
+
"\nSwitch to alternative agent now? [1 to switch, Enter to cancel]: "
|
|
953
|
+
).strip()
|
|
887
954
|
if choice == "1" or choice.lower() == "y":
|
|
888
955
|
target_alt = alternatives[0]
|
|
889
956
|
for a in ready:
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import tempfile
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any
|
|
@@ -42,7 +43,11 @@ def get_config_dir() -> Path:
|
|
|
42
43
|
def get_sessions_dir() -> Path:
|
|
43
44
|
"""Return ~/.thwip/sessions/, creating it if needed."""
|
|
44
45
|
d = get_config_dir() / "sessions"
|
|
45
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
47
|
+
try:
|
|
48
|
+
d.chmod(0o700)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
46
51
|
return d
|
|
47
52
|
|
|
48
53
|
|
|
@@ -158,7 +163,12 @@ def get_key_sources() -> dict[str, str]:
|
|
|
158
163
|
if path.is_file():
|
|
159
164
|
try:
|
|
160
165
|
data = json.loads(path.read_text())
|
|
161
|
-
|
|
166
|
+
key_field = agent_conf["key_field"]
|
|
167
|
+
value = data.get(key_field, "") if isinstance(data, dict) else ""
|
|
168
|
+
credentials = data.get("credentials", {}) if isinstance(data, dict) else {}
|
|
169
|
+
if not value and isinstance(credentials, dict):
|
|
170
|
+
value = credentials.get(key_field, "")
|
|
171
|
+
if value:
|
|
162
172
|
sources[provider] = str(path)
|
|
163
173
|
break
|
|
164
174
|
except (json.JSONDecodeError, OSError):
|
|
@@ -317,7 +327,12 @@ class ThwipConfig:
|
|
|
317
327
|
"auto_save": self.auto_save,
|
|
318
328
|
"confirm_tools": self.confirm_tools,
|
|
319
329
|
},
|
|
320
|
-
|
|
330
|
+
# Never persist keys discovered from environment variables or another
|
|
331
|
+
# application's config. Only values explicitly stored in thwip belong here.
|
|
332
|
+
"keys": {
|
|
333
|
+
k: v for k, v in self.keys.items()
|
|
334
|
+
if self.key_sources.get(k) == "config.toml"
|
|
335
|
+
},
|
|
321
336
|
"ollama": {
|
|
322
337
|
"host": self.ollama_host,
|
|
323
338
|
},
|
|
@@ -342,12 +357,20 @@ class ThwipConfig:
|
|
|
342
357
|
}
|
|
343
358
|
|
|
344
359
|
config_path = get_config_path()
|
|
345
|
-
|
|
346
|
-
tomli_w.dump(data, f)
|
|
360
|
+
temporary_path: Path | None = None
|
|
347
361
|
try:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
362
|
+
with tempfile.NamedTemporaryFile(
|
|
363
|
+
mode="wb", dir=config_path.parent, prefix=".config-", delete=False
|
|
364
|
+
) as temporary:
|
|
365
|
+
temporary_path = Path(temporary.name)
|
|
366
|
+
os.chmod(temporary.name, 0o600)
|
|
367
|
+
tomli_w.dump(data, temporary)
|
|
368
|
+
temporary.flush()
|
|
369
|
+
os.fsync(temporary.fileno())
|
|
370
|
+
temporary_path.replace(config_path)
|
|
371
|
+
finally:
|
|
372
|
+
if temporary_path and temporary_path.exists():
|
|
373
|
+
temporary_path.unlink()
|
|
351
374
|
|
|
352
375
|
def get_key(self, provider: str) -> str | None:
|
|
353
376
|
"""Get API key for a provider, returning None if not found."""
|
|
@@ -7,7 +7,9 @@ Enables seamless context portability across agent switches without losing histor
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
import os
|
|
10
11
|
import re
|
|
12
|
+
import tempfile
|
|
11
13
|
import time
|
|
12
14
|
import uuid
|
|
13
15
|
from dataclasses import asdict, dataclass, field
|
|
@@ -127,10 +129,21 @@ class Session:
|
|
|
127
129
|
"updated_at": self.updated_at,
|
|
128
130
|
"messages": [m.to_dict() for m in self.messages],
|
|
129
131
|
}
|
|
130
|
-
temporary_path =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
temporary_path: Path | None = None
|
|
133
|
+
try:
|
|
134
|
+
with tempfile.NamedTemporaryFile(
|
|
135
|
+
mode="w", encoding="utf-8", dir=sessions_dir,
|
|
136
|
+
prefix=f".{self.name}-", suffix=".tmp", delete=False,
|
|
137
|
+
) as temporary:
|
|
138
|
+
temporary_path = Path(temporary.name)
|
|
139
|
+
os.chmod(temporary.name, 0o600)
|
|
140
|
+
json.dump(data, temporary, indent=2)
|
|
141
|
+
temporary.flush()
|
|
142
|
+
os.fsync(temporary.fileno())
|
|
143
|
+
temporary_path.replace(file_path)
|
|
144
|
+
finally:
|
|
145
|
+
if temporary_path and temporary_path.exists():
|
|
146
|
+
temporary_path.unlink()
|
|
134
147
|
return file_path
|
|
135
148
|
|
|
136
149
|
@classmethod
|
|
@@ -199,7 +199,7 @@ def render_about_guide(
|
|
|
199
199
|
content.append(" 1. Auto-Detection: Scans environment variables, local CLI agents, and existing credentials\n", style="dim")
|
|
200
200
|
content.append(" in ~/.claude.json, ~/.gemini/config.json, ~/.config/openai/, or Ollama.\n", style="dim")
|
|
201
201
|
content.append(" 2. Unified State: Maintains conversation history and tool outputs in a portable format.\n", style="dim")
|
|
202
|
-
content.append(" 3. Hot-Swapping: Switch models
|
|
202
|
+
content.append(" 3. Hot-Swapping: Switch models with completed text history preserved.\n", style="dim")
|
|
203
203
|
content.append(" 4. Universal Tools: Provides safe file editing, shell commands, execution, and git operations.\n", style="dim")
|
|
204
204
|
content.append(" 5. Quota Failover: Catches HTTP 429 errors and offers immediate one-key fallback switching.\n\n", style="dim")
|
|
205
205
|
|
|
@@ -217,22 +217,28 @@ class ToolManager:
|
|
|
217
217
|
def execute_tool(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
218
218
|
"""Execute a tool by name with arguments and return output."""
|
|
219
219
|
if tool_name == "read_file":
|
|
220
|
-
|
|
220
|
+
result = self.file_editor.read_file(args.get("file_path", ""))
|
|
221
221
|
elif tool_name == "write_file":
|
|
222
|
-
|
|
222
|
+
result = self.file_editor.write_file(args.get("file_path", ""), args.get("content", ""))
|
|
223
223
|
elif tool_name == "edit_file":
|
|
224
|
-
|
|
224
|
+
result = self.file_editor.edit_file(
|
|
225
225
|
args.get("file_path", ""), args.get("old_str", ""), args.get("new_str", "")
|
|
226
226
|
)
|
|
227
227
|
elif tool_name == "run_command":
|
|
228
|
-
|
|
228
|
+
result = self.terminal.run_command(args.get("command", ""))
|
|
229
229
|
elif tool_name == "list_files":
|
|
230
|
-
|
|
230
|
+
result = self.file_editor.list_files(args.get("sub_dir", "."))
|
|
231
231
|
elif tool_name == "git_status":
|
|
232
|
-
|
|
232
|
+
result = self.git.status()
|
|
233
233
|
elif tool_name == "git_diff":
|
|
234
|
-
|
|
234
|
+
result = self.git.diff(args.get("staged", False))
|
|
235
235
|
elif tool_name == "run_python":
|
|
236
|
-
|
|
236
|
+
result = self.code_runner.run_python(args.get("code", ""))
|
|
237
237
|
else:
|
|
238
|
-
|
|
238
|
+
result = f"Error: Unknown tool '{tool_name}'."
|
|
239
|
+
|
|
240
|
+
max_chars = 20_000
|
|
241
|
+
if len(result) > max_chars:
|
|
242
|
+
omitted = len(result) - max_chars
|
|
243
|
+
return f"{result[:max_chars]}\n\n[Output truncated: {omitted} characters omitted]"
|
|
244
|
+
return result
|
|
@@ -7,6 +7,7 @@ Read, write, edit, search files within the project working directory.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
import tempfile
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
|
|
@@ -49,7 +50,7 @@ class FileEditor:
|
|
|
49
50
|
try:
|
|
50
51
|
target = self._resolve_path(file_path)
|
|
51
52
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
-
|
|
53
|
+
self._atomic_write(target, content)
|
|
53
54
|
return f"Successfully wrote {len(content.splitlines())} lines to '{file_path}'."
|
|
54
55
|
except Exception as e:
|
|
55
56
|
return f"Error writing to file '{file_path}': {e}"
|
|
@@ -70,11 +71,32 @@ class FileEditor:
|
|
|
70
71
|
if count > 1:
|
|
71
72
|
return f"Error: Target text found {count} times. Please specify a more unique target block."
|
|
72
73
|
new_content = content.replace(old_str, new_str, 1)
|
|
73
|
-
|
|
74
|
+
self._atomic_write(target, new_content)
|
|
74
75
|
return f"Successfully updated '{file_path}'."
|
|
75
76
|
except Exception as e:
|
|
76
77
|
return f"Error editing file '{file_path}': {e}"
|
|
77
78
|
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _atomic_write(target: Path, content: str) -> None:
|
|
81
|
+
"""Replace a file atomically so interrupted writes cannot corrupt it."""
|
|
82
|
+
temporary_path: Path | None = None
|
|
83
|
+
existing_mode = target.stat().st_mode & 0o777 if target.exists() else None
|
|
84
|
+
try:
|
|
85
|
+
with tempfile.NamedTemporaryFile(
|
|
86
|
+
mode="w", encoding="utf-8", dir=target.parent,
|
|
87
|
+
prefix=f".{target.name}-", suffix=".tmp", delete=False,
|
|
88
|
+
) as temporary:
|
|
89
|
+
temporary_path = Path(temporary.name)
|
|
90
|
+
temporary.write(content)
|
|
91
|
+
temporary.flush()
|
|
92
|
+
os.fsync(temporary.fileno())
|
|
93
|
+
if existing_mode is not None:
|
|
94
|
+
temporary_path.chmod(existing_mode)
|
|
95
|
+
temporary_path.replace(target)
|
|
96
|
+
finally:
|
|
97
|
+
if temporary_path and temporary_path.exists():
|
|
98
|
+
temporary_path.unlink()
|
|
99
|
+
|
|
78
100
|
def list_files(self, sub_dir: str = ".", max_entries: int = 100) -> str:
|
|
79
101
|
"""List files and directories within project."""
|
|
80
102
|
try:
|
|
@@ -42,6 +42,7 @@ class TerminalRunner:
|
|
|
42
42
|
|
|
43
43
|
async def run_command_async(self, command: str, timeout: int = 30) -> str:
|
|
44
44
|
"""Run a shell command asynchronously."""
|
|
45
|
+
proc: asyncio.subprocess.Process | None = None
|
|
45
46
|
try:
|
|
46
47
|
proc = await asyncio.create_subprocess_shell(
|
|
47
48
|
command,
|
|
@@ -60,6 +61,9 @@ class TerminalRunner:
|
|
|
60
61
|
res_str += f"STDERR:\n{err}\n"
|
|
61
62
|
return res_str.strip()
|
|
62
63
|
except TimeoutError:
|
|
64
|
+
if proc and proc.returncode is None:
|
|
65
|
+
proc.kill()
|
|
66
|
+
await proc.communicate()
|
|
63
67
|
return f"Error: Command timed out after {timeout} seconds."
|
|
64
68
|
except Exception as e:
|
|
65
69
|
return f"Error executing command: {e}"
|
|
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
|