navaia-code 1.0.50__py3-none-any.whl
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.
- navaia/__init__.py +3 -0
- navaia/api/__init__.py +0 -0
- navaia/api/client.py +72 -0
- navaia/api/normalise.py +148 -0
- navaia/api/retry.py +114 -0
- navaia/api/streaming.py +341 -0
- navaia/api/types.py +213 -0
- navaia/commands/__init__.py +0 -0
- navaia/commands/builtin/__init__.py +0 -0
- navaia/commands/builtin/commands.py +206 -0
- navaia/commands/dispatcher.py +38 -0
- navaia/commands/parser.py +25 -0
- navaia/commands/registry.py +48 -0
- navaia/commands/skills.py +150 -0
- navaia/commands/types.py +26 -0
- navaia/compact/__init__.py +0 -0
- navaia/compact/compact.py +241 -0
- navaia/compact/prompt.py +22 -0
- navaia/compact/restore.py +91 -0
- navaia/config/__init__.py +0 -0
- navaia/config/env.py +53 -0
- navaia/config/global_config.py +43 -0
- navaia/config/project_config.py +53 -0
- navaia/config/providers.py +234 -0
- navaia/config/settings.py +113 -0
- navaia/context/__init__.py +0 -0
- navaia/context/cache.py +29 -0
- navaia/context/claudemd.py +252 -0
- navaia/context/system_prompt.py +172 -0
- navaia/effort/__init__.py +0 -0
- navaia/effort/effort.py +47 -0
- navaia/hooks/__init__.py +0 -0
- navaia/hooks/executor.py +153 -0
- navaia/hooks/settings.py +82 -0
- navaia/hooks/types.py +53 -0
- navaia/main.py +462 -0
- navaia/mcp/__init__.py +0 -0
- navaia/mcp/bootstrap.py +88 -0
- navaia/mcp/client.py +157 -0
- navaia/mcp/settings.py +80 -0
- navaia/mcp/tools.py +118 -0
- navaia/mcp/types.py +29 -0
- navaia/memory/__init__.py +0 -0
- navaia/memory/memdir.py +70 -0
- navaia/memory/paths.py +17 -0
- navaia/memory/scanner.py +85 -0
- navaia/memory/types.py +27 -0
- navaia/permissions/__init__.py +0 -0
- navaia/permissions/checker.py +147 -0
- navaia/permissions/rules.py +88 -0
- navaia/permissions/types.py +39 -0
- navaia/query/__init__.py +0 -0
- navaia/query/engine.py +477 -0
- navaia/query/types.py +43 -0
- navaia/session/__init__.py +0 -0
- navaia/session/history.py +64 -0
- navaia/session/serialise.py +184 -0
- navaia/session/state.py +20 -0
- navaia/session/storage.py +102 -0
- navaia/session/store.py +202 -0
- navaia/state/__init__.py +0 -0
- navaia/tasks/__init__.py +0 -0
- navaia/tasks/cron.py +113 -0
- navaia/tasks/manager.py +112 -0
- navaia/tasks/persistence.py +128 -0
- navaia/tasks/task.py +34 -0
- navaia/thinking/__init__.py +0 -0
- navaia/thinking/budget.py +42 -0
- navaia/thinking/config.py +55 -0
- navaia/tools/__init__.py +0 -0
- navaia/tools/agent_tool/__init__.py +0 -0
- navaia/tools/agent_tool/tool.py +148 -0
- navaia/tools/ask_user/__init__.py +0 -0
- navaia/tools/ask_user/bus.py +51 -0
- navaia/tools/ask_user/tool.py +64 -0
- navaia/tools/base.py +51 -0
- navaia/tools/bash/__init__.py +0 -0
- navaia/tools/bash/background.py +123 -0
- navaia/tools/bash/tool.py +234 -0
- navaia/tools/executor.py +111 -0
- navaia/tools/file_edit/__init__.py +0 -0
- navaia/tools/file_edit/tool.py +206 -0
- navaia/tools/file_read/__init__.py +0 -0
- navaia/tools/file_read/tool.py +209 -0
- navaia/tools/file_write/__init__.py +0 -0
- navaia/tools/file_write/tool.py +112 -0
- navaia/tools/glob_tool/__init__.py +0 -0
- navaia/tools/glob_tool/tool.py +97 -0
- navaia/tools/grep_tool/__init__.py +0 -0
- navaia/tools/grep_tool/tool.py +292 -0
- navaia/tools/monitor/__init__.py +0 -0
- navaia/tools/monitor/tool.py +101 -0
- navaia/tools/plan_mode/__init__.py +0 -0
- navaia/tools/plan_mode/enter.py +38 -0
- navaia/tools/plan_mode/exit.py +36 -0
- navaia/tools/registry.py +71 -0
- navaia/tools/result_storage.py +60 -0
- navaia/tools/skill_tool/__init__.py +0 -0
- navaia/tools/skill_tool/loader.py +147 -0
- navaia/tools/skill_tool/tool.py +88 -0
- navaia/tools/task_tools/__init__.py +0 -0
- navaia/tools/task_tools/create.py +60 -0
- navaia/tools/task_tools/get.py +52 -0
- navaia/tools/task_tools/list.py +39 -0
- navaia/tools/task_tools/manager.py +66 -0
- navaia/tools/task_tools/update.py +88 -0
- navaia/tools/todo_write/__init__.py +0 -0
- navaia/tools/todo_write/tool.py +121 -0
- navaia/tools/tool_search/__init__.py +0 -0
- navaia/tools/tool_search/tool.py +106 -0
- navaia/tools/web_fetch/__init__.py +0 -0
- navaia/tools/web_fetch/tool.py +88 -0
- navaia/tools/worktree/__init__.py +0 -0
- navaia/tools/worktree/enter.py +66 -0
- navaia/tools/worktree/exit.py +51 -0
- navaia/tools/worktree/manager.py +130 -0
- navaia/ui/__init__.py +0 -0
- navaia/ui/app.py +605 -0
- navaia/ui/bidi.py +70 -0
- navaia/ui/input/__init__.py +0 -0
- navaia/ui/input/history.py +84 -0
- navaia/ui/input/suggestions.py +72 -0
- navaia/ui/messages/__init__.py +0 -0
- navaia/ui/messages/assistant_text.py +46 -0
- navaia/ui/messages/bash_output.py +68 -0
- navaia/ui/messages/system_msg.py +25 -0
- navaia/ui/messages/tool_result.py +38 -0
- navaia/ui/messages/tool_use.py +70 -0
- navaia/ui/messages/user_prompt.py +27 -0
- navaia/ui/screens/__init__.py +0 -0
- navaia/ui/screens/repl.py +136 -0
- navaia/ui/styles/app.tcss +48 -0
- navaia/ui/widgets/__init__.py +0 -0
- navaia/ui/widgets/logo.py +48 -0
- navaia/ui/widgets/markdown_view.py +87 -0
- navaia/ui/widgets/message_list.py +387 -0
- navaia/ui/widgets/permission_prompt.py +137 -0
- navaia/ui/widgets/prompt_footer.py +67 -0
- navaia/ui/widgets/prompt_input.py +203 -0
- navaia/ui/widgets/question_prompt.py +58 -0
- navaia/ui/widgets/spinner.py +110 -0
- navaia/ui/widgets/thinking_view.py +124 -0
- navaia_code-1.0.50.dist-info/METADATA +17 -0
- navaia_code-1.0.50.dist-info/RECORD +146 -0
- navaia_code-1.0.50.dist-info/WHEEL +4 -0
- navaia_code-1.0.50.dist-info/entry_points.txt +2 -0
navaia/api/types.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""API layer types — mirrors Claude Code's message and streaming types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Literal, Union
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Content blocks (internal representation, provider-agnostic)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TextBlock:
|
|
19
|
+
text: str
|
|
20
|
+
type: Literal["text"] = "text"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ToolUseBlock:
|
|
25
|
+
name: str
|
|
26
|
+
input: dict
|
|
27
|
+
id: str = field(default_factory=lambda: f"call_{uuid4().hex[:12]}")
|
|
28
|
+
type: Literal["tool_use"] = "tool_use"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ToolResultBlock:
|
|
33
|
+
tool_use_id: str
|
|
34
|
+
content: str | list
|
|
35
|
+
is_error: bool = False
|
|
36
|
+
type: Literal["tool_result"] = "tool_result"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ThinkingBlock:
|
|
41
|
+
thinking: str
|
|
42
|
+
type: Literal["thinking"] = "thinking"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ContentBlock = Union[TextBlock, ToolUseBlock, ToolResultBlock, ThinkingBlock]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Messages (internal representation)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class UserMessage:
|
|
54
|
+
content: list[ContentBlock] | str
|
|
55
|
+
uuid: str = field(default_factory=lambda: uuid4().hex)
|
|
56
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
57
|
+
role: Literal["user"] = "user"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class AssistantMessage:
|
|
62
|
+
content: list[ContentBlock]
|
|
63
|
+
uuid: str = field(default_factory=lambda: uuid4().hex)
|
|
64
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
65
|
+
role: Literal["assistant"] = "assistant"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SystemMessage:
|
|
70
|
+
text: str
|
|
71
|
+
subtype: str = "info"
|
|
72
|
+
is_api_error: bool = False
|
|
73
|
+
type: Literal["system"] = "system"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CompactBoundaryMessage:
|
|
78
|
+
summary: str
|
|
79
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
80
|
+
type: Literal["compact_boundary"] = "compact_boundary"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
Message = Union[UserMessage, AssistantMessage, SystemMessage, CompactBoundaryMessage]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Streaming events (yielded by the streaming layer)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class TextDeltaEvent:
|
|
92
|
+
text: str
|
|
93
|
+
type: Literal["text_delta"] = "text_delta"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ToolCallDeltaEvent:
|
|
98
|
+
"""Raw delta from the OpenAI streaming format."""
|
|
99
|
+
index: int
|
|
100
|
+
id: str | None = None
|
|
101
|
+
name: str | None = None
|
|
102
|
+
arguments_delta: str = ""
|
|
103
|
+
type: Literal["tool_call_delta"] = "tool_call_delta"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ThinkingDeltaEvent:
|
|
108
|
+
text: str
|
|
109
|
+
type: Literal["thinking_delta"] = "thinking_delta"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class FinishEvent:
|
|
114
|
+
reason: str # "stop", "tool_calls", "length"
|
|
115
|
+
type: Literal["finish"] = "finish"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class RetryEvent:
|
|
120
|
+
"""Emitted when a retriable API error triggers a retry attempt."""
|
|
121
|
+
attempt: int
|
|
122
|
+
max_retries: int
|
|
123
|
+
delay_seconds: float
|
|
124
|
+
error_message: str
|
|
125
|
+
type: Literal["retry"] = "retry"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class UsageEvent:
|
|
130
|
+
usage: Usage
|
|
131
|
+
type: Literal["usage"] = "usage"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
StreamEvent = Union[TextDeltaEvent, ToolCallDeltaEvent, ThinkingDeltaEvent, FinishEvent, RetryEvent, UsageEvent]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Usage tracking
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Usage:
|
|
143
|
+
input_tokens: int = 0
|
|
144
|
+
output_tokens: int = 0
|
|
145
|
+
total_tokens: int = 0
|
|
146
|
+
cost_usd: float = 0.0
|
|
147
|
+
|
|
148
|
+
def __add__(self, other: Usage) -> Usage:
|
|
149
|
+
return Usage(
|
|
150
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
151
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
152
|
+
total_tokens=self.total_tokens + other.total_tokens,
|
|
153
|
+
cost_usd=self.cost_usd + other.cost_usd,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# SDK messages (yielded by QueryEngine to the caller / UI)
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
class SDKMessageType(str, Enum):
|
|
162
|
+
TEXT_DELTA = "text_delta"
|
|
163
|
+
THINKING_DELTA = "thinking_delta"
|
|
164
|
+
ASSISTANT = "assistant"
|
|
165
|
+
USER = "user"
|
|
166
|
+
SYSTEM = "system"
|
|
167
|
+
TOOL_USE_START = "tool_use_start"
|
|
168
|
+
TOOL_RESULT = "tool_result"
|
|
169
|
+
COMPACT_BOUNDARY = "compact_boundary"
|
|
170
|
+
RETRY = "retry"
|
|
171
|
+
RESULT = "result"
|
|
172
|
+
PERMISSION_REQUEST = "permission_request"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class SDKMessage:
|
|
177
|
+
type: SDKMessageType
|
|
178
|
+
delta: str | None = None
|
|
179
|
+
message: Message | None = None
|
|
180
|
+
tool_name: str | None = None
|
|
181
|
+
tool_input: dict | None = None
|
|
182
|
+
tool_use_id: str | None = None
|
|
183
|
+
tool_result: str | None = None
|
|
184
|
+
is_error: bool = False
|
|
185
|
+
usage: Usage | None = None
|
|
186
|
+
cost_usd: float = 0.0
|
|
187
|
+
stop_reason: str | None = None
|
|
188
|
+
session_id: str | None = None
|
|
189
|
+
permission_future: asyncio.Future | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Tool call accumulator (for incremental streaming assembly)
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class ToolCallAccumulator:
|
|
198
|
+
id: str = ""
|
|
199
|
+
name: str = ""
|
|
200
|
+
args_str: str = ""
|
|
201
|
+
|
|
202
|
+
def to_tool_use_block(self) -> ToolUseBlock:
|
|
203
|
+
import json
|
|
204
|
+
try:
|
|
205
|
+
parsed = json.loads(self.args_str) if self.args_str else {}
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
import logging
|
|
208
|
+
logging.getLogger(__name__).warning(
|
|
209
|
+
"Malformed JSON from model for tool '%s': %s",
|
|
210
|
+
self.name, self.args_str[:200],
|
|
211
|
+
)
|
|
212
|
+
parsed = {}
|
|
213
|
+
return ToolUseBlock(id=self.id, name=self.name, input=parsed)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Built-in slash commands for Navaia Code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from navaia import __version__
|
|
9
|
+
from navaia.commands.types import Command, CommandResult, CommandType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_commands() -> list[Command]:
|
|
13
|
+
"""Return all built-in command definitions."""
|
|
14
|
+
return [
|
|
15
|
+
Command(
|
|
16
|
+
name="help",
|
|
17
|
+
description="List all available commands",
|
|
18
|
+
type=CommandType.LOCAL,
|
|
19
|
+
aliases=["h", "?"],
|
|
20
|
+
),
|
|
21
|
+
Command(
|
|
22
|
+
name="clear",
|
|
23
|
+
description="Clear conversation history",
|
|
24
|
+
type=CommandType.LOCAL,
|
|
25
|
+
),
|
|
26
|
+
Command(
|
|
27
|
+
name="exit",
|
|
28
|
+
description="Exit Navaia Code",
|
|
29
|
+
type=CommandType.LOCAL,
|
|
30
|
+
aliases=["quit", "q"],
|
|
31
|
+
),
|
|
32
|
+
Command(
|
|
33
|
+
name="version",
|
|
34
|
+
description="Show Navaia Code version",
|
|
35
|
+
type=CommandType.LOCAL,
|
|
36
|
+
aliases=["v"],
|
|
37
|
+
),
|
|
38
|
+
Command(
|
|
39
|
+
name="cost",
|
|
40
|
+
description="Show session cost and token usage",
|
|
41
|
+
type=CommandType.LOCAL,
|
|
42
|
+
),
|
|
43
|
+
Command(
|
|
44
|
+
name="compact",
|
|
45
|
+
description="Compact conversation to reduce context",
|
|
46
|
+
type=CommandType.LOCAL,
|
|
47
|
+
),
|
|
48
|
+
Command(
|
|
49
|
+
name="model",
|
|
50
|
+
description="Show or switch the current model",
|
|
51
|
+
type=CommandType.LOCAL,
|
|
52
|
+
),
|
|
53
|
+
Command(
|
|
54
|
+
name="copy",
|
|
55
|
+
description="Copy last assistant response to clipboard",
|
|
56
|
+
type=CommandType.LOCAL,
|
|
57
|
+
),
|
|
58
|
+
Command(
|
|
59
|
+
name="diff",
|
|
60
|
+
description="Show git diff output",
|
|
61
|
+
type=CommandType.LOCAL,
|
|
62
|
+
),
|
|
63
|
+
Command(
|
|
64
|
+
name="vim",
|
|
65
|
+
description="Toggle vim keybinding mode",
|
|
66
|
+
type=CommandType.LOCAL,
|
|
67
|
+
is_hidden=True,
|
|
68
|
+
),
|
|
69
|
+
Command(
|
|
70
|
+
name="think",
|
|
71
|
+
description="Enable extended thinking",
|
|
72
|
+
type=CommandType.LOCAL,
|
|
73
|
+
),
|
|
74
|
+
Command(
|
|
75
|
+
name="plan",
|
|
76
|
+
description="Enter plan mode for structured reasoning",
|
|
77
|
+
type=CommandType.LOCAL,
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _handle_help(args: str) -> CommandResult:
|
|
83
|
+
"""List all visible commands including skills."""
|
|
84
|
+
from navaia.commands.registry import get_all_commands
|
|
85
|
+
|
|
86
|
+
commands, _ = get_all_commands()
|
|
87
|
+
lines = ["Available commands:", ""]
|
|
88
|
+
for cmd in commands:
|
|
89
|
+
if cmd.is_hidden:
|
|
90
|
+
continue
|
|
91
|
+
alias_str = ""
|
|
92
|
+
if cmd.aliases:
|
|
93
|
+
alias_str = f" (aliases: {', '.join('/' + a for a in cmd.aliases)})"
|
|
94
|
+
prefix = "*" if cmd.type == CommandType.PROMPT else " "
|
|
95
|
+
lines.append(f" {prefix}/{cmd.name:<12} {cmd.description}{alias_str}")
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append(" * = skill (sends prompt to model)")
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append("Type any text without '/' to send a message to the model.")
|
|
100
|
+
lines.append("Add skills: create .md files in ~/.navaia/commands/")
|
|
101
|
+
return CommandResult(output="\n".join(lines))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _handle_clear(_args: str) -> CommandResult:
|
|
105
|
+
return CommandResult(output="[clear]")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _handle_exit(_args: str) -> CommandResult:
|
|
109
|
+
return CommandResult(output="Goodbye!", should_exit=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _handle_version(_args: str) -> CommandResult:
|
|
113
|
+
return CommandResult(output=f"Navaia Code v{__version__}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _handle_cost(_args: str) -> CommandResult:
|
|
117
|
+
# Real handler lives in NavaiaApp._handle_cost_command (needs engine access).
|
|
118
|
+
return CommandResult(
|
|
119
|
+
output="(cost is only meaningful inside an interactive session)"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _handle_compact(_args: str) -> CommandResult:
|
|
124
|
+
# IMPORTANT: This command requires engine access and is intercepted in
|
|
125
|
+
# NavaiaApp._handle_command before reaching this handler. If this stub
|
|
126
|
+
# runs, something bypassed the app-layer intercept.
|
|
127
|
+
return CommandResult(output="Error: /compact must be run from the main interface.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _handle_model(args: str) -> CommandResult:
|
|
131
|
+
# Real handler lives in NavaiaApp._handle_model_command (needs config + UI).
|
|
132
|
+
import os
|
|
133
|
+
current = os.environ.get("NAVAIA_MODEL", "(unset)")
|
|
134
|
+
if args.strip():
|
|
135
|
+
return CommandResult(
|
|
136
|
+
output=(
|
|
137
|
+
"/model only switches live in the interactive UI. "
|
|
138
|
+
f"Current model (from env): {current}"
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return CommandResult(output=f"Current model: {current}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _handle_copy(_args: str) -> CommandResult:
|
|
145
|
+
# Handled directly in NavaiaApp._handle_command before dispatch
|
|
146
|
+
return CommandResult(output="")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _handle_diff(_args: str) -> CommandResult:
|
|
150
|
+
try:
|
|
151
|
+
proc = await asyncio.create_subprocess_exec(
|
|
152
|
+
"git", "diff",
|
|
153
|
+
stdout=asyncio.subprocess.PIPE,
|
|
154
|
+
stderr=asyncio.subprocess.PIPE,
|
|
155
|
+
)
|
|
156
|
+
stdout, stderr = await proc.communicate()
|
|
157
|
+
|
|
158
|
+
if proc.returncode != 0:
|
|
159
|
+
error_msg = stderr.decode().strip() if stderr else "Unknown error"
|
|
160
|
+
return CommandResult(output=f"git diff failed: {error_msg}")
|
|
161
|
+
|
|
162
|
+
output = stdout.decode().strip() if stdout else "No changes."
|
|
163
|
+
return CommandResult(output=output)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
return CommandResult(output="Error: git is not installed or not in PATH.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _handle_vim(_args: str) -> CommandResult:
|
|
169
|
+
# Real handler lives in NavaiaApp._handle_vim_command.
|
|
170
|
+
return CommandResult(output="(vim mode is only toggleable inside the interactive UI)")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _handle_think(_args: str) -> CommandResult:
|
|
174
|
+
# Real handler lives in NavaiaApp._handle_think_command.
|
|
175
|
+
return CommandResult(output="(thinking mode is only toggleable inside the interactive UI)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _handle_plan(_args: str) -> CommandResult:
|
|
179
|
+
# Real handler lives in NavaiaApp._handle_plan_command.
|
|
180
|
+
return CommandResult(output="(plan mode is only toggleable inside the interactive UI)")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_handlers() -> dict[str, Callable[[str], Awaitable[CommandResult]]]:
|
|
184
|
+
"""Return a mapping of command name to async handler function."""
|
|
185
|
+
return {
|
|
186
|
+
"help": _handle_help,
|
|
187
|
+
"clear": _handle_clear,
|
|
188
|
+
"exit": _handle_exit,
|
|
189
|
+
"version": _handle_version,
|
|
190
|
+
"copy": _handle_copy,
|
|
191
|
+
"cost": _handle_cost,
|
|
192
|
+
"compact": _handle_compact,
|
|
193
|
+
"model": _handle_model,
|
|
194
|
+
"diff": _handle_diff,
|
|
195
|
+
"vim": _handle_vim,
|
|
196
|
+
"think": _handle_think,
|
|
197
|
+
"plan": _handle_plan,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_builtin_commands() -> tuple[
|
|
202
|
+
list[Command],
|
|
203
|
+
dict[str, Callable[[str], Awaitable[CommandResult]]],
|
|
204
|
+
]:
|
|
205
|
+
"""Return (commands, handlers) for all built-in slash commands."""
|
|
206
|
+
return _build_commands(), _build_handlers()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Dispatch slash commands to their handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from .parser import is_slash_command, parse_slash_command
|
|
8
|
+
from .registry import find_command
|
|
9
|
+
from .types import Command, CommandResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def process_user_input(
|
|
13
|
+
text: str,
|
|
14
|
+
commands: list[Command],
|
|
15
|
+
handlers: dict[str, Callable[[str], Awaitable[CommandResult]]],
|
|
16
|
+
) -> CommandResult:
|
|
17
|
+
"""Process user input, dispatching slash commands or passing through text.
|
|
18
|
+
|
|
19
|
+
If the input is a slash command, look up the matching command and invoke
|
|
20
|
+
its handler. Otherwise, return the raw text marked for model query.
|
|
21
|
+
"""
|
|
22
|
+
if not is_slash_command(text):
|
|
23
|
+
return CommandResult(output=text, should_query=True)
|
|
24
|
+
|
|
25
|
+
name, args = parse_slash_command(text)
|
|
26
|
+
command = find_command(name, commands)
|
|
27
|
+
|
|
28
|
+
if command is None:
|
|
29
|
+
available = ", ".join(f"/{c.name}" for c in commands if not c.is_hidden)
|
|
30
|
+
return CommandResult(
|
|
31
|
+
output=f"Unknown command: /{name}\nAvailable commands: {available}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
handler = handlers.get(command.name)
|
|
35
|
+
if handler is None:
|
|
36
|
+
return CommandResult(output=f"No handler registered for /{command.name}")
|
|
37
|
+
|
|
38
|
+
return await handler(args)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Parse slash commands from user input."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def is_slash_command(text: str) -> bool:
|
|
5
|
+
"""Check if text is a slash command (starts with '/' followed by a letter)."""
|
|
6
|
+
stripped = text.strip()
|
|
7
|
+
return len(stripped) >= 2 and stripped[0] == "/" and stripped[1].isalpha()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_slash_command(text: str) -> tuple[str, str]:
|
|
11
|
+
"""Parse a slash command into (command_name, arguments).
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
"/search foo bar" -> ("search", "foo bar")
|
|
15
|
+
"/help" -> ("help", "")
|
|
16
|
+
"/model gpt-4" -> ("model", "gpt-4")
|
|
17
|
+
"""
|
|
18
|
+
stripped = text.strip()
|
|
19
|
+
without_slash = stripped[1:]
|
|
20
|
+
|
|
21
|
+
parts = without_slash.split(maxsplit=1)
|
|
22
|
+
command_name = parts[0].lower()
|
|
23
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
24
|
+
|
|
25
|
+
return command_name, args
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Command registry — merges built-in and skill commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from .builtin.commands import get_builtin_commands
|
|
9
|
+
from .skills import get_skill_commands
|
|
10
|
+
from .types import Command, CommandResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_all_commands(
|
|
14
|
+
cwd: str | None = None,
|
|
15
|
+
) -> tuple[
|
|
16
|
+
list[Command],
|
|
17
|
+
dict[str, Callable[[str], Awaitable[CommandResult]]],
|
|
18
|
+
]:
|
|
19
|
+
"""Return merged (commands, handlers) from built-in + skills.
|
|
20
|
+
|
|
21
|
+
Skills discovered from ~/.navaia/commands/ and <cwd>/.navaia/commands/.
|
|
22
|
+
Built-in commands take precedence over skills with the same name.
|
|
23
|
+
"""
|
|
24
|
+
commands, handlers = get_builtin_commands()
|
|
25
|
+
builtin_names = {c.name for c in commands}
|
|
26
|
+
|
|
27
|
+
skill_cmds, skill_handlers = get_skill_commands(cwd or os.getcwd())
|
|
28
|
+
|
|
29
|
+
# Merge skills, skipping any that conflict with built-ins
|
|
30
|
+
for cmd in skill_cmds:
|
|
31
|
+
if cmd.name not in builtin_names:
|
|
32
|
+
commands.append(cmd)
|
|
33
|
+
for name, handler in skill_handlers.items():
|
|
34
|
+
if name not in handlers:
|
|
35
|
+
handlers[name] = handler
|
|
36
|
+
|
|
37
|
+
return commands, handlers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_command(name: str, commands: list[Command]) -> Command | None:
|
|
41
|
+
"""Find a command by name or alias. Returns None if not found."""
|
|
42
|
+
lower_name = name.lower()
|
|
43
|
+
for cmd in commands:
|
|
44
|
+
if cmd.name == lower_name:
|
|
45
|
+
return cmd
|
|
46
|
+
if lower_name in cmd.aliases:
|
|
47
|
+
return cmd
|
|
48
|
+
return None
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Skill loader — discovers and loads user-defined slash commands from .md files.
|
|
2
|
+
|
|
3
|
+
Skills are markdown files with optional YAML frontmatter. Navaia Code
|
|
4
|
+
searches four directories in priority order (later overrides earlier):
|
|
5
|
+
|
|
6
|
+
1. ~/.claude/commands/ (Claude Code user-global — full compatibility)
|
|
7
|
+
2. ~/.navaia/commands/ (Navaia user-global)
|
|
8
|
+
3. <cwd>/.claude/commands/ (Claude Code project-local)
|
|
9
|
+
4. <cwd>/.navaia/commands/ (Navaia project-local — highest priority)
|
|
10
|
+
|
|
11
|
+
This means any skill you install or create for Claude Code works in Navaia
|
|
12
|
+
Code automatically. Skills from the internet, community repos, or your own
|
|
13
|
+
``~/.claude/commands/`` folder are picked up with zero extra setup.
|
|
14
|
+
|
|
15
|
+
Each file's stem becomes the command name (e.g. ``commit.md`` → ``/commit``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Awaitable, Callable
|
|
24
|
+
|
|
25
|
+
from navaia.commands.types import Command, CommandResult, CommandType
|
|
26
|
+
|
|
27
|
+
_FRONTMATTER_RE = re.compile(
|
|
28
|
+
r"^---\s*\n(.*?)\n---\s*\n",
|
|
29
|
+
re.DOTALL,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_DESC_RE = re.compile(r"^description:\s*(.+)$", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SkillDefinition:
|
|
37
|
+
"""Parsed skill file."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
description: str
|
|
41
|
+
body: str
|
|
42
|
+
source_path: Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_skill_file(path: Path) -> SkillDefinition | None:
|
|
46
|
+
"""Parse a skill markdown file. Returns None on failure."""
|
|
47
|
+
try:
|
|
48
|
+
raw = path.read_text(encoding="utf-8")
|
|
49
|
+
except OSError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
name = path.stem
|
|
53
|
+
|
|
54
|
+
# Extract frontmatter
|
|
55
|
+
fm_match = _FRONTMATTER_RE.match(raw)
|
|
56
|
+
if fm_match:
|
|
57
|
+
frontmatter = fm_match.group(1)
|
|
58
|
+
body = raw[fm_match.end():]
|
|
59
|
+
else:
|
|
60
|
+
# No frontmatter — use whole file as body
|
|
61
|
+
frontmatter = ""
|
|
62
|
+
body = raw
|
|
63
|
+
|
|
64
|
+
# Extract description from frontmatter
|
|
65
|
+
desc_match = _DESC_RE.search(frontmatter)
|
|
66
|
+
description = desc_match.group(1).strip().strip("\"'") if desc_match else f"Skill: {name}"
|
|
67
|
+
|
|
68
|
+
return SkillDefinition(
|
|
69
|
+
name=name,
|
|
70
|
+
description=description,
|
|
71
|
+
body=body.strip(),
|
|
72
|
+
source_path=path,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _scan_dir(directory: Path, into: dict[str, SkillDefinition]) -> None:
|
|
77
|
+
"""Scan *directory* for ``.md`` skill files, merging into *into*."""
|
|
78
|
+
if not directory.is_dir():
|
|
79
|
+
return
|
|
80
|
+
for md_file in sorted(directory.glob("*.md")):
|
|
81
|
+
skill = _parse_skill_file(md_file)
|
|
82
|
+
if skill:
|
|
83
|
+
into[skill.name] = skill # later dirs override earlier
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def discover_skills(cwd: str | None = None) -> list[SkillDefinition]:
|
|
87
|
+
"""Discover skill files from all known locations.
|
|
88
|
+
|
|
89
|
+
Search order (later overrides earlier):
|
|
90
|
+
1. ~/.claude/commands/ — Claude Code user-global
|
|
91
|
+
2. ~/.navaia/commands/ — Navaia user-global
|
|
92
|
+
3. <cwd>/.claude/commands/ — Claude Code project-local
|
|
93
|
+
4. <cwd>/.navaia/commands/ — Navaia project-local
|
|
94
|
+
"""
|
|
95
|
+
skills: dict[str, SkillDefinition] = {}
|
|
96
|
+
home = Path.home()
|
|
97
|
+
|
|
98
|
+
# User-global directories
|
|
99
|
+
_scan_dir(home / ".claude" / "commands", skills)
|
|
100
|
+
_scan_dir(home / ".navaia" / "commands", skills)
|
|
101
|
+
|
|
102
|
+
# Project-local directories
|
|
103
|
+
if cwd:
|
|
104
|
+
cwd_path = Path(cwd)
|
|
105
|
+
_scan_dir(cwd_path / ".claude" / "commands", skills)
|
|
106
|
+
_scan_dir(cwd_path / ".navaia" / "commands", skills)
|
|
107
|
+
|
|
108
|
+
return list(skills.values())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _make_skill_handler(
|
|
112
|
+
skill: SkillDefinition,
|
|
113
|
+
) -> Callable[[str], Awaitable[CommandResult]]:
|
|
114
|
+
"""Create an async handler that returns the skill body as a prompt."""
|
|
115
|
+
|
|
116
|
+
async def handler(args: str) -> CommandResult:
|
|
117
|
+
# The skill body is injected as a prompt to the model
|
|
118
|
+
prompt_text = skill.body
|
|
119
|
+
if args.strip():
|
|
120
|
+
prompt_text = f"{prompt_text}\n\nUser arguments: {args.strip()}"
|
|
121
|
+
return CommandResult(output=prompt_text, should_query=True)
|
|
122
|
+
|
|
123
|
+
return handler
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_skill_commands(
|
|
127
|
+
cwd: str | None = None,
|
|
128
|
+
) -> tuple[
|
|
129
|
+
list[Command],
|
|
130
|
+
dict[str, Callable[[str], Awaitable[CommandResult]]],
|
|
131
|
+
]:
|
|
132
|
+
"""Return (commands, handlers) for all discovered skills.
|
|
133
|
+
|
|
134
|
+
Each skill becomes a slash command that, when invoked, sends its
|
|
135
|
+
markdown body as a prompt to the model (should_query=True).
|
|
136
|
+
"""
|
|
137
|
+
skills = discover_skills(cwd)
|
|
138
|
+
commands: list[Command] = []
|
|
139
|
+
handlers: dict[str, Callable[[str], Awaitable[CommandResult]]] = {}
|
|
140
|
+
|
|
141
|
+
for skill in skills:
|
|
142
|
+
cmd = Command(
|
|
143
|
+
name=skill.name,
|
|
144
|
+
description=skill.description,
|
|
145
|
+
type=CommandType.PROMPT,
|
|
146
|
+
)
|
|
147
|
+
commands.append(cmd)
|
|
148
|
+
handlers[skill.name] = _make_skill_handler(skill)
|
|
149
|
+
|
|
150
|
+
return commands, handlers
|