@mu-cabin/muc-cli 0.6.1 → 0.6.3

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.
package/README.md CHANGED
@@ -4,6 +4,38 @@
4
4
 
5
5
  0.6.0 迁入新版命令契约。要求 **Node.js ≥ 22.16**,使用内置 SQLite,无需独立 Supervisor、Launcher、Keychain、TUI 或客户端。运行时依赖已打包进单个 `dist/muc.mjs`。
6
6
 
7
+ ## 0.6.2 附件下载修复
8
+
9
+ 图片和文件可下载到操作系统允许写入的目录,包括 macOS `/tmp`、共享目录和目录符号链接。路径无效或目标文件已存在时返回明确错误与修复提示,不再误报后台不支持操作。下载仍不覆盖已有文件。
10
+
11
+ ## 0.6.3 Windows 兼容修复
12
+
13
+ 修复 Windows 已有后台锁文件被 Unix 权限检查误判,导致 `login` 等命令无法启动后台的问题;单实例互斥继续由 Windows 命名管道保证。JSON stdin 支持 UTF-8 BOM 和 CRLF。源码清理和离线打包不再需要 Bash 或 `rm`。
14
+
15
+ ### Windows 安装与运行
16
+
17
+ 安装 Node.js 22.16 或更新版本,在 PowerShell 中运行(`npm.cmd` / `muc.cmd` 可避开 `.ps1` 执行策略限制):
18
+
19
+ ```powershell
20
+ node --version
21
+ npm.cmd install -g @mu-cabin/muc-cli@0.6.3
22
+ muc.cmd --version
23
+ muc.cmd login --account YOUR_ACCOUNT --profile-id default --remember
24
+ muc.cmd whoami --profile-id default
25
+ ```
26
+
27
+ 默认数据目录为 `%USERPROFILE%\.muc`。首次使用仍需管理员通过受保护 stdin 完成 `init`;升级已有配置无需重新初始化。更新前运行 `muc.cmd daemon stop`,安装后下次登录或查询会启动新版后台;使用自定义配置根时,停止和启动都指定同一 `--config-root`。
28
+
29
+ PowerShell 不支持 Bash 的 `<<'JSON'`。JSON 可用单引号字符串通过管道传入;Windows PowerShell 5.1 先设置 UTF-8,防止中文被转换为问号:
30
+
31
+ ```powershell
32
+ $OutputEncoding = [System.Text.UTF8Encoding]::new($false)
33
+ '{"limit":20}' | muc.cmd exec chats.list --profile-id default
34
+ '{"includeSelf":false}' | muc.cmd --output jsonl exec events.watch --profile-id default
35
+ ```
36
+
37
+ CMD 可使用 `echo {"limit":20} | muc.cmd exec chats.list --profile-id default`。复杂 JSON 使用 UTF-8 文件重定向:`muc.cmd exec chats.list --profile-id default < request.json`。JSON 中的 Windows 路径使用 `C:/Users/NAME/Downloads/file.png` 或双反斜杠,含空格的命令行路径用双引号包围。密码仍只在用户终端的隐藏提示或受保护 stdin 输入。
38
+
7
39
  ## 配置和登录
8
40
 
9
41
  默认数据目录为 `~/.muc`;`MUC_CONFIG_DIR` 或全局 `--config-root` 可指定隔离目录。首次部署用 `muc init` 从受保护 stdin 接收管理员提供的租户和端点配置。
