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.
Files changed (52) hide show
  1. klaude_code/app/__init__.py +1 -2
  2. klaude_code/app/runtime.py +26 -41
  3. klaude_code/cli/main.py +19 -152
  4. klaude_code/config/assets/builtin_config.yaml +13 -0
  5. klaude_code/const.py +1 -1
  6. klaude_code/core/agent_profile.py +38 -3
  7. klaude_code/core/manager/llm_clients_builder.py +1 -1
  8. klaude_code/core/prompts/prompt-nano-banana.md +1 -0
  9. klaude_code/core/reminders.py +20 -4
  10. klaude_code/core/tool/__init__.py +0 -2
  11. klaude_code/core/tool/shell/command_safety.py +4 -189
  12. klaude_code/core/turn.py +2 -5
  13. klaude_code/llm/anthropic/client.py +1 -1
  14. klaude_code/llm/google/client.py +1 -1
  15. klaude_code/llm/openai_compatible/stream.py +1 -1
  16. klaude_code/llm/responses/client.py +1 -1
  17. klaude_code/protocol/commands.py +1 -0
  18. klaude_code/protocol/events/tools.py +5 -1
  19. klaude_code/protocol/message.py +2 -2
  20. klaude_code/protocol/tools.py +0 -1
  21. klaude_code/session/session.py +0 -2
  22. klaude_code/skill/loader.py +31 -87
  23. klaude_code/skill/manager.py +38 -0
  24. klaude_code/tui/command/__init__.py +6 -3
  25. klaude_code/tui/command/clear_cmd.py +1 -1
  26. klaude_code/tui/command/command_abc.py +1 -2
  27. klaude_code/tui/command/copy_cmd.py +52 -0
  28. klaude_code/tui/command/fork_session_cmd.py +4 -4
  29. klaude_code/tui/command/refresh_cmd.py +1 -2
  30. klaude_code/tui/command/resume_cmd.py +3 -4
  31. klaude_code/tui/command/status_cmd.py +1 -1
  32. klaude_code/tui/components/developer.py +11 -11
  33. klaude_code/tui/components/metadata.py +1 -1
  34. klaude_code/tui/components/rich/theme.py +2 -2
  35. klaude_code/tui/components/tools.py +4 -8
  36. klaude_code/tui/components/user_input.py +9 -21
  37. klaude_code/tui/machine.py +3 -1
  38. klaude_code/tui/renderer.py +1 -1
  39. klaude_code/tui/runner.py +2 -2
  40. klaude_code/tui/terminal/selector.py +3 -15
  41. klaude_code/ui/__init__.py +0 -24
  42. klaude_code/ui/common.py +3 -2
  43. klaude_code/ui/core/display.py +2 -2
  44. {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/METADATA +16 -81
  45. {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/RECORD +47 -50
  46. klaude_code/core/tool/skill/__init__.py +0 -0
  47. klaude_code/core/tool/skill/skill_tool.md +0 -24
  48. klaude_code/core/tool/skill/skill_tool.py +0 -89
  49. klaude_code/tui/command/prompt-commit.md +0 -82
  50. klaude_code/ui/exec_mode.py +0 -60
  51. {klaude_code-2.1.0.dist-info → klaude_code-2.2.0.dist-info}/WHEEL +0 -0
  52. {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
- The check is intentionally lightweight: it blocks only a small set of
334
- obviously dangerous patterns (rm/trash/git remotes, unsafe sed/awk,
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 (e.g. due to unterminated
348
- # quotes in a complex heredoc), treat it as safe here and let the
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.ToolCallStartItem() as msg:
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.ToolCallStartItem(
172
+ yield message.ToolCallStartDelta(
173
173
  response_id=response_id,
174
174
  call_id=block.id,
175
175
  name=block.name,
@@ -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.ToolCallStartItem(response_id=response_id, call_id=call_id, name=name)
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.ToolCallStartItem(
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.ToolCallStartItem(
148
+ yield message.ToolCallStartDelta(
149
149
  response_id=response_id,
150
150
  call_id=event.item.call_id,
151
151
  name=event.item.name,
@@ -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")
@@ -25,7 +25,7 @@ from klaude_code.protocol.model import (
25
25
  # Stream items
26
26
 
27
27
 
28
- class ToolCallStartItem(BaseModel):
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 | ToolCallStartItem
178
+ StreamItem = AssistantTextDelta | AssistantImageDelta | ThinkingTextDelta | ToolCallStartDelta
179
179
 
180
180
  LLMStreamItem = HistoryEvent | StreamItem
181
181
 
@@ -6,7 +6,6 @@ READ = "Read"
6
6
  WRITE = "Write"
7
7
  TODO_WRITE = "TodoWrite"
8
8
  UPDATE_PLAN = "update_plan"
9
- SKILL = "Skill"
10
9
  MERMAID = "Mermaid"
11
10
  WEB_FETCH = "WebFetch"
12
11
  WEB_SEARCH = "WebSearch"
@@ -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(
@@ -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
- content: str # Full markdown instructions
18
- location: str # Skill location: 'system', 'user', or 'project'
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
- self.loaded_skills[skill.name] = skill
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
- self.loaded_skills[skill.name] = skill
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
- self.loaded_skills[skill.name] = skill
168
+ register(skill)
178
169
 
179
170
  # Log discovery summary
180
- if skills:
181
- system_count = sum(1 for s in skills if s.location == "system")
182
- user_count = sum(1 for s in skills if s.location == "user")
183
- project_count = sum(1 for s in skills if s.location == "project")
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"Discovered {len(skills)} Claude Skills ({', '.join(parts)})")
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
- for skill in self.loaded_skills.values():
228
- xml_parts.append(f"""<skill>
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.location}</location>
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
@@ -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",
@@ -22,5 +22,5 @@ class ClearCommand(CommandABC):
22
22
 
23
23
  return CommandResult(
24
24
  operations=[op.ClearSessionOperation(session_id=agent.session.id)],
25
- persist_user_input=False,
25
+ persist=False,
26
26
  )
@@ -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
- persist_user_input: bool = True
47
- persist_events: bool = True
46
+ persist: bool = True
48
47
 
49
48
 
50
49
  class CommandABC(ABC):