@cratis/pi 7.93.0 → 7.95.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.
- package/README.md +14 -0
- package/package/corpus/harnesses/copilot/instructions/discoverability.instructions.md +34 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/CorpusServer.ts +16 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/ServerBridge.ts +113 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/StdioConnection.ts +29 -20
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/capabilities.json +1 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/configuration.ts +26 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/index.ts +14 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/process.ts +12 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/serverProtocol.ts +92 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/servers.ts +76 -0
- package/package/corpus/hooks/scripts/cratis-patterns.json +24 -0
- package/package/corpus/prompts/pre-publish-check.prompt.md +26 -0
- package/package/corpus/rules/discoverability.md +33 -0
- package/package/corpus/rules/general.md +1 -0
- package/package/corpus/skills/cratis-discoverability/LICENSE +2 -0
- package/package/corpus/skills/cratis-discoverability/SKILL.md +73 -0
- package/package/corpus/skills/cratis-discoverability/references/entities.md +68 -0
- package/package/corpus/skills/cratis-discoverability/references/package-metadata.md +38 -0
- package/package/corpus/skills/cratis-discoverability/references/page-checklist.md +88 -0
- package/package/corpus/skills/cratis-discoverability/references/structured-data.md +62 -0
- package/package/corpus/skills/cratis-discoverability/scripts/verify-discoverability.mjs +107 -0
- package/package/corpus/skills/cratis-discoverability/verification.json +13 -0
- package/package/profile-catalog.json +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,20 @@ yields to the managed installation to avoid duplicate resources and hooks. The P
|
|
|
33
33
|
package manages only Pi; it neither configures other harnesses nor owns a local
|
|
34
34
|
managed corpus.
|
|
35
35
|
|
|
36
|
+
## MCP servers from the corpus
|
|
37
|
+
|
|
38
|
+
The packaged `cratis-mcp` extension starts every stdio server declared in
|
|
39
|
+
`mcp-servers.json` whose `profiles` intersect the profiles listed explicitly in
|
|
40
|
+
`.cratis/ai.json` (a profile reached only through another profile's `composes` does
|
|
41
|
+
not start a server; Screenplay keeps its composed selection) (for
|
|
42
|
+
example `codescene` for `cratis/codescene`, `playwright` for `cratis/playwright`).
|
|
43
|
+
Each server gets its own connection, so one failing to start never stops the others,
|
|
44
|
+
and its tools are namespaced by server id (`playwright_browser_navigate`). The
|
|
45
|
+
`command`, `args` and optional literal `env` are used verbatim, without a shell. A
|
|
46
|
+
malformed selected server is skipped with one notice; set
|
|
47
|
+
`mcpServers.<id>.enabled` to `false` in `.cratis/ai.json` to opt a server out.
|
|
48
|
+
`capabilities.json` beside the extension declares this behavior to the Cratis CLI.
|
|
49
|
+
|
|
36
50
|
## Screenplay MCP tools
|
|
37
51
|
|
|
38
52
|
The packaged `cratis-mcp` extension starts the Screenplay MCP server
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/Documentation/**/*.md,**/Documentation/**/*.mdx,**/src/pages/**/*.astro,**/src/pages/**/*.md,**/src/pages/**/*.mdx,**/src/pages/**/*.html,**/src/content/**/*.md,**/src/content/**/*.mdx"
|
|
3
|
+
paths:
|
|
4
|
+
- "**/Documentation/**/*.md"
|
|
5
|
+
- "**/Documentation/**/*.mdx"
|
|
6
|
+
- "**/src/pages/**/*.astro"
|
|
7
|
+
- "**/src/pages/**/*.md"
|
|
8
|
+
- "**/src/pages/**/*.mdx"
|
|
9
|
+
- "**/src/pages/**/*.html"
|
|
10
|
+
- "**/src/content/**/*.md"
|
|
11
|
+
- "**/src/content/**/*.mdx"
|
|
12
|
+
---
|
|
13
|
+
<!-- cratis-ai: generated Copilot instructions. Do not edit; rules/ is the source. -->
|
|
14
|
+
|
|
15
|
+
# Writing content that people and assistants can find
|
|
16
|
+
|
|
17
|
+
Readability comes first. If a change for search engines or AI assistants makes a page harder to read, drop the change. Voice, structure and examples stay with [writing-cratis-docs.md](./writing-cratis-docs.instructions.md), [editing-cratis-docs.md](./editing-cratis-docs.instructions.md) and [documentation-structure-and-formatting.md](./documentation-structure-and-formatting.instructions.md); this rule adds only what they do not cover. Before publishing a page, post or package, use **cratis-discoverability**.
|
|
18
|
+
|
|
19
|
+
## Every page, post and README
|
|
20
|
+
|
|
21
|
+
- **Define first, where a definition belongs.** Landing pages, product index pages, glossary entries, READMEs and package descriptions open with one self-contained sentence: "X is a <category> for <audience> that <does>."
|
|
22
|
+
- **Docs tutorials, getting-started pages and explanations follow [writing-cratis-docs.md](./writing-cratis-docs.instructions.md)**: scenario first, pain before relief. Their answer-first, self-contained definition goes in the frontmatter `description`, which serves search and AI. Where it reads naturally, one definition sentence may follow the opening scenario. writing-cratis-docs wins on voice and structure.
|
|
23
|
+
- **Title and description are written by hand.** Title with the distinguishing term first: 50-60 characters for blog and landing pages, 20-60 for docs and reference pages; a README H1 that is the product name is fine. Description is one sentence of 60-160 characters, never over 160, and not a copy of the title or of another page's description.
|
|
24
|
+
- **Name things one way.** The first mention is the full name ("Cratis Chronicle"), later mentions may shorten it. Use the canonical names in the **cratis-discoverability** entity reference, and do not call the platform "Cratis.io".
|
|
25
|
+
- **Links name their destination.** Link text says what is behind it. Outside the docs site, link docs with the absolute canonical host `https://www.cratis.io/...`, never the bare `cratis.io`.
|
|
26
|
+
- **Images carry alt text.** Describe the content. Use an empty `alt=""` only for decoration.
|
|
27
|
+
- **No keyword stuffing, no filler for length, no text hidden from readers.** A page is done when it is complete.
|
|
28
|
+
|
|
29
|
+
## Sites and packages
|
|
30
|
+
|
|
31
|
+
- Public pages need a canonical link, one `<h1>`, Open Graph tags, valid JSON-LD that describes visible content, a sitemap entry and a `robots.txt` that allows crawling. Add no new `HowTo` or `FAQPage` markup; keep the visible Q&A.
|
|
32
|
+
- `llms.txt` is an optional index. Never claim it improves ranking. See **cratis-llm-friendly-documentation**.
|
|
33
|
+
- Package metadata (`Description`, `PackageProjectUrl`, `PackageTags`, npm `description`, `homepage`, `keywords`) and the README opener use the same definition sentence, and the project URL points at the docs page `https://www.cratis.io/<product>/`.
|
|
34
|
+
- State only facts the repository supports: versions, licenses, limits. The claim gate in `BRAND.md`, `MESSAGING.md` and `SITE.md` still applies.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/** A validated stdio server from the corpus descriptor; command, args and env are used verbatim. */
|
|
5
|
+
export interface CorpusServer {
|
|
6
|
+
id: string;
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
env: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The servers to start for a project plus the explicit messages for everything skipped. */
|
|
13
|
+
export interface ServerSelection {
|
|
14
|
+
servers: CorpusServer[];
|
|
15
|
+
messages: string[];
|
|
16
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { object } from './configuration.ts';
|
|
6
|
+
import type { CorpusServer } from './CorpusServer.ts';
|
|
7
|
+
import { startServer } from './process.ts';
|
|
8
|
+
import { discoverServer, mapServerResult, type ServerCatalog } from './serverProtocol.ts';
|
|
9
|
+
import { selectedCorpusServers } from './servers.ts';
|
|
10
|
+
import type { StdioConnection } from './StdioConnection.ts';
|
|
11
|
+
|
|
12
|
+
function withDeadline<T>(work: Promise<T>, milliseconds: number, id: string): Promise<T> {
|
|
13
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
14
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
15
|
+
timer = setTimeout(() => reject(new Error(`MCP server '${id}' did not become ready within ${milliseconds} ms.`)), milliseconds);
|
|
16
|
+
timer.unref();
|
|
17
|
+
});
|
|
18
|
+
work.catch(() => { /* The deadline already reported the outcome; the disposed connection rejects the abandoned request. */ });
|
|
19
|
+
return Promise.race([work, expired]).finally(() => clearTimeout(timer));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Bridge every selected corpus server except Screenplay: one connection each, isolated failures, tools namespaced by server id. */
|
|
23
|
+
export interface ServerBridge {
|
|
24
|
+
refresh(context: ExtensionContext, newSession: boolean): Promise<void>;
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createServerBridge(pi: ExtensionAPI, corpus: string, connect: (project: string, server: CorpusServer) => StdioConnection = startServer, deadlineMilliseconds = 15_000): ServerBridge {
|
|
29
|
+
const connections = new Map<string, StdioConnection>();
|
|
30
|
+
const registered = new Set<string>();
|
|
31
|
+
const paused = new Set<string>();
|
|
32
|
+
let selectionKey: string | undefined;
|
|
33
|
+
|
|
34
|
+
const notify = (context: ExtensionContext, messages: string[]): void => {
|
|
35
|
+
if (context.hasUI) messages.forEach(message => context.ui.notify(message, 'warning'));
|
|
36
|
+
};
|
|
37
|
+
const deactivate = (): void => {
|
|
38
|
+
const active = pi.getActiveTools();
|
|
39
|
+
active.filter(name => registered.has(name)).forEach(name => paused.add(name));
|
|
40
|
+
if (active.some(name => registered.has(name))) pi.setActiveTools(active.filter(name => !registered.has(name)));
|
|
41
|
+
};
|
|
42
|
+
const dispose = (): void => {
|
|
43
|
+
connections.forEach(connection => connection.dispose());
|
|
44
|
+
connections.clear();
|
|
45
|
+
deactivate();
|
|
46
|
+
};
|
|
47
|
+
interface Started { server: CorpusServer; connection: StdioConnection; catalog?: ServerCatalog; messages: string[] }
|
|
48
|
+
/** Connect and discover only; registration happens afterwards in selection order so name claims are deterministic. */
|
|
49
|
+
const start = async (context: ExtensionContext, server: CorpusServer): Promise<Started> => {
|
|
50
|
+
const connection = connect(context.cwd, server);
|
|
51
|
+
connections.set(server.id, connection);
|
|
52
|
+
try {
|
|
53
|
+
const catalog = await withDeadline(discoverServer(connection, server.id), deadlineMilliseconds, server.id);
|
|
54
|
+
if (connections.get(server.id) !== connection || connection.stopped) throw new Error(`MCP server '${server.id}' session ended during discovery.`);
|
|
55
|
+
return { server, connection, catalog, messages: [...catalog.messages] };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
connection.dispose();
|
|
58
|
+
if (connections.get(server.id) === connection) connections.delete(server.id);
|
|
59
|
+
return { server, connection, messages: [`MCP server '${server.id}' is unavailable: ${error instanceof Error ? error.message : String(error)}`] };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const register = ({ server, connection, catalog, messages }: Started, claimed: Set<string>): void => {
|
|
63
|
+
const existing = new Set(pi.getAllTools().map(tool => tool.name));
|
|
64
|
+
for (const tool of catalog?.tools ?? []) {
|
|
65
|
+
if (claimed.has(tool.nativeName) || (existing.has(tool.nativeName) && !registered.has(tool.nativeName))) {
|
|
66
|
+
messages.push(`MCP server '${server.id}' tool '${tool.name}' was skipped: '${tool.nativeName}' conflicts with another tool.`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
claimed.add(tool.nativeName);
|
|
70
|
+
pi.registerTool({
|
|
71
|
+
name: tool.nativeName,
|
|
72
|
+
label: `${server.id} ${tool.name}`,
|
|
73
|
+
description: tool.description,
|
|
74
|
+
parameters: tool.parameters,
|
|
75
|
+
executionMode: 'sequential',
|
|
76
|
+
async execute(_toolCallId, parameters, signal) {
|
|
77
|
+
if (connections.get(server.id) !== connection || connection.stopped || !pi.getActiveTools().includes(tool.nativeName)) {
|
|
78
|
+
throw new Error(`MCP server '${server.id}' is unavailable in this session.`);
|
|
79
|
+
}
|
|
80
|
+
if (!object(parameters)) throw new Error('MCP arguments must be an object.');
|
|
81
|
+
const result = mapServerResult(await connection.request('tools/call', { name: tool.name, arguments: parameters }, signal), server.id);
|
|
82
|
+
if (result.details.isError) throw new Error(result.content.map(part => part.type === 'text' ? part.text : '').join('\n') || `MCP server '${server.id}' reported a failed tool call.`);
|
|
83
|
+
return result;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const refresh = async (context: ExtensionContext, newSession: boolean): Promise<void> => {
|
|
89
|
+
let selection: ReturnType<typeof selectedCorpusServers>;
|
|
90
|
+
try { selection = selectedCorpusServers(context.cwd, corpus); }
|
|
91
|
+
catch (error) { dispose(); selectionKey = undefined; throw error; }
|
|
92
|
+
const key = JSON.stringify(selection);
|
|
93
|
+
if (!newSession && key === selectionKey) return;
|
|
94
|
+
const previouslyActive = new Set([...pi.getActiveTools(), ...paused]);
|
|
95
|
+
const oldNames = new Set(registered);
|
|
96
|
+
dispose();
|
|
97
|
+
selectionKey = key;
|
|
98
|
+
const messages = [...selection.messages];
|
|
99
|
+
const claimed = new Set<string>();
|
|
100
|
+
// Servers start in parallel; a slow or hung one is abandoned at its own deadline and never delays the others.
|
|
101
|
+
const outcomes = await Promise.all(selection.servers.map(server => start(context, server)));
|
|
102
|
+
for (const outcome of outcomes) { register(outcome, claimed); messages.push(...outcome.messages); }
|
|
103
|
+
claimed.forEach(name => registered.add(name));
|
|
104
|
+
// Never re-enable a user-disabled tool. SDK allow/exclude restrictions still filter registration.
|
|
105
|
+
const next = pi.getActiveTools().filter(name => !registered.has(name) || (claimed.has(name) && (!oldNames.has(name) || previouslyActive.has(name))));
|
|
106
|
+
for (const name of claimed) if (oldNames.has(name) && previouslyActive.has(name)) next.push(name);
|
|
107
|
+
pi.setActiveTools([...new Set(next)]);
|
|
108
|
+
paused.clear();
|
|
109
|
+
notify(context, messages);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return { refresh, dispose: () => { dispose(); selectionKey = undefined; } };
|
|
113
|
+
}
|
|
@@ -15,7 +15,7 @@ export class StdioConnection {
|
|
|
15
15
|
private _termination?: ReturnType<typeof setTimeout>;
|
|
16
16
|
private readonly _maximumBytes = 8 * 1024 * 1024;
|
|
17
17
|
|
|
18
|
-
constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000) {
|
|
18
|
+
constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000, private readonly _label = 'Screenplay', private readonly _tolerant = false) {
|
|
19
19
|
_child.stdout.on('data', this.receive);
|
|
20
20
|
_child.stdout.on('end', this.ended);
|
|
21
21
|
_child.stdout.on('error', this.streamFailed);
|
|
@@ -32,22 +32,22 @@ export class StdioConnection {
|
|
|
32
32
|
|
|
33
33
|
async request(method: string, params: Record<string, unknown>, signal?: AbortSignal, mutation = false): Promise<unknown> {
|
|
34
34
|
if (this._failure) throw this._failure;
|
|
35
|
-
if (signal?.aborted) throw new ConnectionFailure(
|
|
36
|
-
if (this._pending) throw new ConnectionFailure(
|
|
35
|
+
if (signal?.aborted) throw new ConnectionFailure(`${this._label} request cancelled before sending.`);
|
|
36
|
+
if (this._pending) throw new ConnectionFailure(`${this._label} is busy; parallel requests are not queued.`);
|
|
37
37
|
const id = ++this._sequence;
|
|
38
38
|
const line = this.encode({ jsonrpc: '2.0', id, method, params });
|
|
39
39
|
return new Promise((resolve, reject) => {
|
|
40
|
-
const abort = () => this.stop(
|
|
41
|
-
const timer = setTimeout(() => this.stop(
|
|
40
|
+
const abort = () => this.stop(`${this._label} request cancelled after sending.`);
|
|
41
|
+
const timer = setTimeout(() => this.stop(`${this._label} request timed out.`), this._timeout);
|
|
42
42
|
this._pending = {
|
|
43
43
|
id, mutation, resolve, reject,
|
|
44
44
|
cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); },
|
|
45
45
|
};
|
|
46
46
|
signal?.addEventListener('abort', abort, { once: true });
|
|
47
47
|
try {
|
|
48
|
-
this._child.stdin.write(line, error => { if (error) this.stop(
|
|
48
|
+
this._child.stdin.write(line, error => { if (error) this.stop(`${this._label} input pipe failed.`); });
|
|
49
49
|
} catch {
|
|
50
|
-
this.stop(
|
|
50
|
+
this.stop(`${this._label} input pipe failed.`);
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
}
|
|
@@ -55,18 +55,18 @@ export class StdioConnection {
|
|
|
55
55
|
notify(method: string): void {
|
|
56
56
|
if (this._failure) throw this._failure;
|
|
57
57
|
try {
|
|
58
|
-
this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop(
|
|
58
|
+
this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop(`${this._label} notification failed.`); });
|
|
59
59
|
} catch {
|
|
60
|
-
this.stop(
|
|
60
|
+
this.stop(`${this._label} notification failed.`);
|
|
61
61
|
throw this._failure;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
dispose(): void { this.stop(
|
|
65
|
+
dispose(): void { this.stop(`${this._label} session closed.`); }
|
|
66
66
|
|
|
67
67
|
private encode(value: unknown): string {
|
|
68
68
|
const line = `${JSON.stringify(value)}\n`;
|
|
69
|
-
if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure(
|
|
69
|
+
if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure(`${this._label} request exceeds the 8 MiB transport limit.`);
|
|
70
70
|
return line;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -82,7 +82,7 @@ export class StdioConnection {
|
|
|
82
82
|
this._buffer = Buffer.alloc(0);
|
|
83
83
|
this.accept(line);
|
|
84
84
|
} catch {
|
|
85
|
-
this.stop(
|
|
85
|
+
this.stop(`${this._label} returned invalid UTF-8 protocol data.`);
|
|
86
86
|
}
|
|
87
87
|
if (this.stopped) return;
|
|
88
88
|
start = index + 1;
|
|
@@ -92,7 +92,7 @@ export class StdioConnection {
|
|
|
92
92
|
|
|
93
93
|
private append(chunk: Buffer): boolean {
|
|
94
94
|
if (this._buffer.length + chunk.length > this._maximumBytes) {
|
|
95
|
-
this.stop(
|
|
95
|
+
this.stop(`${this._label} response exceeds the 8 MiB transport limit.`);
|
|
96
96
|
return false;
|
|
97
97
|
}
|
|
98
98
|
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
@@ -103,6 +103,13 @@ export class StdioConnection {
|
|
|
103
103
|
try {
|
|
104
104
|
const message: unknown = JSON.parse(line);
|
|
105
105
|
if (!object(message) || message.jsonrpc !== '2.0') throw new Error();
|
|
106
|
+
if ('method' in message && this._tolerant) {
|
|
107
|
+
// Third-party servers may notify or ask; this client offers no capabilities, so refuse requests and ignore notifications.
|
|
108
|
+
if ('id' in message && typeof message.method === 'string') {
|
|
109
|
+
this._child.stdin.write(this.encode({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
106
113
|
if ('method' in message) {
|
|
107
114
|
// This pinned server does not send requests or changing tool lists.
|
|
108
115
|
throw new Error();
|
|
@@ -113,14 +120,14 @@ export class StdioConnection {
|
|
|
113
120
|
this._pending = undefined;
|
|
114
121
|
pending.cleanup();
|
|
115
122
|
if (object(message.error)) {
|
|
116
|
-
pending.reject(new ConnectionFailure(
|
|
123
|
+
pending.reject(new ConnectionFailure(`${this._label} protocol error ${message.error.code}: ${message.error.message}`, pending.mutation));
|
|
117
124
|
// A server failure during mutation is not evidence that nothing was written.
|
|
118
|
-
if (pending.mutation) this.stop(
|
|
125
|
+
if (pending.mutation) this.stop(`${this._label} mutation failed with a protocol error.`, true);
|
|
119
126
|
} else {
|
|
120
127
|
pending.resolve(message.result);
|
|
121
128
|
}
|
|
122
129
|
} catch {
|
|
123
|
-
this.stop(
|
|
130
|
+
this.stop(`${this._label} returned malformed or unexpected JSON-RPC.`);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -145,11 +152,13 @@ export class StdioConnection {
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
private readonly drain = (): void => { /* Drain server stderr without exposing it as a protocol result. */ };
|
|
148
|
-
private readonly ended = (): void => this.stop(
|
|
149
|
-
private readonly streamFailed = (): void => this.stop(
|
|
150
|
-
private readonly spawnFailed = (): void => this.stop(
|
|
155
|
+
private readonly ended = (): void => this.stop(`${this._label} output closed before session shutdown.`);
|
|
156
|
+
private readonly streamFailed = (): void => this.stop(`${this._label} stdio failed.`);
|
|
157
|
+
private readonly spawnFailed = (): void => this.stop(this._label !== 'Screenplay'
|
|
158
|
+
? `Cannot launch the ${this._label} MCP server command. Check that it is installed; this extension never downloads a runtime.`
|
|
159
|
+
: 'Cannot launch native cratis screenplay mcp. Install/update the Cratis CLI through its normal channel; this extension never downloads a runtime.');
|
|
151
160
|
private readonly closed = (): void => {
|
|
152
|
-
this.stop('Screenplay process exited. Check that the installed Cratis CLI includes the embedded Screenplay runtime; no runtime is downloaded by this extension.');
|
|
161
|
+
this.stop(this._label !== 'Screenplay' ? `${this._label} process exited.` : 'Screenplay process exited. Check that the installed Cratis CLI includes the embedded Screenplay runtime; no runtime is downloaded by this extension.');
|
|
153
162
|
clearTimeout(this._termination);
|
|
154
163
|
this._child.stdout.removeListener('error', this.streamFailed);
|
|
155
164
|
this._child.stdin.removeListener('error', this.streamFailed);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "servers": "selected-profiles" }
|
|
@@ -8,7 +8,7 @@ export function object(value: unknown): value is Record<string, unknown> {
|
|
|
8
8
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function readJson(path: string): Record<string, unknown> {
|
|
11
|
+
export function readJson(path: string): Record<string, unknown> {
|
|
12
12
|
if (lstatSync(path).size > 1024 * 1024) throw new Error(`Cratis configuration is too large: ${path}`);
|
|
13
13
|
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
14
14
|
if (!object(value)) throw new Error(`Expected a JSON object: ${path}`);
|
|
@@ -21,6 +21,10 @@ function strings(value: unknown): string[] {
|
|
|
21
21
|
return value;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function supportedSchema(version: unknown): boolean {
|
|
25
|
+
return typeof version === 'string' && /^1\.\d+$/.test(version);
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
/** Resolve through existing directories only, refusing every source-root symlink. Never creates a directory. */
|
|
25
29
|
export function physicalRoot(project: string, root: string): string {
|
|
26
30
|
if (!root || isAbsolute(root) || root.includes('\0')) throw new Error('Screenplay root must be a nonempty project-relative directory.');
|
|
@@ -45,8 +49,16 @@ export function physicalRoot(project: string, root: string): string {
|
|
|
45
49
|
return current;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
export interface ProfileResolution {
|
|
53
|
+
configuration: Record<string, unknown>;
|
|
54
|
+
selected: Set<string>;
|
|
55
|
+
/** Profiles listed directly in `.cratis/ai.json`, before any `composes` expansion. */
|
|
56
|
+
explicit: Set<string>;
|
|
57
|
+
nonApplicationSelection: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve the selected profiles (after `composes` expansion) from the project's `.cratis/ai.json`. */
|
|
61
|
+
export function resolveProfiles(project: string, corpus: string): ProfileResolution | undefined {
|
|
50
62
|
let configuration: Record<string, unknown>;
|
|
51
63
|
try {
|
|
52
64
|
configuration = readJson(join(project, '.cratis', 'ai.json'));
|
|
@@ -86,7 +98,14 @@ export function selectedServer(project: string, corpus: string) {
|
|
|
86
98
|
strings(profile.composes).forEach(child => select(child, false, applicationPath));
|
|
87
99
|
};
|
|
88
100
|
requested.forEach(id => select(id, true));
|
|
89
|
-
|
|
101
|
+
return { configuration, selected, explicit: new Set(requested), nonApplicationSelection };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Select only the shipped Screenplay descriptor, never project-supplied commands or environments. */
|
|
105
|
+
export function selectedServer(project: string, corpus: string) {
|
|
106
|
+
const resolution = resolveProfiles(project, corpus);
|
|
107
|
+
if (!resolution?.selected.has('cratis/screenplay')) return undefined;
|
|
108
|
+
const { configuration, nonApplicationSelection } = resolution;
|
|
90
109
|
const overrides = configuration.mcpServers;
|
|
91
110
|
if (overrides !== undefined && !object(overrides)) throw new Error('Invalid mcpServers configuration.');
|
|
92
111
|
const screenplay = object(overrides) ? overrides.screenplay : undefined;
|
|
@@ -94,7 +113,9 @@ export function selectedServer(project: string, corpus: string) {
|
|
|
94
113
|
if (object(screenplay) && screenplay.enabled === false) return undefined;
|
|
95
114
|
if (object(screenplay) && screenplay.enabled !== undefined && screenplay.enabled !== true) throw new Error('Screenplay MCP enabled must be a boolean.');
|
|
96
115
|
const descriptor = readJson(join(corpus, 'mcp-servers.json'));
|
|
97
|
-
|
|
116
|
+
// Any 1.x descriptor is accepted; another major loads no corpus MCP servers (the generic bridge reports it once).
|
|
117
|
+
if (!supportedSchema(descriptor.schemaVersion)) return undefined;
|
|
118
|
+
if (!Array.isArray(descriptor.servers)) throw new Error('Unsupported Cratis MCP descriptor.');
|
|
98
119
|
const servers = descriptor.servers.filter(server => object(server) && server.id === 'screenplay');
|
|
99
120
|
if (servers.length !== 1 || !object(servers[0])) throw new Error('Expected exactly one Screenplay MCP descriptor.');
|
|
100
121
|
const server = servers[0];
|
|
@@ -7,8 +7,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { ConnectionFailure } from './ConnectionFailure.ts';
|
|
9
9
|
import { object, selectedServer } from './configuration.ts';
|
|
10
|
-
import {
|
|
10
|
+
import type { CorpusServer } from './CorpusServer.ts';
|
|
11
|
+
import { startConnection, startServer, supportsNoModelStartup } from './process.ts';
|
|
11
12
|
import { discover, failedResult, mapResult } from './protocol.ts';
|
|
13
|
+
import { createServerBridge } from './ServerBridge.ts';
|
|
12
14
|
import type { DiscoveredTool } from './DiscoveredTool.ts';
|
|
13
15
|
import type { StdioConnection } from './StdioConnection.ts';
|
|
14
16
|
|
|
@@ -16,7 +18,8 @@ const extensionDirectory = dirname(fileURLToPath(import.meta.url));
|
|
|
16
18
|
const bundledCorpus = resolve(extensionDirectory, '..', '..', '..', '..');
|
|
17
19
|
|
|
18
20
|
/** Native tools go through Pi's tool_call restrictions and normal execution/result pipeline. */
|
|
19
|
-
export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup): void {
|
|
21
|
+
export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup, connectServer: (project: string, server: CorpusServer) => StdioConnection = startServer): void {
|
|
22
|
+
const servers = createServerBridge(pi, corpus, connectServer);
|
|
20
23
|
let connection: StdioConnection | undefined;
|
|
21
24
|
let selectionKey: string | undefined;
|
|
22
25
|
let attempted = false;
|
|
@@ -149,9 +152,15 @@ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = start
|
|
|
149
152
|
}
|
|
150
153
|
};
|
|
151
154
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
// Screenplay and the other corpus servers fail independently; the first failure is surfaced after both ran.
|
|
156
|
+
const refreshAll = async (context: ExtensionContext, newSession: boolean): Promise<void> => {
|
|
157
|
+
const outcomes = await Promise.allSettled([refresh(context, newSession), servers.refresh(context, newSession)]);
|
|
158
|
+
const failed = outcomes.find(outcome => outcome.status === 'rejected');
|
|
159
|
+
if (failed) throw failed.reason;
|
|
160
|
+
};
|
|
161
|
+
pi.on('session_start', (_event, context) => refreshAll(context, true));
|
|
162
|
+
pi.on('before_agent_start', async (_event, context) => { await refreshAll(context, false); });
|
|
163
|
+
pi.on('session_shutdown', () => { dispose(); servers.dispose(); failures.clear(); });
|
|
155
164
|
pi.on('tool_result', event => {
|
|
156
165
|
if (!registered.has(event.toolName)) return;
|
|
157
166
|
const failure = failures.get(event.toolCallId);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
4
|
import { execFile, spawn } from 'node:child_process';
|
|
5
|
+
import type { CorpusServer } from './CorpusServer.ts';
|
|
5
6
|
import { StdioConnection } from './StdioConnection.ts';
|
|
6
7
|
|
|
7
8
|
/** Preserve OS/runtime lookup only; never copy arbitrary credentials or descriptor environment values. */
|
|
@@ -43,3 +44,14 @@ export function startConnection(project: string, launch: typeof spawn = spawn):
|
|
|
43
44
|
windowsHide: true,
|
|
44
45
|
}));
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Launch a corpus server verbatim (no shell, no interpolation): the default environment plus its declared literal env only. */
|
|
49
|
+
export function startServer(project: string, server: CorpusServer, launch: typeof spawn = spawn): StdioConnection {
|
|
50
|
+
return new StdioConnection(launch(server.command, server.args, {
|
|
51
|
+
cwd: project,
|
|
52
|
+
env: { ...childEnvironment(), ...server.env },
|
|
53
|
+
shell: false,
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
}), 30_000, server.id, true);
|
|
57
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { TSchema } from 'typebox';
|
|
5
|
+
import { object } from './configuration.ts';
|
|
6
|
+
import type { StdioConnection } from './StdioConnection.ts';
|
|
7
|
+
|
|
8
|
+
/** A tool of a corpus server other than Screenplay, exposed under a name namespaced by the server id. */
|
|
9
|
+
export interface ServerTool {
|
|
10
|
+
name: string;
|
|
11
|
+
nativeName: string;
|
|
12
|
+
description: string;
|
|
13
|
+
parameters: TSchema;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ServerCatalog {
|
|
17
|
+
tools: ServerTool[];
|
|
18
|
+
messages: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const maximumTools = 128;
|
|
22
|
+
const maximumPages = 8;
|
|
23
|
+
const maximumNativeNameLength = 64;
|
|
24
|
+
const maximumDescriptionLength = 4 * 1024;
|
|
25
|
+
const maximumSchemaLength = 64 * 1024;
|
|
26
|
+
const maximumImageLength = 2 * 1024 * 1024;
|
|
27
|
+
const imageTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
28
|
+
|
|
29
|
+
/** Initialize and list tools; tools that cannot be exposed safely are skipped with a message rather than failing the server. */
|
|
30
|
+
export async function discoverServer(connection: StdioConnection, id: string): Promise<ServerCatalog> {
|
|
31
|
+
const initialized = await connection.request('initialize', {
|
|
32
|
+
protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'cratis.pi', version: '1.0.0' },
|
|
33
|
+
});
|
|
34
|
+
if (!object(initialized) || !object(initialized.capabilities) || !object(initialized.capabilities.tools)) {
|
|
35
|
+
throw new Error(`MCP server '${id}' did not advertise tool support.`);
|
|
36
|
+
}
|
|
37
|
+
connection.notify('notifications/initialized');
|
|
38
|
+
const listed: unknown[] = [];
|
|
39
|
+
let cursor: unknown;
|
|
40
|
+
for (let page = 0; page < maximumPages; page++) {
|
|
41
|
+
const response = await connection.request('tools/list', cursor === undefined ? {} : { cursor });
|
|
42
|
+
if (!object(response) || !Array.isArray(response.tools)) throw new Error(`MCP server '${id}' returned an invalid tool list.`);
|
|
43
|
+
listed.push(...response.tools);
|
|
44
|
+
if (listed.length > maximumTools) throw new Error(`MCP server '${id}' exposes more than ${maximumTools} tools.`);
|
|
45
|
+
cursor = response.nextCursor;
|
|
46
|
+
if (cursor === undefined) break;
|
|
47
|
+
if (typeof cursor !== 'string') throw new Error(`MCP server '${id}' returned an invalid pagination cursor.`);
|
|
48
|
+
}
|
|
49
|
+
const catalog: ServerCatalog = { tools: [], messages: [] };
|
|
50
|
+
const names = new Set<string>();
|
|
51
|
+
for (const tool of listed) {
|
|
52
|
+
const name = object(tool) ? tool.name : undefined;
|
|
53
|
+
if (!object(tool) || typeof name !== 'string' || !name || !object(tool.inputSchema) || tool.inputSchema.type !== 'object' ||
|
|
54
|
+
(tool.description !== undefined && typeof tool.description !== 'string')) {
|
|
55
|
+
catalog.messages.push(`MCP server '${id}' has a tool with an invalid name or input schema; it was skipped.`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (JSON.stringify(tool.inputSchema).length > maximumSchemaLength || ((tool.description as string | undefined)?.length ?? 0) > maximumDescriptionLength) {
|
|
59
|
+
catalog.messages.push(`MCP server '${id}' tool '${name}' was skipped: its description or input schema is too large.`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const nativeName = `${id}_${name.replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
63
|
+
if (nativeName.length > maximumNativeNameLength || names.has(nativeName)) {
|
|
64
|
+
catalog.messages.push(`MCP server '${id}' tool '${name}' was skipped: its namespaced name is too long or not unique.`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
names.add(nativeName);
|
|
68
|
+
catalog.tools.push({ name, nativeName, description: (tool.description as string | undefined) ?? '', parameters: tool.inputSchema as TSchema });
|
|
69
|
+
}
|
|
70
|
+
if (!catalog.tools.length) throw new Error(`MCP server '${id}' exposes no usable tools.`);
|
|
71
|
+
return catalog;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Map an MCP result to Pi content. Server-reported errors throw so Pi marks the call failed. */
|
|
75
|
+
export function mapServerResult(result: unknown, id: string) {
|
|
76
|
+
if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== 'boolean')) {
|
|
77
|
+
throw new Error(`Malformed MCP result from '${id}'.`);
|
|
78
|
+
}
|
|
79
|
+
const content = result.content.map((part: unknown) => {
|
|
80
|
+
if (object(part) && part.type === 'text' && typeof part.text === 'string') return { type: 'text' as const, text: part.text };
|
|
81
|
+
if (object(part) && part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string') {
|
|
82
|
+
if (!imageTypes.has(part.mimeType) || part.data.length > maximumImageLength) return { type: 'text' as const, text: `[image omitted: unsupported type or larger than ${maximumImageLength / 1024 / 1024} MiB]` };
|
|
83
|
+
return { type: 'image' as const, data: part.data, mimeType: part.mimeType };
|
|
84
|
+
}
|
|
85
|
+
return { type: 'text' as const, text: `[unsupported ${object(part) ? String(part.type) : 'unknown'} content omitted]` };
|
|
86
|
+
});
|
|
87
|
+
const maximumCharacters = 32 * 1024;
|
|
88
|
+
const bounded = content.map(part => part.type === 'text' && part.text.length > maximumCharacters
|
|
89
|
+
? { ...part, text: `${part.text.slice(0, maximumCharacters)}\n[display truncated]` }
|
|
90
|
+
: part);
|
|
91
|
+
return { content: bounded, details: { isError: result.isError === true, structuredContent: result.structuredContent } };
|
|
92
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { object, readJson, resolveProfiles, supportedSchema } from './configuration.ts';
|
|
6
|
+
import type { CorpusServer, ServerSelection } from './CorpusServer.ts';
|
|
7
|
+
|
|
8
|
+
const validId = /^[A-Za-z0-9_-]+$/;
|
|
9
|
+
|
|
10
|
+
function isSelected(entry: Record<string, unknown>, selected: Set<string>): boolean {
|
|
11
|
+
return Array.isArray(entry.profiles) && entry.profiles.some(profile => typeof profile === 'string' && selected.has(profile));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function problem(entry: Record<string, unknown>): string | undefined {
|
|
15
|
+
if (typeof entry.id !== 'string' || !validId.test(entry.id)) return 'the id must match ^[A-Za-z0-9_-]+$';
|
|
16
|
+
if (entry.transport !== 'stdio') return 'only the stdio transport is supported';
|
|
17
|
+
if (typeof entry.command !== 'string' || !entry.command) return 'the command is missing or empty';
|
|
18
|
+
if (!Array.isArray(entry.args) || !entry.args.every(argument => typeof argument === 'string')) return 'args must be an array of strings';
|
|
19
|
+
if (entry.env !== undefined && (!object(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
|
|
20
|
+
return 'env must be an object of literal string values';
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `mcpServers.<id>.enabled`: false opts out, a non-boolean is an error for that server only. */
|
|
26
|
+
function optOut(overrides: Record<string, unknown>, id: string): 'enabled' | 'disabled' | string {
|
|
27
|
+
const override = overrides[id];
|
|
28
|
+
if (override === undefined) return 'enabled';
|
|
29
|
+
if (!object(override)) return 'the mcpServers configuration must be an object';
|
|
30
|
+
if (override.enabled === undefined || override.enabled === true) return 'enabled';
|
|
31
|
+
return override.enabled === false ? 'disabled' : 'mcpServers enabled must be a boolean';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Select every stdio server whose profiles intersect the explicitly listed profiles (never composed ones), except Screenplay which keeps its own path. */
|
|
35
|
+
export function selectedCorpusServers(project: string, corpus: string): ServerSelection {
|
|
36
|
+
const selection: ServerSelection = { servers: [], messages: [] };
|
|
37
|
+
const resolution = resolveProfiles(project, corpus);
|
|
38
|
+
if (!resolution) return selection;
|
|
39
|
+
const descriptor = readJson(join(corpus, 'mcp-servers.json'));
|
|
40
|
+
if (!supportedSchema(descriptor.schemaVersion)) {
|
|
41
|
+
selection.messages.push(`Cratis MCP descriptor schemaVersion '${String(descriptor.schemaVersion)}' is not supported; no corpus MCP servers were loaded.`);
|
|
42
|
+
return selection;
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(descriptor.servers)) {
|
|
45
|
+
selection.messages.push('Cratis MCP descriptor has no servers list; no corpus MCP servers were loaded.');
|
|
46
|
+
return selection;
|
|
47
|
+
}
|
|
48
|
+
const overrides = resolution.configuration.mcpServers;
|
|
49
|
+
if (overrides !== undefined && !object(overrides)) {
|
|
50
|
+
selection.messages.push('Invalid mcpServers configuration; no corpus MCP servers were loaded.');
|
|
51
|
+
return selection;
|
|
52
|
+
}
|
|
53
|
+
const entries = descriptor.servers.filter(object);
|
|
54
|
+
const counts = new Map<unknown, number>();
|
|
55
|
+
entries.forEach(entry => counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1));
|
|
56
|
+
const reported = new Set<unknown>();
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.id === 'screenplay') continue;
|
|
59
|
+
// An entry without profiles can never be selected, so it is ignored like any other unselected entry.
|
|
60
|
+
if (!isSelected(entry, resolution.explicit)) continue;
|
|
61
|
+
const label = `MCP server '${String(entry.id)}'`;
|
|
62
|
+
if (counts.get(entry.id)! > 1) {
|
|
63
|
+
if (!reported.has(entry.id)) selection.messages.push(`${label} was skipped: the descriptor declares the id more than once.`);
|
|
64
|
+
reported.add(entry.id);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const invalid = problem(entry);
|
|
68
|
+
if (invalid) { selection.messages.push(`${label} was skipped: ${invalid}.`); continue; }
|
|
69
|
+
const id = entry.id as string;
|
|
70
|
+
const state = object(overrides) ? optOut(overrides, id) : 'enabled';
|
|
71
|
+
if (state === 'disabled') continue;
|
|
72
|
+
if (state !== 'enabled') { selection.messages.push(`${label} was skipped: ${state}.`); continue; }
|
|
73
|
+
selection.servers.push({ id, command: entry.command as string, args: entry.args as string[], env: { ...(entry.env as Record<string, string> | undefined) } });
|
|
74
|
+
}
|
|
75
|
+
return selection;
|
|
76
|
+
}
|
|
@@ -87,6 +87,30 @@
|
|
|
87
87
|
"paths": ["**/*.ts", "**/*.tsx"],
|
|
88
88
|
"exclude_paths": ["**/node_modules/**", "**/dist/**", "**/wwwroot/**", "**/obj/**", "**/bin/**"],
|
|
89
89
|
"message": "Cratis Components 4 has no PrimeReact underneath — a `primereact/dialog` import is a leftover from Components 3. Use `CommandDialog` from `@cratis/components/CommandDialog` or `Dialog` from `@cratis/components/Dialogs`."
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "cratis-apex-docs-link",
|
|
93
|
+
"rule": "discoverability.md — canonical links",
|
|
94
|
+
"pattern": "https?://cratis\\.io([/)\"' <]|$)",
|
|
95
|
+
"paths": ["**/*.md", "**/*.mdx", "**/*.html", "**/*.astro"],
|
|
96
|
+
"exclude_paths": ["**/node_modules/**", "**/dist/**", "**/obj/**", "**/bin/**", "**/.ai-work/**"],
|
|
97
|
+
"message": "Discoverability (advisory): the docs host is `https://www.cratis.io`. Link the canonical host, not the bare `cratis.io`."
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"id": "cratis-empty-image-alt",
|
|
101
|
+
"rule": "discoverability.md — images carry alt text",
|
|
102
|
+
"pattern": "!\\[\\]\\(",
|
|
103
|
+
"paths": ["**/*.md", "**/*.mdx"],
|
|
104
|
+
"exclude_paths": ["**/node_modules/**", "**/dist/**", "**/obj/**", "**/bin/**", "**/.ai-work/**"],
|
|
105
|
+
"message": "Discoverability (advisory): an image with empty alt text. Describe the content, or keep it empty only when the image is purely decorative."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "cratis-package-url-github-only",
|
|
109
|
+
"rule": "discoverability.md — package metadata",
|
|
110
|
+
"pattern": "<PackageProjectUrl>[[:space:]]*https?://github\\.com",
|
|
111
|
+
"paths": ["**/*.csproj", "**/*.props"],
|
|
112
|
+
"exclude_paths": ["**/node_modules/**", "**/obj/**", "**/bin/**"],
|
|
113
|
+
"message": "Discoverability (advisory): `PackageProjectUrl` should point at the product's docs page `https://www.cratis.io/<product>/`, not only at GitHub."
|
|
90
114
|
}
|
|
91
115
|
]
|
|
92
116
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
agent: agent
|
|
3
|
+
description: "Run the discoverability check on content about to be published."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pre-publish check
|
|
7
|
+
|
|
8
|
+
Run the discoverability check on what is about to be published: a docs page, blog
|
|
9
|
+
post, landing page, README or package metadata. Follow
|
|
10
|
+
`.cratis/ai/rules/discoverability.md` and invoke **cratis-discoverability**.
|
|
11
|
+
|
|
12
|
+
## Identify the content
|
|
13
|
+
|
|
14
|
+
Find the files in the change, the content type of each, and the owning source (never
|
|
15
|
+
a synced copy). Ask only when the content type is unclear.
|
|
16
|
+
|
|
17
|
+
## Check and fix
|
|
18
|
+
|
|
19
|
+
Apply the skill's checks for each content type to the draft: opening definition,
|
|
20
|
+
title and description, canonical names, canonical `https://www.cratis.io/...` links,
|
|
21
|
+
alt text and, for sites and packages, the structured data and metadata. Make the
|
|
22
|
+
fixes in the owning files. Where a fix would hurt readability, keep the text and
|
|
23
|
+
report it. For a built site, run the skill's checker on the build output.
|
|
24
|
+
|
|
25
|
+
Report what changed, what was left alone and why, and any check that could not run.
|
|
26
|
+
Publishing, merging and releasing stay with a person.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/Documentation/**/*.md,**/Documentation/**/*.mdx,**/src/pages/**/*.astro,**/src/pages/**/*.md,**/src/pages/**/*.mdx,**/src/pages/**/*.html,**/src/content/**/*.md,**/src/content/**/*.mdx"
|
|
3
|
+
paths:
|
|
4
|
+
- "**/Documentation/**/*.md"
|
|
5
|
+
- "**/Documentation/**/*.mdx"
|
|
6
|
+
- "**/src/pages/**/*.astro"
|
|
7
|
+
- "**/src/pages/**/*.md"
|
|
8
|
+
- "**/src/pages/**/*.mdx"
|
|
9
|
+
- "**/src/pages/**/*.html"
|
|
10
|
+
- "**/src/content/**/*.md"
|
|
11
|
+
- "**/src/content/**/*.mdx"
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Writing content that people and assistants can find
|
|
15
|
+
|
|
16
|
+
Readability comes first. If a change for search engines or AI assistants makes a page harder to read, drop the change. Voice, structure and examples stay with [writing-cratis-docs.md](./writing-cratis-docs.md), [editing-cratis-docs.md](./editing-cratis-docs.md) and [documentation-structure-and-formatting.md](./documentation-structure-and-formatting.md); this rule adds only what they do not cover. Before publishing a page, post or package, use **cratis-discoverability**.
|
|
17
|
+
|
|
18
|
+
## Every page, post and README
|
|
19
|
+
|
|
20
|
+
- **Define first, where a definition belongs.** Landing pages, product index pages, glossary entries, READMEs and package descriptions open with one self-contained sentence: "X is a <category> for <audience> that <does>."
|
|
21
|
+
- **Docs tutorials, getting-started pages and explanations follow [writing-cratis-docs.md](./writing-cratis-docs.md)**: scenario first, pain before relief. Their answer-first, self-contained definition goes in the frontmatter `description`, which serves search and AI. Where it reads naturally, one definition sentence may follow the opening scenario. writing-cratis-docs wins on voice and structure.
|
|
22
|
+
- **Title and description are written by hand.** Title with the distinguishing term first: 50-60 characters for blog and landing pages, 20-60 for docs and reference pages; a README H1 that is the product name is fine. Description is one sentence of 60-160 characters, never over 160, and not a copy of the title or of another page's description.
|
|
23
|
+
- **Name things one way.** The first mention is the full name ("Cratis Chronicle"), later mentions may shorten it. Use the canonical names in the **cratis-discoverability** entity reference, and do not call the platform "Cratis.io".
|
|
24
|
+
- **Links name their destination.** Link text says what is behind it. Outside the docs site, link docs with the absolute canonical host `https://www.cratis.io/...`, never the bare `cratis.io`.
|
|
25
|
+
- **Images carry alt text.** Describe the content. Use an empty `alt=""` only for decoration.
|
|
26
|
+
- **No keyword stuffing, no filler for length, no text hidden from readers.** A page is done when it is complete.
|
|
27
|
+
|
|
28
|
+
## Sites and packages
|
|
29
|
+
|
|
30
|
+
- Public pages need a canonical link, one `<h1>`, Open Graph tags, valid JSON-LD that describes visible content, a sitemap entry and a `robots.txt` that allows crawling. Add no new `HowTo` or `FAQPage` markup; keep the visible Q&A.
|
|
31
|
+
- `llms.txt` is an optional index. Never claim it improves ranking. See **cratis-llm-friendly-documentation**.
|
|
32
|
+
- Package metadata (`Description`, `PackageProjectUrl`, `PackageTags`, npm `description`, `homepage`, `keywords`) and the README opener use the same definition sentence, and the project URL points at the docs page `https://www.cratis.io/<product>/`.
|
|
33
|
+
- State only facts the repository supports: versions, licenses, limits. The claim gate in `BRAND.md`, `MESSAGING.md` and `SITE.md` still applies.
|
|
@@ -131,6 +131,7 @@ A claim is only as good as the signal behind it — a build result, a test run,
|
|
|
131
131
|
| C# / TypeScript style and code-quality principles | `csharp.md`, `typescript.md`, `code-quality.md`, `code-quality.csharp.md`, `code-quality.typescript.md` |
|
|
132
132
|
| Kotlin / Java / Elixir style; Orleans grains; reactors; GitHub Actions workflows | `kotlin.md`, `java.md`, `elixir.md`, `orleans.md`, `reactors.md`, `github-actions.md` |
|
|
133
133
|
| Documentation pages (`Documentation/`): structure, voice, examples, editing | `documentation.md`, `documentation-structure-and-formatting.md`, `writing-cratis-docs.md`, `editing-cratis-docs.md`, `writing-correct-examples.md` |
|
|
134
|
+
| Findability of published pages, READMEs and package metadata | `discoverability.md` |
|
|
134
135
|
| Go style, source and module files | `go.md`; framework contributors also read `go-engineering.md`; product APIs use `cratis-chronicle-client-go`, `cratis-arc-go` and `cratis-fundamentals-concept-go` |
|
|
135
136
|
| Service lifetimes — why anything taking a scoped dependency is scoped or transient, never a singleton | `csharp.md` |
|
|
136
137
|
| React + Arc + Cratis Components + MVVM + dialogs | `react.md`, `components.md`, `dialogs.md` |
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-discoverability
|
|
3
|
+
description: Run the pre-publish check that makes Cratis content easy to find in search and AI answers without making it harder to read - a docs page, blog post, landing page, README or package metadata (title, description, opening definition, canonical links, entity names, alt text, structured data). Use when about to publish or review content for discoverability, SEO, AEO or GEO, or when writing package descriptions and README openers. Not for page voice and structure (cratis-documentation-writing), llms.txt design (cratis-llm-friendly-documentation), distribution plans (cratis-developer-marketing) or length decisions (cratis-content-length).
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Discoverability before you publish
|
|
8
|
+
|
|
9
|
+
Discoverability is a property of good content: a clear definition, an honest title, one name per
|
|
10
|
+
thing and working links. The always-on rule is [discoverability.md](../../rules/discoverability.md);
|
|
11
|
+
this skill is the procedure to run on the finished draft. If a fix makes the page worse to read,
|
|
12
|
+
drop the fix and say so.
|
|
13
|
+
|
|
14
|
+
## Procedure
|
|
15
|
+
|
|
16
|
+
1. Name the content type: docs page, blog post, landing page, README or package metadata.
|
|
17
|
+
2. Name the reader's one job and, for docs, the page type (tutorial, how-to, reference, explanation).
|
|
18
|
+
3. Apply the checks for that type below. Fix the draft; do not annotate it.
|
|
19
|
+
4. For a built site, run the checker and fix errors; warnings are judgment calls to report.
|
|
20
|
+
5. Report what was changed, what was left alone and why.
|
|
21
|
+
|
|
22
|
+
Read `references/page-checklist.md` for the full per-type checklist and the field lengths.
|
|
23
|
+
|
|
24
|
+
## Checks by content type
|
|
25
|
+
|
|
26
|
+
- **Docs page.** The `description` is written by hand as one self-contained
|
|
27
|
+
definition or outcome sentence. Tutorials, getting-started pages and explanations follow
|
|
28
|
+
`writing-cratis-docs` (scenario first); the definition lives in the `description`, and one
|
|
29
|
+
definition sentence may follow the opening scenario where it reads naturally. `writing-cratis-docs`
|
|
30
|
+
wins on voice and structure. Product index pages open with the definition sentence.
|
|
31
|
+
Reference pages stay terse: title is the exact name plus its product, no question headings, no
|
|
32
|
+
intro. Titles are unique across the site and name the product ("Chronicle projections").
|
|
33
|
+
- **Blog post.** The title states the topic in the words a reader would search. The intro gives the
|
|
34
|
+
conclusion in two sentences. Show the named author and date, add `lastUpdated` only for real
|
|
35
|
+
changes, link the owning docs pages with `https://www.cratis.io/...`, keep `description` apart
|
|
36
|
+
from the card `excerpt`.
|
|
37
|
+
- **Landing page.** One primary term in the title, the `<h1>` or the first paragraph, and a
|
|
38
|
+
one-sentence definition above the fold. Brand lines stay; pair them with a category term in the
|
|
39
|
+
title and description. Respect the claim gate in `BRAND.md`, `MESSAGING.md` and `SITE.md`.
|
|
40
|
+
- **README.** The H1 may be the product name ("Cratis <Product>"). The first sentence defines the product in 15-35 plain
|
|
41
|
+
nouns, with no logo-only or metaphor opener. Links are absolute; link the docs page.
|
|
42
|
+
- **Package metadata.** One sentence naming what the package is and which Cratis product it belongs
|
|
43
|
+
to, identical to the README opener. `PackageProjectUrl` and `homepage` point at the docs page, not
|
|
44
|
+
only at GitHub. Read `references/package-metadata.md` for the field map.
|
|
45
|
+
|
|
46
|
+
## Names
|
|
47
|
+
|
|
48
|
+
Use the canonical names, one per concept, and the first-mention rule. Read
|
|
49
|
+
`references/entities.md` when naming a product, writing a definition sentence or filling `sameAs`.
|
|
50
|
+
Do not invent a name, URL, maturity word or claim that the references do not state.
|
|
51
|
+
|
|
52
|
+
## Structured data
|
|
53
|
+
|
|
54
|
+
Describe only what is visible on the page, one `@graph` per page, stable `@id`s. Add no `HowTo` and
|
|
55
|
+
no new `FAQPage`. Read `references/structured-data.md` for the patterns and what each page type
|
|
56
|
+
gets. Do not name people in structured data.
|
|
57
|
+
|
|
58
|
+
## Checking a built site
|
|
59
|
+
|
|
60
|
+
For a built static site (Astro `dist/` or a plain-HTML repository root), run with Node.js:
|
|
61
|
+
`node <skill-directory>/scripts/verify-discoverability.mjs --root <dir> --site <https://host>`.
|
|
62
|
+
It reports `<html lang>`, title, description, canonical host, `<h1>` count, JSON-LD parse errors,
|
|
63
|
+
`robots.txt`, the sitemap and `llms.txt` structure. Errors exit 1; warnings do not. Pass
|
|
64
|
+
`--warn-only` to downgrade errors and `--require-llms` where the site ships `llms.txt`.
|
|
65
|
+
Read `scripts/verify-discoverability.mjs` only when changing the checker.
|
|
66
|
+
|
|
67
|
+
## Pitfalls
|
|
68
|
+
|
|
69
|
+
- Question headings everywhere: use them only where they read naturally (concept pages, FAQs,
|
|
70
|
+
comparisons, blog), never on reference pages or tutorial steps.
|
|
71
|
+
- Padding to a word count: there is no minimum length; a complete page is done.
|
|
72
|
+
- Apex `https://cratis.io/` links: the docs host is `https://www.cratis.io`.
|
|
73
|
+
- A description that repeats the title or a sibling page's description.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Entity names and definitions
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Naming rules
|
|
6
|
+
- Shared identifiers
|
|
7
|
+
- Definition sentences
|
|
8
|
+
- Disambiguation
|
|
9
|
+
|
|
10
|
+
Only facts the Cratis repositories state are listed. If a product, URL or identifier is not here,
|
|
11
|
+
find it in the product's own README or docs before writing it; do not guess.
|
|
12
|
+
|
|
13
|
+
## Naming rules
|
|
14
|
+
|
|
15
|
+
- First mention on a page: "Cratis Chronicle". Later mentions may be "Chronicle". In package
|
|
16
|
+
descriptions, README H1s and GitHub About text, always the prefixed form.
|
|
17
|
+
- The product is Cratis Direct. "Direct" and "Stagehand" are internal or older names.
|
|
18
|
+
- Cratis Build, Cratis Studio and Cratis Assurance are the three offers on cratis.no. In technical
|
|
19
|
+
copy and metadata, name the product (Chronicle, Arc), not the offer.
|
|
20
|
+
- The product is not "Cratis.io". `cratis.io` is the docs host, canonical `https://www.cratis.io`.
|
|
21
|
+
- "Cratis" alone is both the company and the platform. Say "the Cratis platform" where the company
|
|
22
|
+
could be meant.
|
|
23
|
+
- Cratis Studio maturity: follow `BRAND.md` (Preview) until the owner decides otherwise.
|
|
24
|
+
|
|
25
|
+
## Shared identifiers
|
|
26
|
+
|
|
27
|
+
| Item | Value |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| Organization `@id` | `https://cratis.no/#organization` |
|
|
30
|
+
| Company site | `https://cratis.no` |
|
|
31
|
+
| Docs (canonical host) | `https://www.cratis.io` |
|
|
32
|
+
| Blog | `https://blog.cratis.io` |
|
|
33
|
+
| GitHub organization | `https://github.com/Cratis` |
|
|
34
|
+
| NuGet profile | `https://www.nuget.org/profiles/Cratis` |
|
|
35
|
+
| npm organization | `https://www.npmjs.com/org/cratis` |
|
|
36
|
+
| YouTube | `https://www.youtube.com/@CratisStack` |
|
|
37
|
+
|
|
38
|
+
Product docs follow `https://www.cratis.io/<product>/`.
|
|
39
|
+
|
|
40
|
+
## Definition sentences
|
|
41
|
+
|
|
42
|
+
Use these as the opening sentence of a definitional page, README or package description. Adjust only
|
|
43
|
+
to fit the page; keep the facts.
|
|
44
|
+
|
|
45
|
+
| Name | Sentence | Docs page (under `https://www.cratis.io`) |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| Cratis (platform) | Cratis is an open-source (MIT) event sourcing and CQRS platform for .NET, built around the Chronicle event-sourcing database. | `https://www.cratis.io/` |
|
|
48
|
+
| Cratis Chronicle | Cratis Chronicle is an open-source (MIT) event-sourcing database and runtime with a .NET kernel and clients for several languages. | `/chronicle/` |
|
|
49
|
+
| Cratis Arc | Cratis Arc is an open-source (MIT) CQRS application framework for ASP.NET Core with generated TypeScript proxies. | `/arc/` |
|
|
50
|
+
| Cratis Fundamentals | Cratis Fundamentals is the set of shared building blocks beneath the Cratis stack for .NET and TypeScript. | `/fundamentals/` |
|
|
51
|
+
| Cratis Studio | Cratis Studio is a collaborative environment for designing, visualizing and editing Screenplay event models. | `/studio/` |
|
|
52
|
+
| Cratis Direct | Cratis Direct is the managed control plane for the Cratis software factory: it turns GitHub signals into prioritized, governed AI work and reviewed pull requests. | none confirmed |
|
|
53
|
+
| Cratis Cast | Cratis Cast is a profile-site engine and directory for the people who build with Cratis, with verified, event-sourced profile pages. | `https://cast.cratis.io` |
|
|
54
|
+
| Cratis Screenplay | Cratis Screenplay is a business-oriented declarative language for specifying the desired functionality of an information system. | `/screenplay/` |
|
|
55
|
+
| Cratis Stage | Cratis Stage compiles Screenplay source, verifies modeled specifications, and renders or partially performs Cratis applications. | none confirmed |
|
|
56
|
+
| Cratis Prologue | Cratis Prologue captures selected signals from an existing system and interprets them into a provisional event model for review. | `/prologue/` |
|
|
57
|
+
| Cratis CLI | The Cratis CLI is a terminal tool for inspecting and diagnosing Cratis Chronicle. | `/cli/` |
|
|
58
|
+
| Chronicle Workbench | Chronicle Workbench is the web dashboard bundled with Cratis Chronicle for browsing events and operating a deployment. | `/chronicle/workbench/` |
|
|
59
|
+
| Chronicle MCP | The Chronicle MCP server connects an AI agent to a running Cratis Chronicle database over the Model Context Protocol. | `/chronicle-mcp/` |
|
|
60
|
+
|
|
61
|
+
Chronicle storage is pluggable (MongoDB by default, PostgreSQL, SQL Server, SQLite, in-memory).
|
|
62
|
+
Arc works without event sourcing; the Chronicle integration is optional.
|
|
63
|
+
|
|
64
|
+
## Disambiguation
|
|
65
|
+
|
|
66
|
+
- `@cratis/arc` is the browser client of Arc .NET; `@cratis/arc.core` is the Node server package.
|
|
67
|
+
- "Cast" alone is not the streaming protocol: write "Cratis Cast".
|
|
68
|
+
- A docs slug not in the table is unconfirmed. Check the docs site before linking it.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Package metadata
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Field map
|
|
6
|
+
- Writing the description
|
|
7
|
+
- Pitfalls
|
|
8
|
+
|
|
9
|
+
## Field map
|
|
10
|
+
|
|
11
|
+
| Field | NuGet (`.csproj`, `Directory.Build.props`) | npm (`package.json`) |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| Description | `<Description>`, 80-250 characters; the first 100 are what shows | `description`, 80-160 characters |
|
|
14
|
+
| Tags | `<PackageTags>`, 5-10, `cratis` plus category terms | `keywords`, the same list |
|
|
15
|
+
| Project URL | `<PackageProjectUrl>` = `https://www.cratis.io/<product>/` | `homepage`, the same |
|
|
16
|
+
| Repository | `RepositoryUrl` | `repository` |
|
|
17
|
+
| Authors | `Cratis` | `author` `Cratis` |
|
|
18
|
+
| License | `PackageLicenseExpression` `MIT` | `license` `MIT` |
|
|
19
|
+
| Readme | A packed README with the same opener | `README.md` |
|
|
20
|
+
|
|
21
|
+
Use a `https://www.cratis.io/<product>/` page that exists; check the docs site rather than guessing
|
|
22
|
+
a slug. If none exists, leave the existing URL and report it.
|
|
23
|
+
|
|
24
|
+
## Writing the description
|
|
25
|
+
|
|
26
|
+
One sentence: what it is, for whom, and which Cratis product it belongs to - "<what it is> for
|
|
27
|
+
<audience>, part of Cratis <Parent>". Name the parent so the package disambiguates ("the client for
|
|
28
|
+
Cratis Chronicle, the open-source event-sourcing database"). The text equals the README opener and
|
|
29
|
+
the GitHub About text. Tags are specific ("event-sourcing", "cqrs", "dotnet"); never "utilities".
|
|
30
|
+
|
|
31
|
+
## Pitfalls
|
|
32
|
+
|
|
33
|
+
- An empty `Description`.
|
|
34
|
+
- Mixed `Authors` values across packages of one product.
|
|
35
|
+
- `PackageProjectUrl` pointing only at GitHub.
|
|
36
|
+
- Two packages with near-identical descriptions. Where `@cratis/arc` (the browser client of Arc
|
|
37
|
+
.NET) and `@cratis/arc.core` (the Node server package) could be confused, each description says
|
|
38
|
+
which one it is.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Page checklist
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Field targets
|
|
6
|
+
- Every page
|
|
7
|
+
- Docs pages
|
|
8
|
+
- Blog posts
|
|
9
|
+
- Landing pages
|
|
10
|
+
- READMEs
|
|
11
|
+
- Before publishing a site change
|
|
12
|
+
|
|
13
|
+
## Field targets
|
|
14
|
+
|
|
15
|
+
Lengths are targets, counted in characters including any site suffix.
|
|
16
|
+
|
|
17
|
+
| Field | Target |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Title | Distinguishing term first. Blog and landing pages 50-60; docs and reference pages 20-60; a README H1 that is the product name is fine |
|
|
20
|
+
| Description | One sentence, 60-160, never over 160; the point comes first |
|
|
21
|
+
| Lead answer passage | 40-60 words, names its subject, one key property, one limit |
|
|
22
|
+
| Heading | Under about 70 characters |
|
|
23
|
+
| `og:image` | 1200x630, under 200 KB, with `og:image:alt` |
|
|
24
|
+
|
|
25
|
+
## Every page
|
|
26
|
+
|
|
27
|
+
- [ ] One reader job. For docs, one page type: tutorial, how-to, reference or explanation.
|
|
28
|
+
- [ ] Title unique, distinguishing term near the front.
|
|
29
|
+
- [ ] Description written by hand; not the title, not another page's description.
|
|
30
|
+
- [ ] The `description` answers what the page is or achieves in one self-contained sentence; the first paragraph does too on landing, index, glossary and README pages.
|
|
31
|
+
- [ ] Canonical names; first mention uses the full "Cratis X" form.
|
|
32
|
+
- [ ] Facts, numbers and comparisons carry a source or a date; nothing beyond the claim gate.
|
|
33
|
+
- [ ] Links have descriptive text. Root-relative inside the docs site, absolute
|
|
34
|
+
`https://www.cratis.io/...` elsewhere.
|
|
35
|
+
- [ ] Images have alt text (empty only when decorative) and sensible size.
|
|
36
|
+
- [ ] Linked from at least two other pages and from the right hub; no orphan.
|
|
37
|
+
- [ ] Read aloud once: if it sounds written for a crawler, rewrite it.
|
|
38
|
+
|
|
39
|
+
## Docs pages
|
|
40
|
+
|
|
41
|
+
- Voice and structure follow `writing-cratis-docs`, which wins on any conflict: tutorials,
|
|
42
|
+
getting-started pages and explanations open with a concrete scenario (pain, then relief). Put the
|
|
43
|
+
answer-first definition in the frontmatter `description`; one definition sentence may follow the
|
|
44
|
+
scenario where it reads naturally.
|
|
45
|
+
- Product index page: opens with the one-sentence definition, then the without-versus-with framing.
|
|
46
|
+
- Concept page: scenario first; then why it exists, when to use it, when not to, how it works.
|
|
47
|
+
H2s such as "What is X" and "When to use X" only where they fit. No H1 in the body.
|
|
48
|
+
- Tutorial: title names the outcome and starts with a verb; the scenario, then what the reader will
|
|
49
|
+
have running; prerequisites and versions up front; one visible result per step; "Last updated" and the
|
|
50
|
+
version it applies to.
|
|
51
|
+
- How-to: the task as the title; goal, prerequisites, numbered steps, a done check, the two likely
|
|
52
|
+
failures; versions stated.
|
|
53
|
+
- Reference: the title is the exact name with its product; first sentence says what it is for;
|
|
54
|
+
tables and signatures; stable anchors; no intro, no pitch. Merge or enrich near-empty stub pages.
|
|
55
|
+
|
|
56
|
+
## Blog posts
|
|
57
|
+
|
|
58
|
+
- [ ] Title states the topic; series name goes in a `series` field or at the end.
|
|
59
|
+
- [ ] Two-sentence conclusion up front; "when this is the wrong fit" where it applies.
|
|
60
|
+
- [ ] Named author where agreed (`cratis-team` only for company announcements), date, `lastUpdated`
|
|
61
|
+
only on real changes.
|
|
62
|
+
- [ ] At least one internal link to the owning docs page; tags reused from the existing list, no
|
|
63
|
+
singleton tags; a cover image with alt text for posts likely to be shared.
|
|
64
|
+
|
|
65
|
+
## Landing pages
|
|
66
|
+
|
|
67
|
+
- [ ] One primary term in title, `<h1>` or first paragraph; one-sentence definition above the fold.
|
|
68
|
+
- [ ] Description differs per page and is not a 300-character paragraph.
|
|
69
|
+
- [ ] No testimonials, numbers, customers, prices or maturity claims the claim gate does not allow.
|
|
70
|
+
- [ ] Norwegian pages are written natively, keep established English technical terms, link to the
|
|
71
|
+
English page with `hreflang`, and are not machine-translated and published unreviewed.
|
|
72
|
+
|
|
73
|
+
## READMEs
|
|
74
|
+
|
|
75
|
+
- [ ] H1 may be the product name ("Cratis <Product>"); first sentence is the definition (15-35 words, no metaphor).
|
|
76
|
+
- [ ] Then install, a minimal working example, a link to `https://www.cratis.io/<product>/`,
|
|
77
|
+
license and how it relates to the rest of Cratis.
|
|
78
|
+
- [ ] GitHub About text equals the README opener (80-160 characters); website is the docs page;
|
|
79
|
+
topics include `cratis` plus category terms.
|
|
80
|
+
|
|
81
|
+
## Before publishing a site change
|
|
82
|
+
|
|
83
|
+
- [ ] Title, description, canonical, Open Graph and JSON-LD present and unique on changed pages.
|
|
84
|
+
- [ ] Sitemap lists new pages; `robots.txt` unchanged unless intended.
|
|
85
|
+
- [ ] JSON-LD validates; no `HowTo`; no new `FAQPage`.
|
|
86
|
+
- [ ] The content is in the raw HTML the server returns, not only rendered by JavaScript.
|
|
87
|
+
- [ ] Internal links resolve; no apex `cratis.io` links.
|
|
88
|
+
- [ ] A real HTTP 404 for unknown URLs; redirect stubs keep a canonical and stay out of the sitemap.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Structured data patterns
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Rules
|
|
6
|
+
- Types per page type
|
|
7
|
+
- Pattern
|
|
8
|
+
|
|
9
|
+
## Rules
|
|
10
|
+
|
|
11
|
+
- One `@graph` per page with stable `@id` values. The organization `@id` is
|
|
12
|
+
`https://cratis.no/#organization`.
|
|
13
|
+
- Markup describes what is visible on the page, and every date is a real one.
|
|
14
|
+
- Add no `HowTo`. Add no new `FAQPage`; keep the visible question and answer text. Use `QAPage` only
|
|
15
|
+
for real user questions and answers.
|
|
16
|
+
- Do not name people (`Person`, `author` name) in structured data unless the owner has approved it.
|
|
17
|
+
Reference the organization by `@id`.
|
|
18
|
+
- Do not add `offers`, an address or contact data without approval (see `SITE.md`).
|
|
19
|
+
- Validate with the schema.org validator or Google's Rich Results Test. Invalid markup is worse than
|
|
20
|
+
none. Static sites hand-write `<script type="application/ld+json">`; Astro sites generate it in
|
|
21
|
+
the head component, so change it there rather than per page.
|
|
22
|
+
|
|
23
|
+
## Types per page type
|
|
24
|
+
|
|
25
|
+
| Page type | Types |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Company home | `Organization` (name, url, logo, `sameAs`), `WebSite` |
|
|
28
|
+
| Product marketing page | `SoftwareApplication` (name, applicationCategory, license, url, `sameAs`) |
|
|
29
|
+
| Docs concept, tutorial, how-to, reference | `TechArticle` with `headline`, `dateModified`, plus `BreadcrumbList` |
|
|
30
|
+
| Docs hub and landing page | `CollectionPage` or `WebPage`, not `TechArticle` |
|
|
31
|
+
| Blog post | `BlogPosting` (`datePublished`, `dateModified`, `publisher`, `image`) |
|
|
32
|
+
| Repository or sample code | `SoftwareSourceCode` |
|
|
33
|
+
| Legal pages | `WebPage` |
|
|
34
|
+
| 404 and app hosts | none |
|
|
35
|
+
|
|
36
|
+
## Pattern
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"@context": "https://schema.org",
|
|
41
|
+
"@graph": [
|
|
42
|
+
{
|
|
43
|
+
"@type": "TechArticle",
|
|
44
|
+
"@id": "https://www.cratis.io/chronicle/projections/#article",
|
|
45
|
+
"headline": "Chronicle projections",
|
|
46
|
+
"description": "A projection declares how Chronicle builds a read model from events.",
|
|
47
|
+
"dateModified": "YYYY-MM-DD",
|
|
48
|
+
"publisher": { "@id": "https://cratis.no/#organization" },
|
|
49
|
+
"mainEntityOfPage": "https://www.cratis.io/chronicle/projections/"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"@type": "BreadcrumbList",
|
|
53
|
+
"itemListElement": [
|
|
54
|
+
{ "@type": "ListItem", "position": 1, "name": "Chronicle", "item": "https://www.cratis.io/chronicle/" },
|
|
55
|
+
{ "@type": "ListItem", "position": 2, "name": "Projections" }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The values are illustrative: take the title, description and dates from the page itself.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
//
|
|
4
|
+
// Checks a built static site (Astro dist/ or a plain-HTML repo root) before it is published:
|
|
5
|
+
// every indexable page has <html lang>, one <title>, a meta description, a canonical on the
|
|
6
|
+
// expected host and parseable JSON-LD; the site has robots.txt, a sitemap and (optionally) a
|
|
7
|
+
// well-formed llms.txt. Node standard library only.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node verify-discoverability.mjs --root dist --site https://blog.cratis.io [--require-llms] [--warn-only]
|
|
10
|
+
// Exit codes: 0 clean or warnings only, 1 errors, 2 could not run.
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const option = (name, fallback) => { const i = args.indexOf(name); return i < 0 ? fallback : args[i + 1]; };
|
|
16
|
+
const root = option('--root', 'dist');
|
|
17
|
+
const site = option('--site');
|
|
18
|
+
const requireLlms = args.includes('--require-llms');
|
|
19
|
+
const warnOnly = args.includes('--warn-only');
|
|
20
|
+
const ignore = (option('--ignore', 'node_modules,.git,.cratis,.github,404.html,_astro,storybook') ?? '').split(',');
|
|
21
|
+
|
|
22
|
+
if (!site || !existsSync(root)) {
|
|
23
|
+
console.error(`could not run: need --site and an existing --root (got ${root})`);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
const host = new URL(site).host;
|
|
27
|
+
let errors = 0;
|
|
28
|
+
let warnings = 0;
|
|
29
|
+
const report = (level, file, message) => {
|
|
30
|
+
if (level === 'error' && !warnOnly) errors++; else warnings++;
|
|
31
|
+
console.log(`::${level === 'error' && !warnOnly ? 'error' : 'warning'} file=${file}::${message}`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function* htmlFiles(dir) {
|
|
35
|
+
for (const name of readdirSync(dir)) {
|
|
36
|
+
if (ignore.includes(name)) continue;
|
|
37
|
+
const path = join(dir, name);
|
|
38
|
+
if (statSync(path).isDirectory()) yield* htmlFiles(path);
|
|
39
|
+
else if (name.endsWith('.html')) yield path;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const attr = (tag, name) => tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, 'i'))?.[1];
|
|
44
|
+
const tags = (html, pattern) => [...html.matchAll(pattern)].map(match => match[0]);
|
|
45
|
+
const titles = new Map();
|
|
46
|
+
|
|
47
|
+
for (const path of htmlFiles(root)) {
|
|
48
|
+
const file = relative(process.cwd(), path);
|
|
49
|
+
const html = readFileSync(path, 'utf8');
|
|
50
|
+
const metas = tags(html, /<meta\b[^>]*>/gi);
|
|
51
|
+
const robots = metas.find(tag => /name\s*=\s*["']robots["']/i.test(tag));
|
|
52
|
+
if (robots && /noindex/i.test(attr(robots, 'content') ?? '')) continue; // deliberately not indexed
|
|
53
|
+
if (/http-equiv\s*=\s*["']refresh["']/i.test(html)) continue; // redirect stub
|
|
54
|
+
|
|
55
|
+
if (!/<html\b[^>]*\blang\s*=/i.test(html)) report('error', file, 'missing <html lang>');
|
|
56
|
+
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
|
|
57
|
+
if (!title) report('error', file, 'missing or empty <title>');
|
|
58
|
+
else {
|
|
59
|
+
if (title.length > 65) report('warning', file, `title is ${title.length} characters; keep the distinguishing words in the first 60`);
|
|
60
|
+
titles.set(title, [...(titles.get(title) ?? []), file]);
|
|
61
|
+
}
|
|
62
|
+
const description = metas.find(tag => /name\s*=\s*["']description["']/i.test(tag));
|
|
63
|
+
const descriptionText = description ? attr(description, 'content') ?? '' : '';
|
|
64
|
+
if (!descriptionText) report('error', file, 'missing meta description');
|
|
65
|
+
else if (descriptionText.length < 60 || descriptionText.length > 160) report('warning', file, `meta description is ${descriptionText.length} characters (aim for 60-160)`);
|
|
66
|
+
const canonical = tags(html, /<link\b[^>]*>/gi).find(tag => /rel\s*=\s*["']canonical["']/i.test(tag));
|
|
67
|
+
const href = canonical ? attr(canonical, 'href') : undefined;
|
|
68
|
+
if (!href) report('error', file, 'missing <link rel="canonical">');
|
|
69
|
+
else {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(href);
|
|
72
|
+
if (url.host !== host) report('error', file, `canonical points at ${url.host}, expected ${host}`);
|
|
73
|
+
} catch {
|
|
74
|
+
report('error', file, `canonical is not an absolute URL: ${href}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if ((html.match(/<h1\b/gi) ?? []).length !== 1) report('warning', file, 'expected exactly one <h1>');
|
|
78
|
+
for (const block of html.matchAll(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(block[1]);
|
|
81
|
+
const nodes = Array.isArray(data) ? data : data['@graph'] ?? [data];
|
|
82
|
+
if (!Array.isArray(data) && !String(data['@context'] ?? '').includes('schema.org')) report('error', file, 'JSON-LD without a schema.org @context');
|
|
83
|
+
for (const node of nodes) if (!node['@type']) report('warning', file, 'JSON-LD node without @type');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
report('error', file, `JSON-LD does not parse: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const [title, files] of titles) if (files.length > 1) report('warning', files[0], `title "${title}" is shared by ${files.length} pages`);
|
|
91
|
+
|
|
92
|
+
const robotsPath = join(root, 'robots.txt');
|
|
93
|
+
if (!existsSync(robotsPath)) report('error', root, 'no robots.txt');
|
|
94
|
+
else if (!/^\s*sitemap\s*:/im.test(readFileSync(robotsPath, 'utf8'))) report('warning', robotsPath, 'robots.txt does not declare a Sitemap');
|
|
95
|
+
if (!readdirSync(root).some(name => /^sitemap.*\.xml$/.test(name))) report('error', root, 'no sitemap*.xml at the site root');
|
|
96
|
+
|
|
97
|
+
const llmsPath = join(root, 'llms.txt');
|
|
98
|
+
if (existsSync(llmsPath)) {
|
|
99
|
+
// Same structure rules as vendor/claude-seo/scripts/agentic_check.py evaluate_llms_txt (Lighthouse llms-txt audit).
|
|
100
|
+
const llms = readFileSync(llmsPath, 'utf8').replace(/^\uFEFF/, '');
|
|
101
|
+
if (!/^\s*#\s+.+/m.test(llms)) report('error', llmsPath, 'llms.txt has no H1');
|
|
102
|
+
if (!/\[.+\]\(.+\)/.test(llms)) report('error', llmsPath, 'llms.txt has no Markdown links');
|
|
103
|
+
if (!/^\s*>\s+\S/m.test(llms)) report('warning', llmsPath, "llms.txt has no '>' summary");
|
|
104
|
+
} else if (requireLlms) report('error', root, 'no llms.txt');
|
|
105
|
+
|
|
106
|
+
console.log(`verify-discoverability: ${errors} error(s), ${warnings} warning(s)`);
|
|
107
|
+
process.exit(errors ? 1 : 0);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "cratis-discoverability",
|
|
3
|
+
"input": "Run the pre-publish discoverability check on a new blog post, a package README and the package metadata for a Cratis library.",
|
|
4
|
+
"fixtures": [],
|
|
5
|
+
"assertions": [
|
|
6
|
+
{ "kind": "skill-contains", "value": "If a fix makes the page worse to read," },
|
|
7
|
+
{ "kind": "skill-contains", "value": "Add no `HowTo` and" },
|
|
8
|
+
{ "kind": "skill-contains", "value": "Do not name people in structured data." },
|
|
9
|
+
{ "kind": "skill-contains", "value": "Apex `https://cratis.io/` links: the docs host is `https://www.cratis.io`." },
|
|
10
|
+
{ "kind": "skill-contains", "value": "no logo-only or metaphor opener" },
|
|
11
|
+
{ "kind": "skill-contains", "value": "Padding to a word count: there is no minimum length" }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -657,6 +657,7 @@
|
|
|
657
657
|
"availableTargets": [
|
|
658
658
|
"cratis-content-length",
|
|
659
659
|
"cratis-developer-marketing",
|
|
660
|
+
"cratis-discoverability",
|
|
660
661
|
"cratis-release-notes",
|
|
661
662
|
"cratis-social-feed-post",
|
|
662
663
|
"cratis-technical-examples",
|
|
@@ -670,6 +671,7 @@
|
|
|
670
671
|
{
|
|
671
672
|
"id": "cratis/documentation",
|
|
672
673
|
"availableTargets": [
|
|
674
|
+
"cratis-discoverability",
|
|
673
675
|
"cratis-documentation-writing",
|
|
674
676
|
"cratis-engineering-docs-authoring",
|
|
675
677
|
"cratis-llm-friendly-documentation",
|