@bussolabs/closeyourit-cli 0.25.1 → 0.27.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.
@@ -0,0 +1,310 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DOCTOR_CHECK_CODES = void 0;
4
+ exports.titleKey = titleKey;
5
+ exports.stripCode = stripCode;
6
+ exports.wikilinkTitles = wikilinkTitles;
7
+ exports.runDoctorChecks = runDoctorChecks;
8
+ exports.doctorTotals = doctorTotals;
9
+ exports.renderDoctorReport = renderDoctorReport;
10
+ exports.fetchDoctorPages = fetchDoctorPages;
11
+ const node_path_1 = require("node:path");
12
+ const knowledge_1 = require("./knowledge");
13
+ const output_1 = require("./output");
14
+ /**
15
+ * I codici delle verifiche, nell'ordine in cui vengono mostrate: prima ciò che rende una pagina
16
+ * introvabile, poi ciò che spezza i collegamenti, infine le code ferme e il repo dei documenti.
17
+ * Codici stabili: chi ne legge l'esito da un programma può filtrare per codice senza inseguire
18
+ * il testo, che è per le persone.
19
+ */
20
+ exports.DOCTOR_CHECK_CODES = [
21
+ 'empty-body',
22
+ 'orphan-scope',
23
+ 'duplicate-title',
24
+ 'dangling-link',
25
+ 'stale-review',
26
+ 'stale-consolidation',
27
+ 'inconsistent-consolidation',
28
+ 'missing-source-file',
29
+ ];
30
+ /** Quante pagine elenca il referto a schermo per ciascuna verifica, prima di riassumere il resto. */
31
+ const LISTED_PER_CHECK = 10;
32
+ /** Wikilink `[[Titolo]]` / `[[Titolo|etichetta]]`, com'è scritto lato servizio (Knowledge::Links::Parse). */
33
+ const WIKILINK = /\[\[([^\n[\]|]{1,255})(?:\|[^\n[\]]{1,255})?]]/g;
34
+ /** Il tetto ai riferimenti letti per pagina, come lato servizio: una pagina è un documento, non un grafo. */
35
+ const MAX_LINKS_PER_PAGE = 50;
36
+ function text(value) {
37
+ return typeof value === 'string' ? value : '';
38
+ }
39
+ /**
40
+ * La chiave con cui il servizio risolve un wikilink verso una pagina: titolo senza spazi ai bordi,
41
+ * minuscolo (`LOWER(BTRIM(title))` in Knowledge::Links::Sync). Deliberatamente NON normalizza gli
42
+ * spazi interni né gli accenti: due titoli che il servizio considera diversi devono restare diversi
43
+ * anche qui, o il referto segnalerebbe collegamenti rotti che rotti non sono.
44
+ */
45
+ function titleKey(value) {
46
+ return text(value).trim().toLowerCase();
47
+ }
48
+ /**
49
+ * Il markdown senza le parti di codice: blocchi recintati (``` / ~~~) e code span inline.
50
+ * Una guida che documenta la sintassi scrivendo `[[Titolo]]` fra i backtick non sta collegando
51
+ * niente, e il servizio infatti non la collega (Knowledge::Links::Segments).
52
+ */
53
+ function stripCode(markdown) {
54
+ const lines = markdown.split('\n');
55
+ const prose = [];
56
+ let fence;
57
+ for (const line of lines) {
58
+ if (fence === undefined) {
59
+ const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line);
60
+ if (opening) {
61
+ fence = opening[1];
62
+ continue;
63
+ }
64
+ prose.push(line);
65
+ }
66
+ else {
67
+ const stripped = line.trim();
68
+ // Chiude solo una riga fatta dello stesso carattere della recinzione, lunga almeno quanto
69
+ // l'apertura: `~~~` non chiude un blocco aperto con ```.
70
+ if (stripped.length >= fence.length && new Set(stripped).size === 1 && stripped.startsWith(fence[0])) {
71
+ fence = undefined;
72
+ }
73
+ }
74
+ }
75
+ // Code span inline: N backtick, contenuto, gli stessi N backtick.
76
+ return prose.join('\n').replaceAll(/(`+)[^]*?\1/g, ' ');
77
+ }
78
+ /**
79
+ * I titoli citati come wikilink nel corpo, deduplicati senza distinzione di maiuscole e nell'ordine
80
+ * di apparizione — la stessa lettura che fa il servizio quando costruisce il grafo dei collegamenti.
81
+ */
82
+ function wikilinkTitles(body) {
83
+ const seen = new Set();
84
+ const titles = [];
85
+ for (const match of stripCode(text(body)).matchAll(WIKILINK)) {
86
+ const title = match[1].trim();
87
+ if (title === '' || seen.has(title.toLowerCase()))
88
+ continue;
89
+ seen.add(title.toLowerCase());
90
+ titles.push(title);
91
+ if (titles.length >= MAX_LINKS_PER_PAGE)
92
+ break;
93
+ }
94
+ return titles;
95
+ }
96
+ /** Quanti giorni sono passati da `value`, o undefined se il servizio non ha mandato una data leggibile. */
97
+ function daysSince(value, now) {
98
+ const raw = text(value).trim();
99
+ if (raw === '')
100
+ return undefined;
101
+ const when = Date.parse(raw);
102
+ if (Number.isNaN(when))
103
+ return undefined;
104
+ return (now.getTime() - when) / 86_400_000;
105
+ }
106
+ /** L'ultima data nota della pagina: quando è stata toccata, o quando è nata. */
107
+ function ageInDays(page, now) {
108
+ return daysSince(page.updated_at, now) ?? daysSince(page.created_at, now);
109
+ }
110
+ function finding(page, detail) {
111
+ return { id: String(page.id ?? ''), title: text(page.title), ...(detail === undefined ? {} : { detail }) };
112
+ }
113
+ /** Una verifica il cui esito è dato dalle pagine trovate: nessuna → superata. */
114
+ function check(code, summary, nextStep, pages, failing) {
115
+ return {
116
+ code,
117
+ status: pages.length === 0 ? 'ok' : failing ? 'fail' : 'warning',
118
+ count: pages.length,
119
+ summary,
120
+ next_step: nextStep,
121
+ pages,
122
+ };
123
+ }
124
+ /** Una verifica che non è stato possibile fare: esito neutro, e come renderla possibile. */
125
+ function skipped(code, summary, nextStep) {
126
+ return { code, status: 'skipped', count: 0, summary, next_step: nextStep, pages: [] };
127
+ }
128
+ function listLength(value) {
129
+ return Array.isArray(value) ? value.length : 0;
130
+ }
131
+ /** La pagina non ha corpo: non c'è niente da indicizzare, nessuna ricerca la troverà mai. */
132
+ function emptyBodyCheck(pages) {
133
+ return check('empty-body', 'Pages whose body is empty. There is nothing to index, so no search will ever return them.', 'Read it with `cyi kb show <id>`, then write the body with `cyi kb update <id> --body ...`.', pages.filter((page) => text(page.body).trim() === '').map((page) => finding(page)), true);
134
+ }
135
+ /**
136
+ * Nessun progetto e nessun gruppo. La visibilità si concede per progetto o per gruppo
137
+ * (Knowledge::Page.visible_to), quindi una pagina senza né l'uno né l'altro la vede SOLO chi ha
138
+ * accesso pieno all'organizzazione: per tutti gli altri è come se non ci fosse.
139
+ *
140
+ * Avviso e non guasto: il dominio la consente deliberatamente — Knowledge::CreatePage rifiuta lo
141
+ * scope vuoto a chiunque tranne a chi ha accesso pieno — e da fuori non si può sapere se questa
142
+ * pagina è una dimenticanza o una scelta. Segnalarla come rottura farebbe uscire il comando in
143
+ * errore su una conoscenza sana.
144
+ */
145
+ function orphanScopeCheck(pages) {
146
+ return check('orphan-scope', 'Pages attached to no project and no group. Only someone with full access to the organization can see them; for everybody else they are not there at all.', 'Give it a scope with `cyi kb update <id> --project <key>` (or a group). Leave it as it is if the page really is meant for the whole organization.', pages.filter((page) => listLength(page.projects) === 0 && listLength(page.groups) === 0).map((page) => finding(page)), false);
147
+ }
148
+ /**
149
+ * Due pagine con lo stesso titolo: il servizio collega un `[[Titolo]]` solo quando UNA pagina lo
150
+ * porta, quindi ogni citazione a un titolo doppio resta testo semplice.
151
+ */
152
+ function duplicateTitleCheck(pages) {
153
+ const byTitle = new Map();
154
+ for (const page of pages) {
155
+ const key = titleKey(page.title);
156
+ if (key === '')
157
+ continue;
158
+ byTitle.set(key, [...(byTitle.get(key) ?? []), page]);
159
+ }
160
+ const found = [...byTitle.values()]
161
+ .filter((group) => group.length > 1)
162
+ .flatMap((group) => group.map((page) => finding(page, `same title as ${group.filter((other) => other !== page).map((other) => String(other.id ?? '')).join(', ')}`)));
163
+ return check('duplicate-title', 'Two or more pages carry the same title. A `[[Title]]` pointing there stays plain text, because the link cannot tell which one is meant.', 'Rename one of them with `cyi kb update <id> --title ...`, or merge them by hand and delete the leftover.', found, false);
164
+ }
165
+ /**
166
+ * Wikilink verso un titolo che nessuna pagina porta. Il confronto usa le pagine VISIBILI a questo
167
+ * accesso: il servizio risolve su tutta l'organizzazione, quindi un collegamento verso una pagina
168
+ * fuori dal proprio scope compare qui pur essendo sano — ed è detto nel referto, non nascosto.
169
+ */
170
+ function danglingLinkCheck(pages) {
171
+ const known = new Set(pages.map((page) => titleKey(page.title)).filter((key) => key !== ''));
172
+ const found = pages.flatMap((page) => {
173
+ const missing = wikilinkTitles(page.body).filter((title) => !known.has(title.trim().toLowerCase()));
174
+ return missing.length === 0 ? [] : [finding(page, missing.join(', '))];
175
+ });
176
+ return check('dangling-link', 'Pages linking to `[[Title]]` when no visible page carries that title. Read from the titles as they are now, over the pages this access can see: a link into a project you cannot read shows up here, and so does one whose target was renamed after the link was made (the service kept that one attached).', 'Fix the quoted title, or write the missing page — the link attaches itself as soon as the title exists.', found, false);
177
+ }
178
+ /** Proposte mai decise: restano fuori da ricerca, risposte e pannelli finché qualcuno non sceglie. */
179
+ function staleReviewCheck(pages, staleDays, now) {
180
+ const found = pages
181
+ .filter((page) => page.status === 'in_review')
182
+ .flatMap((page) => {
183
+ const age = ageInDays(page, now);
184
+ return age === undefined || age <= staleDays ? [] : [finding(page, `waiting ${Math.floor(age)} days`)];
185
+ });
186
+ return check('stale-review', `Proposals nobody decided for more than ${staleDays} days. Until someone does, they stay out of search, answers and related panels.`, 'Accept it with `cyi kb approve <id>` or discard it with `cyi kb reject <id>`.', found, false);
187
+ }
188
+ /** Accettate ma mai scritte fra i documenti versionati: la conoscenza vive in un posto solo. */
189
+ function staleConsolidationCheck(awaiting, staleDays, now) {
190
+ const found = awaiting.flatMap((page) => {
191
+ const age = ageInDays(page, now);
192
+ return age === undefined || age <= staleDays ? [] : [finding(page, `accepted ${Math.floor(age)} days ago`)];
193
+ });
194
+ return check('stale-consolidation', `Accepted pages not yet written to the versioned docs for more than ${staleDays} days.`, 'Write the document in the knowledge-base repo, then record it with `cyi kb consolidate <id> --path <path>`.', found, false);
195
+ }
196
+ /** Percorso e data di archiviazione devono dire la stessa cosa: o entrambi ci sono, o nessuno dei due. */
197
+ function inconsistentConsolidationCheck(pages) {
198
+ const found = pages.flatMap((page) => {
199
+ const hasPath = text(page.source_path).trim() !== '';
200
+ const hasDate = text(page.consolidated_at).trim() !== '';
201
+ if (hasPath === hasDate)
202
+ return [];
203
+ return [finding(page, hasPath ? 'path without a filing date' : 'filed without a path')];
204
+ });
205
+ return check('inconsistent-consolidation', 'Pages whose document path and filing date disagree: one is set and the other is not, so the filing queue cannot be trusted.', 'Re-record the filing with `cyi kb consolidate <id> --path <path>`.', found, true);
206
+ }
207
+ /**
208
+ * Il documento versionato dichiarato dalla pagina esiste davvero sul disco? Senza `--docs-root`
209
+ * la verifica non si fa e lo dice: inventare un esito su una radice indovinata segnalerebbe come
210
+ * mancante ogni documento del parco.
211
+ */
212
+ function missingSourceFileCheck(pages, docsRoot, fileExists) {
213
+ const summary = 'Pages that name a versioned document. The file has to be there, or the page points at knowledge that no longer exists.';
214
+ if (docsRoot === undefined || fileExists === undefined) {
215
+ return skipped('missing-source-file', `${summary} Not checked: the docs repository lives on this machine, not on the service.`, 'Run it again with `--docs-root <path to the knowledge-base repo>`.');
216
+ }
217
+ const root = (0, node_path_1.resolve)(docsRoot);
218
+ const found = pages.flatMap((page) => {
219
+ const path = text(page.source_path).trim();
220
+ if (path === '')
221
+ return [];
222
+ const target = (0, node_path_1.isAbsolute)(path) ? (0, node_path_1.resolve)(path) : (0, node_path_1.resolve)(root, path);
223
+ if (target !== root && !target.startsWith(root + node_path_1.sep))
224
+ return [finding(page, `${path} — outside the docs root`)];
225
+ return fileExists(target) ? [] : [finding(page, path)];
226
+ });
227
+ return check('missing-source-file', summary, 'Write the missing document, or point the page at the right one with `cyi kb consolidate <id> --path <path>`.', found, true);
228
+ }
229
+ /**
230
+ * Tutte le verifiche, sempre tutte e sempre nello stesso ordine: un referto che tace sulle prove
231
+ * superate non si distingue da un referto che non le ha fatte.
232
+ */
233
+ function runDoctorChecks(input) {
234
+ const { awaitingConsolidation, docsRoot, fileExists, now, pages, scopedToProject, staleDays } = input;
235
+ return [
236
+ emptyBodyCheck(pages),
237
+ orphanScopeCheck(pages),
238
+ scopedToProject
239
+ ? skipped('duplicate-title', 'Two or more pages carrying the same title. Not checked: titles are unique across the whole organization, and this run only read one project.', 'Run it again without `--project` to compare every visible page.')
240
+ : duplicateTitleCheck(pages),
241
+ scopedToProject
242
+ ? skipped('dangling-link', 'Pages linking to `[[Title]]` when no page carries that title. Not checked: the service resolves those links across the whole organization, and this run only read one project — every link pointing outside it would look broken.', 'Run it again without `--project` to compare every visible page.')
243
+ : danglingLinkCheck(pages),
244
+ staleReviewCheck(pages, staleDays, now),
245
+ staleConsolidationCheck(awaitingConsolidation, staleDays, now),
246
+ inconsistentConsolidationCheck(pages),
247
+ missingSourceFileCheck(pages, docsRoot, fileExists),
248
+ ];
249
+ }
250
+ /** Quante verifiche sono fallite e quante hanno solo avvisato. */
251
+ function doctorTotals(checks) {
252
+ return {
253
+ failures: checks.filter((entry) => entry.status === 'fail').length,
254
+ warnings: checks.filter((entry) => entry.status === 'warning').length,
255
+ };
256
+ }
257
+ /**
258
+ * Il referto a schermo: prima la tabella di tutte le verifiche con il loro esito, poi un blocco per
259
+ * ciascuna di quelle che hanno qualcosa da dire — la spiegazione, il passo da fare e le pagine.
260
+ */
261
+ function renderDoctorReport(report) {
262
+ const scanned = report.truncated
263
+ ? `${report.scanned} pages examined (the collection is larger — raise --limit to cover it)`
264
+ : `${report.scanned} pages examined`;
265
+ const table = (0, output_1.renderTable)(['STATUS', 'CHECK', 'FOUND'], report.checks.map((entry) => [`${(0, output_1.statusDot)(entry.status)} ${entry.status}`, entry.code, String(entry.count)]));
266
+ const blocks = report.checks
267
+ .filter((entry) => entry.status !== 'ok')
268
+ .map((entry) => {
269
+ const heading = `${(0, output_1.statusDot)(entry.status)} ${(0, output_1.sanitize)(entry.code)} (${entry.count})`;
270
+ const lines = [`\n${heading}`, ` ${(0, output_1.sanitize)(entry.summary)}`, ` Next step: ${(0, output_1.sanitize)(entry.next_step)}`];
271
+ for (const page of entry.pages.slice(0, LISTED_PER_CHECK)) {
272
+ const detail = page.detail ? ` — ${(0, output_1.sanitize)((0, knowledge_1.truncate)(page.detail, 80))}` : '';
273
+ lines.push(` [${(0, output_1.sanitize)(page.id)}] ${(0, output_1.sanitize)((0, knowledge_1.truncate)(page.title))}${detail}`);
274
+ }
275
+ if (entry.pages.length > LISTED_PER_CHECK)
276
+ lines.push(` … and ${entry.pages.length - LISTED_PER_CHECK} more`);
277
+ return lines.join('\n');
278
+ });
279
+ return [`🩺 Knowledge doctor · ${scanned}`, '', table, ...blocks].join('\n');
280
+ }
281
+ /**
282
+ * Legge le pagine da esaminare, fermandosi a `limit` righe.
283
+ *
284
+ * Il tetto c'è perché un controllo che scandisce tutto il parco non deve poter diventare una
285
+ * scansione infinita lanciata in linea: quando si ferma prima della fine lo DICE (`truncated`), così
286
+ * il referto non fa passare per "tutto a posto" ciò che semplicemente non ha guardato.
287
+ */
288
+ async function fetchDoctorPages(api, opts) {
289
+ const per = Math.min(100, opts.limit);
290
+ const pages = [];
291
+ let page = 1;
292
+ let totalPages = 1;
293
+ do {
294
+ const query = (0, knowledge_1.buildPagesQuery)({
295
+ awaitingConsolidation: opts.awaitingConsolidation,
296
+ kinds: [],
297
+ page,
298
+ per,
299
+ project: opts.project,
300
+ status: opts.awaitingConsolidation ? undefined : 'all',
301
+ });
302
+ // eslint-disable-next-line no-await-in-loop
303
+ const res = await api.get(`/cli/v1/knowledge/pages?${query}`);
304
+ pages.push(...(Array.isArray(res.data) ? res.data : []));
305
+ totalPages = Number(res.meta?.total_pages ?? 1);
306
+ page += 1;
307
+ } while (page <= totalPages && pages.length < opts.limit);
308
+ const truncated = pages.length > opts.limit || page <= totalPages;
309
+ return { pages: pages.slice(0, opts.limit), truncated };
310
+ }
@@ -95,3 +95,33 @@ export declare function renderBook(book: Record<string, unknown>): string;
95
95
  * state is the whole point, gets it automatically.
96
96
  */
97
97
  export declare function renderPagesTable(pages: Array<Record<string, unknown>>): string;
98
+ /** One page of the knowledge context printed when a working session starts (CYCL-55). */
99
+ export interface ContextPage extends Record<string, unknown> {
100
+ id?: string;
101
+ title?: string;
102
+ kind?: string;
103
+ }
104
+ /**
105
+ * The pages out of a knowledge-context payload, in either shape the endpoint may answer with: an
106
+ * object carrying `pages`, or the bare list. Anything else reads as "no pages" rather than as a
107
+ * failure — at session start an unexpected payload must not be louder than an empty one.
108
+ */
109
+ export declare function contextPages(data: unknown): ContextPage[];
110
+ /**
111
+ * How to name the project in the heading: the key the server sent back when it names one, otherwise
112
+ * the reference the caller asked with — which for a git-resolved project is already its key.
113
+ */
114
+ export declare function contextProjectLabel(data: unknown, fallback: string): string;
115
+ /**
116
+ * Render the knowledge context: one heading plus one line per page, id included so the reader can
117
+ * go straight to `kb show <id>`. Deliberately a short list and not a table — it is printed at the
118
+ * start of every session, where anything taller would be scrolled past instead of read.
119
+ */
120
+ export declare function renderKnowledgeContext(pages: ContextPage[], project: string): string;
121
+ /**
122
+ * The same payload with at most `limit` pages, whichever shape it came in. The cap the caller asked
123
+ * for has to hold on the JSON envelope too: the server is told `per`, but nothing guarantees the
124
+ * context route honours it, and a machine consumer that asked for five pages must not silently get
125
+ * ten. Everything else in the payload is preserved.
126
+ */
127
+ export declare function capContextPages<T>(data: T, limit: number): T;
@@ -12,6 +12,10 @@ exports.pageScopeLines = pageScopeLines;
12
12
  exports.renderBooksTable = renderBooksTable;
13
13
  exports.renderBook = renderBook;
14
14
  exports.renderPagesTable = renderPagesTable;
15
+ exports.contextPages = contextPages;
16
+ exports.contextProjectLabel = contextProjectLabel;
17
+ exports.renderKnowledgeContext = renderKnowledgeContext;
18
+ exports.capContextPages = capContextPages;
15
19
  const output_1 = require("./output");
16
20
  /** Knowledge page kinds accepted by the backend (Knowledge::Page). */
17
21
  exports.KNOWLEDGE_KINDS = ['note', 'decision', 'guide'];
@@ -206,3 +210,63 @@ function renderPagesTable(pages) {
206
210
  return row;
207
211
  }));
208
212
  }
213
+ /**
214
+ * The pages out of a knowledge-context payload, in either shape the endpoint may answer with: an
215
+ * object carrying `pages`, or the bare list. Anything else reads as "no pages" rather than as a
216
+ * failure — at session start an unexpected payload must not be louder than an empty one.
217
+ */
218
+ function contextPages(data) {
219
+ if (Array.isArray(data))
220
+ return data;
221
+ if (data && typeof data === 'object') {
222
+ const { pages } = data;
223
+ if (Array.isArray(pages))
224
+ return pages;
225
+ }
226
+ return [];
227
+ }
228
+ /**
229
+ * How to name the project in the heading: the key the server sent back when it names one, otherwise
230
+ * the reference the caller asked with — which for a git-resolved project is already its key.
231
+ */
232
+ function contextProjectLabel(data, fallback) {
233
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
234
+ const { project } = data;
235
+ if (typeof project === 'string' && project.trim() !== '')
236
+ return project;
237
+ if (project && typeof project === 'object') {
238
+ const { key, name } = project;
239
+ if (typeof key === 'string' && key.trim() !== '')
240
+ return key;
241
+ if (typeof name === 'string' && name.trim() !== '')
242
+ return name;
243
+ }
244
+ }
245
+ return fallback;
246
+ }
247
+ /**
248
+ * Render the knowledge context: one heading plus one line per page, id included so the reader can
249
+ * go straight to `kb show <id>`. Deliberately a short list and not a table — it is printed at the
250
+ * start of every session, where anything taller would be scrolled past instead of read.
251
+ */
252
+ function renderKnowledgeContext(pages, project) {
253
+ const heading = `📚 Knowledge · ${(0, output_1.sanitize)(project)} (${pages.length})`;
254
+ const lines = pages.map((page) => ` [${(0, output_1.sanitize)(page.kind)}] ${(0, output_1.sanitize)(truncate(String(page.title ?? '')))} · ${(0, output_1.sanitize)(page.id)}`);
255
+ return [heading, ...lines].join('\n');
256
+ }
257
+ /**
258
+ * The same payload with at most `limit` pages, whichever shape it came in. The cap the caller asked
259
+ * for has to hold on the JSON envelope too: the server is told `per`, but nothing guarantees the
260
+ * context route honours it, and a machine consumer that asked for five pages must not silently get
261
+ * ten. Everything else in the payload is preserved.
262
+ */
263
+ function capContextPages(data, limit) {
264
+ if (Array.isArray(data))
265
+ return (data.length <= limit ? data : data.slice(0, limit));
266
+ if (data && typeof data === 'object') {
267
+ const { pages } = data;
268
+ if (Array.isArray(pages) && pages.length > limit)
269
+ return { ...data, pages: pages.slice(0, limit) };
270
+ }
271
+ return data;
272
+ }
@@ -0,0 +1,30 @@
1
+ import { type CliApi } from './api';
2
+ export declare const UUID_RE: RegExp;
3
+ /**
4
+ * Risoluzione di un riferimento leggibile (chiave di progetto, nome di gruppo) nel suo id.
5
+ *
6
+ * Vive qui e non più dentro `BaseCommand` perché non è più solo roba da comando: il server MCP
7
+ * (CYCL-56) accetta gli stessi riferimenti che accetta la riga di comando, e una seconda copia di
8
+ * questa ricerca vorrebbe dire due comportamenti che divergono al primo cambio.
9
+ */
10
+ /**
11
+ * Resolve a project reference (UUID passes through; otherwise matched by key, case-insensitive).
12
+ * Walks every page of `/cli/v1/projects`: a key can live past the first page, so a single-page
13
+ * lookup would 404 keys like legalbloom LBRA/LBWB while their UUID (and first-page keys) worked (CYCL-1).
14
+ */
15
+ export declare function resolveProjectId(api: CliApi, value: string): Promise<string>;
16
+ /** Resolve an `{id, name}` reference by name (UUID passes through). */
17
+ export declare function resolveNamedId(api: CliApi, path: string, value: string, code: string, label: string): Promise<string>;
18
+ /**
19
+ * Every row of an organization lookup (teams, roles, groups, members) — all its pages.
20
+ *
21
+ * The server serves ten per page: stopping at the first made the eleventh team or the eleventh
22
+ * person invisible, and a perfectly good name or email came back as "not found" (CYCL-42, the
23
+ * lesson `resolveProjectId` already learned on project keys in CYCL-1).
24
+ *
25
+ * A 403 here is not about the command being run: listing teams needs `permissions.manage` and
26
+ * listing members `members.view`, permissions that a team workload board never asks for. Left
27
+ * alone it would read as "Permesso negato" on an operation the caller is entitled to, so the
28
+ * refusal carries the way out instead — name the id, skip the lookup.
29
+ */
30
+ export declare function fetchLookupRows<T>(api: CliApi, path: string, hint: string): Promise<T[]>;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UUID_RE = void 0;
4
+ exports.resolveProjectId = resolveProjectId;
5
+ exports.resolveNamedId = resolveNamedId;
6
+ exports.fetchLookupRows = fetchLookupRows;
7
+ const api_1 = require("./api");
8
+ const error_codes_1 = require("../errors/error-codes");
9
+ exports.UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ /**
11
+ * Risoluzione di un riferimento leggibile (chiave di progetto, nome di gruppo) nel suo id.
12
+ *
13
+ * Vive qui e non più dentro `BaseCommand` perché non è più solo roba da comando: il server MCP
14
+ * (CYCL-56) accetta gli stessi riferimenti che accetta la riga di comando, e una seconda copia di
15
+ * questa ricerca vorrebbe dire due comportamenti che divergono al primo cambio.
16
+ */
17
+ /**
18
+ * Resolve a project reference (UUID passes through; otherwise matched by key, case-insensitive).
19
+ * Walks every page of `/cli/v1/projects`: a key can live past the first page, so a single-page
20
+ * lookup would 404 keys like legalbloom LBRA/LBWB while their UUID (and first-page keys) worked (CYCL-1).
21
+ */
22
+ async function resolveProjectId(api, value) {
23
+ if (exports.UUID_RE.test(value))
24
+ return value;
25
+ let page = 1;
26
+ let totalPages = 1;
27
+ do {
28
+ // eslint-disable-next-line no-await-in-loop
29
+ const res = await api.get(`/cli/v1/projects?page=${page}&per=100`);
30
+ const match = (res.data ?? []).find((project) => (project.key ?? '').toLowerCase() === value.toLowerCase());
31
+ if (match)
32
+ return match.id;
33
+ totalPages = Number(res.meta?.total_pages ?? 1);
34
+ page += 1;
35
+ } while (page <= totalPages);
36
+ throw new api_1.ApiRequestError(404, error_codes_1.ErrorCodes.Project.notFound, `Project not found: ${value}`);
37
+ }
38
+ /** Resolve an `{id, name}` reference by name (UUID passes through). */
39
+ async function resolveNamedId(api, path, value, code, label) {
40
+ if (exports.UUID_RE.test(value))
41
+ return value;
42
+ const noun = label.toLowerCase();
43
+ const rows = await fetchLookupRows(api, path, `Listing the ${noun}s needs the permissions.manage permission — pass the ${noun} id instead of its name.`);
44
+ const match = rows.find((row) => (row.name ?? '').toLowerCase() === value.toLowerCase());
45
+ if (!match) {
46
+ throw new api_1.ApiRequestError(404, code, `${label} not found: ${value}`);
47
+ }
48
+ return match.id;
49
+ }
50
+ /**
51
+ * Every row of an organization lookup (teams, roles, groups, members) — all its pages.
52
+ *
53
+ * The server serves ten per page: stopping at the first made the eleventh team or the eleventh
54
+ * person invisible, and a perfectly good name or email came back as "not found" (CYCL-42, the
55
+ * lesson `resolveProjectId` already learned on project keys in CYCL-1).
56
+ *
57
+ * A 403 here is not about the command being run: listing teams needs `permissions.manage` and
58
+ * listing members `members.view`, permissions that a team workload board never asks for. Left
59
+ * alone it would read as "Permesso negato" on an operation the caller is entitled to, so the
60
+ * refusal carries the way out instead — name the id, skip the lookup.
61
+ */
62
+ async function fetchLookupRows(api, path, hint) {
63
+ const rows = [];
64
+ let page = 1;
65
+ let totalPages = 1;
66
+ try {
67
+ do {
68
+ // eslint-disable-next-line no-await-in-loop
69
+ const res = await api.get(`${path}?page=${page}&per=100`);
70
+ rows.push(...(res.data ?? []));
71
+ totalPages = Number(res.meta?.total_pages ?? 1);
72
+ page += 1;
73
+ } while (page <= totalPages);
74
+ }
75
+ catch (error) {
76
+ if (error instanceof api_1.ApiRequestError && error.status === 403) {
77
+ throw new api_1.ApiRequestError(error.status, error.code, `${error.message}. ${hint}`);
78
+ }
79
+ throw error;
80
+ }
81
+ return rows;
82
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Il livello JSON-RPC 2.0 del server MCP (CYCL-56).
3
+ *
4
+ * Sul trasporto stdio il Model Context Protocol non incapsula niente: un messaggio JSON per riga,
5
+ * senza a capo dentro, e nient'altro su standard output. Quello che entra arriva da un processo
6
+ * estraneo, quindi ogni forma sbagliata ha qui una risposta prevista: un server che cade sulla
7
+ * prima riga storta lascerebbe l'assistente senza conoscenza e senza spiegazione.
8
+ */
9
+ export declare const JSONRPC_VERSION = "2.0";
10
+ export type JsonRpcId = string | number;
11
+ /** Una richiesta già ripulita: `id` assente vuol dire notifica, cioè nessuna risposta attesa. */
12
+ export interface JsonRpcRequest {
13
+ id?: JsonRpcId;
14
+ method: string;
15
+ params: Record<string, unknown>;
16
+ }
17
+ export interface JsonRpcErrorBody {
18
+ code: number;
19
+ message: string;
20
+ data?: unknown;
21
+ }
22
+ export interface JsonRpcResponse {
23
+ jsonrpc: string;
24
+ id: JsonRpcId | null;
25
+ result?: unknown;
26
+ error?: JsonRpcErrorBody;
27
+ }
28
+ /** I codici standard di JSON-RPC 2.0, gli unici che il protocollo di base assegna. */
29
+ export declare const RPC_ERROR: {
30
+ readonly parse: -32700;
31
+ readonly invalidRequest: -32600;
32
+ readonly methodNotFound: -32601;
33
+ readonly invalidParams: -32602;
34
+ readonly internal: -32603;
35
+ };
36
+ export declare function rpcResult(id: JsonRpcId | null, result: unknown): JsonRpcResponse;
37
+ export declare function rpcFailure(id: JsonRpcId | null, code: number, message: string, data?: unknown): JsonRpcResponse;
38
+ /**
39
+ * Cosa fare di una riga letta: servirla, rispedire un errore già pronto, o tacere.
40
+ * Il terzo caso non è un ripiego: a una riga vuota e a una risposta del client non si replica.
41
+ */
42
+ export type ParsedLine = {
43
+ kind: 'request';
44
+ request: JsonRpcRequest;
45
+ } | {
46
+ kind: 'response';
47
+ response: JsonRpcResponse;
48
+ } | {
49
+ kind: 'ignore';
50
+ };
51
+ export declare function parseLine(line: string): ParsedLine;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * Il livello JSON-RPC 2.0 del server MCP (CYCL-56).
4
+ *
5
+ * Sul trasporto stdio il Model Context Protocol non incapsula niente: un messaggio JSON per riga,
6
+ * senza a capo dentro, e nient'altro su standard output. Quello che entra arriva da un processo
7
+ * estraneo, quindi ogni forma sbagliata ha qui una risposta prevista: un server che cade sulla
8
+ * prima riga storta lascerebbe l'assistente senza conoscenza e senza spiegazione.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.RPC_ERROR = exports.JSONRPC_VERSION = void 0;
12
+ exports.rpcResult = rpcResult;
13
+ exports.rpcFailure = rpcFailure;
14
+ exports.parseLine = parseLine;
15
+ exports.JSONRPC_VERSION = '2.0';
16
+ /** I codici standard di JSON-RPC 2.0, gli unici che il protocollo di base assegna. */
17
+ exports.RPC_ERROR = {
18
+ parse: -32_700,
19
+ invalidRequest: -32_600,
20
+ methodNotFound: -32_601,
21
+ invalidParams: -32_602,
22
+ internal: -32_603,
23
+ };
24
+ function rpcResult(id, result) {
25
+ return { jsonrpc: exports.JSONRPC_VERSION, id, result };
26
+ }
27
+ function rpcFailure(id, code, message, data) {
28
+ return { jsonrpc: exports.JSONRPC_VERSION, id, error: { code, message, ...(data === undefined ? {} : { data }) } };
29
+ }
30
+ function isRecord(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+ /** L'id solo nella forma che JSON-RPC gli riconosce; `null` (come l'assenza) significa notifica. */
34
+ function readId(value) {
35
+ if (typeof value === 'string')
36
+ return value;
37
+ if (typeof value === 'number')
38
+ return value;
39
+ return undefined;
40
+ }
41
+ function parseLine(line) {
42
+ const trimmed = line.trim();
43
+ if (trimmed === '')
44
+ return { kind: 'ignore' };
45
+ let message;
46
+ try {
47
+ message = JSON.parse(trimmed);
48
+ }
49
+ catch {
50
+ // Senza un messaggio leggibile non c'è nemmeno un id da citare: JSON-RPC prescrive `null`.
51
+ return { kind: 'response', response: rpcFailure(null, exports.RPC_ERROR.parse, 'Invalid JSON message') };
52
+ }
53
+ if (!isRecord(message)) {
54
+ return { kind: 'response', response: rpcFailure(null, exports.RPC_ERROR.invalidRequest, 'Expected a JSON-RPC object') };
55
+ }
56
+ // Una busta con `result`/`error` è la risposta del client a una nostra richiesta. Non ne mandiamo,
57
+ // ma se ne arrivasse una non va replicata: rispondere a una risposta è come si aprono i cicli.
58
+ if (message.method === undefined && ('result' in message || 'error' in message))
59
+ return { kind: 'ignore' };
60
+ const id = readId(message.id);
61
+ if (typeof message.method !== 'string' || message.method === '') {
62
+ return { kind: 'response', response: rpcFailure(id ?? null, exports.RPC_ERROR.invalidRequest, 'Missing method name') };
63
+ }
64
+ // I metodi MCP prendono solo parametri per nome: una lista posizionale non avrebbe dove finire.
65
+ const params = isRecord(message.params) ? message.params : {};
66
+ return { kind: 'request', request: { id, method: message.method, params } };
67
+ }