8000 feat(config): make most CLI args toml-configurable by Tom94 · Pull Request #7 · Tom94/tclaude · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat(config): make most CLI args toml-configurable #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
aiofiles
aiohttp
beautifulsoup4
dataclasses-json
docstring-parser
html2text
humanize
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
"aiofiles",
"aiohttp",
"beautifulsoup4",
"dataclasses-json",
"docstring_parser",
"html2text",
"humanize",
Expand Down
66 changes: 33 additions & 33 deletions src/tclaude/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

from . import commands, common
from .common import History, is_valid_metadata
from .config import TClaudeArgs, load_system_prompt
from .config import TClaudeConfig, load_system_prompt
from .json import JSON, get_or
from .live_print import live_print
from .mcp import setup_mcp
Expand Down Expand Up @@ -86,18 +86,18 @@ async def gather_file_uploads(tasks: list[asyncio.Task[dict[str, JSON]]]) -> lis
return results


async def single_prompt(args: TClaudeArgs, config: dict[str, JSON], history: History, user_input: str, print_text_only: bool):
async def single_prompt(config: TClaudeConfig, history: History, user_input: str, print_text_only: bool):
"""
Main function to parse arguments, get user input, and print Anthropic's response.
"""

system_prompt = load_system_prompt(args.role) if args.role else None
system_prompt = load_system_prompt(config.role) if config.role else None
session = ChatSession(
history=history,
model=args.model,
model=config.model,
system_prompt=system_prompt,
role=os.path.splitext(os.path.basename(args.role))[0] if args.role and system_prompt else None,
name=deduce_session_name(args.session) if args.session else None,
role=os.path.splitext(os.path.basename(config.role))[0] if config.role and system_prompt else None,
name=deduce_session_name(config.session) if config.session else None,
)

