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.
Files changed (146) hide show
  1. navaia/__init__.py +3 -0
  2. navaia/api/__init__.py +0 -0
  3. navaia/api/client.py +72 -0
  4. navaia/api/normalise.py +148 -0
  5. navaia/api/retry.py +114 -0
  6. navaia/api/streaming.py +341 -0
  7. navaia/api/types.py +213 -0
  8. navaia/commands/__init__.py +0 -0
  9. navaia/commands/builtin/__init__.py +0 -0
  10. navaia/commands/builtin/commands.py +206 -0
  11. navaia/commands/dispatcher.py +38 -0
  12. navaia/commands/parser.py +25 -0
  13. navaia/commands/registry.py +48 -0
  14. navaia/commands/skills.py +150 -0
  15. navaia/commands/types.py +26 -0
  16. navaia/compact/__init__.py +0 -0
  17. navaia/compact/compact.py +241 -0
  18. navaia/compact/prompt.py +22 -0
  19. navaia/compact/restore.py +91 -0
  20. navaia/config/__init__.py +0 -0
  21. navaia/config/env.py +53 -0
  22. navaia/config/global_config.py +43 -0
  23. navaia/config/project_config.py +53 -0
  24. navaia/config/providers.py +234 -0
  25. navaia/config/settings.py +113 -0
  26. navaia/context/__init__.py +0 -0
  27. navaia/context/cache.py +29 -0
  28. navaia/context/claudemd.py +252 -0
  29. navaia/context/system_prompt.py +172 -0
  30. navaia/effort/__init__.py +0 -0
  31. navaia/effort/effort.py +47 -0
  32. navaia/hooks/__init__.py +0 -0
  33. navaia/hooks/executor.py +153 -0
  34. navaia/hooks/settings.py +82 -0
  35. navaia/hooks/types.py +53 -0
  36. navaia/main.py +462 -0
  37. navaia/mcp/__init__.py +0 -0
  38. navaia/mcp/bootstrap.py +88 -0
  39. navaia/mcp/client.py +157 -0
  40. navaia/mcp/settings.py +80 -0
  41. navaia/mcp/tools.py +118 -0
  42. navaia/mcp/types.py +29 -0
  43. navaia/memory/__init__.py +0 -0
  44. navaia/memory/memdir.py +70 -0
  45. navaia/memory/paths.py +17 -0
  46. navaia/memory/scanner.py +85 -0
  47. navaia/memory/types.py +27 -0
  48. navaia/permissions/__init__.py +0 -0
  49. navaia/permissions/checker.py +147 -0
  50. navaia/permissions/rules.py +88 -0
  51. navaia/permissions/types.py +39 -0
  52. navaia/query/__init__.py +0 -0
  53. navaia/query/engine.py +477 -0
  54. navaia/query/types.py +43 -0
  55. navaia/session/__init__.py +0 -0
  56. navaia/session/history.py +64 -0
  57. navaia/session/serialise.py +184 -0
  58. navaia/session/state.py +20 -0
  59. navaia/session/storage.py +102 -0
  60. navaia/session/store.py +202 -0
  61. navaia/state/__init__.py +0 -0
  62. navaia/tasks/__init__.py +0 -0
  63. navaia/tasks/cron.py +113 -0
  64. navaia/tasks/manager.py +112 -0
  65. navaia/tasks/persistence.py +128 -0
  66. navaia/tasks/task.py +34 -0
  67. navaia/thinking/__init__.py +0 -0
  68. navaia/thinking/budget.py +42 -0
  69. navaia/thinking/config.py +55 -0
  70. navaia/tools/__init__.py +0 -0
  71. navaia/tools/agent_tool/__init__.py +0 -0
  72. navaia/tools/agent_tool/tool.py +148 -0
  73. navaia/tools/ask_user/__init__.py +0 -0
  74. navaia/tools/ask_user/bus.py +51 -0
  75. navaia/tools/ask_user/tool.py +64 -0
  76. navaia/tools/base.py +51 -0
  77. navaia/tools/bash/__init__.py +0 -0
  78. navaia/tools/bash/background.py +123 -0
  79. navaia/tools/bash/tool.py +234 -0
  80. navaia/tools/executor.py +111 -0
  81. navaia/tools/file_edit/__init__.py +0 -0
  82. navaia/tools/file_edit/tool.py +206 -0
  83. navaia/tools/file_read/__init__.py +0 -0
  84. navaia/tools/file_read/tool.py +209 -0
  85. navaia/tools/file_write/__init__.py +0 -0
  86. navaia/tools/file_write/tool.py +112 -0
  87. navaia/tools/glob_tool/__init__.py +0 -0
  88. navaia/tools/glob_tool/tool.py +97 -0
  89. navaia/tools/grep_tool/__init__.py +0 -0
  90. navaia/tools/grep_tool/tool.py +292 -0
  91. navaia/tools/monitor/__init__.py +0 -0
  92. navaia/tools/monitor/tool.py +101 -0
  93. navaia/tools/plan_mode/__init__.py +0 -0
  94. navaia/tools/plan_mode/enter.py +38 -0
  95. navaia/tools/plan_mode/exit.py +36 -0
  96. navaia/tools/registry.py +71 -0
  97. navaia/tools/result_storage.py +60 -0
  98. navaia/tools/skill_tool/__init__.py +0 -0
  99. navaia/tools/skill_tool/loader.py +147 -0
  100. navaia/tools/skill_tool/tool.py +88 -0
  101. navaia/tools/task_tools/__init__.py +0 -0
  102. navaia/tools/task_tools/create.py +60 -0
  103. navaia/tools/task_tools/get.py +52 -0
  104. navaia/tools/task_tools/list.py +39 -0
  105. navaia/tools/task_tools/manager.py +66 -0
  106. navaia/tools/task_tools/update.py +88 -0
  107. navaia/tools/todo_write/__init__.py +0 -0
  108. navaia/tools/todo_write/tool.py +121 -0
  109. navaia/tools/tool_search/__init__.py +0 -0
  110. navaia/tools/tool_search/tool.py +106 -0
  111. navaia/tools/web_fetch/__init__.py +0 -0
  112. navaia/tools/web_fetch/tool.py +88 -0
  113. navaia/tools/worktree/__init__.py +0 -0
  114. navaia/tools/worktree/enter.py +66 -0
  115. navaia/tools/worktree/exit.py +51 -0
  116. navaia/tools/worktree/manager.py +130 -0
  117. navaia/ui/__init__.py +0 -0
  118. navaia/ui/app.py +605 -0
  119. navaia/ui/bidi.py +70 -0
  120. navaia/ui/input/__init__.py +0 -0
  121. navaia/ui/input/history.py +84 -0
  122. navaia/ui/input/suggestions.py +72 -0
  123. navaia/ui/messages/__init__.py +0 -0
  124. navaia/ui/messages/assistant_text.py +46 -0
  125. navaia/ui/messages/bash_output.py +68 -0
  126. navaia/ui/messages/system_msg.py +25 -0
  127. navaia/ui/messages/tool_result.py +38 -0
  128. navaia/ui/messages/tool_use.py +70 -0
  129. navaia/ui/messages/user_prompt.py +27 -0
  130. navaia/ui/screens/__init__.py +0 -0
  131. navaia/ui/screens/repl.py +136 -0
  132. navaia/ui/styles/app.tcss +48 -0
  133. navaia/ui/widgets/__init__.py +0 -0
  134. navaia/ui/widgets/logo.py +48 -0
  135. navaia/ui/widgets/markdown_view.py +87 -0
  136. navaia/ui/widgets/message_list.py +387 -0
  137. navaia/ui/widgets/permission_prompt.py +137 -0
  138. navaia/ui/widgets/prompt_footer.py +67 -0
  139. navaia/ui/widgets/prompt_input.py +203 -0
  140. navaia/ui/widgets/question_prompt.py +58 -0
  141. navaia/ui/widgets/spinner.py +110 -0
  142. navaia/ui/widgets/thinking_view.py +124 -0
  143. navaia_code-1.0.50.dist-info/METADATA +17 -0
  144. navaia_code-1.0.50.dist-info/RECORD +146 -0
  145. navaia_code-1.0.50.dist-info/WHEEL +4 -0
  146. 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