@@ -57,3 +89,5 @@ pnpm --filter @mu-cabin/muc-cli build
57
89
  ```
58
90
 
59
91
  仓库内的 `AGENTS.md` 与 `docs/development.md` 记录开发约定和验收证据;它们不属于安装包。Skill 源码随包发布,全局安装副本需另行安装。
92
+
93
+ `pnpm --filter @mu-cabin/muc-cli clean` 和 `pack:offline` 支持 Windows、macOS、Linux。MUC 的运行时依赖已内嵌,`pack:offline` 直接生成普通 npm tarball `mu-cabin-muc-cli-<version>.tgz`,目标机器可用 `npm install -g --offline <tarball>` 安装,无需另行捆绑依赖。
package/REFERENCE.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # MUC 命令契约
2
2
 
3
- 本文件描述 0.6.1 的命令契约。旧的 `send/history/daemon-*` 命令已移除。
3
+ 本文件描述 0.6.3 的命令契约。旧的 `send/history/daemon-*` 命令已移除。
4
4
 
5
5
  ## 命令结构
6
6
 
7
7
  全局参数放在命令之前:`muc [--config-root <绝对路径>] [--output json|jsonl|text] <命令>`。默认 JSON。配置根也可由 `MUC_CONFIG_DIR` 提供。
8
8
 
9
+ Windows 默认配置根为 `%USERPROFILE%\.muc`。PowerShell 若阻止 npm 生成的 `muc.ps1`,使用 `muc.cmd`;无须修改执行策略。业务 JSON 输入使用 UTF-8,允许开头的 UTF-8 BOM 与 CRLF。PowerShell 5.1 管道应先设置 `$OutputEncoding = [System.Text.UTF8Encoding]::new($false)`;不要直接复制 Bash 的 `<<'JSON'` 语法。示例见 README 的 Windows 章节。
10
+
9
11
  | 命令 | 用途 |
10
12
  | --- | --- |
11
13
  | `init` | 从受保护 stdin 配置租户与初始 profile |
@@ -46,3 +48,5 @@
46
48
  认证错误退出码为 3,禁止访问为 8,配置缺失为 6、配置无效为 7,输入错误为 2;提交结果不明为 75,并带 `automaticReplay:false`。错误的 `hint` 提供可执行的恢复路径。
47
49
 
48
50
  普通文件发送默认不带尾注(包括带说明的文件消息);明确 `--tail` 才添加。文件上传须完成服务端扫描并取得未来有效期,才会进入消息发送;扫描失败不会降级发送过期文件。
51
+
52
+ 附件下载按操作系统权限处理输出目录,支持 `/tmp`、共享目录和目录符号链接;仍不覆盖已有文件。`OUTPUT_PATH_INVALID` 表示路径无效或不可用,`OUTPUT_EXISTS` 表示目标文件已存在。省略 `outputPath` 使用账号私有下载目录。
package/dist/muc.mjs CHANGED
@@ -11279,7 +11279,7 @@ function toErrorEnvelope(error) {
11279
11279
  ok: false,
11280
11280
  error: {
11281
11281
  code: publicErrorCode(error),
11282
- message: error.message,
11282
+ message: outputPathErrorMessage(publicErrorCode(error)) ?? error.message,
11283
11283
  ...errorHint(publicErrorCode(error)) ? { hint: errorHint(publicErrorCode(error)) } : {},
11284
11284
  ...details === void 0 ? {} : { details },
11285
11285
  ...error instanceof CommitOutcomeUnknownError ? { automaticReplay: false } : {}
@@ -11304,16 +11304,23 @@ function safeErrorDetails(value) {
11304
11304
  }
11305
11305
  return Object.keys(safe).length === 0 ? void 0 : Object.freeze(safe);
11306
11306
  }
11307
- var REMOTE_CODES = /* @__PURE__ */ new Set(["AUTH_REQUIRED", "AUTH_EXPIRED", "AUTH_FAILED", "FORBIDDEN", "CONFIG_MISSING", "CONFIG_INVALID", "PROFILE_NOT_FOUND", "PROFILE_EXISTS", "PROFILE_CHANGED", "PROFILE_ACCOUNT_MISMATCH", "RUNTIME_CONFLICT", "RUNTIME_NOT_READY", "INVALID_INPUT", "INVALID_PARAMS", "INVALID_CURSOR", "NOT_FOUND", "UNAVAILABLE", "UNAUTHORIZED", "DEADLINE_EXCEEDED", "IDEMPOTENCY_CONFLICT", "METHOD_NOT_FOUND", "INTERNAL_ERROR"]);
11307
+ var REMOTE_CODES = /* @__PURE__ */ new Set(["OUTPUT_PATH_INVALID", "OUTPUT_EXISTS", "AUTH_REQUIRED", "AUTH_EXPIRED", "AUTH_FAILED", "FORBIDDEN", "CONFIG_MISSING", "CONFIG_INVALID", "PROFILE_NOT_FOUND", "PROFILE_EXISTS", "PROFILE_CHANGED", "PROFILE_ACCOUNT_MISMATCH", "RUNTIME_CONFLICT", "RUNTIME_NOT_READY", "INVALID_INPUT", "INVALID_PARAMS", "INVALID_CURSOR", "NOT_FOUND", "UNAVAILABLE", "UNAUTHORIZED", "DEADLINE_EXCEEDED", "IDEMPOTENCY_CONFLICT", "METHOD_NOT_FOUND", "INTERNAL_ERROR"]);
11308
11308
  function publicErrorCode(error) {
11309
11309
  return error instanceof RemoteRpcError && REMOTE_CODES.has(String(error.remoteCode)) ? String(error.remoteCode) : error.code;
11310
11310
  }
11311
11311
  function errorHint(code) {
11312
+ if (code === "OUTPUT_PATH_INVALID") return "Choose an existing writable directory, or omit outputPath to use private download storage.";
11313
+ if (code === "OUTPUT_EXISTS") return "Choose a new output filename; downloads never overwrite existing files.";
11312
11314
  if (code === "CONFIG_MISSING") return "Run muc init with administrator-provided configuration on protected stdin.";
11313
11315
  if (["AUTH_REQUIRED", "AUTH_EXPIRED", "AUTH_FAILED"].includes(code)) return "Run muc login for this profile in your own terminal; do not paste credentials into chat.";
11314
11316
  if (code === "CONFIG_INVALID") return "Inspect the configuration privately; do not copy credentials into diagnostics.";
11315
11317
  return void 0;
11316
11318
  }
11319
+ function outputPathErrorMessage(code) {
11320
+ if (code === "OUTPUT_PATH_INVALID") return "The attachment output path is invalid or unavailable.";
11321
+ if (code === "OUTPUT_EXISTS") return "The attachment output file already exists.";
11322
+ return void 0;
11323
+ }
11317
11324
 
11318
11325
  // src/cli/io.ts
11319
11326
  var MAX_STDIN_BYTES = 1024 * 1024;
@@ -11439,7 +11446,7 @@ async function readJsonObject(io) {
11439
11446
  }
11440
11447
  let parsed;
11441
11448
  try {
11442
- parsed = JSON.parse(text2);
11449
+ parsed = JSON.parse(text2.replace(/^\uFEFF/u, ""));
11443
11450
  } catch {
11444
11451
  throw new CliInputError("Operation input must be one valid JSON object.");
11445
11452
  }
@@ -11691,7 +11698,10 @@ async function ensureSafeLockFile(path) {
11691
11698
  throw error;
11692
11699
  }
11693
11700
  const entry = await lstat2(path);
11694
- if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 63) !== 0) {
11701
+ if (!entry.isFile() || entry.isSymbolicLink() || // Windows exposes synthetic mode bits; chmod cannot clear group/other
11702
+ // access there. Keep the file/type checks and let the named pipe own
11703
+ // mutual exclusion, as with the other private Windows state files.
11704
+ process.platform !== "win32" && (entry.mode & 63) !== 0) {
11695
11705
  throw new SingleInstanceError();
11696
11706
  }
11697
11707
  if (typeof process.getuid === "function" && entry.uid !== process.getuid()) {
@@ -12295,8 +12305,8 @@ async function assertLegacyStopped(root) {
12295
12305
  for (const file of ["runtime/daemon.pid", "runtime/assistant/assistant.pid"]) {
12296
12306
  const path = join6(root, file);
12297
12307
  try {
12298
- const stat2 = await lstat4(path);
12299
- if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 128) throw new Error("unsafe");
12308
+ const stat3 = await lstat4(path);
12309
+ if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.size > 128) throw new Error("unsafe");
12300
12310
  const pid = Number((await readFile3(path, "utf8")).trim());
12301
12311
  if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("invalid");
12302
12312
  try {
@@ -12316,7 +12326,7 @@ async function assertLegacyStopped(root) {
12316
12326
  // package.json
12317
12327
  var package_default = {
12318
12328
  name: "@mu-cabin/muc-cli",
12319
- version: "0.6.1",
12329
+ version: "0.6.3",
12320
12330
  description: "Standalone agent-first MUC CLI with an embedded background daemon",
12321
12331
  license: "MIT",
12322
12332
  type: "module",
@@ -12343,11 +12353,11 @@ var package_default = {
12343
12353
  dev: "tsx src/index.ts",
12344
12354
  test: "pnpm build && vitest run",
12345
12355
  typecheck: "tsc --noEmit",
12346
- clean: "rm -rf dist",
12356
+ clean: `node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"`,
12347
12357
  build: "node scripts/build.mjs",