async with AsyncExitStack() as stack:
Expand All @@ -106,8 +106,8 @@ async def single_prompt(args: TClaudeArgs, config: dict[str, JSON], history: His
async with asyncio.TaskGroup() as tg:
if session.uploaded_files:
_ = tg.create_task(session.verify_file_uploads(client_session))
file_metadata = [tg.create_task(session.upload_file(client_session, f)) for f in args.file]
mcp = await stack.enter_async_context(setup_mcp(client_session, config))
file_metadata = [tg.create_task(session.upload_file(client_session, f)) for f in config.files]
mcp = await stack.enter_async_context(setup_mcp(client_session, config.mcp))

user_content: list[JSON] = [{"type": "text", "text": user_input}]
user_content.extend(chain.from_iterable(file_metadata_to_content(m.result()) for m in file_metadata if m))
Expand All @@ -125,15 +125,15 @@ async def single_prompt(args: TClaudeArgs, config: dict[str, JSON], history: His
session=client_session,
model=session.model,
history=history,
max_tokens=args.max_tokens,
enable_web_search=not args.no_web_search, # Web search is enabled by default
enable_code_exec=not args.no_code_execution, # Code execution is enabled by default
max_tokens=config.max_tokens,
enable_web_search=config.web_search, # Web search is enabled by default
enable_code_exec=config.code_execution, # Code execution is enabled by default
external_tools_available=available_tools,
external_tool_definitions=tool_definitions,
mcp_remote_servers=await mcp.get_remote_server_descs(client_session),
system_prompt=system_prompt,
enable_thinking=args.thinking,
thinking_budget=args.thinking_budget,
enable_thinking=config.thinking,
thinking_budget=config.thinking_budget,
)

history.extend(response.messages)
Expand All @@ -146,27 +146,27 @@ async def single_prompt(args: TClaudeArgs, config: dict[str, JSON], history: His
logger.error("Broken pipe. Response could not passed on to the next command in the pipeline.")


async def chat(args: TClaudeArgs, config: dict[str, JSON], history: History, user_input: str):
async def chat(config: TClaudeConfig, history: History, user_input: str):
"""
Main function to get user input, and print Anthropic's response.
"""

system_prompt = load_system_prompt(args.role) if args.role else None
system_prompt = load_system_prompt(config.role) if config.role else None
session = ChatSession(
history=history,
model=args.model,
model=config.model,
system_prompt=system_prompt,
role=os.path.splitext(os.path.basename(args.role))[0] if args.role and system_prompt else None,
name=deduce_session_name(args.session) if args.session else None,
role=os.path.splitext(os.path.basename(config.role))[0] if config.role and system_prompt else None,
name=deduce_session_name(config.session) if config.session else None,
)

async with AsyncExitStack() as stack:
client = await stack.enter_async_context(aiohttp.ClientSession())

file_upload_verification_task = asyncio.create_task(session.verify_file_uploads(client)) if session.uploaded_files else None
file_upload_tasks = [asyncio.create_task(session.upload_file(client, f)) for f in args.file if f]
file_upload_tasks = [asyncio.create_task(session.upload_file(client, f)) for f in config.files if f]

mcp = setup_mcp(client, config)
mcp = setup_mcp(client, config.mcp)
mcp_setup = None if mcp.empty else await stack.enter_async_context(TaskAsyncContextManager(mcp))

input = create_input(always_prefer_tty=True)
Expand Down Expand Up @@ -301,9 +301,9 @@ def interrupt_handler(_signum: int, _frame: object):
tool_definitions.extend(mcp_tool_definitions)

container = common.get_latest_container(session.history)
write_cache = should_cache(response.tokens, args.model) if response is not None else False
write_cache = should_cache(response.tokens, config.model) if response is not None else False

if args.verbose:
if config.verbose:
if container is not None:
logger.info(f"Reusing code execution container `{container.id}`")

Expand All @@ -316,17 +316,17 @@ def interrupt_handler(_signum: int, _frame: object):
stream_task = asyncio.create_task(
stream_response(
session=client,
model=args.model,
model=config.model,
history=session.history,
max_tokens=args.max_tokens,
enable_web_search=not args.no_web_search, # Web search is enabled by default
enable_code_exec=not args.no_code_execution, # Code execution is enabled by default
max_tokens=config.max_tokens,
enable_web_search=config.web_search, # Web search is enabled by default
enable_code_exec=config.code_execution, # Code execution is enabled by default
external_tools_available=available_tools,
external_tool_definitions=tool_definitions,
mcp_remote_servers=await mcp.get_remote_server_descs(client),
system_prompt=session.system_prompt,
enable_thinking=args.thinking,
thinking_budget=args.thinking_budget,
enable_thinking=config.thinking,
thinking_budget=config.thinking_budget,
write_cache=write_cache,
on_response_update=lambda r: partial_response.__setattr__("messages", r.messages),
)
Expand Down Expand Up @@ -358,9 +358,9 @@ def interrupt_handler(_signum: int, _frame: object):
session.uploaded_files.update(common.get_uploaded_files(response.messages))

print("\n")
if args.verbose:
if config.verbose:
response.tokens.print_tokens()
response.tokens.print_cost(args.model)
response.tokens.print_cost(config.model)

# If we're beginning the next user turn, let us spawn a few background tasks to finish processing responses received so far.
if is_user_turn:
Expand All @@ -387,13 +387,13 @@ def interrupt_handler(_signum: int, _frame: object):
session_path += ".json"

if not os.path.isfile(session_path):
session_path = os.path.join(args.sessions_dir, session_path)
session_path = os.path.join(config.sessions_dir, session_path)

with open(session_path, "w") as f:
json.dump(session.history, f, indent=2)

logger.info(f"[✓] Saved session to {session_path}")

if args.verbose:
if config.verbose:
session.total_tokens.print_tokens()
session.total_tokens.print_cost(args.model)
session.total_tokens.print_cost(config.model)
110 changes: 89 additions & 21 deletions src/tclaude/config.py
B3E9
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
import os
import sys
import tomllib
from dataclasses import dataclass, field
from typing import cast

from dataclasses_json import Undefined, dataclass_json
from dataclasses_json.undefined import UndefinedParameterError

from .json import JSON

Expand Down Expand Up @@ -47,6 +52,14 @@ def default_sessions_dir() -> str:
return "."


def default_role() -> str | None:
default_role = os.path.join(get_config_dir(), "roles", "default.md")
if not os.path.isfile(default_role):
default_role = None

return default_role


def load_system_prompt(path: str) -> str | None:
system_prompt = None
if not os.path.isfile(path):
Expand Down Expand Up @@ -83,26 +96,24 @@ class TClaudeArgs(argparse.Namespace):
def __init__(self):
super().__init__()

default_role = os.path.join(get_config_dir(), "roles", "default.md")
if not os.path.isfile(default_role):
default_role = None

self.input: list[str]

self.config: str = "tclaude.toml"
self.version: bool = False
self.verbose: bool | None = None

# Configuration overrides (default values are set in TClaudeConfig)
self.file: list[str] = []
self.max_tokens: int = 2**14 # 16k tokens
self.model: str = "claude-sonnet-4-0"
self.no_code_execution: bool = False
self.no_web_search: bool = False
self.print_history: bool = False
self.role: str | None = default_role
self.max_tokens: int | None = None
self.model: str | None = None
self.no_code_execution: bool | None = None
self.no_web_search: bool | None = None
self.print_history: bool | None = None
self.role: str | None = None
self.session: str | None = None
self.sessions_dir: str = default_sessions_dir()
self.thinking: bool = False
self.sessions_dir: str | None = None
self.thinking: bool | None = None
self.thinking_budget: int | None = None
self.verbose: bool = False
self.version: bool = False


def parse_tclaude_args():
Expand All @@ -112,7 +123,7 @@ def parse_tclaude_args():
_ = parser.add_argument("--config", help="Path to the configuration file (default: tclaude.toml)")
_ = parser.add_argument("-f", "--file", action="append", help="Path to a file that should be sent to Claude as input")
_ = parser.add_argument("--max-tokens", help="Maximum number of tokens in the response (default: 16384)")
_ = parser.add_argument("-m", "--model", help="Anthropic model to use (default: claude-sonnet-4-0)")
_ = parser.add_argument("-m", "--model", help="Anthropic model to use (default: claude-sonnet-4-20250514)")
_ = parser.add_argument("--no-code-execution", action="store_true", help="Disable code execution capability")
_ = parser.add_argument("--no-web-search", action="store_true", help="Disable web search capability")
_ = parser.add_argument("-p", "--print_history", help="Print the conversation history only, without prompting.", action="store_true")
Expand All @@ -131,11 +142,65 @@ def parse_tclaude_args():
print(f"tclaude — Claude in the te F438 rminal\nversion {__version__}")
sys.exit(0)

args.model = deduce_model_name(args.model)
return args


def load_config(filename: str | None) -> dict[str, JSON]:
@dataclass_json(undefined=Undefined.RAISE)
@dataclass
class McpConfig:
local_servers: list[dict[str, JSON]] = field(default_factory=list)
remote_servers: list[dict[str, JSON]] = field(default_factory=list)


@dataclass_json(undefined=Undefined.RAISE)
@dataclass
class TClaudeConfig:
max_tokens: int = 2**14 # 16k tokens
model: str = "claude-sonnet-4-20250514"
role: str | None = default_role()

code_execution: bool = True
web_search: bool = True
thinking: bool = False
thinking_budget: int | None = None

sessions_dir: str = default_sessions_dir()

mcp: McpConfig = field(default_factory=McpConfig)

# Expected to come from args, but can *technically* be set in the config file.
files: list[str] = field(default_factory=list)
session: str | None = None
verbose: bool = False

def apply_args_override(self, args: TClaudeArgs):
if args.max_tokens is not None:
self.max_tokens = args.max_tokens
if args.model is not None:
self.model = deduce_model_name(args.model)
if args.role is not None:
self.role = args.role

if args.no_code_execution is not None:
self.code_execution = not args.no_code_execution
if args.no_web_search is not None:
self.web_search = not args.no_web_search
if args.thinking is not None:
self.thinking = args.thinking
if args.thinking_budget is not None:
self.thinking_budget = args.thinking_budget

if args.sessions_dir is not None:
self.sessions_dir = args.sessions_dir

self.files.extend(args.file)
if args.session is not None:
self.session = args.session
if args.verbose is not None:
self.verbose = args.verbose


def load_config(filename: str | None) -> TClaudeConfig:
"""
Load the configuration from the tclaude.toml file located in the config directory.
"""
Expand All @@ -154,8 +219,11 @@ def load_config(filename: str | None) -> dict[str, JSON]:

try:
with open(filename, "rb") as f:
config = tomllib.load(f)
config = cast(TClaudeConfig, TClaudeConfig.from_dict(tomllib.load(f))) # pyright: ignore
return config
except Exception as e:
logger.error(f"Failed to load configuration from {filename}: {e}")
return {}
except FileNotFoundError as e:
logger.error(f"Failed to load {filename}: {e}")
except (tomllib.TOMLDecodeError, ValueError, UndefinedParameterError) as e:
logger.error(f"{filename} is invalid: {e}")

return TClaudeConfig()
10 changes: 10 additions & 0 deletions src/tclaude/default-config/tclaude.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Example configuration for remote Model Context Protocol (MCP) servers

# [[mcp.local_servers]]
# name = "filesystem"
# command = "npx" # command and arguments to start the MCP server
# args = [
# "-y",
# "@modelcontextprotocol/server-filesystem",
# "~", # access to the home directory
# ]
# or: url = "http://localhost:3000" # if the server is already running

# [[mcp.remote_servers]]
# name = "example-mcp"
# url = "https://example-server.modelcontextprotocol.io/sse"
Expand Down
4 changes: 4 additions & 0 deletions src/tclaude/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

# Using TypeAlias instead of defining a new type such that isinstance(obj, JSON) works as expected.
JSON: TypeAlias = Mapping[str, "JSON"] | Sequence["JSON"] | str | int | float | bool | None
# type JSON = Mapping[str, JSON] | Sequence[JSON] | str | int | float | bool | None

# Allows for nested generic types, as well as unions. The type taken by `isinstance`.
ClassOrTuple: TypeAlias = type | tuple["ClassOrTuple", ...] | UnionType
Expand All @@ -29,6 +30,9 @@ def generic_is_instance(obj: JSON, target_type: ClassOrTuple) -> bool:
"""
Check if an object is an instance of a generic type, including nested types.
"""
# if target_type is JSON:
# return isinstance(obj, (str, int, float, bool, type(None), list, dict))

origin = get_origin(target_type)
if origin is None or target_type is UnionType:
return isinstance(obj, target_type)
Expand Down
Loading
0