@uniqbit/mate-core 0.15.1 → 0.15.2-canary.0

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.
@@ -36,7 +36,84 @@ export interface ReportData {
36
36
  netSpend: number;
37
37
  }
38
38
 
39
+ export const REPORT_DOCUMENT_VERSION = 1 as const;
40
+
41
+ export type ReportValue = string | number | boolean | null;
42
+
43
+ export interface ReportKeyValue {
44
+ label: string;
45
+ value: ReportValue;
46
+ }
47
+
48
+ export interface ReportMetric {
49
+ label: string;
50
+ value: ReportValue;
51
+ detail?: string;
52
+ }
53
+
54
+ export interface ReportStatus {
55
+ label: string;
56
+ status: string;
57
+ detail?: string;
58
+ }
59
+
60
+ export interface ReportSectionBase {
61
+ id: string;
62
+ title: string;
63
+ }
64
+
65
+ export interface ReportMetadataSection extends ReportSectionBase {
66
+ type: "metadata";
67
+ items: ReportKeyValue[];
68
+ }
69
+
70
+ export interface ReportMetricsSection extends ReportSectionBase {
71
+ type: "metrics";
72
+ items: ReportMetric[];
73
+ }
74
+
75
+ export interface ReportKeyValueSection extends ReportSectionBase {
76
+ type: "key-value";
77
+ items: ReportKeyValue[];
78
+ }
79
+
80
+ export interface ReportTableSection extends ReportSectionBase {
81
+ type: "table";
82
+ columns: string[];
83
+ rows: ReportValue[][];
84
+ }
85
+
86
+ export interface ReportStatusesSection extends ReportSectionBase {
87
+ type: "statuses";
88
+ items: ReportStatus[];
89
+ }
90
+
91
+ export interface ReportTextSection extends ReportSectionBase {
92
+ type: "text";
93
+ content: string;
94
+ }
95
+
96
+ export type ReportSection =
97
+ | ReportMetadataSection
98
+ | ReportMetricsSection
99
+ | ReportKeyValueSection
100
+ | ReportTableSection
101
+ | ReportStatusesSection
102
+ | ReportTextSection;
103
+
104
+ export interface ReportDocument {
105
+ version: typeof REPORT_DOCUMENT_VERSION;
106
+ title: string;
107
+ generatedAt: string;
108
+ period?: string;
109
+ context?: string;
110
+ metadata: ReportKeyValue[];
111
+ summary: ReportMetric[];
112
+ sections: ReportSection[];
113
+ }
114
+
39
115
  export interface ReportOptions {
40
116
  days: number;
41
117
  json: boolean;
118
+ input?: string;
42
119
  }
package/src/cli/main.ts CHANGED
@@ -51,17 +51,18 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
51
51
  return;
52
52
  }
53
53
 
54
+ // Companion-declared plugins register before cap-command detection and
55
+ // before help text is printed, so their commands route like compiled-in
56
+ // ones (including MCP servers whose command is their own cap subcommand)
57
+ // and show up in `mate help`. Diagnostics stay on stderr; a missing or
58
+ // ambiguous companion makes this a no-op.
59
+ await deps.hydrateDynamicPlugins();
60
+
54
61
  if (!command || command === "help" || command === "--help" || command === "-h") {
55
62
  console.log(usage());
56
63
  return;
57
64
  }
58
65
 
59
- // Companion-declared plugins register before cap-command detection so
60
- // their commands route like compiled-in ones (including MCP servers whose
61
- // command is their own cap subcommand). Diagnostics stay on stderr; a
62
- // missing or ambiguous companion makes this a no-op.
63
- await deps.hydrateDynamicPlugins();
64
-
65
66
  // Plugin commands (`mate cap <namespace> <command>`) own their stdout (an
66
67
  // MCP server speaks JSON-RPC over it), so banners and background chatter
67
68
  // are suppressed for them.
@@ -95,11 +95,36 @@ export async function ensureCapabilityEnabled(
95
95
  return false;
96
96
  }
97
97
 
