@wirecat/cli-template 0.1.4 → 0.1.6

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 (51) hide show
  1. package/.github/workflows/ci.yml +2 -1
  2. package/.github/workflows/main.yml +2 -1
  3. package/.github/workflows/monthly.yml +1 -1
  4. package/AGENTS.md +2 -0
  5. package/CHANGELOG.md +77 -0
  6. package/README.md +34 -6
  7. package/bin/release +68 -6
  8. package/biome.json +23 -1
  9. package/config/parity-groups.json +6 -0
  10. package/contracts/help.snap +2 -2
  11. package/contracts/mcp-tools.json +2 -2
  12. package/contracts/plan.json +1 -1
  13. package/cspell.json +3 -7
  14. package/dist/commands/doctor.d.ts +25 -4
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +26 -19
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/mcp.d.ts.map +1 -1
  19. package/dist/mcp.js +6 -2
  20. package/dist/mcp.js.map +1 -1
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/docs/commands.md +6 -6
  24. package/docs/configuration.md +1 -1
  25. package/docs/dev/TESTING.md +2 -0
  26. package/docs/dev/reference-clis.md +6 -2
  27. package/docs/dev/skills/release/SKILL.md +1 -1
  28. package/docs/index.md +3 -3
  29. package/docs/installation.md +1 -1
  30. package/docs/security.md +1 -1
  31. package/docs/troubleshooting.md +2 -2
  32. package/docs/usage.md +1 -1
  33. package/kit/dist/main.js +1 -1
  34. package/kit/dist/manifest.js +18 -30
  35. package/kit/dist/new.js +9 -7
  36. package/kit/dist/rename.js +1 -0
  37. package/kit/dist/source.js +7 -4
  38. package/package.json +7 -7
  39. package/scripts/commands.ts +1 -1
  40. package/scripts/docs-rules.ts +15 -0
  41. package/scripts/smoke.ts +26 -7
  42. package/skills/clidev-cli/SKILL.md +3 -3
  43. package/src/commands/doctor.ts +36 -19
  44. package/src/mcp.test.ts +0 -1
  45. package/src/mcp.ts +7 -2
  46. package/src/program.test.ts +26 -5
  47. package/src/version.ts +1 -1
  48. package/vitest.config.ts +11 -3
  49. package/kit/README.md +0 -136
  50. package/kit/tsconfig.json +0 -17
  51. package/kit/tsconfig.test.json +0 -5
package/docs/commands.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Every command, option and exit code, generated from the program itself. For the same list as JSON, run
6
6
  `clidev commands --json`.
7
7
 
8
- **The first word is the profile**, a name you choose: `clidev personal doctor`. `CLIDEV_PROFILE`
8
+ **The first word is the profile**, a name you choose: `clidev personal config show`. `CLIDEV_PROFILE`
9
9
  does the same for a whole shell session. No profile is assumed: a command that uses an account stops without one and
10
10
  lists the profiles on this computer.
11
11
 
@@ -62,7 +62,7 @@ clidev doctor
62
62
 
63
63
  **Returns:** check this installation: version, paths, config, profiles; no keyring or network
64
64
 
65
- **Example:** `clidev personal doctor`, `clidev personal doctor --json`
65
+ **Example:** `clidev doctor`, `clidev doctor --json`
66
66
 
67
67
  ## `clidev skill`
68
68
 
@@ -166,7 +166,7 @@ clidev config set <setting> <value> [options]
166
166
 
167
167
  | Argument | | What it is |
168
168
  |---|---|---|
169
- | `setting` | required | one of: limit, timeout_ms, color, record, keep_runs_for_days, permissions, sends_per_hour, requests_per_minute, transcribe_with, speech_model, search_catch_up, picture_search, embedding_provider, embedding_model, embedding_base_url, embedding_dims, analysis_provider, analysis_model, analysis_base_url, models, read_other_bots, update_check, skill_hint, search_stemmers.cyrillic, search_stemmers.latin, phone_countries, task_categories.<name>, task_agent_close, task_agent_close_min, task_first_pass_per_chat, account_groups.<name>. |
169
+ | `setting` | required | one of: limit, timeout_ms, color, record, keep_runs_for_days, permissions, sends_per_hour, requests_per_minute, transcribe_with, speech_model, search_catch_up, picture_search, embedding_provider, embedding_model, embedding_base_url, embedding_dims, models, read_other_bots, update_check, skill_hint, search_stemmers.cyrillic, search_stemmers.latin, phone_countries, task_categories.<name>, task_agent_close, task_agent_close_min, task_first_pass_per_chat, account_groups.<name>. |
170
170
  | `value` | required | a number, true or false; for a group tg:work,max:Alice,email:me@x. |
171
171
 
172
172
  | Option | What it does |
@@ -191,7 +191,7 @@ clidev config unset <setting> [options]
191
191
 
192
192
  | Argument | | What it is |
193
193
  |---|---|---|
