chatmd 0.2.7__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 (67) hide show
  1. chatmd/__init__.py +3 -0
  2. chatmd/__main__.py +6 -0
  3. chatmd/cli.py +214 -0
  4. chatmd/commands/__init__.py +1 -0
  5. chatmd/commands/agent_lifecycle.py +327 -0
  6. chatmd/commands/init_workspace.py +189 -0
  7. chatmd/commands/migrations.py +246 -0
  8. chatmd/commands/mode.py +43 -0
  9. chatmd/commands/service.py +418 -0
  10. chatmd/commands/upgrade.py +76 -0
  11. chatmd/commands/win_service.py +348 -0
  12. chatmd/engine/__init__.py +1 -0
  13. chatmd/engine/agent.py +1213 -0
  14. chatmd/engine/confirm.py +179 -0
  15. chatmd/engine/cron_inline.py +118 -0
  16. chatmd/engine/cron_log.py +51 -0
  17. chatmd/engine/cron_parser.py +523 -0
  18. chatmd/engine/cron_safety.py +58 -0
  19. chatmd/engine/cron_scheduler.py +395 -0
  20. chatmd/engine/cron_state.py +122 -0
  21. chatmd/engine/parser.py +350 -0
  22. chatmd/engine/reference_resolver.py +204 -0
  23. chatmd/engine/router.py +217 -0
  24. chatmd/engine/scheduler.py +251 -0
  25. chatmd/engine/state.py +134 -0
  26. chatmd/exceptions.py +33 -0
  27. chatmd/i18n/__init__.py +76 -0
  28. chatmd/i18n/en.py +647 -0
  29. chatmd/i18n/zh_CN.py +626 -0
  30. chatmd/infra/__init__.py +1 -0
  31. chatmd/infra/config.py +205 -0
  32. chatmd/infra/file_writer.py +256 -0
  33. chatmd/infra/git_sync.py +166 -0
  34. chatmd/infra/git_utils.py +122 -0
  35. chatmd/infra/index_manager.py +88 -0
  36. chatmd/infra/notification.py +456 -0
  37. chatmd/infra/offline_queue.py +97 -0
  38. chatmd/providers/__init__.py +1 -0
  39. chatmd/providers/base.py +16 -0
  40. chatmd/providers/liteagent.py +183 -0
  41. chatmd/providers/litestartup.py +477 -0
  42. chatmd/providers/openai_compat.py +146 -0
  43. chatmd/security/__init__.py +1 -0
  44. chatmd/security/kernel_gate.py +91 -0
  45. chatmd/skills/__init__.py +5 -0
  46. chatmd/skills/ai.py +233 -0
  47. chatmd/skills/base.py +81 -0
  48. chatmd/skills/bind.py +244 -0
  49. chatmd/skills/builtin.py +610 -0
  50. chatmd/skills/canvas.py +341 -0
  51. chatmd/skills/confirm.py +67 -0
  52. chatmd/skills/cron.py +518 -0
  53. chatmd/skills/hot_reload.py +109 -0
  54. chatmd/skills/inbox.py +132 -0
  55. chatmd/skills/infra.py +272 -0
  56. chatmd/skills/loader.py +303 -0
  57. chatmd/skills/notify.py +137 -0
  58. chatmd/skills/upload.py +320 -0
  59. chatmd/watcher/__init__.py +1 -0
  60. chatmd/watcher/auto_upload.py +166 -0
  61. chatmd/watcher/file_watcher.py +186 -0
  62. chatmd/watcher/suffix_trigger.py +91 -0
  63. chatmd-0.2.7.dist-info/METADATA +477 -0
  64. chatmd-0.2.7.dist-info/RECORD +67 -0
  65. chatmd-0.2.7.dist-info/WHEEL +4 -0
  66. chatmd-0.2.7.dist-info/entry_points.txt +2 -0
  67. chatmd-0.2.7.dist-info/licenses/LICENSE +21 -0