98
+ function formatCommand(
99
+ distributionName: string,
100
+ namespace: string,
101
+ command: PluginCliCommand,
102
+ ): string {
103
+ return ` ${distributionName} cap ${namespace} ${command.name} ${command.description}`;
104
+ }
105
+
98
106
  function usageFor(plugin: Plugin): string {
99
107
  const distributionName = frameworkCommandName();
100
108
  const namespace = namespaceOf(plugin);
101
- const lines = (plugin.cliCommands ?? []).map(
102
- (command) => ` ${distributionName} cap ${namespace} ${command.name} ${command.description}`,
109
+ const lines = (plugin.cliCommands ?? []).map((command) =>
110
+ formatCommand(distributionName, namespace, command),
103
111
  );
104
112
  return ["Available commands:", ...lines].join("\n");
105
113
  }
114
+
115
+ /**
116
+ * Cap-command lines contributed by every registered plugin that declares
117
+ * `cliCommands` (compiled-in or companion-declared/dynamic), formatted as
118
+ * ` <n> cap <namespace> <name> <description>`. Used by `usage()` so plugin
119
+ * commands show up in `mate help` alongside framework builtins.
120
+ */
121
+ export function pluginCliCommandLines(): string[] {
122
+ const distributionName = frameworkCommandName();
123
+ return getActiveDistribution()
124
+ .registry.getAll()
125
+ .flatMap((plugin) =>
126
+ (plugin.cliCommands ?? []).map((command) =>
127
+ formatCommand(distributionName, namespaceOf(plugin), command),
128
+ ),
129
+ );
130
+ }
package/src/cli/usage.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getActiveDistribution } from "../distribution";
2
2
  import { FRAMEWORK_NAME, frameworkCommandName } from "../framework";
3
+ import { pluginCliCommandLines } from "./plugin-commands";
3
4
 
