q-bot 1.4.0__tar.gz → 2.0.0.dev1__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.
- q_bot-2.0.0.dev1/PKG-INFO +75 -0
- q_bot-2.0.0.dev1/README.md +56 -0
- q_bot-2.0.0.dev1/pyproject.toml +37 -0
- q_bot-2.0.0.dev1/q/__init__.py +1 -0
- q_bot-2.0.0.dev1/q/agents.py +63 -0
- q_bot-2.0.0.dev1/q/cli/commands.py +434 -0
- q_bot-2.0.0.dev1/q/cli/main.py +26 -0
- q_bot-2.0.0.dev1/q/cli/models.py +90 -0
- q_bot-2.0.0.dev1/q/cli/parser.py +136 -0
- q_bot-2.0.0.dev1/q/cli/session.py +207 -0
- q_bot-2.0.0.dev1/q/cli/terminal.py +66 -0
- q_bot-2.0.0.dev1/q/client.py +53 -0
- q_bot-2.0.0.dev1/q/message.py +14 -0
- q_bot-2.0.0.dev1/q/providers/__init__.py +21 -0
- q_bot-2.0.0.dev1/q/providers/anthropic.py +66 -0
- q_bot-2.0.0.dev1/q/providers/openai.py +69 -0
- q_bot-2.0.0.dev1/q_bot.egg-info/PKG-INFO +75 -0
- q_bot-2.0.0.dev1/q_bot.egg-info/SOURCES.txt +21 -0
- q_bot-2.0.0.dev1/q_bot.egg-info/entry_points.txt +2 -0
- q_bot-2.0.0.dev1/q_bot.egg-info/requires.txt +9 -0
- q_bot-1.4.0/PKG-INFO +0 -237
- q_bot-1.4.0/README.md +0 -223
- q_bot-1.4.0/pyproject.toml +0 -31
- q_bot-1.4.0/q.py +0 -403
- q_bot-1.4.0/q_bot.egg-info/PKG-INFO +0 -237
- q_bot-1.4.0/q_bot.egg-info/SOURCES.txt +0 -9
- q_bot-1.4.0/q_bot.egg-info/entry_points.txt +0 -2
- q_bot-1.4.0/q_bot.egg-info/requires.txt +0 -4
- {q_bot-1.4.0 → q_bot-2.0.0.dev1}/q_bot.egg-info/dependency_links.txt +0 -0
- {q_bot-1.4.0 → q_bot-2.0.0.dev1}/q_bot.egg-info/top_level.txt +0 -0
- {q_bot-1.4.0 → q_bot-2.0.0.dev1}/setup.cfg +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: q-bot
|
|
3
|
+
Version: 2.0.0.dev1
|
|
4
|
+
Summary: An LLM agent from the comfort of your command line
|
|
5
|
+
Author-email: Tushar Khan <dev@tusharkhan.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/tk755/q
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: anthropic==0.75.0
|
|
11
|
+
Requires-Dist: colorama==0.4.6
|
|
12
|
+
Requires-Dist: distro==1.9.0
|
|
13
|
+
Requires-Dist: humanize==4.14.0
|
|
14
|
+
Requires-Dist: openai==2.9.0
|
|
15
|
+
Requires-Dist: pydantic==2.12.5
|
|
16
|
+
Requires-Dist: pyperclip==1.11.0
|
|
17
|
+
Requires-Dist: python-dotenv==1.2.1
|
|
18
|
+
Requires-Dist: termcolor==3.2.0
|
|
19
|
+
|
|
20
|
+
# Overview
|
|
21
|
+
|
|
22
|
+
`q` is a lightweight, flexible, multi-provider LLM-agent framework for the terminal.
|
|
23
|
+
|
|
24
|
+
I built this before Claude Code ever existed, and it remains useful to me for quick CLI interactions or prototyping multi-agent experiments. However, for most complex coding tasks, Claude Code is unquestionably superior.
|
|
25
|
+
|
|
26
|
+
# Installation
|
|
27
|
+
|
|
28
|
+
Install using any pip-compatible package manager (e.g. `pip`, `pipx`, `uv`, etc.):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pipx install q-bot
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.12+.
|
|
35
|
+
|
|
36
|
+
# CLI Usage
|
|
37
|
+
|
|
38
|
+
`q` uses a simple paradigm where each character from a-z is mapped to a single flag representing a command or option. This enables concise combinations of flags to achieve complex behavior.
|
|
39
|
+
|
|
40
|
+
## Flag Reference
|
|
41
|
+
|
|
42
|
+
| Flag | Name | Arg | Description | Type |
|
|
43
|
+
| ---- | ------------ | ------- | ------------------------------ | ------: |
|
|
44
|
+
| `-a` | agent | | *[reserved for future use]* | Command |
|
|
45
|
+
| `-b` | batch | | *[reserved for future use]* | |
|
|
46
|
+
| `-c` | code | str | generate code | Command |
|
|
47
|
+
| `-d` | directory | - / str | add a directory to context | Option |
|
|
48
|
+
| `-e` | explain | - / str | explain code or text | Command |
|
|
49
|
+
| `-f` | file | str | read input from file | Option |
|
|
50
|
+
| `-g` | | | | |
|
|
51
|
+
| `-h` | help | - / str | help message / help agent | Command |
|
|
52
|
+
| `-i` | image | str | generate/edit an image | Command |
|
|
53
|
+
| `-j` | json | - | output as JSON | Option |
|
|
54
|
+
| `-k` | api key | str | *[reserved for future use]* | Option |
|
|
55
|
+
| `-l` | load | - / int | list all / load session by id | Command |
|
|
56
|
+
| `-m` | model | str | set model and/or provider | Option |
|
|
57
|
+
| `-n` | | | | |
|
|
58
|
+
| `-o` | output | str | output file | Option |
|
|
59
|
+
| `-p` | | | | |
|
|
60
|
+
| `-q` | | | | |
|
|
61
|
+
| `-r` | rag | - / str | *[reserved for future use]* | Command |
|
|
62
|
+
| `-s` | shell | - / str | generate a shell command | Command |
|
|
63
|
+
| `-t` | text | str | generate text | Command |
|
|
64
|
+
| `-u` | user command | str | *[reserved for future use]* | Command |
|
|
65
|
+
| `-v` | verbose | - | debug logging | Option |
|
|
66
|
+
| `-w` | web search | str | search the web | Command |
|
|
67
|
+
| `-x` | execute | - | execute a shell command | Option |
|
|
68
|
+
| `-y` | | | | |
|
|
69
|
+
| `-z` | undo | - / int | undo exchanges (default 1) | Option |
|
|
70
|
+
|
|
71
|
+
<!-- [TODO]
|
|
72
|
+
|
|
73
|
+
# Library Usage
|
|
74
|
+
|
|
75
|
+
`q` follows a highly modular and provider-agnostic capability-driven design. -->
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Overview
|
|
2
|
+
|
|
3
|
+
`q` is a lightweight, flexible, multi-provider LLM-agent framework for the terminal.
|
|
4
|
+
|
|
5
|
+
I built this before Claude Code ever existed, and it remains useful to me for quick CLI interactions or prototyping multi-agent experiments. However, for most complex coding tasks, Claude Code is unquestionably superior.
|
|
6
|
+
|
|
7
|
+
# Installation
|
|
8
|
+
|
|
9
|
+
Install using any pip-compatible package manager (e.g. `pip`, `pipx`, `uv`, etc.):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pipx install q-bot
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Python 3.12+.
|
|
16
|
+
|
|
17
|
+
# CLI Usage
|
|
18
|
+
|
|
19
|
+
`q` uses a simple paradigm where each character from a-z is mapped to a single flag representing a command or option. This enables concise combinations of flags to achieve complex behavior.
|
|
20
|
+
|
|
21
|
+
## Flag Reference
|
|
22
|
+
|
|
23
|
+
| Flag | Name | Arg | Description | Type |
|
|
24
|
+
| ---- | ------------ | ------- | ------------------------------ | ------: |
|
|
25
|
+
| `-a` | agent | | *[reserved for future use]* | Command |
|
|
26
|
+
| `-b` | batch | | *[reserved for future use]* | |
|
|
27
|
+
| `-c` | code | str | generate code | Command |
|
|
28
|
+
| `-d` | directory | - / str | add a directory to context | Option |
|
|
29
|
+
| `-e` | explain | - / str | explain code or text | Command |
|
|
30
|
+
| `-f` | file | str | read input from file | Option |
|
|
31
|
+
| `-g` | | | | |
|
|
32
|
+
| `-h` | help | - / str | help message / help agent | Command |
|
|
33
|
+
| `-i` | image | str | generate/edit an image | Command |
|
|
34
|
+
| `-j` | json | - | output as JSON | Option |
|
|
35
|
+
| `-k` | api key | str | *[reserved for future use]* | Option |
|
|
36
|
+
| `-l` | load | - / int | list all / load session by id | Command |
|
|
37
|
+
| `-m` | model | str | set model and/or provider | Option |
|
|
38
|
+
| `-n` | | | | |
|
|
39
|
+
| `-o` | output | str | output file | Option |
|
|
40
|
+
| `-p` | | | | |
|
|
41
|
+
| `-q` | | | | |
|
|
42
|
+
| `-r` | rag | - / str | *[reserved for future use]* | Command |
|
|
43
|
+
| `-s` | shell | - / str | generate a shell command | Command |
|
|
44
|
+
| `-t` | text | str | generate text | Command |
|
|
45
|
+
| `-u` | user command | str | *[reserved for future use]* | Command |
|
|
46
|
+
| `-v` | verbose | - | debug logging | Option |
|
|
47
|
+
| `-w` | web search | str | search the web | Command |
|
|
48
|
+
| `-x` | execute | - | execute a shell command | Option |
|
|
49
|
+
| `-y` | | | | |
|
|
50
|
+
| `-z` | undo | - / int | undo exchanges (default 1) | Option |
|
|
51
|
+
|
|
52
|
+
<!-- [TODO]
|
|
53
|
+
|
|
54
|
+
# Library Usage
|
|
55
|
+
|
|
56
|
+
`q` follows a highly modular and provider-agnostic capability-driven design. -->
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "q-bot"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "An LLM agent from the comfort of your command line"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"anthropic==0.75.0",
|
|
12
|
+
"colorama==0.4.6",
|
|
13
|
+
"distro==1.9.0",
|
|
14
|
+
"humanize==4.14.0",
|
|
15
|
+
"openai==2.9.0",
|
|
16
|
+
"pydantic==2.12.5",
|
|
17
|
+
"pyperclip==1.11.0",
|
|
18
|
+
"python-dotenv==1.2.1",
|
|
19
|
+
"termcolor==3.2.0",
|
|
20
|
+
]
|
|
21
|
+
authors = [
|
|
22
|
+
{name = "Tushar Khan", email = "dev@tusharkhan.com"}
|
|
23
|
+
]
|
|
24
|
+
readme = "README.md"
|
|
25
|
+
license = "MIT"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
q = "q.cli.main:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Repository = "https://github.com/tk755/q"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["q*"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.dynamic]
|
|
37
|
+
version = {attr = "q.__version__"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.0.dev1"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .message import Message, Role
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChatAgent[T]:
|
|
8
|
+
"""Conversational agent with persistent message history."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, client: Client[T], system: str | None = None, messages: list[Message] | None = None):
|
|
11
|
+
self.client = client
|
|
12
|
+
self.system = system
|
|
13
|
+
self.messages: list[Message] = messages.copy() if messages else []
|
|
14
|
+
|
|
15
|
+
async def prompt(self, text: str) -> T:
|
|
16
|
+
"""Generate response and update conversation history."""
|
|
17
|
+
self.messages.append(Message(role=Role.USER, content=text))
|
|
18
|
+
|
|
19
|
+
messages = self.messages
|
|
20
|
+
if self.system:
|
|
21
|
+
messages = [Message(role=Role.SYSTEM, content=self.system), *self.messages]
|
|
22
|
+
response = await self.client.generate(messages)
|
|
23
|
+
|
|
24
|
+
if isinstance(response, str):
|
|
25
|
+
self.messages.append(Message(role=Role.ASSISTANT, content=response))
|
|
26
|
+
|
|
27
|
+
return response
|
|
28
|
+
|
|
29
|
+
def drop_exchanges(self, n: int = 1) -> None:
|
|
30
|
+
"""Drop the last N conversation exchanges (user message + responses)."""
|
|
31
|
+
if n <= 0:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
user_messages_found = 0
|
|
35
|
+
for i in range(len(self.messages) - 1, -1, -1):
|
|
36
|
+
if self.messages[i].role == Role.USER:
|
|
37
|
+
user_messages_found += 1
|
|
38
|
+
if user_messages_found == n:
|
|
39
|
+
self.messages = self.messages[:i]
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BatchAgent[T]:
|
|
44
|
+
"""Batch agent for applying a single prompt to multiple inputs concurrently."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, client: Client[T], system: str | None = None):
|
|
47
|
+
self.client = client
|
|
48
|
+
self.system = system
|
|
49
|
+
|
|
50
|
+
async def batch_prompt(self, text_list: list[str], n_threads: int = 8) -> list[T]:
|
|
51
|
+
"""Process multiple inputs concurrently and return the outputs in order."""
|
|
52
|
+
semaphore = asyncio.Semaphore(n_threads)
|
|
53
|
+
|
|
54
|
+
async def process(text: str) -> T:
|
|
55
|
+
async with semaphore:
|
|
56
|
+
messages: list[Message] = []
|
|
57
|
+
if self.system:
|
|
58
|
+
messages.append(Message(role=Role.SYSTEM, content=self.system))
|
|
59
|
+
messages.append(Message(role=Role.USER, content=text))
|
|
60
|
+
return await self.client.generate(messages)
|
|
61
|
+
|
|
62
|
+
tasks = [process(text) for text in text_list]
|
|
63
|
+
return await asyncio.gather(*tasks)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import string
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import distro
|
|
16
|
+
import humanize
|
|
17
|
+
import pyperclip
|
|
18
|
+
from termcolor import colored
|
|
19
|
+
|
|
20
|
+
from q import __version__
|
|
21
|
+
from q.providers import load_client_class
|
|
22
|
+
|
|
23
|
+
from ..agents import ChatAgent
|
|
24
|
+
from ..message import Role
|
|
25
|
+
from .models import Tier, resolve_model_arg
|
|
26
|
+
from .session import SessionManager
|
|
27
|
+
from .terminal import UserError, format_response, qprint
|
|
28
|
+
|
|
29
|
+
# region Registry
|
|
30
|
+
|
|
31
|
+
COMMANDS: list[type[Command]] = []
|
|
32
|
+
OPTIONS: list[type[Flag]] = []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_default_command() -> type[Command]:
|
|
36
|
+
char = SessionManager.load_command_char()
|
|
37
|
+
if char:
|
|
38
|
+
for cmd in COMMANDS:
|
|
39
|
+
if cmd.char == char:
|
|
40
|
+
return cmd
|
|
41
|
+
return TextCommand
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# region Types
|
|
45
|
+
|
|
46
|
+
type Value = str | int | None
|
|
47
|
+
type ArgMap = dict[str, Value]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ValueType(Enum):
|
|
51
|
+
NONE = None
|
|
52
|
+
TEXT = "text"
|
|
53
|
+
STR = "str"
|
|
54
|
+
INT = "N"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# region Base Classes
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Flag(ABC):
|
|
61
|
+
"""Base class for CLI flags. Subclasses auto-register to OPTIONS."""
|
|
62
|
+
|
|
63
|
+
char: str
|
|
64
|
+
desc: str
|
|
65
|
+
value_type: ValueType = ValueType.NONE
|
|
66
|
+
required: bool = False
|
|
67
|
+
default: Value = None
|
|
68
|
+
|
|
69
|
+
def __init_subclass__(cls, **kwargs):
|
|
70
|
+
"""Auto-register subclass to OPTIONS if it defines a char."""
|
|
71
|
+
super().__init_subclass__(**kwargs)
|
|
72
|
+
if hasattr(cls, "char"):
|
|
73
|
+
OPTIONS.append(cls)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Command(Flag):
|
|
77
|
+
"""Base class for CLI commands. Subclasses auto-register to COMMANDS."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, args: ArgMap):
|
|
80
|
+
self.args = args
|
|
81
|
+
|
|
82
|
+
def __init_subclass__(cls, **kwargs):
|
|
83
|
+
"""Move subclass from OPTIONS to COMMANDS if it defines a char."""
|
|
84
|
+
super().__init_subclass__(**kwargs)
|
|
85
|
+
if hasattr(cls, "char"):
|
|
86
|
+
OPTIONS.remove(cls)
|
|
87
|
+
COMMANDS.append(cls)
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
async def execute(self) -> None: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AgentCommand(Command):
|
|
94
|
+
"""Base class for commands that prompt an agent."""
|
|
95
|
+
|
|
96
|
+
client_str: str = "TextClient"
|
|
97
|
+
tier: Tier
|
|
98
|
+
system: str | None = None
|
|
99
|
+
clip: bool = False
|
|
100
|
+
|
|
101
|
+
async def execute(self) -> None:
|
|
102
|
+
if "n" in self.args:
|
|
103
|
+
SessionManager.new_session()
|
|
104
|
+
|
|
105
|
+
# resolve provider, model, and model args
|
|
106
|
+
default_provider = SessionManager.load_default_provider()
|
|
107
|
+
provider, model, model_args = resolve_model_arg(self.args.get("m"), self.tier, default_provider)
|
|
108
|
+
|
|
109
|
+
# create client dynamically
|
|
110
|
+
client_class = load_client_class(provider, self.client_str)
|
|
111
|
+
api_key = SessionManager.load_api_key(provider)
|
|
112
|
+
client = client_class(api_key, model, **model_args)
|
|
113
|
+
|
|
114
|
+
if "v" in self.args:
|
|
115
|
+
qprint("MODEL PARAMETERS:", color="cyan", file=sys.stderr)
|
|
116
|
+
qprint("model: ", color="green", file=sys.stderr, end="")
|
|
117
|
+
qprint(f"{client.model} ({provider})", file=sys.stderr)
|
|
118
|
+
if client.model_args:
|
|
119
|
+
for k, v in client.model_args.items():
|
|
120
|
+
qprint(f"{k}: ", color="green", file=sys.stderr, end="")
|
|
121
|
+
qprint(f"{v}", file=sys.stderr)
|
|
122
|
+
|
|
123
|
+
# resolve system prompt (command's system overrides saved system)
|
|
124
|
+
system = self.system if self.system is not None else SessionManager.load_system()
|
|
125
|
+
|
|
126
|
+
# create agent
|
|
127
|
+
agent = ChatAgent(client, system, SessionManager.load_messages())
|
|
128
|
+
if "z" in self.args:
|
|
129
|
+
agent.drop_exchanges(self.args["z"])
|
|
130
|
+
|
|
131
|
+
# prompt agent and save session
|
|
132
|
+
response = await agent.prompt(self.args[self.char])
|
|
133
|
+
SessionManager.save_session(agent.system, agent.messages, self.char)
|
|
134
|
+
|
|
135
|
+
if "v" in self.args:
|
|
136
|
+
qprint("\nMESSAGES:", color="cyan", file=sys.stderr)
|
|
137
|
+
if agent.system:
|
|
138
|
+
qprint("system: ", color="green", file=sys.stderr, end="")
|
|
139
|
+
qprint(agent.system, file=sys.stderr)
|
|
140
|
+
for msg in agent.messages:
|
|
141
|
+
qprint(f"{msg.role.value}: ", color="green", file=sys.stderr, end="")
|
|
142
|
+
qprint(msg.content, file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
# process response
|
|
145
|
+
self.process_response(response)
|
|
146
|
+
|
|
147
|
+
def process_response(self, response: str) -> None:
|
|
148
|
+
"""Format response and route output."""
|
|
149
|
+
if "j" not in self.args:
|
|
150
|
+
response = format_response(response)
|
|
151
|
+
|
|
152
|
+
if "o" in self.args:
|
|
153
|
+
Path(self.args["o"]).write_text(response)
|
|
154
|
+
qprint(f"Response saved to {self.args['o']}", color="yellow", file=sys.stderr)
|
|
155
|
+
else:
|
|
156
|
+
if "v" not in self.args:
|
|
157
|
+
qprint(response)
|
|
158
|
+
|
|
159
|
+
# copy output to clipboard
|
|
160
|
+
if self.clip:
|
|
161
|
+
with contextlib.suppress(pyperclip.PyperclipException):
|
|
162
|
+
pyperclip.copy(response)
|
|
163
|
+
qprint("Copied to clipboard.", color="yellow", file=sys.stderr)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# region Commands
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TextCommand(AgentCommand):
|
|
170
|
+
char = "t"
|
|
171
|
+
desc = "text"
|
|
172
|
+
value_type = ValueType.TEXT
|
|
173
|
+
required = True
|
|
174
|
+
tier = Tier.MED
|
|
175
|
+
system = ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ExplainCommand(AgentCommand):
|
|
179
|
+
char = "e"
|
|
180
|
+
desc = "explain"
|
|
181
|
+
value_type = ValueType.TEXT
|
|
182
|
+
tier = Tier.HIGH
|
|
183
|
+
system = "You are a programming assistant. Given a shell command, code snippet, or technical concept, provide a concise and technical explanation. Assume the reader is an experienced developer. Avoid restating the code or command. Avoid explaining obvious syntax. Avoid breaking the answer into bullet points unless necessary. The response should be a single short paragraph optimized for clarity."
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class CodeCommand(AgentCommand):
|
|
187
|
+
char = "c"
|
|
188
|
+
desc = "code"
|
|
189
|
+
value_type = ValueType.TEXT
|
|
190
|
+
required = True
|
|
191
|
+
tier = Tier.HIGH
|
|
192
|
+
clip = True
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def system(self) -> str:
|
|
196
|
+
return f"You are a coding assistant. Given a natural language description, generate a code snippet that accomplishes the requested task. The code should be correct, efficient, concise, and idiomatic. Respond with only the code snippet, without explanations, additional text, or formatting. Assume the programming language is {SessionManager.load_code_lang()} unless otherwise specified."
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ShellCommand(AgentCommand):
|
|
200
|
+
char = "s"
|
|
201
|
+
desc = "shell"
|
|
202
|
+
value_type = ValueType.TEXT
|
|
203
|
+
required = False
|
|
204
|
+
tier = Tier.MED
|
|
205
|
+
clip = True
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def system(self) -> str:
|
|
209
|
+
return f"You are a command-line assistant. Given a description, generate the simplest single shell command that accomplishes the task. Favor minimal, commonly available commands with no extra formatting or piping. Avoid commands that could delete, overwrite, or modify important files or system settings (e.g., rm -rf, dd, mkfs, chmod -R, chown, kill -9). Respond with only the command, without explanations, additional text, or formatting. System is running {self._get_system_info()}."
|
|
210
|
+
|
|
211
|
+
def _get_system_info(self) -> str:
|
|
212
|
+
shell = os.environ.get("SHELL") or os.environ.get("COMSPEC")
|
|
213
|
+
shell = Path(shell).name if shell else ""
|
|
214
|
+
|
|
215
|
+
sys_name = platform.system()
|
|
216
|
+
if sys_name == "Linux":
|
|
217
|
+
with contextlib.suppress(ImportError):
|
|
218
|
+
sys_name = distro.name(pretty=True)
|
|
219
|
+
|
|
220
|
+
if shell:
|
|
221
|
+
return f"{shell} on {sys_name}"
|
|
222
|
+
return sys_name
|
|
223
|
+
|
|
224
|
+
async def execute(self) -> None:
|
|
225
|
+
# rerun and fix last command (requires shell integration)
|
|
226
|
+
if self.args[self.char] is None:
|
|
227
|
+
cmd = os.environ.get("Q_CMD", None)
|
|
228
|
+
exit_code = os.environ.get("Q_EXIT", None)
|
|
229
|
+
if cmd is None or exit_code is None:
|
|
230
|
+
raise UserError(
|
|
231
|
+
"q -s without a prompt requires shell integration. Add to ~/.bashrc:\n"
|
|
232
|
+
' q() { Q_EXIT=$? Q_CMD=$(fc -ln -1) command q "$@"; }'
|
|
233
|
+
)
|
|
234
|
+
cmd, exit_code = cmd.strip(), exit_code.strip()
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# run command and capture output
|
|
238
|
+
proc = await asyncio.create_subprocess_shell(
|
|
239
|
+
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
240
|
+
)
|
|
241
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
242
|
+
text = f"The command `{cmd}` failed with exit code {proc.returncode}. Fix it."
|
|
243
|
+
if stderr:
|
|
244
|
+
text += f"\nSTDERR:\n{stderr.decode().strip()}"
|
|
245
|
+
if stdout:
|
|
246
|
+
text += f"\nSTDOUT:\n{stdout.decode().strip()}"
|
|
247
|
+
except TimeoutError:
|
|
248
|
+
# kill long-running command
|
|
249
|
+
proc.kill()
|
|
250
|
+
text = f"The command `{cmd}` failed with exit code {exit_code}. Fix it."
|
|
251
|
+
self.args[self.char] = text
|
|
252
|
+
|
|
253
|
+
await super().execute()
|
|
254
|
+
|
|
255
|
+
def process_response(self, response: str) -> None:
|
|
256
|
+
# execute command
|
|
257
|
+
if "x" in self.args:
|
|
258
|
+
qprint(f"> {response}", color="green", file=sys.stderr)
|
|
259
|
+
subprocess.run(response, shell=True)
|
|
260
|
+
else:
|
|
261
|
+
super().process_response(response)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class WebCommand(AgentCommand):
|
|
265
|
+
char = "w"
|
|
266
|
+
desc = "web"
|
|
267
|
+
value_type = ValueType.TEXT
|
|
268
|
+
required = True
|
|
269
|
+
tier = Tier.LOW
|
|
270
|
+
client_str = "WebClient"
|
|
271
|
+
system = "You fetch real-time data from the internet. Always respond with only the data requested. Do not provide additional information in the form of context, background, or links. The response should be less than a single sentence. Always search the internet."
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class ImageCommand(AgentCommand):
|
|
275
|
+
char = "i"
|
|
276
|
+
desc = "image"
|
|
277
|
+
value_type = ValueType.TEXT
|
|
278
|
+
required = True
|
|
279
|
+
tier = Tier.MED
|
|
280
|
+
client_str = "ImageClient"
|
|
281
|
+
system = "Generate an image of the following description."
|
|
282
|
+
|
|
283
|
+
def process_response(self, response: bytes) -> None:
|
|
284
|
+
text = self.args[self.char].translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
|
|
285
|
+
path = self.args.get("o") or f"q_{text}"
|
|
286
|
+
path = path if path.lower().endswith(".png") else f"{path}.png"
|
|
287
|
+
Path(path).write_bytes(response)
|
|
288
|
+
qprint(f"Image saved to {path}", color="yellow", file=sys.stderr)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class HelpCommand(AgentCommand):
|
|
292
|
+
char = "h"
|
|
293
|
+
desc = "help"
|
|
294
|
+
value_type = ValueType.TEXT
|
|
295
|
+
required = False
|
|
296
|
+
tier = Tier.LOW
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def system(self) -> str:
|
|
300
|
+
cli_dir = Path(__file__).parent
|
|
301
|
+
source_code = "\n\n".join((cli_dir / name).read_text() for name in Path(cli_dir).glob("*.py"))
|
|
302
|
+
return (
|
|
303
|
+
"You are `q`, a command-line LLM tool. Answer questions about usage based on the source code."
|
|
304
|
+
f"\n\n{source_code}\n\n"
|
|
305
|
+
"Be extremely concise. Answer in one line. Focus on usage."
|
|
306
|
+
"Always surround code snippets, commands, flags, and paths with backticks."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def execute(self) -> None:
|
|
310
|
+
if self.args.get(self.char):
|
|
311
|
+
await super().execute()
|
|
312
|
+
else:
|
|
313
|
+
qprint(self._help_text())
|
|
314
|
+
|
|
315
|
+
def _help_text(self) -> str:
|
|
316
|
+
command_color = "cyan"
|
|
317
|
+
flags = []
|
|
318
|
+
for f in sorted(COMMANDS + OPTIONS, key=lambda f: f.char):
|
|
319
|
+
flag_arg = f.value_type.value or ""
|
|
320
|
+
if flag_arg:
|
|
321
|
+
flag_arg = f"<{flag_arg}>" if f.required else f"[{flag_arg}]"
|
|
322
|
+
flag_str = f" -{f.char} {f.desc} {flag_arg}"
|
|
323
|
+
flags.append(colored(flag_str, command_color if f in COMMANDS else "dark_grey"))
|
|
324
|
+
|
|
325
|
+
lines = [
|
|
326
|
+
f"q {__version__} - a command line programming agent",
|
|
327
|
+
"",
|
|
328
|
+
"Usage: q [-flag [value]] ...",
|
|
329
|
+
"",
|
|
330
|
+
" Flags can be combined: -sx = -s -x",
|
|
331
|
+
" Use -- to disable remaining flag parsing.",
|
|
332
|
+
f" One {colored('command', command_color)} is required.",
|
|
333
|
+
"",
|
|
334
|
+
"Flags:",
|
|
335
|
+
*flags,
|
|
336
|
+
]
|
|
337
|
+
return "\n".join(lines)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class LoadCommand(Command):
|
|
341
|
+
char = "l"
|
|
342
|
+
desc = "load session"
|
|
343
|
+
value_type = ValueType.INT
|
|
344
|
+
|
|
345
|
+
async def execute(self) -> None:
|
|
346
|
+
session_id = self.args.get(self.char)
|
|
347
|
+
if session_id is None:
|
|
348
|
+
self._print_session_list()
|
|
349
|
+
elif not SessionManager.switch_session(session_id):
|
|
350
|
+
raise UserError(f"invalid session: {session_id}")
|
|
351
|
+
else:
|
|
352
|
+
qprint(f"Loaded session {session_id}", color="yellow", file=sys.stderr)
|
|
353
|
+
|
|
354
|
+
def _print_session_list(self) -> None:
|
|
355
|
+
sessions = SessionManager.list_sessions()
|
|
356
|
+
if not sessions:
|
|
357
|
+
qprint("No sessions found.")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
current_id = SessionManager.load_session_id()
|
|
361
|
+
term_width = shutil.get_terminal_size().columns
|
|
362
|
+
|
|
363
|
+
for s in sessions:
|
|
364
|
+
age = humanize.naturaltime(s.updated) if s.updated else "unknown"
|
|
365
|
+
prefix_len = len(f" {s.id}. ")
|
|
366
|
+
suffix_len = len(f" ({age})")
|
|
367
|
+
max_len = max(20, term_width - prefix_len - suffix_len - 5)
|
|
368
|
+
|
|
369
|
+
preview = "(empty)"
|
|
370
|
+
for msg in reversed(s.messages):
|
|
371
|
+
if msg.role == Role.USER:
|
|
372
|
+
preview = msg.content[:max_len] + "..." if len(msg.content) > max_len else msg.content
|
|
373
|
+
break
|
|
374
|
+
|
|
375
|
+
line = f" {s.id}. {preview} ({age})"
|
|
376
|
+
color = None if s.id == current_id else "dark_grey"
|
|
377
|
+
qprint(line, color=color)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# region Options
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class DirectoryOption(Flag):
|
|
384
|
+
char = "d"
|
|
385
|
+
desc = "directory"
|
|
386
|
+
value_type = ValueType.STR
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class FileOption(Flag):
|
|
390
|
+
char = "f"
|
|
391
|
+
desc = "file"
|
|
392
|
+
value_type = ValueType.STR
|
|
393
|
+
required = True
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class JsonOption(Flag):
|
|
397
|
+
char = "j"
|
|
398
|
+
desc = "json"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class ModelOption(Flag):
|
|
402
|
+
char = "m"
|
|
403
|
+
desc = "model"
|
|
404
|
+
value_type = ValueType.STR
|
|
405
|
+
required = True
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class NewSessionOption(Flag):
|
|
409
|
+
char = "n"
|
|
410
|
+
desc = "new session"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class OutputOption(Flag):
|
|
414
|
+
char = "o"
|
|
415
|
+
desc = "output"
|
|
416
|
+
value_type = ValueType.STR
|
|
417
|
+
required = True
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class VerboseOption(Flag):
|
|
421
|
+
char = "v"
|
|
422
|
+
desc = "verbose"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class ExecuteOption(Flag):
|
|
426
|
+
char = "x"
|
|
427
|
+
desc = "execute"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class UndoOption(Flag):
|
|
431
|
+
char = "z"
|
|
432
|
+
desc = "undo"
|
|
433
|
+
value_type = ValueType.INT
|
|
434
|
+
default = 1
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .parser import parse
|
|
7
|
+
from .terminal import UserError, is_terminal, qprint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
# suppress stderr when piped
|
|
12
|
+
if not is_terminal():
|
|
13
|
+
sys.stderr = Path(os.devnull).open("w") # noqa: SIM115
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
command = parse(sys.argv[1:])
|
|
17
|
+
asyncio.run(command.execute())
|
|
18
|
+
except (UserError, ImportError) as e:
|
|
19
|
+
qprint(str(e), color="red", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
except KeyboardInterrupt:
|
|
22
|
+
sys.exit(130)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|