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.
- chatmd/__init__.py +3 -0
- chatmd/__main__.py +6 -0
- chatmd/cli.py +214 -0
- chatmd/commands/__init__.py +1 -0
- chatmd/commands/agent_lifecycle.py +327 -0
- chatmd/commands/init_workspace.py +189 -0
- chatmd/commands/migrations.py +246 -0
- chatmd/commands/mode.py +43 -0
- chatmd/commands/service.py +418 -0
- chatmd/commands/upgrade.py +76 -0
- chatmd/commands/win_service.py +348 -0
- chatmd/engine/__init__.py +1 -0
- chatmd/engine/agent.py +1213 -0
- chatmd/engine/confirm.py +179 -0
- chatmd/engine/cron_inline.py +118 -0
- chatmd/engine/cron_log.py +51 -0
- chatmd/engine/cron_parser.py +523 -0
- chatmd/engine/cron_safety.py +58 -0
- chatmd/engine/cron_scheduler.py +395 -0
- chatmd/engine/cron_state.py +122 -0
- chatmd/engine/parser.py +350 -0
- chatmd/engine/reference_resolver.py +204 -0
- chatmd/engine/router.py +217 -0
- chatmd/engine/scheduler.py +251 -0
- chatmd/engine/state.py +134 -0
- chatmd/exceptions.py +33 -0
- chatmd/i18n/__init__.py +76 -0
- chatmd/i18n/en.py +647 -0
- chatmd/i18n/zh_CN.py +626 -0
- chatmd/infra/__init__.py +1 -0
- chatmd/infra/config.py +205 -0
- chatmd/infra/file_writer.py +256 -0
- chatmd/infra/git_sync.py +166 -0
- chatmd/infra/git_utils.py +122 -0
- chatmd/infra/index_manager.py +88 -0
- chatmd/infra/notification.py +456 -0
- chatmd/infra/offline_queue.py +97 -0
- chatmd/providers/__init__.py +1 -0
- chatmd/providers/base.py +16 -0
- chatmd/providers/liteagent.py +183 -0
- chatmd/providers/litestartup.py +477 -0
- chatmd/providers/openai_compat.py +146 -0
- chatmd/security/__init__.py +1 -0
- chatmd/security/kernel_gate.py +91 -0
- chatmd/skills/__init__.py +5 -0
- chatmd/skills/ai.py +233 -0
- chatmd/skills/base.py +81 -0
- chatmd/skills/bind.py +244 -0
- chatmd/skills/builtin.py +610 -0
- chatmd/skills/canvas.py +341 -0
- chatmd/skills/confirm.py +67 -0
- chatmd/skills/cron.py +518 -0
- chatmd/skills/hot_reload.py +109 -0
- chatmd/skills/inbox.py +132 -0
- chatmd/skills/infra.py +272 -0
- chatmd/skills/loader.py +303 -0
- chatmd/skills/notify.py +137 -0
- chatmd/skills/upload.py +320 -0
- chatmd/watcher/__init__.py +1 -0
- chatmd/watcher/auto_upload.py +166 -0
- chatmd/watcher/file_watcher.py +186 -0
- chatmd/watcher/suffix_trigger.py +91 -0
- chatmd-0.2.7.dist-info/METADATA +477 -0
- chatmd-0.2.7.dist-info/RECORD +67 -0
- chatmd-0.2.7.dist-info/WHEEL +4 -0
- chatmd-0.2.7.dist-info/entry_points.txt +2 -0
- chatmd-0.2.7.dist-info/licenses/LICENSE +21 -0
chatmd/__init__.py
ADDED
chatmd/__main__.py
ADDED
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))
|