4
5
  export function usage(): string {
5
6
  const n = frameworkCommandName();
@@ -17,13 +18,14 @@ export function usage(): string {
17
18
  ` ${n} companion tui`,
18
19
  ` ${n} artifact finish <change-name> [--type openspec] [--force] [--no-push] [--json]`,
19
20
  ` ${n} doctor`,
20
- ` ${n} report [--days N] [--json]`,
21
+ ` ${n} report [--days N] [--input FILE|-] [--json]`,
21
22
  ` ${n} config`,
22
23
  ` ${n} claude [args...] (use -- --no-git to bypass companion Git sync)`,
23
24
  ` ${n} opencode [args...] (use -- --no-git to bypass companion Git sync)`,
24
25
  ` ${n} cap openspec <subcommand> [args...]`,
25
26
  ` ${n} cap graphify <subcommand> [args...]`,
26
27
  ` ${n} cap index [--graphify] [--tokensave]`,
28
+ ...pluginCliCommandLines(),
27
29
  ` ${n} update`,
28
30
  ` ${n} update --check`,
29
31
  "",
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
- import { frameworkCommandName } from "../../../framework";
7
6
  import { getOpenSpecSchemaSelection } from "../../../lib/orchestrator/setup-compatibilities";
8
7
  import { fetchPublicPackageVersion } from "../../../lib/public-npm";
9
8
  import { isNewer } from "../../../lib/update-checker";
@@ -18,6 +17,9 @@ import {
18
17
  runCommand,
19
18
  runCommandSilently,
20
19
  } from "../utils";
20
+ import { applyMateSkills, teardownMateSkills } from "../mate";
21
+
22
+ export { MATE_ARTIFACT_SKILLS, MATE_SKILLS, deployMateSkillDir } from "../mate";
21
23
 
22
24
  const execFileAsync = promisify(execFileCb);
23
25
 
@@ -30,14 +32,8 @@ export const OPENSPEC_SKILLS = [
30
32
 
31
33
  // Mate-authored skills (not generated by the openspec CLI) that ship alongside the
32
34
  // openspec workflow skills and are installed per active provider.
33
- export const MATE_ARTIFACT_SKILLS = ["mate-artifact-finish"] as const;
34
35
  const LEGACY_CLAUDE_STATE_PATTERN = /^mate-artifact-finish\..+\.json$/;
35
36
 
36
- const MATE_SKILLS_SOURCE = path.join(
37
- import.meta.dirname,
38
- "../../../templates/capabilities/openspec-cap/mate-skills",
39
- );
40
-
41
37
  const MATE_V1_SCHEMA_SOURCE = path.join(
42
38
  import.meta.dirname,
43
39
  "../../../templates/capabilities/openspec-cap/mate-v1",
@@ -110,24 +106,14 @@ export async function teardownOpenspecSkills(
110
106
  skillsDir: string,
111
107
  companionPath: string,
112
108
  ): Promise<void> {
113
- for (const skill of [...OPENSPEC_SKILLS, ...MATE_ARTIFACT_SKILLS]) {
114
- try {
115
- await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
116
- } catch {
117
- /* not present */
118
- }
119
- }
120
- await pruneEmptyAncestors(skillsDir, companionPath);
121
- }
122
-
123
- async function teardownMateOpenspecSkills(skillsDir: string, companionPath: string): Promise<void> {
124
- for (const skill of MATE_ARTIFACT_SKILLS) {
109
+ for (const skill of OPENSPEC_SKILLS) {
125
110
  try {
126
111
  await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
127
112
  } catch {
128
113
  /* not present */
129
114
  }
130
115
  }
116
+ await teardownMateSkills(skillsDir, companionPath);
131
117
  await pruneEmptyAncestors(skillsDir, companionPath);
132
118
  }
133
119
 
@@ -135,42 +121,13 @@ async function teardownToolRuntime(companionPath: string, tool: OpenSpecTool): P
135
121
  await teardownOpenspecSkills(getSkillsDir(companionPath, tool), companionPath);
136
122
  }
137
123
 
138
- const MATE_COMMAND_PLACEHOLDER = "{{MATE_COMMAND}}";
139
-
140
- // Like mergeDir, but renders the invocation-name placeholder at deploy time.
141
- // Files without the placeholder are copied byte-identical.
142
- export async function deployMateSkillDir(src: string, dest: string): Promise<void> {
143
- await fs.mkdir(dest, { recursive: true });
144
- const entries = await fs.readdir(src, { withFileTypes: true });
145
- for (const entry of entries) {
146
- const srcPath = path.join(src, entry.name);
147
- const destPath = path.join(dest, entry.name);
148
- if (entry.isDirectory()) {
149
- await deployMateSkillDir(srcPath, destPath);
150
- continue;
151
- }
152
- const content = await fs.readFile(srcPath, "utf8");
153
- if (content.includes(MATE_COMMAND_PLACEHOLDER)) {
154
- await fs.writeFile(
155
- destPath,
156
- content.replaceAll(MATE_COMMAND_PLACEHOLDER, frameworkCommandName()),
157
- "utf8",
158
- );
159
- } else {
160
- await fs.copyFile(srcPath, destPath);
161
- }
162
- }
163
- }
164
-
165
124
  async function applyMateOpenspecSkills(
166
125
  companionPath: string,
167
126
  tools: OpenSpecTool[],
168
127
  ): Promise<void> {
169
128
  for (const tool of tools) {
170
129
  const skillsDir = getSkillsDir(companionPath, tool);
171
- for (const skill of MATE_ARTIFACT_SKILLS) {
172
- await deployMateSkillDir(path.join(MATE_SKILLS_SOURCE, skill), path.join(skillsDir, skill));
173
- }
130
+ await applyMateSkills(skillsDir);
174
131
  }
175
132
  }
176
133
 
@@ -194,7 +151,7 @@ async function reconcileOpenSpecTools(
194
151
  await applyMateOpenspecSkills(ctx.companionPath, tools);
195
152
  } else {
196
153
  for (const tool of tools) {
197
- await teardownMateOpenspecSkills(getSkillsDir(ctx.companionPath, tool), ctx.companionPath);
154
+ await teardownMateSkills(getSkillsDir(ctx.companionPath, tool), ctx.companionPath);
198
155
  }
199
156
  }
200
157
  }
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { frameworkCommandName } from "../../framework";
5
+ import { pruneEmptyAncestors } from "./utils";
6
+
7
+ export const MATE_ARTIFACT_SKILLS = ["mate-artifact-finish"] as const;
8
+ export const MATE_SKILLS = ["mate-artifact-finish", "mate-create-report"] as const;
9
+
10
+ const MATE_COMMAND_PLACEHOLDER = "{{MATE_COMMAND}}";
11
+ const MATE_SKILLS_SOURCE = path.join(
12
+ import.meta.dirname,
13
+ "../../templates/capabilities/openspec-cap/mate-skills",
14
+ );
15
+
16
+ const MATE_CREATE_REPORT_SKILL = `---
17
+ name: mate-create-report
18
+ description: Create a browser-first Mate report from an explicit structured ReportDocument. Use when a skill needs to present supplied metrics, tables, statuses, metadata, or narrative text.
19
+ allowed-tools: Bash({{MATE_COMMAND}}:*)
20
+ license: MIT
21
+ compatibility: Requires the {{MATE_COMMAND}} CLI and the OpenSpec capability enabled.
22
+ metadata:
23
+ author: mate
24
+ version: "1.0"
25
+ ---
26
+
27
+ Create a report only from structured data supplied by the calling skill. Do not infer missing facts from conversation state.
28
+
29
+ ## Contract
30
+
31
+ Write a JSON document with this shape:
32
+
33
+ \`\`\`json
34
+ {
35
+ "version": 1,
36
+ "title": "Report title",
37
+ "generatedAt": "2026-01-01T00:00:00Z",
38
+ "period": "Optional reporting period",
39
+ "context": "Optional context",
40
+ "metadata": [{ "label": "Owner", "value": "acme" }],
41
+ "summary": [{ "label": "Requests", "value": 42, "detail": "Optional detail" }],
42
+ "sections": [
43
+ { "id": "notes", "title": "Notes", "type": "text", "content": "Narrative text." },
44
+ {
45
+ "id": "results",
46
+ "title": "Results",
47
+ "type": "table",
48
+ "columns": ["Name", "Value"],
49
+ "rows": [["Example", 1]]
50
+ }
51
+ ]
52
+ }
53
+ \`\`\`
54
+
55
+ Supported section types are \`metadata\`, \`metrics\`, \`key-value\`, \`table\`, \`statuses\`, and \`text\`. Use only strings, numbers, booleans, and null values. Section IDs must be unique. Validate required fields before invoking the CLI.
56
+
57
+ ## Invocation
58
+
59
+ 1. Serialize the complete document to a temporary file or pipe it to stdin.
60
+ 2. Run \`{{MATE_COMMAND}} report --input <file-or->\`.
61
+ 3. Add \`--json\` when the caller needs normalized JSON instead of browser delivery.
62
+
63
+ The default path writes self-contained HTML to a unique OS temporary directory and opens it in the default browser. The report includes a visible "Print / Save as PDF" control that calls the browser's native print dialog. If HTML delivery fails, the CLI warns on stderr and emits the complete report document as JSON on stdout.
64
+
65
+ The built-in \`{{MATE_COMMAND}} report\` path collects Mate usage data and adapts it to the same contract and renderer.
66
+ `;
67
+
68
+ function currentMateCommand(): string {
69
+ try {
70
+ return frameworkCommandName();
71
+ } catch {
72
+ return "mate";
73
+ }
74
+ }
75
+
76
+ export async function deployMateSkillDir(src: string, dest: string): Promise<void> {
77
+ await fs.mkdir(dest, { recursive: true });
78
+ const entries = await fs.readdir(src, { withFileTypes: true });
79
+ for (const entry of entries) {
80
+ const srcPath = path.join(src, entry.name);
81
+ const destPath = path.join(dest, entry.name);
82
+ if (entry.isDirectory()) {
83
+ await deployMateSkillDir(srcPath, destPath);
84
+ continue;
85
+ }
86
+ const content = await fs.readFile(srcPath, "utf8");
87
+ if (content.includes(MATE_COMMAND_PLACEHOLDER)) {
88
+ await fs.writeFile(
89
+ destPath,
90
+ content.replaceAll(MATE_COMMAND_PLACEHOLDER, currentMateCommand()),
91
+ "utf8",
92
+ );
93
+ } else {
94
+ await fs.copyFile(srcPath, destPath);
95
+ }
96
+ }
97
+ }
98
+
99
+ export async function applyMateSkills(skillsDir: string): Promise<void> {
100
+ for (const skill of MATE_SKILLS) {
101
+ const destination = path.join(skillsDir, skill);
102
+ if (skill === "mate-create-report") {
103
+ await fs.mkdir(destination, { recursive: true });
104
+ await fs.writeFile(
105
+ path.join(destination, "SKILL.md"),
106
+ MATE_CREATE_REPORT_SKILL.replaceAll(MATE_COMMAND_PLACEHOLDER, currentMateCommand()),
107
+ "utf8",
108
+ );
109
+ } else {
110
+ await deployMateSkillDir(path.join(MATE_SKILLS_SOURCE, skill), destination);
111
+ }
112
+ }
113
+ }
114
+
115
+ export async function teardownMateSkills(skillsDir: string, companionPath: string): Promise<void> {
116
+ for (const skill of MATE_SKILLS) {
117
+ try {
118
+ await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
119
+ } catch {
120
+ /* not present */
121
+ }
122
+ }
123
+ await pruneEmptyAncestors(skillsDir, companionPath);
124
+ }
@@ -64,6 +64,7 @@ export function collectManagedGitignoreEntries(ctx: SetupContext, plugins: Plugi
64
64
  const entries = [
65
65
  "node_modules/",
66
66
  `.${FRAMEWORK_NAME}/dependencies/`,
67
+ ".mcp.json*",
67
68
  ...plugins
68
69
  .filter((p) => p.kind !== "root" && (p.isEnabled(ctx.config) || p.persistGitignoreEntries))
69
70
  .flatMap((p) => p.gitignoreEntries?.(ctx) ?? []),