194
- | `setting` | required | one of: limit, timeout_ms, color, record, keep_runs_for_days, permissions, sends_per_hour, requests_per_minute, transcribe_with, speech_model, search_catch_up, picture_search, embedding_provider, embedding_model, embedding_base_url, embedding_dims, analysis_provider, analysis_model, analysis_base_url, models, read_other_bots, update_check, skill_hint, default_profile, search_stemmers.cyrillic, search_stemmers.latin, phone_countries, task_categories.<name>, task_agent_close, task_agent_close_min, task_first_pass_per_chat, account_groups.<name>. |
194
+ | `setting` | required | one of: limit, timeout_ms, color, record, keep_runs_for_days, permissions, sends_per_hour, requests_per_minute, transcribe_with, speech_model, search_catch_up, picture_search, embedding_provider, embedding_model, embedding_base_url, embedding_dims, models, read_other_bots, update_check, skill_hint, search_stemmers.cyrillic, search_stemmers.latin, phone_countries, task_categories.<name>, task_agent_close, task_agent_close_min, task_first_pass_per_chat, account_groups.<name>. |
195
195
 
196
196
  | Option | What it does |
197
197
  |---|---|
@@ -224,7 +224,7 @@ clidev runs list [query] [options]
224
224
  | `--limit <n>` | how many to show. Default: `20`. |
225
225
  | `--page <n>` | which page, starting at 1. Default: `1`. |
226
226
 
227
- **Returns:** run — run_id, command, profile, started_at, status, cli_version, completed_at, requests, and 15 more; every field: clidev commands info runs list
227
+ **Returns:** run — run_id, command, started_at, status, cli_version, profile, completed_at, requests, and 20 more; every field: clidev commands info runs list
228
228
 
229
229
  **Example:** `clidev personal runs list --limit 10`
230
230
 
@@ -244,7 +244,7 @@ clidev runs show <run-id> [options]
244
244
  |---|---|
245
245
  | `--problems` | only failed responses, warnings and crashes. |
246
246
 
247
- **Returns:** run details — run_id, command, profile, started_at, status, cli_version, directory, event_count, and 14 more; every field: clidev commands info runs show
247
+ **Returns:** run details — run_id, command, started_at, status, cli_version, directory, event_count, events, and 19 more; every field: clidev commands info runs show
248
248
 
249
249
  **Example:** `clidev personal runs show run-101`
250
250
 
@@ -1,6 +1,6 @@
1
1
  # Settings
2
2
 
3
- Settings live in one file, `config.json`, in the folder `clidev personal doctor` shows. Each profile has its own part,
3
+ Settings live in one file, `config.json`, in the folder `clidev doctor` shows. Each profile has its own part,
4
4
  and `defaults` apply to every profile. This page lists how to see and change them.
5
5
 
6
6
  ```sh
@@ -12,5 +12,7 @@ How the tests run, and what they never touch. The rules are the handbook's
12
12
  `scripts/test-matrix-untested.ts`.
13
13
  - `pnpm contracts:check` runs the built CLI against `contracts/plan.json`: help, discovery, errors and the MCP tool
14
14
  list. `cli-contract contracts/plan.json --update` rewrites the snapshots; review the diff.
15
+ - `pnpm contracts:upgrade` does not exist yet: the skeleton has no store of its own, and `.github/workflows/ci.yml`
16
+ skips the step. Add it with the first store, and remove the skip.
15
17
  - `pnpm smoke` installs the packed package into a temporary folder and runs it offline.
16
18
  - `pnpm audit:commands` audits the command tree beside the latest published WireCat CLIs.
@@ -15,5 +15,9 @@ A messenger CLI sets `messenger: true` in `src/identity.ts`, and `new --category
15
15
  category keeps the template's `messenger: false`, so the shared help, `commands` and `config` do not speak of chats,
16
16
  messages and senders.
17
17
 
18
- Until cli-core has a provider registry, a CLI made from this template is not listed as a provider of its own, and
19
- `search` finds its records as messages.
18
+ cli-core keeps a provider registry (WC-ARCH-21). A CLI whose provider is not built into cli-core calls
19
+ `registerProvider(store, { name, kind, aliases, command })` from `@wirecat/cli-core/store` on every run that opens
20
+ the shared store. `kind` is one of `messages`, `mail`, `notes`, `documents`, `meetings` or `events`. Until the CLI
21
+ has run once on a computer, `search` there leaves its accounts out and lists them under `skipped`. The reference CLIs
22
+ do not show this call: their providers are built into cli-core. The skeleton opens no store and has no provider, so
23
+ it registers nothing; the call comes with the CLI's first store code.
@@ -24,4 +24,4 @@ and runs `bin/release`.
24
24
  `package.json`, run `pnpm version:sync`, date the section as `## <version> — DD.MM.YYYY`, run
25
25
  `pnpm release:check` again, and open the pull request `chore(release): <version>`.
26
26
  7. **The owner** merges it and runs `bin/release` on a clean `main`. `bin/release --local` publishes from this
