klaude-code 2.1.0__py3-none-any.whl → 2.2.0__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.
- klaude_code/app/__init__.py +1 -2
- klaude_code/app/runtime.py +26 -41
- klaude_code/cli/main.py +19 -152
- klaude_code/config/assets/builtin_config.yaml +13 -0
- klaude_code/const.py +1 -1
- klaude_code/core/agent_profile.py +38 -3
- klaude_code/core/manager/llm_clients_builder.py +1 -1
- klaude_code/core/prompts/prompt-nano-banana.md +1 -0
- klaude_code/core/reminders.py +20 -4
- klaude_code/core/tool/__init__.py +0 -2
- klaude_code/core/tool/shell/command_safety.py +4 -189
- klaude_code/core/turn.py +2 -5
- klaude_code/llm/anthropic/client.py +1 -1
- klaude_code/llm/google/client.py +1 -1
- klaude_code/llm/openai_compatible/stream.py +1 -1
- klaude_code/llm/responses/client.py +1 -1
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/events/tools.py +5 -1
- klaude_code/protocol/message.py +2 -2
- klaude_code/protocol/tools.py +0 -1
- klaude_code/session/session.py +0 -2
- klaude_code/skill/loader.py +31 -87
- klaude_code/skill/manager.py +38 -0
- klaude_code/tui/command/__init__.py +6 -3
- klaude_code/tui/command/clear_cmd.py +1 -1
- klaude_code/tui/command/command_abc.py +1 -2
- klaude_code/tui/command/copy_cmd.py +52 -0
- klaude_code/tui/command/fork_session_cmd.py +4 -4
- klaude_code/tui/command/refresh_cmd.py +1 -2
- klaude_code/tui/command/resume_cmd.py +3 -4
- klaude_code/tui/command/status_cmd.py +1 -1
- klaude_code/tui/components/developer.py +11 -11
- klaude_code/tui/components/metadata.py +1 -1
- klaude_code/tui/components/rich/theme.py +2 -2
- klaude_code/tui/components/tools.py +4 -8
- klaude_code/tui/components/user_input.py +9 -21
- klaude_code/tui/machine.py +3 -1
- klaude_code/tui/renderer.py +1 -1
- klaude_code/tui/runner.py +2 -2
- klaude_code/tui/terminal/selector.py +3 -15
- klaude_code/ui/__init__.py +0 -24
- klaude_code/ui/common.py +3 -2
- klaude_code/ui/core/display.py +2 -2
- {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/METADATA +16 -81
- {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/RECORD +47 -50
- klaude_code/core/tool/skill/__init__.py +0 -0
- klaude_code/core/tool/skill/skill_tool.md +0 -24
- klaude_code/core/tool/skill/skill_tool.py +0 -89
- klaude_code/tui/command/prompt-commit.md +0 -82
- klaude_code/ui/exec_mode.py +0 -60
- {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import re
|
|
3
2
|
import shlex
|
|
4
3
|
|
|
5
4
|
|
|
@@ -11,76 +10,6 @@ class SafetyCheckResult:
|
|
|
11
10
|
self.error_msg = error_msg
|
|
12
11
|
|
|
13
12
|
|
|
14
|
-
def _is_valid_sed_n_arg(s: str | None) -> bool:
|
|
15
|
-
if not s:
|
|
16
|
-
return False
|
|
17
|
-
# Matches: Np or M,Np where M,N are positive integers
|
|
18
|
-
return bool(re.fullmatch(r"\d+(,\d+)?p", s))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _is_safe_awk_program(program: str) -> SafetyCheckResult:
|
|
22
|
-
lowered = program.lower()
|
|
23
|
-
|
|
24
|
-
if "`" in program:
|
|
25
|
-
return SafetyCheckResult(False, "awk: backticks not allowed in program")
|
|
26
|
-
if "$(" in program:
|
|
27
|
-
return SafetyCheckResult(False, "awk: command substitution not allowed in program")
|
|
28
|
-
if "|&" in program:
|
|
29
|
-
return SafetyCheckResult(False, "awk: background pipeline not allowed in program")
|
|
30
|
-
|
|
31
|
-
if "system(" in lowered:
|
|
32
|
-
return SafetyCheckResult(False, "awk: system() call not allowed in program")
|
|
33
|
-
|
|
34
|
-
if re.search(r"(?<![|&>])\bprint\s*\|", program, re.IGNORECASE):
|
|
35
|
-
return SafetyCheckResult(False, "awk: piping output to external command not allowed")
|
|
36
|
-
if re.search(r"\bprintf\s*\|", program, re.IGNORECASE):
|
|
37
|
-
return SafetyCheckResult(False, "awk: piping output to external command not allowed")
|
|
38
|
-
|
|
39
|
-
return SafetyCheckResult(True)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _is_safe_awk_argv(argv: list[str]) -> SafetyCheckResult:
|
|
43
|
-
if len(argv) < 2:
|
|
44
|
-
return SafetyCheckResult(False, "awk: Missing program")
|
|
45
|
-
|
|
46
|
-
program: str | None = None
|
|
47
|
-
|
|
48
|
-
i = 1
|
|
49
|
-
while i < len(argv):
|
|
50
|
-
arg = argv[i]
|
|
51
|
-
|
|
52
|
-
if arg in {"-f", "--file", "--source"} or arg.startswith("-f"):
|
|
53
|
-
return SafetyCheckResult(False, "awk: -f/--file not allowed")
|
|
54
|
-
|
|
55
|
-
if arg in {"-e", "--exec"}:
|
|
56
|
-
if i + 1 >= len(argv):
|
|
57
|
-
return SafetyCheckResult(False, "awk: Missing program for -e")
|
|
58
|
-
script = argv[i + 1]
|
|
59
|
-
program_check = _is_safe_awk_program(script)
|
|
60
|
-
if not program_check.is_safe:
|
|
61
|
-
return program_check
|
|
62
|
-
if program is None:
|
|
63
|
-
program = script
|
|
64
|
-
i += 2
|
|
65
|
-
continue
|
|
66
|
-
|
|
67
|
-
if arg.startswith("-"):
|
|
68
|
-
i += 1
|
|
69
|
-
continue
|
|
70
|
-
|
|
71
|
-
if program is None:
|
|
72
|
-
program_check = _is_safe_awk_program(arg)
|
|
73
|
-
if not program_check.is_safe:
|
|
74
|
-
return program_check
|
|
75
|
-
program = arg
|
|
76
|
-
i += 1
|
|
77
|
-
|
|
78
|
-
if program is None:
|
|
79
|
-
return SafetyCheckResult(False, "awk: Missing program")
|
|
80
|
-
|
|
81
|
-
return SafetyCheckResult(True)
|
|
82
|
-
|
|
83
|
-
|
|
84
13
|
def _is_safe_rm_argv(argv: list[str]) -> SafetyCheckResult:
|
|
85
14
|
"""Check safety of rm command arguments."""
|
|
86
15
|
# Enforce strict safety rules for rm operands
|
|
@@ -217,112 +146,12 @@ def _is_safe_argv(argv: list[str]) -> SafetyCheckResult:
|
|
|
217
146
|
|
|
218
147
|
cmd0 = argv[0]
|
|
219
148
|
|
|
220
|
-
# if _has_shell_redirection(argv):
|
|
221
|
-
# return SafetyCheckResult(False, "Shell redirection and pipelines are not allowed in single commands")
|
|
222
|
-
|
|
223
|
-
# Special handling for rm to prevent dangerous operations
|
|
224
149
|
if cmd0 == "rm":
|
|
225
150
|
return _is_safe_rm_argv(argv)
|
|
226
151
|
|
|
227
|
-
# Special handling for trash to prevent dangerous operations
|
|
228
152
|
if cmd0 == "trash":
|
|
229
153
|
return _is_safe_trash_argv(argv)
|
|
230
154
|
|
|
231
|
-
if cmd0 == "find":
|
|
232
|
-
unsafe_opts = {
|
|
233
|
-
"-exec": "command execution",
|
|
234
|
-
"-execdir": "command execution",
|
|
235
|
-
"-ok": "interactive command execution",
|
|
236
|
-
"-okdir": "interactive command execution",
|
|
237
|
-
"-delete": "file deletion",
|
|
238
|
-
"-fls": "file output",
|
|
239
|
-
"-fprint": "file output",
|
|
240
|
-
"-fprint0": "file output",
|
|
241
|
-
"-fprintf": "formatted file output",
|
|
242
|
-
}
|
|
243
|
-
for arg in argv[1:]:
|
|
244
|
-
if arg in unsafe_opts:
|
|
245
|
-
return SafetyCheckResult(False, f"find: {unsafe_opts[arg]} option '{arg}' not allowed")
|
|
246
|
-
return SafetyCheckResult(True)
|
|
247
|
-
|
|
248
|
-
if cmd0 == "git":
|
|
249
|
-
sub = argv[1] if len(argv) > 1 else None
|
|
250
|
-
if not sub:
|
|
251
|
-
return SafetyCheckResult(False, "git: Missing subcommand")
|
|
252
|
-
|
|
253
|
-
# Allow most local git operations, but block remote operations
|
|
254
|
-
allowed_git_cmds = {
|
|
255
|
-
"add",
|
|
256
|
-
"branch",
|
|
257
|
-
"checkout",
|
|
258
|
-
"commit",
|
|
259
|
-
"config",
|
|
260
|
-
"diff",
|
|
261
|
-
"fetch",
|
|
262
|
-
"init",
|
|
263
|
-
"log",
|
|
264
|
-
"merge",
|
|
265
|
-
"mv",
|
|
266
|
-
"rebase",
|
|
267
|
-
"reset",
|
|
268
|
-
"restore",
|
|
269
|
-
"revert",
|
|
270
|
-
"rm",
|
|
271
|
-
"show",
|
|
272
|
-
"stash",
|
|
273
|
-
"status",
|
|
274
|
-
"switch",
|
|
275
|
-
"tag",
|
|
276
|
-
"clone",
|
|
277
|
-
"worktree",
|
|
278
|
-
"push",
|
|
279
|
-
"pull",
|
|
280
|
-
"remote",
|
|
281
|
-
}
|
|
282
|
-
if sub not in allowed_git_cmds:
|
|
283
|
-
return SafetyCheckResult(False, f"git: Subcommand '{sub}' not in allow list")
|
|
284
|
-
return SafetyCheckResult(True)
|
|
285
|
-
|
|
286
|
-
# Build tools and linters - allow all subcommands
|
|
287
|
-
if cmd0 in {
|
|
288
|
-
"cargo",
|
|
289
|
-
"uv",
|
|
290
|
-
"go",
|
|
291
|
-
"ruff",
|
|
292
|
-
"pyright",
|
|
293
|
-
"make",
|
|
294
|
-
"npm",
|
|
295
|
-
"pnpm",
|
|
296
|
-
"bun",
|
|
297
|
-
}:
|
|
298
|
-
return SafetyCheckResult(True)
|
|
299
|
-
|
|
300
|
-
if cmd0 == "sed":
|
|
301
|
-
# Allow sed -n patterns (line printing)
|
|
302
|
-
if len(argv) >= 3 and argv[1] == "-n" and _is_valid_sed_n_arg(argv[2]):
|
|
303
|
-
return SafetyCheckResult(True)
|
|
304
|
-
# Allow simple text replacement: sed 's/old/new/g' file
|
|
305
|
-
# or sed -i 's/old/new/g' file for in-place editing
|
|
306
|
-
if len(argv) >= 3:
|
|
307
|
-
# Find the sed script argument (usually starts with 's/')
|
|
308
|
-
for arg in argv[1:]:
|
|
309
|
-
if arg.startswith("s/") or arg.startswith("s|"):
|
|
310
|
-
# Basic safety check: no command execution in replacement
|
|
311
|
-
if ";" in arg:
|
|
312
|
-
return SafetyCheckResult(False, f"sed: Command separator ';' not allowed in '{arg}'")
|
|
313
|
-
if "`" in arg:
|
|
314
|
-
return SafetyCheckResult(False, f"sed: Backticks not allowed in '{arg}'")
|
|
315
|
-
if "$(" in arg:
|
|
316
|
-
return SafetyCheckResult(False, f"sed: Command substitution not allowed in '{arg}'")
|
|
317
|
-
return SafetyCheckResult(True)
|
|
318
|
-
return SafetyCheckResult(
|
|
319
|
-
False,
|
|
320
|
-
"sed: Only text replacement (s/old/new/) or line printing (-n 'Np') is allowed",
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
if cmd0 == "awk":
|
|
324
|
-
return _is_safe_awk_argv(argv)
|
|
325
|
-
|
|
326
155
|
# Default allow when command is not explicitly restricted
|
|
327
156
|
return SafetyCheckResult(True)
|
|
328
157
|
|
|
@@ -330,30 +159,16 @@ def _is_safe_argv(argv: list[str]) -> SafetyCheckResult:
|
|
|
330
159
|
def is_safe_command(command: str) -> SafetyCheckResult:
|
|
331
160
|
"""Determine if a command is safe enough to run.
|
|
332
161
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
find -exec/-delete, etc.) and otherwise lets the real shell surface
|
|
336
|
-
syntax errors (for example, unmatched quotes in complex multiline
|
|
337
|
-
scripts).
|
|
162
|
+
Only rm and trash commands are checked for safety. All other commands
|
|
163
|
+
are allowed by default.
|
|
338
164
|
"""
|
|
339
|
-
|
|
340
|
-
# Try to parse into an argv-style list first. If this fails (e.g. due
|
|
341
|
-
# to unterminated quotes in a complex heredoc), treat the command as
|
|
342
|
-
# safe here and let bash itself perform syntax checking instead of
|
|
343
|
-
# blocking execution pre-emptively.
|
|
344
165
|
try:
|
|
345
166
|
argv = shlex.split(command, posix=True)
|
|
346
167
|
except ValueError:
|
|
347
|
-
# If we cannot reliably parse the command
|
|
348
|
-
#
|
|
349
|
-
# real shell surface any syntax errors instead of blocking execution
|
|
350
|
-
# pre-emptively.
|
|
168
|
+
# If we cannot reliably parse the command, treat it as safe here
|
|
169
|
+
# and let the real shell surface any syntax errors
|
|
351
170
|
return SafetyCheckResult(True)
|
|
352
171
|
|
|
353
|
-
# All further safety checks are done directly on the parsed argv via
|
|
354
|
-
# _is_safe_argv. We intentionally avoid trying to re-interpret complex
|
|
355
|
-
# shell sequences here and rely on the real shell to handle syntax.
|
|
356
|
-
|
|
357
172
|
if not argv:
|
|
358
173
|
return SafetyCheckResult(False, "Empty command")
|
|
359
174
|
|
klaude_code/core/turn.py
CHANGED
|
@@ -69,7 +69,6 @@ def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEv
|
|
|
69
69
|
)
|
|
70
70
|
)
|
|
71
71
|
case ToolExecutionResult(tool_call=tool_call, tool_result=tool_result, is_last_in_turn=is_last_in_turn):
|
|
72
|
-
status = "success" if tool_result.status == "success" else "error"
|
|
73
72
|
ui_events.append(
|
|
74
73
|
events.ToolResultEvent(
|
|
75
74
|
session_id=session_id,
|
|
@@ -78,13 +77,11 @@ def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEv
|
|
|
78
77
|
tool_name=tool_call.tool_name,
|
|
79
78
|
result=tool_result.output_text,
|
|
80
79
|
ui_extra=tool_result.ui_extra,
|
|
81
|
-
status=status,
|
|
80
|
+
status=tool_result.status,
|
|
82
81
|
task_metadata=tool_result.task_metadata,
|
|
83
82
|
is_last_in_turn=is_last_in_turn,
|
|
84
83
|
)
|
|
85
84
|
)
|
|
86
|
-
if tool_result.status == "aborted":
|
|
87
|
-
ui_events.append(events.InterruptEvent(session_id=session_id))
|
|
88
85
|
case ToolExecutionTodoChange(todos=todos):
|
|
89
86
|
ui_events.append(
|
|
90
87
|
events.TodoChangeEvent(
|
|
@@ -351,7 +348,7 @@ class TurnExecutor:
|
|
|
351
348
|
style="red",
|
|
352
349
|
debug_type=DebugType.RESPONSE,
|
|
353
350
|
)
|
|
354
|
-
case message.
|
|
351
|
+
case message.ToolCallStartDelta() as msg:
|
|
355
352
|
if thinking_active:
|
|
356
353
|
thinking_active = False
|
|
357
354
|
yield events.ThinkingEndEvent(
|
|
@@ -169,7 +169,7 @@ async def parse_anthropic_stream(
|
|
|
169
169
|
match event.content_block:
|
|
170
170
|
case BetaToolUseBlock() as block:
|
|
171
171
|
metadata_tracker.record_token()
|
|
172
|
-
yield message.
|
|
172
|
+
yield message.ToolCallStartDelta(
|
|
173
173
|
response_id=response_id,
|
|
174
174
|
call_id=block.id,
|
|
175
175
|
name=block.name,
|
klaude_code/llm/google/client.py
CHANGED
|
@@ -242,7 +242,7 @@ async def parse_google_stream(
|
|
|
242
242
|
|
|
243
243
|
if call_id not in started_tool_items:
|
|
244
244
|
started_tool_items.add(call_id)
|
|
245
|
-
yield message.
|
|
245
|
+
yield message.ToolCallStartDelta(response_id=response_id, call_id=call_id, name=name)
|
|
246
246
|
|
|
247
247
|
args_obj = getattr(function_call, "args", None)
|
|
248
248
|
if args_obj is not None:
|
|
@@ -303,7 +303,7 @@ async def parse_chat_completions_stream(
|
|
|
303
303
|
for tc in tool_calls:
|
|
304
304
|
if tc.index not in state.emitted_tool_start_indices and tc.function and tc.function.name:
|
|
305
305
|
state.emitted_tool_start_indices.add(tc.index)
|
|
306
|
-
yield message.
|
|
306
|
+
yield message.ToolCallStartDelta(
|
|
307
307
|
response_id=state.response_id,
|
|
308
308
|
call_id=tc.id or "",
|
|
309
309
|
name=tc.function.name,
|
|
@@ -145,7 +145,7 @@ async def parse_responses_stream(
|
|
|
145
145
|
case responses.ResponseOutputItemAddedEvent() as event:
|
|
146
146
|
if isinstance(event.item, responses.ResponseFunctionToolCall):
|
|
147
147
|
metadata_tracker.record_token()
|
|
148
|
-
yield message.
|
|
148
|
+
yield message.ToolCallStartDelta(
|
|
149
149
|
response_id=response_id,
|
|
150
150
|
call_id=event.item.call_id,
|
|
151
151
|
name=event.item.name,
|
klaude_code/protocol/commands.py
CHANGED
|
@@ -28,6 +28,7 @@ class CommandName(str, Enum):
|
|
|
28
28
|
THINKING = "thinking"
|
|
29
29
|
FORK_SESSION = "fork-session"
|
|
30
30
|
RESUME = "resume"
|
|
31
|
+
COPY = "copy"
|
|
31
32
|
# PLAN and DOC are dynamically registered now, but kept here if needed for reference
|
|
32
33
|
# or we can remove them if no code explicitly imports them.
|
|
33
34
|
# PLAN = "plan"
|
|
@@ -18,6 +18,10 @@ class ToolResultEvent(ResponseEvent):
|
|
|
18
18
|
tool_name: str
|
|
19
19
|
result: str
|
|
20
20
|
ui_extra: model.ToolResultUIExtra | None = None
|
|
21
|
-
status: Literal["success", "error"]
|
|
21
|
+
status: Literal["success", "error", "aborted"]
|
|
22
22
|
task_metadata: model.TaskMetadata | None = None
|
|
23
23
|
is_last_in_turn: bool = True
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_error(self) -> bool:
|
|
27
|
+
return self.status in ("error", "aborted")
|
klaude_code/protocol/message.py
CHANGED
|
@@ -25,7 +25,7 @@ from klaude_code.protocol.model import (
|
|
|
25
25
|
# Stream items
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
class
|
|
28
|
+
class ToolCallStartDelta(BaseModel):
|
|
29
29
|
"""Transient streaming signal when LLM starts a tool call.
|
|
30
30
|
|
|
31
31
|
This is NOT persisted to conversation history. Used only for
|
|
@@ -175,7 +175,7 @@ Message = SystemMessage | DeveloperMessage | UserMessage | AssistantMessage | To
|
|
|
175
175
|
|
|
176
176
|
HistoryEvent = Message | StreamErrorItem | TaskMetadataItem
|
|
177
177
|
|
|
178
|
-
StreamItem = AssistantTextDelta | AssistantImageDelta | ThinkingTextDelta |
|
|
178
|
+
StreamItem = AssistantTextDelta | AssistantImageDelta | ThinkingTextDelta | ToolCallStartDelta
|
|
179
179
|
|
|
180
180
|
LLMStreamItem = HistoryEvent | StreamItem
|
|
181
181
|
|
klaude_code/protocol/tools.py
CHANGED
klaude_code/session/session.py
CHANGED
|
@@ -350,8 +350,6 @@ class Session(BaseModel):
|
|
|
350
350
|
is_last_in_turn=is_last_in_turn,
|
|
351
351
|
)
|
|
352
352
|
yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
|
|
353
|
-
if tr.status == "aborted":
|
|
354
|
-
yield events.InterruptEvent(session_id=self.id)
|
|
355
353
|
case message.UserMessage() as um:
|
|
356
354
|
images = [part for part in um.parts if isinstance(part, message.ImageURLPart)]
|
|
357
355
|
yield events.UserMessageEvent(
|
klaude_code/skill/loader.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import re
|
|
2
1
|
from dataclasses import dataclass
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
from typing import ClassVar
|
|
@@ -14,12 +13,12 @@ class Skill:
|
|
|
14
13
|
|
|
15
14
|
name: str # Skill identifier (lowercase-hyphen)
|
|
16
15
|
description: str # What the skill does and when to use it
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
location: str # Skill source: 'system', 'user', or 'project'
|
|
17
|
+
skill_path: Path
|
|
18
|
+
base_dir: Path
|
|
19
19
|
license: str | None = None
|
|
20
20
|
allowed_tools: list[str] | None = None
|
|
21
21
|
metadata: dict[str, str] | None = None
|
|
22
|
-
skill_path: Path | None = None
|
|
23
22
|
|
|
24
23
|
@property
|
|
25
24
|
def short_description(self) -> str:
|
|
@@ -31,17 +30,6 @@ class Skill:
|
|
|
31
30
|
return self.metadata["short-description"]
|
|
32
31
|
return self.description
|
|
33
32
|
|
|
34
|
-
def to_prompt(self) -> str:
|
|
35
|
-
"""Convert skill to prompt format for agent consumption"""
|
|
36
|
-
return f"""# Skill: {self.name}
|
|
37
|
-
|
|
38
|
-
{self.description}
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
{self.content}
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
33
|
|
|
46
34
|
class SkillLoader:
|
|
47
35
|
"""Load and manage Claude Skills from SKILL.md files"""
|
|
@@ -79,7 +67,6 @@ class SkillLoader:
|
|
|
79
67
|
|
|
80
68
|
# Parse YAML frontmatter
|
|
81
69
|
frontmatter: dict[str, object] = {}
|
|
82
|
-
markdown_content = content
|
|
83
70
|
|
|
84
71
|
if content.startswith("---"):
|
|
85
72
|
parts = content.split("---", 2)
|
|
@@ -87,7 +74,6 @@ class SkillLoader:
|
|
|
87
74
|
loaded: object = yaml.safe_load(parts[1])
|
|
88
75
|
if isinstance(loaded, dict):
|
|
89
76
|
frontmatter = dict(loaded) # type: ignore[arg-type]
|
|
90
|
-
markdown_content = parts[2].strip()
|
|
91
77
|
|
|
92
78
|
# Extract skill metadata
|
|
93
79
|
name = str(frontmatter.get("name", ""))
|
|
@@ -96,10 +82,6 @@ class SkillLoader:
|
|
|
96
82
|
if not name or not description:
|
|
97
83
|
return None
|
|
98
84
|
|
|
99
|
-
# Process relative paths in content
|
|
100
|
-
skill_dir = skill_path.parent
|
|
101
|
-
processed_content = self._process_skill_paths(markdown_content, skill_dir)
|
|
102
|
-
|
|
103
85
|
# Create Skill object
|
|
104
86
|
license_val = frontmatter.get("license")
|
|
105
87
|
allowed_tools_val = frontmatter.get("allowed-tools")
|
|
@@ -118,12 +100,12 @@ class SkillLoader:
|
|
|
118
100
|
skill = Skill(
|
|
119
101
|
name=name,
|
|
120
102
|
description=description,
|
|
121
|
-
content=processed_content,
|
|
122
103
|
location=location,
|
|
123
104
|
license=str(license_val) if license_val is not None else None,
|
|
124
105
|
allowed_tools=allowed_tools,
|
|
125
106
|
metadata=metadata,
|
|
126
|
-
skill_path=skill_path,
|
|
107
|
+
skill_path=skill_path.resolve(),
|
|
108
|
+
base_dir=skill_path.parent.resolve(),
|
|
127
109
|
)
|
|
128
110
|
|
|
129
111
|
return skill
|
|
@@ -144,6 +126,15 @@ class SkillLoader:
|
|
|
144
126
|
List of successfully loaded Skill objects
|
|
145
127
|
"""
|
|
146
128
|
skills: list[Skill] = []
|
|
129
|
+
priority = {"system": 0, "user": 1, "project": 2}
|
|
130
|
+
|
|
131
|
+
def register(skill: Skill) -> None:
|
|
132
|
+
existing = self.loaded_skills.get(skill.name)
|
|
133
|
+
if existing is None:
|
|
134
|
+
self.loaded_skills[skill.name] = skill
|
|
135
|
+
return
|
|
136
|
+
if priority.get(skill.location, -1) >= priority.get(existing.location, -1):
|
|
137
|
+
self.loaded_skills[skill.name] = skill
|
|
147
138
|
|
|
148
139
|
# Load system-level skills first (lowest priority, can be overridden)
|
|
149
140
|
system_dir = self.SYSTEM_SKILLS_DIR.expanduser()
|
|
@@ -152,7 +143,7 @@ class SkillLoader:
|
|
|
152
143
|
skill = self.load_skill(skill_file, location="system")
|
|
153
144
|
if skill:
|
|
154
145
|
skills.append(skill)
|
|
155
|
-
|
|
146
|
+
register(skill)
|
|
156
147
|
|
|
157
148
|
# Load user-level skills (override system skills if same name)
|
|
158
149
|
for user_dir in self.USER_SKILLS_DIRS:
|
|
@@ -165,7 +156,7 @@ class SkillLoader:
|
|
|
165
156
|
skill = self.load_skill(skill_file, location="user")
|
|
166
157
|
if skill:
|
|
167
158
|
skills.append(skill)
|
|
168
|
-
|
|
159
|
+
register(skill)
|
|
169
160
|
|
|
170
161
|
# Load project-level skills (override user skills if same name)
|
|
171
162
|
project_dir = self.PROJECT_SKILLS_DIR.resolve()
|
|
@@ -174,13 +165,14 @@ class SkillLoader:
|
|
|
174
165
|
skill = self.load_skill(skill_file, location="project")
|
|
175
166
|
if skill:
|
|
176
167
|
skills.append(skill)
|
|
177
|
-
|
|
168
|
+
register(skill)
|
|
178
169
|
|
|
179
170
|
# Log discovery summary
|
|
180
|
-
if
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
171
|
+
if self.loaded_skills:
|
|
172
|
+
selected = list(self.loaded_skills.values())
|
|
173
|
+
system_count = sum(1 for s in selected if s.location == "system")
|
|
174
|
+
user_count = sum(1 for s in selected if s.location == "user")
|
|
175
|
+
project_count = sum(1 for s in selected if s.location == "project")
|
|
184
176
|
parts: list[str] = []
|
|
185
177
|
if system_count > 0:
|
|
186
178
|
parts.append(f"{system_count} system")
|
|
@@ -188,7 +180,7 @@ class SkillLoader:
|
|
|
188
180
|
parts.append(f"{user_count} user")
|
|
189
181
|
if project_count > 0:
|
|
190
182
|
parts.append(f"{project_count} project")
|
|
191
|
-
log_debug(f"
|
|
183
|
+
log_debug(f"Loaded {len(self.loaded_skills)} Claude Skills ({', '.join(parts)})")
|
|
192
184
|
|
|
193
185
|
return skills
|
|
194
186
|
|
|
@@ -224,62 +216,14 @@ class SkillLoader:
|
|
|
224
216
|
XML string with all skill metadata
|
|
225
217
|
"""
|
|
226
218
|
xml_parts: list[str] = []
|
|
227
|
-
|
|
228
|
-
|
|
219
|
+
# Prefer showing higher-priority skills first (project > user > system).
|
|
220
|
+
location_order = {"project": 0, "user": 1, "system": 2}
|
|
221
|
+
for skill in sorted(self.loaded_skills.values(), key=lambda s: location_order.get(s.location, 3)):
|
|
222
|
+
xml_parts.append(
|
|
223
|
+
f"""<skill>
|
|
229
224
|
<name>{skill.name}</name>
|
|
230
225
|
<description>{skill.description}</description>
|
|
231
|
-
<location>{skill.
|
|
232
|
-
</skill>"""
|
|
226
|
+
<location>{skill.skill_path}</location>
|
|
227
|
+
</skill>"""
|
|
228
|
+
)
|
|
233
229
|
return "\n".join(xml_parts)
|
|
234
|
-
|
|
235
|
-
def _process_skill_paths(self, content: str, skill_dir: Path) -> str:
|
|
236
|
-
"""Convert relative paths to absolute paths for Level 3+
|
|
237
|
-
|
|
238
|
-
Supports:
|
|
239
|
-
- scripts/, examples/, templates/, reference/ directories
|
|
240
|
-
- Markdown document references
|
|
241
|
-
- Markdown links [text](path)
|
|
242
|
-
|
|
243
|
-
Args:
|
|
244
|
-
content: Original skill content
|
|
245
|
-
skill_dir: Directory containing the SKILL.md file
|
|
246
|
-
|
|
247
|
-
Returns:
|
|
248
|
-
Content with absolute paths
|
|
249
|
-
"""
|
|
250
|
-
# Pattern 1: Directory-based paths (scripts/, examples/, etc.)
|
|
251
|
-
# e.g., "python scripts/generate.py" -> "python /abs/path/to/scripts/generate.py"
|
|
252
|
-
dir_pattern = r"\b(scripts|examples|templates|reference)/([^\s\)]+)"
|
|
253
|
-
|
|
254
|
-
def replace_dir_path(match: re.Match[str]) -> str:
|
|
255
|
-
directory = match.group(1)
|
|
256
|
-
filename = match.group(2)
|
|
257
|
-
abs_path = skill_dir / directory / filename
|
|
258
|
-
return str(abs_path)
|
|
259
|
-
|
|
260
|
-
content = re.sub(dir_pattern, replace_dir_path, content)
|
|
261
|
-
|
|
262
|
-
# Pattern 2: Markdown links [text](./path or path)
|
|
263
|
-
# e.g., "[Guide](./docs/guide.md)" -> "[Guide](`/abs/path/to/docs/guide.md`) (use the Read tool to access)"
|
|
264
|
-
link_pattern = r"\[([^\]]+)\]\((\./)?([^\)]+\.md)\)"
|
|
265
|
-
|
|
266
|
-
def replace_link(match: re.Match[str]) -> str:
|
|
267
|
-
text = match.group(1)
|
|
268
|
-
filename = match.group(3)
|
|
269
|
-
abs_path = skill_dir / filename
|
|
270
|
-
return f"[{text}](`{abs_path}`) (use the Read tool to access)"
|
|
271
|
-
|
|
272
|
-
content = re.sub(link_pattern, replace_link, content)
|
|
273
|
-
|
|
274
|
-
# Pattern 3: Standalone markdown references
|
|
275
|
-
# e.g., "see reference.md" -> "see `/abs/path/to/reference.md` (use the Read tool to access)"
|
|
276
|
-
standalone_pattern = r"(?<!\])\b(\w+\.md)\b(?!\))"
|
|
277
|
-
|
|
278
|
-
def replace_standalone(match: re.Match[str]) -> str:
|
|
279
|
-
filename = match.group(1)
|
|
280
|
-
abs_path = skill_dir / filename
|
|
281
|
-
return f"`{abs_path}` (use the Read tool to access)"
|
|
282
|
-
|
|
283
|
-
content = re.sub(standalone_pattern, replace_standalone, content)
|
|
284
|
-
|
|
285
|
-
return content
|
klaude_code/skill/manager.py
CHANGED
|
@@ -68,3 +68,41 @@ def list_skill_names() -> list[str]:
|
|
|
68
68
|
List of skill names
|
|
69
69
|
"""
|
|
70
70
|
return _ensure_initialized().list_skills()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_available_skills_for_system_prompt() -> str:
|
|
74
|
+
"""Format skills metadata for inclusion in the system prompt.
|
|
75
|
+
|
|
76
|
+
This follows the progressive-disclosure approach:
|
|
77
|
+
- Keep only name/description + file location in the always-on system prompt
|
|
78
|
+
- Load the full SKILL.md content on demand via the Read tool when needed
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
loader = _ensure_initialized()
|
|
83
|
+
skills_xml = loader.get_skills_xml().strip()
|
|
84
|
+
if not skills_xml:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
return f"""\
|
|
88
|
+
# Skills
|
|
89
|
+
|
|
90
|
+
Skills are optional task-specific instructions stored as `SKILL.md` files.
|
|
91
|
+
|
|
92
|
+
How to use skills:
|
|
93
|
+
- Use the metadata in <available_skills> to decide whether a skill applies.
|
|
94
|
+
- When the task matches a skill's description, use the `Read` tool to load the `SKILL.md` at the given <location>.
|
|
95
|
+
- If the user explicitly activates a skill by starting their message with `$skill-name`, prioritize that skill.
|
|
96
|
+
|
|
97
|
+
Important:
|
|
98
|
+
- Only use skills listed in <available_skills> below.
|
|
99
|
+
- Keep context small: do NOT load skill files unless needed.
|
|
100
|
+
|
|
101
|
+
The list below is metadata only (name/description/location). The full instructions live in the referenced file.
|
|
102
|
+
|
|
103
|
+
<available_skills>
|
|
104
|
+
{skills_xml}
|
|
105
|
+
</available_skills>"""
|
|
106
|
+
except Exception:
|
|
107
|
+
# Skills are an optional enhancement; do not fail prompt construction if discovery breaks.
|
|
108
|
+
return ""
|
|
@@ -30,6 +30,7 @@ def ensure_commands_loaded() -> None:
|
|
|
30
30
|
|
|
31
31
|
# Import and register commands in display order
|
|
32
32
|
from .clear_cmd import ClearCommand
|
|
33
|
+
from .copy_cmd import CopyCommand
|
|
33
34
|
from .debug_cmd import DebugCommand
|
|
34
35
|
from .export_cmd import ExportCommand
|
|
35
36
|
from .export_online_cmd import ExportOnlineCommand
|
|
@@ -44,17 +45,18 @@ def ensure_commands_loaded() -> None:
|
|
|
44
45
|
from .thinking_cmd import ThinkingCommand
|
|
45
46
|
|
|
46
47
|
# Register in desired display order
|
|
48
|
+
register(CopyCommand())
|
|
47
49
|
register(ExportCommand())
|
|
48
|
-
register(ExportOnlineCommand())
|
|
49
50
|
register(RefreshTerminalCommand())
|
|
50
|
-
register(ThinkingCommand())
|
|
51
51
|
register(ModelCommand())
|
|
52
|
+
register(ThinkingCommand())
|
|
52
53
|
register(ForkSessionCommand())
|
|
53
|
-
register(ResumeCommand())
|
|
54
54
|
load_prompt_commands()
|
|
55
55
|
register(StatusCommand())
|
|
56
|
+
register(ResumeCommand())
|
|
56
57
|
register(HelpCommand())
|
|
57
58
|
register(ReleaseNotesCommand())
|
|
59
|
+
register(ExportOnlineCommand())
|
|
58
60
|
register(TerminalSetupCommand())
|
|
59
61
|
register(DebugCommand())
|
|
60
62
|
register(ClearCommand())
|
|
@@ -66,6 +68,7 @@ def ensure_commands_loaded() -> None:
|
|
|
66
68
|
def __getattr__(name: str) -> object:
|
|
67
69
|
_commands_map = {
|
|
68
70
|
"ClearCommand": "clear_cmd",
|
|
71
|
+
"CopyCommand": "copy_cmd",
|
|
69
72
|
"DebugCommand": "debug_cmd",
|
|
70
73
|
"ExportCommand": "export_cmd",
|
|
71
74
|
"ExportOnlineCommand": "export_online_cmd",
|
|
@@ -43,8 +43,7 @@ class CommandResult(BaseModel):
|
|
|
43
43
|
operations: list[op.Operation] | None = None
|
|
44
44
|
|
|
45
45
|
# Persistence controls: some slash commands are UI/control actions and should not be written to session history.
|
|
46
|
-
|
|
47
|
-
persist_events: bool = True
|
|
46
|
+
persist: bool = True
|
|
48
47
|
|
|
49
48
|
|
|
50
49
|
class CommandABC(ABC):
|