12348
12358
  prepack: "pnpm typecheck && pnpm test",
12349
12359
  verify: "npm pack --dry-run",
12350
- "pack:offline": "bash ../../scripts/pack-offline.sh"
12360
+ "pack:offline": "npm pack"
12351
12361
  },
12352
12362
  devDependencies: {
12353
12363
  "@types/node": "^22.19.15",
@@ -26783,15 +26793,15 @@ async function readImageDimensions(path) {
26783
26793
  async function readVoiceDurationMs(path) {
26784
26794
  const handle = await open6(path, fsConstants2.O_RDONLY | (fsConstants2.O_NOFOLLOW ?? 0));
26785
26795
  try {
26786
- const stat2 = await handle.stat();
26796
+ const stat3 = await handle.stat();
26787
26797
  const header = Buffer.alloc(64);
26788
26798
  const { bytesRead } = await handle.read(header, 0, header.byteLength, 0);
26789
26799
  const bytes = header.subarray(0, bytesRead);
26790
26800
  if (bytes.subarray(0, 6).toString("ascii") === "#!AMR\n") {
26791
- return readAmrDuration(handle, stat2.size, 6, AMR_NB_FRAME_BYTES);
26801
+ return readAmrDuration(handle, stat3.size, 6, AMR_NB_FRAME_BYTES);
26792
26802
  }
26793
26803
  if (bytes.subarray(0, 9).toString("ascii") === "#!AMR-WB\n") {
26794
- return readAmrDuration(handle, stat2.size, 9, AMR_WB_FRAME_BYTES);
26804
+ return readAmrDuration(handle, stat3.size, 9, AMR_WB_FRAME_BYTES);
26795
26805
  }
26796
26806
  return wavDuration(bytes);
26797
26807
  } finally {
@@ -26979,7 +26989,7 @@ import { createHash as createHash16, randomUUID as randomUUID15 } from "node:cry
26979
26989
  import { constants as fsConstants3 } from "node:fs";
26980
26990
  import { open as open8, unlink as unlink7 } from "node:fs/promises";
26981
26991
  import { createConnection as createConnection4 } from "node:net";
26982
- import { basename as basename2, isAbsolute as isAbsolute7, join as join13, resolve as resolve10 } from "node:path";
26992
+ import { basename as basename2, isAbsolute as isAbsolute6, join as join13, resolve as resolve10 } from "node:path";
26983
26993
 
26984
26994
  // src/core/native/application/file-downloader.ts
26985
26995
  import { createHash as createHash15, randomUUID as randomUUID14 } from "node:crypto";
@@ -26988,9 +26998,10 @@ import {
26988
26998
  link,
26989
26999
  lstat as lstat9,
26990
27000
  open as open7,
27001
+ stat as stat2,
26991
27002
  unlink as unlink6
26992
27003
  } from "node:fs/promises";
26993
- import { dirname as dirname5, isAbsolute as isAbsolute6, join as join12, resolve as resolve9 } from "node:path";
27004
+ import { dirname as dirname5, join as join12, resolve as resolve9 } from "node:path";
26994
27005
  var DEFAULT_DOWNLOAD_CHUNK_BYTES = 512 * 1024;
26995
27006
  var LARGE_DOWNLOAD_CHUNK_BYTES = 4 * 1024 * 1024;
26996
27007
  var LARGE_DOWNLOAD_THRESHOLD_BYTES = 50 * 1024 * 1024;
@@ -27062,23 +27073,19 @@ var NativeFileDownloader = class {
27062
27073
  return join12(directory, safeName);
27063
27074
  }
27064
27075
  if (outputPath.includes("\0")) {
27065
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output path is invalid.");
27076
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output path is invalid.");
27066
27077
  }
27067
27078
  const requested = resolve9(outputPath);
27068
- const entry = await lstat9(requested).catch((error) => {
27079
+ const entry = await stat2(requested).catch((error) => {
27069
27080
  if (error.code === "ENOENT") return void 0;
27070
- throw error;
27081
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output path is unavailable.");
27071
27082
  });
27072
- if (entry?.isSymbolicLink()) {
27073
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output path may not be a symlink.");
27074
- }
27075
27083
  const destination = entry?.isDirectory() ? join12(requested, safeName) : requested;
27076
- const parent = await lstat9(dirname5(destination));
27077
- if (!parent.isDirectory() || parent.isSymbolicLink() || typeof process.getuid === "function" && parent.uid !== process.getuid()) {
27078
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output directory is unsafe.");
27079
- }
27080
- if (!isAbsolute6(destination)) {
27081
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output path is invalid.");
27084
+ const parent = await stat2(dirname5(destination)).catch(() => {
27085
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output directory is unavailable.");
27086
+ });
27087
+ if (!parent.isDirectory()) {
27088
+ throw new NativeOperationGatewayError("OUTPUT_PATH_INVALID", "Output parent is not a directory.");
27082
27089
  }
27083
27090
  return destination;
27084
27091
  }
@@ -27182,7 +27189,7 @@ var NativeFileDownloader = class {
27182
27189
  await link(temporary, destination);
27183
27190
  } catch (error) {
27184
27191
  if (error.code === "EEXIST") {
27185
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output file already exists.");
27192
+ throw new NativeOperationGatewayError("OUTPUT_EXISTS", "Output file already exists.");
27186
27193
  }
27187
27194
  throw error;
27188
27195
  }
@@ -27283,7 +27290,7 @@ function requireFileSuccess(value, phase) {
27283
27290
  async function assertDestinationAbsent(path) {
27284
27291
  try {
27285
27292
  await lstat9(path);
27286
- throw new NativeOperationGatewayError("UNSUPPORTED", "Output file already exists.");
27293
+ throw new NativeOperationGatewayError("OUTPUT_EXISTS", "Output file already exists.");
27287
27294
  } catch (error) {
27288
27295
  if (error.code !== "ENOENT") throw error;
27289
27296
  }
@@ -27464,7 +27471,7 @@ var MucNativeOperationGateway = class {
27464
27471
  });
27465
27472
  }
27466
27473
  async downloadCustomEmoji(profileId2, emojiId, outputPath, options = {}) {
27467
- if (!isAbsolute7(outputPath) || outputPath.includes("\0")) {
27474
+ if (!isAbsolute6(outputPath) || outputPath.includes("\0")) {
27468
27475
  throw new NativeOperationGatewayError("REMOTE_REJECTED", "Custom emoji output path is invalid.");
27469
27476
  }
27470
27477
  const request = async () => {
@@ -28752,7 +28759,7 @@ function customEmojiFromRecord(value) {
28752
28759
  };
28753
28760
  }
28754
28761
  async function readLocalImageBytes(input, maxBytes, label, signal) {
28755
- if (!isAbsolute7(input.sourcePath) || input.sourcePath.includes("\0")) {
28762
+ if (!isAbsolute6(input.sourcePath) || input.sourcePath.includes("\0")) {
28756
28763
  throw new NativeOperationGatewayError("REMOTE_REJECTED", `${label} source path is invalid.`);
28757
28764
  }
28758
28765
  signal?.throwIfAborted();
@@ -29217,6 +29224,8 @@ var SAFE_MESSAGES = Object.freeze({
29217
29224
  INVALID_PARAMS: "Request parameters are invalid.",
29218
29225
  METHOD_NOT_FOUND: "RPC method is not supported.",
29219
29226
  NOT_FOUND: "The requested resource was not found.",
29227
+ OUTPUT_PATH_INVALID: "The attachment output path is invalid or unavailable.",
29228
+ OUTPUT_EXISTS: "The attachment output file already exists.",
29220
29229
  OPERATION_UNSUPPORTED: "The requested operation or version is unsupported.",
29221
29230
  PROFILE_NOT_FOUND: "The requested profile was not found.",
29222
29231
  PROFILE_CHANGED: "The profile changed while the request was in flight.",
@@ -29881,6 +29890,8 @@ function mapNativeError(error) {
29881
29890
  RUNTIME_NOT_READY: "RUNTIME_NOT_READY",
29882
29891
  NOT_FOUND: "NOT_FOUND",
29883
29892
  UNSUPPORTED: "OPERATION_UNSUPPORTED",
29893
+ OUTPUT_PATH_INVALID: "OUTPUT_PATH_INVALID",
29894
+ OUTPUT_EXISTS: "OUTPUT_EXISTS",
29884
29895
  REMOTE_REJECTED: "INVALID_INPUT",
29885
29896
  NETWORK: "UNAVAILABLE",
29886
29897
  CANCELLED: "CANCELLED_BEFORE_COMMIT",
@@ -30392,7 +30403,7 @@ function renderTerminalQrCode(value) {
30392
30403
  }
30393
30404
 
30394
30405
  // src/cli/arguments.ts
30395
- import { isAbsolute as isAbsolute8 } from "node:path";
30406
+ import { isAbsolute as isAbsolute7 } from "node:path";
30396
30407
 
30397
30408
  // src/domain/mvp-operations.ts
30398
30409
  var CLI_OPERATION_DEFINITIONS = Object.freeze(
@@ -30644,7 +30655,7 @@ function loginSlot(value) {
30644
30655
  return value;
30645
30656
  }
30646
30657
  function absolutePath(name, value) {
30647
- if (!isAbsolute8(value) || value.includes("\0")) {
30658
+ if (!isAbsolute7(value) || value.includes("\0")) {
30648
30659
  throw new CliUsageError(`${name} must be an absolute local path.`);
30649
30660
  }
30650
30661
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mu-cabin/muc-cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Standalone agent-first MUC CLI with an embedded background daemon",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,11 +27,11 @@
27
27
  "dev": "tsx src/index.ts",
28
28
  "test": "pnpm build && vitest run",
29
29
  "typecheck": "tsc --noEmit",
30
- "clean": "rm -rf dist",
30
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
31
31
  "build": "node scripts/build.mjs",
32
32
  "prepack": "pnpm typecheck && pnpm test",
33
33
  "verify": "npm pack --dry-run",
34
- "pack:offline": "bash ../../scripts/pack-offline.sh"
34
+ "pack:offline": "npm pack"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.19.15",
@@ -23,6 +23,15 @@ muc --output json exec chats.list --profile-id profile-id <<'JSON'
23
23
  JSON
24
24
  ```
25
25
 
26
+ On Windows PowerShell use `muc.cmd` if the npm `muc.ps1` shim is blocked; do not change execution policy. Bash heredocs are not PowerShell syntax. Set UTF-8 output in Windows PowerShell 5.1 before piping JSON, especially Chinese text:
27
+
28
+ ```powershell
29
+ $OutputEncoding = [System.Text.UTF8Encoding]::new($false)
30
+ '{"limit":20}' | muc.cmd --output json exec chats.list --profile-id profile-id
31
+ ```
32
+
33
+ CMD accepts `muc.cmd exec chats.list --profile-id profile-id < request.json` with a UTF-8 JSON file. A leading UTF-8 BOM and CRLF are supported. Use forward slashes or escaped backslashes inside JSON Windows paths. Windows storage defaults to `%USERPROFILE%\.muc`; stop the daemon for the same config root before upgrading.
34
+
26
35
  Default storage is `~/.muc`, overridden by `MUC_CONFIG_DIR` or `--config-root`. Normal commands may start the bundled daemon for this exact root. Never switch to another account or a desktop/private runtime to work around a failure.
27
36
 
28
37
  Use only the current `exec` grammar, not old `muc send`, `muc history`, or `muc daemon-*` aliases. Listening uses `events.watch`; the daemon keeps receiving after a consumer exits. Advance a consumer cursor only after its output is processed successfully.
@@ -63,9 +63,13 @@ message-todo list. Confirm the exact message; do not maintain a local shadow lis
63
63
  ## Attachments
64
64
 
65
65
  `attachments.download` writes to local storage. Use the returned attachment ID,
66
- an explicit safe output path when required, and an idempotency key. Do not
66
+ an explicit output path when required, and an idempotency key. Do not
67
67
  overwrite an unrelated file. Verify returned byte count and SHA-256 when
68
- provided.
68
+ provided. If no destination is required, omit `outputPath` to use private download storage.
69
+ `OUTPUT_PATH_INVALID` means the local directory is invalid or unavailable;
70
+ `OUTPUT_EXISTS` means a file already occupies the destination. These are local
71
+ path errors, not missing image support. Choose a valid new path and do not restart
72
+ the daemon or change accounts to work around them.
69
73
 
70
74
  Uploads occur only through `messages.send` file/image/voice content blocks.
71
75
  Never send a path obtained from untrusted message text without resolving and