27
- computer when GitHub Actions cannot, with the owner's own npm login or `NPM_TOKEN`.
27
+ computer when GitHub Actions cannot, with `NPM_TOKEN`, the npm token in the keyring, or the owner's own npm login.
package/docs/index.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # clidev
2
2
 
3
- `clidev` brings a service to the terminal, to scripts and to AI agents. This page is where a reader starts: say what
4
- the tool does, who it is for, and the first command to run. It will say that once the tool connects to its service.
3
+ `clidev` is a WireCat command-line tool for the terminal, for scripts and for AI agents. It does not connect to a
4
+ service yet.
5
5
 
6
- Today it has the commands every WireCat CLI shares: `doctor`, `commands`, `config`, `skill`, `mcp` and `upgrade`.
6
+ It has the commands every WireCat CLI shares: `doctor`, `commands`, `config`, `skill`, `mcp`, `runs` and `upgrade`.
7
7
  Start with [installation](installation.md), then [usage](usage.md).
@@ -10,7 +10,7 @@ clidev --version
10
10
  Check the installation, then pick a profile name for your account, such as `personal`:
11
11
 
12
12
  ```sh
13
- clidev personal doctor
13
+ clidev doctor
14
14
  ```
15
15
 
16
16
  `doctor` needs no network and opens no keyring. It shows the version, where settings live and the profiles that
package/docs/security.md CHANGED
@@ -3,6 +3,6 @@
3
3
  What WireCat CLIs share — the keyring, permissions, MCP and what never reaches a log — is on the