chatmd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ChatMD — A local-first, text-driven personal AI Agent engine."""
2
+
3
+ __version__ = "0.2.7"
chatmd/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running chatmd as `python -m chatmd`."""
2
+
3
+ from chatmd.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
chatmd/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """ChatMD CLI entry point using Click."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from chatmd import __version__
12
+
13
+
14
+ def _ensure_utf8_stdout() -> None:
15
+ """Reconfigure stdout/stderr to UTF-8 on Windows to avoid GBK encoding errors."""
16
+ if sys.platform == "win32":
17
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
18
+ if hasattr(sys.stdout, "reconfigure"):
19
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
20
+ else:
21
+ sys.stdout = io.TextIOWrapper(
22
+ sys.stdout.buffer, encoding="utf-8", errors="replace"
23
+ )
24
+ if hasattr(sys.stderr, "reconfigure"):
25
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
26
+ else:
27
+ sys.stderr = io.TextIOWrapper(
28
+ sys.stderr.buffer, encoding="utf-8", errors="replace"
29
+ )
30
+
31
+
32
+ _ensure_utf8_stdout()
33
+
34
+
35
+ @click.group()
36
+ @click.version_option(version=__version__, prog_name="chatmd")
37
+ def main() -> None:
38
+ """ChatMD — A local-first, text-driven personal AI Agent engine."""
39
+
40
+
41
+ @main.command()
42
+ @click.argument("path", type=click.Path())
43
+ @click.option("--no-git", is_flag=True, default=False, help="Skip Git initialization.")
44
+ def init(path: str, no_git: bool) -> None:
45
+ """Initialize a ChatMD workspace at PATH."""
46
+ from chatmd.commands.init_workspace import run_init
47
+
48
+ run_init(path, no_git=no_git)
49
+
50
+
51
+ @main.command()
52
+ @click.option(
53
+ "--workspace",
54
+ "-w",
55
+ type=click.Path(exists=True),
56
+ default=".",
57
+ help="Workspace directory (default: current dir).",
58
+ )
59
+ @click.option(
60
+ "--daemon",
61
+ "-d",
62
+ is_flag=True,
63
+ default=False,
64
+ help="Run Agent in the background (detached process).",
65
+ )
66
+ def start(workspace: str, daemon: bool) -> None:
67
+ """Start the ChatMD Agent."""
68
+ from chatmd.commands.agent_lifecycle import run_start, run_start_daemon
69
+
70
+ if daemon:
71
+ run_start_daemon(workspace)
72
+ else:
73
+ run_start(workspace)
74
+
75
+
76
+ @main.command()
77
+ @click.option(
78
+ "--workspace",
79
+ "-w",
80
+ type=click.Path(exists=True),
81
+ default=".",
82
+ help="Workspace directory (default: current dir).",
83
+ )
84
+ def stop(workspace: str) -> None:
85
+ """Stop the ChatMD Agent."""
86
+ from chatmd.commands.agent_lifecycle import run_stop
87
+
88
+ run_stop(workspace)
89
+
90
+
91
+ @main.command()
92
+ @click.option(
93
+ "--workspace",
94
+ "-w",
95
+ type=click.Path(exists=True),
96
+ default=".",
97
+ help="Workspace directory (default: current dir).",
98
+ )
99
+ def status(workspace: str) -> None:
100
+ """Show Agent status."""
101
+ from chatmd.commands.agent_lifecycle import run_status
102
+
103
+ run_status(workspace)
104
+
105
+
106
+ @main.command()
107
+ @click.option(
108
+ "--workspace",
109
+ "-w",
110
+ type=click.Path(exists=True),
111
+ default=".",
112
+ help="Workspace directory (default: current dir).",
113
+ )
114
+ def restart(workspace: str) -> None:
115
+ """Restart the ChatMD Agent (stop + start daemon)."""
116
+ from chatmd.commands.agent_lifecycle import run_restart
117
+
118
+ run_restart(workspace)
119
+
120
+
121
+ @main.command()
122
+ @click.option(
123
+ "--workspace",
124
+ "-w",
125
+ type=click.Path(exists=True),
126
+ default=".",
127
+ help="Workspace directory (default: current dir).",
128
+ )
129
+ @click.option("--full", is_flag=True, default=False, help="Upgrade to full workspace mode.")
130
+ def upgrade(workspace: str, full: bool) -> None:
131
+ """Upgrade an existing ChatMD workspace."""
132
+ from chatmd.commands.upgrade import run_upgrade
133
+
134
+ run_upgrade(workspace, full=full)
135
+
136
+
137
+ @main.command()
138
+ @click.argument("mode", type=click.Choice(["suffix", "save"]), required=False)
139
+ @click.option(
140
+ "--workspace",
141
+ "-w",
142
+ type=click.Path(exists=True),
143
+ default=".",
144
+ help="Workspace directory (default: current dir).",
145
+ )
146
+ def mode(mode: str | None, workspace: str) -> None:
147
+ """Show or switch trigger mode (suffix / save)."""
148
+ from chatmd.commands.mode import run_mode
149
+
150
+ run_mode(workspace, mode)
151
+
152
+
153
+ @main.group()
154
+ def service() -> None:
155
+ """Manage system service (auto-start on boot)."""
156
+
157
+
158
+ @service.command("install")
159
+ @click.option(
160
+ "--workspace",
161
+ "-w",
162
+ type=click.Path(exists=True),
163
+ default=".",
164
+ help="Workspace directory (default: current dir).",
165
+ )
166
+ def service_install(workspace: str) -> None:
167
+ """Install ChatMD Agent as a system service."""
168
+ from chatmd.commands.service import run_service_install
169
+
170
+ run_service_install(workspace)
171
+
172
+
173
+ @service.command("uninstall")
174
+ @click.option(
175
+ "--workspace",
176
+ "-w",
177
+ type=click.Path(exists=True),
178
+ default=None,
179
+ help="Workspace directory. Omit to use --all.",
180
+ )
181
+ @click.option(
182
+ "--all",
183
+ "uninstall_all",
184
+ is_flag=True,
185
+ default=False,
186
+ help="Uninstall all ChatMD services.",
187
+ )
188
+ def service_uninstall(workspace: str | None, uninstall_all: bool) -> None:
189
+ """Uninstall ChatMD Agent system service."""
190
+ if uninstall_all:
191
+ from chatmd.commands.service import run_service_uninstall_all
192
+ run_service_uninstall_all()
193
+ elif workspace:
194
+ from chatmd.commands.service import run_service_uninstall
195
+ run_service_uninstall(workspace)
196
+ else:
197
+ # Default: uninstall current dir workspace
198
+ from chatmd.commands.service import run_service_uninstall
199
+ run_service_uninstall(".")
200
+
201
+
202
+ @service.command("status")
203
+ @click.option(
204
+ "--workspace",
205
+ "-w",
206
+ type=click.Path(exists=True),
207
+ default=None,
208
+ help="Workspace directory. Omit to list all services.",
209
+ )
210
+ def service_status(workspace: str | None) -> None:
211
+ """Show ChatMD Agent system service status."""
212
+ from chatmd.commands.service import run_service_status
213
+
214
+ run_service_status(workspace)
@@ -0,0 +1 @@
1
+ """ChatMD CLI command implementations."""
@@ -0,0 +1,327 @@
1
+ """Agent lifecycle commands — start, stop, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from chatmd.exceptions import AgentError
13
+ from chatmd.i18n import t
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _is_process_alive(pid: int) -> bool:
19
+ """Check whether a process with *pid* is currently running (cross-platform)."""
20
+ if sys.platform == "win32":
21
+ import ctypes
22
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
23
+ query_limited = 0x1000
24
+ handle = kernel32.OpenProcess(query_limited, False, pid)
25
+ if handle:
26
+ kernel32.CloseHandle(handle)
27
+ return True
28
+ return False
29
+ try:
30
+ os.kill(pid, 0)
31
+ return True
32
+ except ProcessLookupError:
33
+ return False
34
+ except PermissionError:
35
+ return True
36
+
37
+
38
+ def _read_log_level(workspace: Path) -> int:
39
+ """Read ``logging.level`` from agent.yaml, defaulting to INFO."""
40
+ agent_yaml = workspace / ".chatmd" / "agent.yaml"
41
+ if agent_yaml.exists():
42
+ try:
43
+ import yaml
44
+ with open(agent_yaml, encoding="utf-8") as fh:
45
+ data = yaml.safe_load(fh) or {}
46
+ level_str = (data.get("logging") or {}).get("level", "INFO")
47
+ return getattr(logging, str(level_str).upper(), logging.INFO)
48
+ except Exception:
49
+ pass
50
+ return logging.INFO
51
+
52
+
53
+ def _setup_logging(workspace: Path) -> None:
54
+ """Configure logging for the Agent process."""
55
+ log_dir = workspace / ".chatmd" / "logs"
56
+ log_dir.mkdir(parents=True, exist_ok=True)
57
+
58
+ file_level = _read_log_level(workspace)
59
+
60
+ root = logging.getLogger("chatmd")
61
+ root.setLevel(logging.DEBUG)
62
+
63
+ # Console handler — always INFO regardless of config
64
+ console = logging.StreamHandler(sys.stderr)
65
+ console.setLevel(logging.INFO)
66
+ console.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
67
+ root.addHandler(console)
68
+
69
+ # File handler — respects logging.level from agent.yaml
70
+ fh = logging.FileHandler(log_dir / "agent.log", encoding="utf-8")
71
+ fh.setLevel(file_level)
72
+ fh.setFormatter(
73
+ logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
74
+ )
75
+ root.addHandler(fh)
76
+
77
+
78
+ def run_start(workspace_str: str) -> None:
79
+ """Start the ChatMD Agent in the foreground."""
80
+ workspace = Path(workspace_str).resolve()
81
+ chatmd_dir = workspace / ".chatmd"
82
+
83
+ if not chatmd_dir.is_dir():
84
+ click.echo(t("cli.not_workspace", workspace=workspace))
85
+ click.echo(t("cli.run_init_first"))
86
+ sys.exit(1)
87
+
88
+ _setup_logging(workspace)
89
+
90
+ from chatmd.engine.agent import Agent
91
+
92
+ try:
93
+ agent = Agent(workspace)
94
+ click.echo(t("cli.starting", workspace=workspace))
95
+ click.echo(t("cli.press_ctrl_c"))
96
+ agent.run_forever()
97
+ except AgentError as exc:
98
+ click.echo(f"❌ {exc}")
99
+ sys.exit(1)
100
+ except KeyboardInterrupt:
101
+ click.echo(t("cli.agent_stopped"))
102
+
103
+
104
+ def run_start_daemon(workspace_str: str) -> None:
105
+ """Start the ChatMD Agent as a detached background process.
106
+
107
+ Spawns ``chatmd start -w <workspace>`` in a new detached process,
108
+ then the current (parent) process exits immediately.
109
+
110
+ Cross-platform:
111
+ - Windows: subprocess.CREATE_NO_WINDOW + CREATE_NEW_PROCESS_GROUP
112
+ - Unix: start_new_session=True (setsid)
113
+
114
+ Logs go to .chatmd/logs/agent.log (same as foreground mode).
115
+ """
116
+ import subprocess
117
+
118
+ workspace = Path(workspace_str).resolve()
119
+ chatmd_dir = workspace / ".chatmd"
120
+
121
+ if not chatmd_dir.is_dir():
122
+ click.echo(t("cli.not_workspace", workspace=workspace))
123
+ click.echo(t("cli.run_init_first"))
124
+ sys.exit(1)
125
+
126
+ # Check for existing instance via PID file
127
+ pid_file = chatmd_dir / "agent.pid"
128
+ if pid_file.exists():
129
+ try:
130
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
131
+ if _is_process_alive(pid):
132
+ click.echo(t("cli.daemon_already_running", pid=pid))
133
+ return
134
+ pid_file.unlink(missing_ok=True)
135
+ except (ValueError, OSError):
136
+ pid_file.unlink(missing_ok=True)
137
+
138
+ # Build the command: re-invoke chatmd start (without --daemon) for this workspace
139
+ cmd = [sys.executable, "-m", "chatmd", "start", "-w", str(workspace)]
140
+
141
+ # Ensure log directory exists
142
+ log_dir = chatmd_dir / "logs"
143
+ log_dir.mkdir(parents=True, exist_ok=True)
144
+ log_file = log_dir / "agent.log"
145
+
146
+ # Inherit current env and force UTF-8 for the child process (avoids GBK crash on Windows)
147
+ env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
148
+
149
+ if sys.platform == "win32":
150
+ # Windows: CREATE_NO_WINDOW prevents a console window from appearing
151
+ # CREATE_NEW_PROCESS_GROUP detaches from parent's console group
152
+ creation_flags = (
153
+ subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
154
+ )
155
+ proc = subprocess.Popen(
156
+ cmd,
157
+ stdout=open(log_dir / "daemon_stdout.log", "a", encoding="utf-8"),
158
+ stderr=open(log_dir / "daemon_stderr.log", "a", encoding="utf-8"),
159
+ stdin=subprocess.DEVNULL,
160
+ creationflags=creation_flags,
161
+ env=env,
162
+ )
163
+ else:
164
+ # Unix: start_new_session=True calls setsid(), fully detaching
165
+ proc = subprocess.Popen(
166
+ cmd,
167
+ stdout=open(log_dir / "daemon_stdout.log", "a", encoding="utf-8"),
168
+ stderr=open(log_dir / "daemon_stderr.log", "a", encoding="utf-8"),
169
+ stdin=subprocess.DEVNULL,
170
+ start_new_session=True,
171
+ env=env,
172
+ )
173
+
174
+ # Wait briefly to check if the process started successfully
175
+ import time
176
+ time.sleep(0.5)
177
+
178
+ if proc.poll() is not None:
179
+ click.echo(t("cli.daemon_failed", code=proc.returncode))
180
+ sys.exit(1)
181
+
182
+ click.echo(t("cli.daemon_started", pid=proc.pid, workspace=workspace))
183
+ click.echo(t("cli.daemon_log_hint", log=log_file))
184
+ click.echo(t("cli.daemon_stop_hint"))
185
+
186
+
187
+ def run_stop(workspace_str: str) -> None:
188
+ """Stop a running ChatMD Agent using a signal file (cross-platform).
189
+
190
+ Strategy:
191
+ 1. Write .chatmd/stop.signal — the Agent main loop detects this and
192
+ shuts down gracefully (works on all platforms).
193
+ 2. On Unix, also send SIGTERM as a belt-and-suspenders fallback.
194
+ 3. Wait briefly for the process to exit, then clean up.
195
+ """
196
+ workspace = Path(workspace_str).resolve()
197
+ chatmd_dir = workspace / ".chatmd"
198
+ pid_file = chatmd_dir / "agent.pid"
199
+ stop_signal = chatmd_dir / "stop.signal"
200
+
201
+ if not pid_file.exists():
202
+ click.echo(t("cli.no_running_agent"))
203
+ return
204
+
205
+ try:
206
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
207
+ except (ValueError, OSError):
208
+ click.echo(t("cli.pid_corrupted"))
209
+ pid_file.unlink(missing_ok=True)
210
+ return
211
+
212
+ # Verify the process is actually running before sending stop signal
213
+ if not _is_process_alive(pid):
214
+ click.echo(t("cli.process_not_found"))
215
+ pid_file.unlink(missing_ok=True)
216
+ stop_signal.unlink(missing_ok=True)
217
+ return
218
+
219
+ # 1. Write stop signal file (cross-platform, always works)
220
+ try:
221
+ stop_signal.write_text(str(pid), encoding="utf-8")
222
+ except OSError as exc:
223
+ logger.warning("Failed to write stop signal file: %s", exc)
224
+
225
+ # 2. On Unix, also send SIGTERM as fallback
226
+ if sys.platform != "win32":
227
+ try:
228
+ import signal
229
+ os.kill(pid, signal.SIGTERM)
230
+ except ProcessLookupError:
231
+ click.echo(t("cli.process_not_found"))
232
+ pid_file.unlink(missing_ok=True)
233
+ stop_signal.unlink(missing_ok=True)
234
+ return
235
+ except PermissionError:
236
+ click.echo(t("cli.no_permission", pid=pid))
237
+ return
238
+
239
+ # 3. Wait for process to exit (up to 5 seconds)
240
+ import time
241
+ for _ in range(10):
242
+ if not _is_process_alive(pid):
243
+ break
244
+ time.sleep(0.5)
245
+
246
+ click.echo(t("cli.stop_signal_sent", pid=pid))
247
+
248
+
249
+ def run_restart(workspace_str: str) -> None:
250
+ """Restart the ChatMD Agent (stop then start as daemon).
251
+
252
+ Behaviour:
253
+ - If a daemon is running → stop it, then start a new daemon.
254
+ - If no daemon is running → just start a new daemon.
255
+ - Always restarts in daemon (background) mode.
256
+ """
257
+ workspace = Path(workspace_str).resolve()
258
+ chatmd_dir = workspace / ".chatmd"
259
+
260
+ if not chatmd_dir.is_dir():
261
+ click.echo(t("cli.not_workspace", workspace=workspace))
262
+ click.echo(t("cli.run_init_first"))
263
+ sys.exit(1)
264
+
265
+ pid_file = chatmd_dir / "agent.pid"
266
+ was_running = False
267
+
268
+ if pid_file.exists():
269
+ try:
270
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
271
+ if _is_process_alive(pid):
272
+ was_running = True
273
+ click.echo(t("cli.restart_stopping", pid=pid))
274
+ run_stop(workspace_str)
275
+
276
+ # Wait for process to fully exit (up to 5s)
277
+ import time
278
+ for _ in range(10):
279
+ if not _is_process_alive(pid):
280
+ break
281
+ time.sleep(0.5)
282
+ except (ValueError, OSError):
283
+ pid_file.unlink(missing_ok=True)
284
+
285
+ if was_running:
286
+ click.echo(t("cli.restart_starting"))
287
+ else:
288
+ click.echo(t("cli.restart_not_running"))
289
+
290
+ run_start_daemon(workspace_str)
291
+
292
+
293
+ def run_status(workspace_str: str) -> None:
294
+ """Show Agent status."""
295
+ workspace = Path(workspace_str).resolve()
296
+ chatmd_dir = workspace / ".chatmd"
297
+
298
+ if not chatmd_dir.is_dir():
299
+ click.echo(t("cli.not_workspace", workspace=workspace))
300
+ return
301
+
302
+ pid_file = chatmd_dir / "agent.pid"
303
+ if not pid_file.exists():
304
+ click.echo(t("cli.agent_not_running"))
305
+ return
306
+
307
+ try:
308
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
309
+ except (ValueError, OSError):
310
+ click.echo(t("cli.agent_not_running_stale"))
311
+ pid_file.unlink(missing_ok=True)
312
+ return
313
+
314
+ if _is_process_alive(pid):
315
+ click.echo(t("cli.agent_running", pid=pid))
316
+ else:
317
+ click.echo(t("cli.agent_not_running_stale"))
318
+ pid_file.unlink(missing_ok=True)
319
+
320
+ # Show workspace info
321
+ click.echo(t("cli.workspace_label", workspace=workspace))
322
+
323
+ skills_dir = chatmd_dir / "skills"
324
+ if skills_dir.exists():
325
+ yaml_count = len(list(skills_dir.glob("*.yaml")))
326
+ py_count = len(list(skills_dir.glob("*.py")))
327
+ click.echo(t("cli.custom_skills", yaml_count=yaml_count, py_count=py_count))