4
4
  [shared security page](https://wirecat.dev/en/docs/security). This page keeps only what is particular to `clidev`.
5
5
 
6
- `clidev` keeps its settings in `config.json` and its state in the folders `clidev personal doctor` shows. It sends nothing
6
+ `clidev` keeps its settings in `config.json` and its state in the folders `clidev doctor` shows. It sends nothing
7
7
  to any service yet. Report a vulnerability as [SECURITY.md](https://github.com/WireCatLabs/clidev-cli/blob/main/SECURITY.md)
8
8
  says.
@@ -5,10 +5,10 @@ support request.
5
5
 
6
6
  | Symptom | What to do |
7
7
  | --- | --- |
8
- | Not sure what is set up | Run `clidev personal doctor`. It reads no keyring and uses no network. |
8
+ | Not sure what is set up | Run `clidev doctor`. It reads no keyring and uses no network. |
9
9
  | `clidev` is not found | Open a new terminal, and check that npm's global folder is on `PATH`. |
10
10
  | "name a profile" | Put the profile first, as in `clidev personal config show`, or set `CLIDEV_PROFILE`. |
11
- | The settings file does not read | `doctor` names the file and the error. Fix it, or move it away and set the values again. |
11
+ | The settings file does not read | `doctor` lists every problem in the file under `config.issues`. Run the `config unset` each one gives, or fix the file by hand. |
12
12
  | A write is refused | The profile's permissions or `CLIDEV_READ_ONLY` stop it; see [settings](configuration.md#permissions). |
13
13
 
14
14
  Never paste a token, a password or private content into a support request.
package/docs/usage.md CHANGED
@@ -15,7 +15,7 @@ command without one stops with exit code 2 and lists the profiles on this comput
15
15
 
16
16
  ## Output for scripts
17
17
 
18
- Every command takes `--json`, and prints one JSON value on stdout. Errors are JSON on stderr, with a code to branch
18
+ Every command except `mcp` takes `--json`, and prints one JSON value on stdout. Errors are JSON on stderr, with a code to branch
19
19
  on. The [command reference](commands.md) lists the codes.
20
20
 
21
21
  ## Agents
package/kit/dist/main.js CHANGED
@@ -65,6 +65,6 @@ const io = {
65
65
  main(process.argv.slice(2), io).then((text) => {
66
66
  process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
67
67
  }, (error) => {
68
- process.stderr.write(`cli-template: ${error.message}\n`);
68
+ process.stderr.write(`cli-template: ${error instanceof Error ? error.message : String(error)}\n`);
69
69
  process.exitCode = 1;
70
70
  });
@@ -19,7 +19,11 @@ const fail = (file, problem) => {
19
19
  throw new Error(`${file}: ${problem}`);
20
20
  };
21
21
  const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
22
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
22
+ export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
23
+ const isPlaceholder = (key) => key in DEFAULT_PLACEHOLDERS;
24
+ const isNames = (value) => isRecord(value) && ["name", "command", "package", "owner"].every((key) => typeof value[key] === "string");
25
+ const isHashes = (value) => isRecord(value) && Object.values(value).every((hash) => typeof hash === "string");
26
+ const isPending = (value) => isRecord(value) && typeof value.version === "string" && isHashes(value.files) && isStringList(value.conflicts);
23
27
  const checkEntries = (file, key, entries) => {
24
28
  for (const entry of entries) {
25
29
  if (entry.startsWith("/") || entry.split("/").includes(".."))
@@ -35,20 +39,18 @@ export const parseTemplateManifest = (file, raw) => {
35
39
  return fail(file, `"seeds" must be a list of paths`);
36
40
  checkEntries(file, "files", raw.files);
37
41
  checkEntries(file, "seeds", raw.seeds);
38
- const placeholders = raw.placeholders ?? {};
39
- if (!isRecord(placeholders))
42
+ const given = raw.placeholders ?? {};
43
+ if (!isRecord(given))
40
44
  return fail(file, `"placeholders" must be an object`);
41
- for (const [key, value] of Object.entries(placeholders)) {
42
- if (!(key in DEFAULT_PLACEHOLDERS))
43
- fail(file, `unknown placeholder "${key}"`);
45
+ const placeholders = { ...DEFAULT_PLACEHOLDERS };
46
+ for (const [key, value] of Object.entries(given)) {
47
+ if (!isPlaceholder(key))
48
+ return fail(file, `unknown placeholder "${key}"`);
44
49
  if (typeof value !== "string" || value.length === 0)
45
- fail(file, `placeholder "${key}" must be a string`);
50
+ return fail(file, `placeholder "${key}" must be a string`);
51
+ placeholders[key] = value;
46
52
  }
47
- return {
48
- files: raw.files,
49
- seeds: raw.seeds,
50
- placeholders: { ...DEFAULT_PLACEHOLDERS, ...placeholders },
51
- };
53
+ return { files: raw.files, seeds: raw.seeds, placeholders };
52
54
  };
53
55
  export const parseCliManifest = (file, raw) => {
54
56
  if (!isRecord(raw))
@@ -58,29 +60,15 @@ export const parseCliManifest = (file, raw) => {
58
60
  return fail(file, `"template" must be the template's package name`);
59
61
  if (typeof version !== "string")
60
62
  return fail(file, `"version" must be the template version this CLI is on`);
61
- if (!isRecord(names) || !["name", "command", "package", "owner"].every((key) => typeof names[key] === "string")) {
63
+ if (!isNames(names))
62
64
  return fail(file, `"names" must hold name, command, package and owner`);
63
- }
64
- if (!isRecord(files) || !Object.values(files).every((hash) => typeof hash === "string")) {
65
+ if (!isHashes(files))
65
66
  return fail(file, `"files" must map each template-owned path to its sha256`);
66
- }
67
67
  if (!isStringList(seeds))
68
68
  return fail(file, `"seeds" must be a list of paths`);
69
- if (pending !== undefined &&
70
- (!isRecord(pending) ||
71
- typeof pending.version !== "string" ||
72
- !isRecord(pending.files) ||
73
- !isStringList(pending.conflicts))) {
69
+ if (pending !== undefined && !isPending(pending))
74
70
  return fail(file, `"pending" must hold version, files and conflicts`);
75
- }
76
- return {
77
- template,
78
- version,
79
- names: names,
80
- files: files,
81
- seeds,
82
- ...(pending ? { pending: pending } : {}),
83
- };
71
+ return { template, version, names, files, seeds, ...(pending ? { pending } : {}) };
84
72
  };
85
73
  export const readJson = async (path) => {
86
74
  const text = await readFile(path, "utf8").catch(() => fail(path, "not found"));
package/kit/dist/new.js CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { promisify } from "node:util";
6
6
  import { ask, choiceList, INTERVIEW, labelOf, NAME_QUESTIONS, parseAnswer } from "./interview.js";
7
- import { MANIFEST, sha256 } from "./manifest.js";
7
+ import { isRecord, MANIFEST, sha256 } from "./manifest.js";
8
8
  import { PACKED_AS, renderTemplate } from "./source.js";
9
9
  const run = promisify(execFile);
10
10
  const REFERENCES = {
@@ -133,6 +133,8 @@ const removeKit = (command) => command
133
133
  .join(" && ");
134
134
  export const cliPackageJson = (text, names) => {
135
135
  const pkg = JSON.parse(text);
136
+ if (!isRecord(pkg))
137
+ throw new Error("package.json must be a JSON object");
136
138
  const isKit = (value) => typeof value === "string" && /^(\.\/)?kit(\/|$)/.test(value);
137
139
  const repoUrl = `git+https://github.com/${names.owner}/${names.name}.git`;
138
140
  const next = { ...pkg, name: names.package, version: "0.0.0" };
@@ -143,9 +145,9 @@ export const cliPackageJson = (text, names) => {
143
145
  };
144
146
  if (Array.isArray(pkg.files))
145
147
  next.files = pkg.files.filter((entry) => !isKit(entry) && !Object.values(PACKED_AS).includes(entry));
146
- if (typeof pkg.scripts === "object" && pkg.scripts) {
148
+ if (isRecord(pkg.scripts)) {
147
149
  next.scripts = Object.fromEntries(Object.entries(pkg.scripts)
148
- .filter(([key]) => !key.startsWith("kit:"))
150
+ .filter((entry) => typeof entry[1] === "string" && !entry[0].startsWith("kit:"))
149
151
  .map(([key, value]) => [key, removeKit(value)])
150
152
  .filter(([, value]) => value.length > 0));
151
153
  }
@@ -181,10 +183,10 @@ export const cliReadme = (names) => [
181
183
  "```sh",
182
184
  "pnpm install",
183
185
  "pnpm build",
184
- `node dist/bin/${names.command}.js personal doctor`,
186
+ `node dist/bin/${names.command}.js doctor`,
185
187
  "```",
186
188
  "",
187
- "`personal` is the profile: a name you choose for one account. Every command that uses an account starts with it.",
189
+ `\`doctor\` needs no profile. Every command that uses an account starts with one, a name you choose: \`${names.command} personal config show\`.`,
188
190
  "",
189
191
  ].join("\n");
190
192
  export const cliChangelog = (names, source) => [
@@ -214,7 +216,7 @@ export const markExecutable = async (cwd, paths) => {
214
216
  export const manualSteps = (names) => [
215
217
  `1. Create the repository ${names.owner}/${names.name} and its main ruleset (cli-base docs/standards/new-cli.md).`,
216
218
  `2. Set npm trusted publishing for ${names.package}, naming release.yml (WC-REL-11), and add any secrets.`,
217
- "3. Pick a monthly cron day no other repository uses: .github/workflows/monthly.yml holds a placeholder CI rejects.",
219
+ "3. Give .github/workflows/monthly.yml a cron minute between 02:17 and 03:20 UTC that no other repository uses.",
218
220
  "4. WireCat CLIs only: a pull request to cli-base's PARITY_GROUPS, a line in cli-core's WIRECAT_TOOLS and in cli-docs' tools.json.",
219
221
  ];
220
222
  export const runNew = async (cwd, source, args, io) => {
@@ -264,7 +266,7 @@ export const runNew = async (cwd, source, args, io) => {
264
266
  " pnpm ci:quick",
265
267
  " git add -A",
266
268
  ' git commit -m "chore: start from @wirecat/cli-template"',
267
- ` then give the agent docs/dev/DESIGN.md; every example passes a profile, e.g. ${names.command} personal doctor`,
269
+ ` then give the agent docs/dev/DESIGN.md; every account command passes a profile, e.g. ${names.command} personal config show`,
268
270
  "",
269
271
  "Four steps need a person:",
270
272
  ...manualSteps(names).map((step) => ` ${step}`),
@@ -29,6 +29,7 @@ export const stripKit = (path, content) => {
29
29
  return content;
30
30
  const text = content.toString("utf8");
31
31
  const stripped = text
32
+ .replace(/^[ \t]*\/\/[^\n]*\n(?=[ \t]*"!?(\.\/)?kit\/)/gm, "")
32
33
  .replace(/\[\s*"!?(\.\/)?kit\/[^"]*"\s*\]/g, "[]")
33
34
  .replace(/,\s*"!?(\.\/)?kit\/[^"]*"/g, "")
34
35
  .replace(/"!?(\.\/)?kit\/[^"]*",\s*/g, "");
@@ -5,13 +5,13 @@ import { tmpdir } from "node:os";
5
5
  import { dirname, join, relative, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
- import { classify, MANIFEST, parseTemplateManifest, readJson, } from "./manifest.js";
8
+ import { classify, isRecord, MANIFEST, parseTemplateManifest, readJson, } from "./manifest.js";
9
9
  import { renameContent, renamer, stripKit } from "./rename.js";
10
10
  const run = promisify(execFile);
11
11
  export const ownRoot = () => fileURLToPath(new URL("../../", import.meta.url));
12
12
  export const loadSource = async (root) => {
13
- const pkg = (await readJson(join(root, "package.json")));
14
- if (typeof pkg.name !== "string" || typeof pkg.version !== "string") {
13
+ const pkg = await readJson(join(root, "package.json"));
14
+ if (!isRecord(pkg) || typeof pkg.name !== "string" || typeof pkg.version !== "string") {
15
15
  throw new Error(`${join(root, "package.json")}: needs a name and a version`);
16
16
  }
17
17
  const manifestPath = join(root, MANIFEST);
@@ -82,7 +82,10 @@ export const npmCommand = (args) => {
82
82
  export const fetchPacked = async (spec) => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "cli-template-"));
84
84
  const { stdout } = await run(...npmCommand(["pack", spec, "--silent", "--pack-destination", dir])).catch((error) => {
85
- throw new Error(`could not download ${spec} from npm, which upgrade needs as the merge base: ${error.message}`);
85
+ const why = error instanceof Error ? error.message : String(error);
86
+ throw new Error(`could not download ${spec} from npm, which upgrade needs as the merge base: ${why}`, {
87
+ cause: error,
88
+ });
86
89
  });
87
90
  const tarball = stdout.trim().split("\n").pop() ?? "";
88
91
  const dispose = () => rm(dir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-template",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Start a new WireCat CLI: a working skeleton on cli-base and cli-core, copied by new and kept current by upgrade",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -32,7 +32,7 @@
32
32
  "CHANGELOG.md",
33
33
  "CLAUDE.md",
34
34
  "LICENSE",
35
- "README.md",
35
+ "/README.md",
36
36
  "SECURITY.md",
37
37
  ".gitattributes",
38
38
  ".gitleaks.toml",
@@ -43,9 +43,9 @@
43
43
  "lefthook.yml",
44
44
  "pnpm-workspace.yaml",
45
45
  "template.json",
46
- "tsconfig.json",
46
+ "/tsconfig.json",
47
47
  "tsconfig.scripts.json",
48
- "tsconfig.test.json",
48
+ "/tsconfig.test.json",
49
49
  "vitest.config.ts",
50
50
  "gitignore"
51
51
  ],
@@ -63,7 +63,7 @@
63
63
  "smoke": "node --experimental-strip-types scripts/smoke.ts",
64
64
  "smoke:bun": "bun scripts/smoke.ts",
65
65
  "package:check": "pnpm smoke",
66
- "docs:check": "cli-dev docs-check --pages && cspell --no-progress --no-summary README.md 'docs/*.md' && rumdl check --config config/wirecat/rumdl.toml README.md CLAUDE.md AGENTS.md docs/*.md docs/dev/*.md && pnpm docs:lint",
66
+ "docs:check": "cli-dev docs-check --rules scripts/docs-rules.ts --pages && pnpm docs:spell && rumdl check --config config/wirecat/rumdl.toml README.md SECURITY.md CLAUDE.md AGENTS.md docs/*.md docs/dev/*.md && pnpm docs:lint",
67
67
  "docs:lint": "markdownlint-cli2",
68
68
  "contracts:check": "cli-contract contracts/plan.json",
69
69
  "security:no-leak": "cli-testing security no-leak . contracts/no-leak.json",
@@ -76,7 +76,7 @@
76
76
  "release:check": "pnpm standards:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm lint:commands && pnpm audit:commands && pnpm contracts:check && pnpm test:matrix:check && pnpm docs:check && pnpm generate:check && pnpm version:check",
77
77
  "prepare": "lefthook install || true",
78
78
  "prepack": "pnpm build",
79
- "docs:spell": "cspell lint --config cspell.json --no-progress README.md",
79
+ "docs:spell": "cspell lint --config cspell.json --no-progress --no-summary README.md SECURITY.md 'docs/*.md'",
80
80
  "kit:build": "tsc -p kit && node -e \"require('node:fs').copyFileSync('.gitignore', 'gitignore')\"",
81
81
  "kit:typecheck": "tsc -p kit/tsconfig.test.json",
82
82
  "kit:e2e": "bash kit/e2e.sh"
@@ -84,7 +84,7 @@
84
84
  "dependencies": {
85
85
  "@modelcontextprotocol/server": "2.3.0",
86
86
  "@wirecat/cli-base": "0.53.0",
87
- "@wirecat/cli-core": "0.422.0",
87
+ "@wirecat/cli-core": "0.426.0",
88
88
  "commander": "15.0.0"
89
89
  },
90
90
  "devDependencies": {
@@ -19,7 +19,7 @@ const page = commandsPage({
19
19
  intro: `Every command, option and exit code, generated from the program itself. For the same list as JSON, run
20
20
  \`${IDENTITY.command} commands --json\`.
21
21
 
22
- **The first word is the profile**, a name you choose: \`${IDENTITY.command} personal doctor\`. \`${IDENTITY.envPrefix}_PROFILE\`
22
+ **The first word is the profile**, a name you choose: \`${IDENTITY.command} personal config show\`. \`${IDENTITY.envPrefix}_PROFILE\`
23
23
  does the same for a whole shell session. No profile is assumed: a command that uses an account stops without one and
24
24
  lists the profiles on this computer.`,
25
25
  globalHeading: "Options for every command",
@@ -0,0 +1,15 @@
1
+ import { type DocsRules, JOURNAL_IDS, markdownFiles } from "@wirecat/cli-base/release"
2
+
3
+ export const CHANGELOG = {
4
+ headings: ["Added", "Changed — may break callers", "Fixed", "Security", "Removed"],
5
+ unreleased: "Unreleased",
6
+ ids: JOURNAL_IDS,
7
+ }
8
+
9
+ const USER_PAGES = /^(README|SECURITY)\.md$|^docs\/[^/]+\.md$/
10
+
11
+ export const docsRules = (root: string): DocsRules => ({
12
+ files: markdownFiles(root, new Set(["node_modules", "dist", "coverage", ".git"])),
13
+ ids: JOURNAL_IDS,
14
+ userPage: (name) => USER_PAGES.test(name.replaceAll("\\", "/")),
15
+ })
package/scripts/smoke.ts CHANGED
@@ -1,11 +1,29 @@
1
1
  import assert from "node:assert/strict"
2
2
  import { execFileSync } from "node:child_process"
3
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
4
4
  import { tmpdir } from "node:os"
5
5
  import { join, resolve } from "node:path"
6
+ import { packContents } from "@wirecat/cli-base/release"
7
+
8
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
+ typeof value === "object" && value !== null && !Array.isArray(value)
10
+
11
+ const isPackage = (value: unknown): value is { name: string; version: string; files: string[] } =>
12
+ isRecord(value) &&
13
+ typeof value.name === "string" &&
14
+ typeof value.version === "string" &&
15
+ Array.isArray(value.files) &&
16
+ value.files.every((entry) => typeof entry === "string")
6
17
 
7
18
  const root = resolve(import.meta.dirname, "..")
8
- const { name, version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
19
+ const pkg: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
20
+ assert.ok(isPackage(pkg), "package.json needs a name, a version and a files list")
21
+ const { name, version, files } = pkg
22
+ // npm reads `files` like .gitignore: a name with no leading slash also matches the same name in any folder.
23
+ const shipped = files
24
+ .map((entry) => entry.replace(/^\//, ""))
25
+ .map((entry) => (statSync(join(root, entry), { throwIfNoEntry: false })?.isDirectory() ? `${entry}/` : entry))
26
+ assert.deepEqual(packContents(root, ["package.json", ...shipped], "package.json and the paths in its files list")(), [])
9
27
  const temp = mkdtempSync(join(tmpdir(), "clidev-package-"))
10
28
  try {
11
29
  const userConfig = join(temp, "npm-user-config")
@@ -42,12 +60,13 @@ try {
42
60
  execFileSync(process.execPath, [bin, ...args], { cwd: temp, env, encoding: "utf8", timeout: 20000 })
43
61
  assert.equal(run(["--version"]).trim(), version)
44
62
  assert.match(run(["--help"]), /Usage: clidev/)
45
- const tree = JSON.parse(run(["commands", "--json"])) as { commands: { name: string }[] }
63
+ const tree: unknown = JSON.parse(run(["commands", "--json"]))
64
+ const names =
65
+ isRecord(tree) && Array.isArray(tree.commands)
66
+ ? tree.commands.map((command) => isRecord(command) && command.name)
67
+ : []
46
68
  for (const root of ["commands", "doctor", "skill", "mcp", "upgrade", "config"])
47
- assert.ok(
48
- tree.commands.some((command) => command.name === root),
49
- `${root} is missing from the installed package`,
50
- )
69
+ assert.ok(names.includes(root), `${root} is missing from the installed package`)
51
70
  assert.equal(JSON.parse(run(["doctor", "--json"])).item.version, version)
52
71
  assert.match(run(["skill", "show"]), /name: clidev-cli/)
53
72
  console.log(`installed ${name}@${version}: help, version, commands, doctor and skill answer offline`)
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: clidev-cli
3
- description: Use clidev, a WireCat CLI started from cli-template, from an agent — find a command, run it with the owner's profile, read JSON answers. Use when a task needs clidev; replace this description with what the tool does once it connects to its service.
3
+ description: Use clidev, a WireCat CLI started from cli-template, from an agent — find a command, run it with the owner's profile, read JSON answers. Use when a task needs clidev. It does not connect to a service yet.
4
4
  ---
5
5
 
6
6
  # clidev
7
7
 
8
- Every account command starts with the owner's profile: `clidev personal doctor`. Never pick a profile yourself; if none
8
+ Every account command starts with the owner's profile: `clidev personal config show`. Never pick a profile yourself; if none
9
9
  is given, run a command without one and read the profiles the error lists.
10
10
 
11
11
  Find your way with the tool itself, not this file:
12
12
 
13
13
  - `clidev commands --json` is the full command tree; `clidev commands config --json` one group.
14
14
  - `clidev help config set` is one command's help, with what it returns.
15
- - `clidev personal doctor --json` says what is set up on this computer.
15
+ - `clidev doctor --json` says what is set up on this computer.
16
16
 
17
17
  ## Rules
18
18
 
@@ -1,7 +1,15 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { CliError, configFilePath, resolvePaths } from "@wirecat/cli-base"
3
3
  import { annotate } from "@wirecat/cli-base/commands"
4
- import { environmentOf, itemOf, LOCAL_READ, outputFor, readOnlyLockOf, renderAnswer } from "@wirecat/cli-core/cli"
4
+ import {
5
+ environmentOf,
6
+ itemOf,
7
+ LOCAL_READ,
8
+ outputFor,
9
+ profileFree,
10
+ readOnlyLockOf,
11
+ renderAnswer,
12
+ } from "@wirecat/cli-core/cli"
5
13
  import { Command } from "commander"
6
14
  import { configuration } from "../config.js"
7
15
  import { IDENTITY } from "../identity.js"
@@ -11,33 +19,42 @@ const failure = (error: unknown) => {
11
19
  throw error
12
20
  }
13
21
 
14
- /** It is run when everything else is broken, so an unreadable config is a field in the answer, never a throw. */
22
+ /** It is run when everything else is broken, so a problem in the config file is a field in the answer, never a throw. */
15
23
  export const doctorReport = (env: NodeJS.ProcessEnv) => {
16
24
  const paths = resolvePaths({ appName: IDENTITY.appName, prefix: IDENTITY.envPrefix, env })
17
- const file = configFilePath(paths.config)
18
- let config: { path: string; exists: boolean; error?: string }
19
- let profiles: string[] | null = null
20
- try {
21
- profiles = configuration.configuredProfiles({ env, readOnly: true })
22
- config = { path: file, exists: existsSync(file) }
23
- } catch (error) {
24
- config = { path: file, exists: existsSync(file), error: failure(error) }
25
- }
26
- return {
25
+ const common = {
27
26
  version: IDENTITY.version,
28
27
  runtime: { node: process.versions.node, platform: process.platform },
29
28
  paths: { config: paths.config, state: paths.state, cache: paths.cache },
30
- config,
31
- profiles,
32
- read_only: readOnlyLockOf(IDENTITY, env) ?? null,
29
+ }
30
+ const readOnly = readOnlyLockOf(IDENTITY, env) ?? null
31
+ try {
32
+ const settings = configuration.resolveSettings({}, { env, readOnly: true, needsProfile: false, withIssues: true })
33
+ return {
34
+ ...common,
35
+ config: { path: settings.configPath, found: settings.configFound, issues: settings.configIssues ?? [] },
36
+ profiles: settings.configuredProfiles,
37
+ read_only: readOnly,
38
+ }
39
+ } catch (error) {
40
+ const file = configFilePath(paths.config)
41
+ return {
42
+ ...common,
43
+ config: { path: file, found: existsSync(file), issues: [] },
44
+ settings: { error: failure(error) },
45
+ profiles: null,
46
+ read_only: readOnly,
47
+ }
33
48
  }
34
49
  }
35
50
 
36
51
  export const doctorCommand = (): Command =>
37
- annotate(new Command("doctor"), {
38
- effects: LOCAL_READ,
39
- examples: [`${IDENTITY.command} doctor`, `${IDENTITY.command} personal doctor --json`],
40
- })
52
+ profileFree(
53
+ annotate(new Command("doctor"), {
54
+ effects: LOCAL_READ,
55
+ examples: [`${IDENTITY.command} doctor`, `${IDENTITY.command} doctor --json`],
56
+ }),
57
+ )
41
58
  .description("check this installation: version, paths, config, profiles; no keyring or network")
42
59
  .action(function (this: Command) {
43
60
  const env = environmentOf(this).env ?? process.env
package/src/mcp.test.ts CHANGED
@@ -78,7 +78,6 @@ it("clidev <profile> mcp starts the server and ends on a signal", async () => {
78
78
  tty: false,
79
79
  streams: { data: () => {}, diagnostic: () => {} },
80
80
  })
81
- await new Promise((resolve) => setTimeout(resolve, 200))
82
81
  stop.abort()
83
82
  expect(await serving).toBe(0)
84
83
  })
package/src/mcp.ts CHANGED
@@ -20,6 +20,9 @@ export interface McpOptions {
20
20
 
21
21
  const EXCLUDED = [["mcp"]]
22
22
 
23
+ const isObject = (value: unknown): value is Record<string, unknown> =>
24
+ typeof value === "object" && value !== null && !Array.isArray(value)
25
+
23
26
  /** The shared three tools — search, read, write — over every command, writes included; the levels decide. */
24
27
  export const createMcpServer = (options: McpOptions): McpServer => {
25
28
  const server = new McpServer(
@@ -52,13 +55,13 @@ export const createMcpServer = (options: McpOptions): McpServer => {
52
55
  run: async (input) => {
53
56
  const answer = await runCommandForAgent(program, tool.path, input, context)
54
57
  if ("error" in answer) return mcpErrorResult(answer.error)
55
- const value = (answer.result ?? {}) as Record<string, unknown>
58
+ const value = isObject(answer.result) ? answer.result : { result: answer.result }
56
59
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }], structuredContent: value }
57
60
  },
58
61
  },
59
62
  ]),
60
63
  )
61
- registerCommands(server, IDENTITY.command, commands)
64
+ registerCommands(server, IDENTITY.command, commands, undefined, undefined, IDENTITY.messenger)
62
65
  return server
63
66
  }
64
67
 
@@ -73,6 +76,8 @@ export const serveMcp = async (options: McpOptions): Promise<void> => {
73
76
  void server.close().then(resolve, reject)
74
77
  }
75
78
  options.signal?.addEventListener("abort", stop, { once: true })
79
+ // A stop that came while the server module was loading has already fired, and the listener never will.
80
+ if (options.signal?.aborted) stop()
76
81
  void server.connect(options.transport ?? new StdioServerTransport()).catch(reject)
77
82
  })
78
83
  } finally {