@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.
package/dist/lib/api.d.ts CHANGED
@@ -10,6 +10,13 @@ export interface RequestOptions {
10
10
  body?: unknown;
11
11
  /** Defaults to true; set false for unauthenticated endpoints. */
12
12
  auth?: boolean;
13
+ /**
14
+ * Abort the request when the caller cannot afford to wait (CYCL-55): `kb context` runs at the
15
+ * start of every session, where a stalled connection would be paid on each one. Without it fetch
16
+ * waits as long as the OS lets it. An aborted call surfaces as a network ApiRequestError, like
17
+ * any other unreachable server.
18
+ */
19
+ signal?: AbortSignal;
13
20
  }
14
21
  export interface Envelope<T = unknown> {
15
22
  data: T;
@@ -42,6 +49,15 @@ export declare class CliApi {
42
49
  * without a body is covered too. Reads never carry it: looking is not executing.
43
50
  */
44
51
  confirm: boolean;
52
+ /**
53
+ * Interrompe ogni richiesta che parte da questo client, quando è ora di chiudere (CYCL-56).
54
+ *
55
+ * Vive sull'istanza per la stessa ragione di `confirm`: il server MCP deve poter mollare TUTTE le
56
+ * chiamate in volo — quelle degli strumenti e quelle delle ricerche per nome che si portano
57
+ * dietro — senza infilare un parametro in ogni firma che sta in mezzo. Una `signal` passata alla
58
+ * singola richiesta (`RequestOptions`) resta più specifica e vince su questa.
59
+ */
60
+ signal?: AbortSignal;
45
61
  constructor(config: CliConfig);
46
62
  request<T = unknown>(method: HttpMethod, path: string, opts?: RequestOptions): Promise<Envelope<T>>;
47
63
  /**
package/dist/lib/api.js CHANGED
@@ -36,6 +36,15 @@ class CliApi {
36
36
  * without a body is covered too. Reads never carry it: looking is not executing.
37
37
  */
38
38
  confirm = false;
39
+ /**
40
+ * Interrompe ogni richiesta che parte da questo client, quando è ora di chiudere (CYCL-56).
41
+ *
42
+ * Vive sull'istanza per la stessa ragione di `confirm`: il server MCP deve poter mollare TUTTE le
43
+ * chiamate in volo — quelle degli strumenti e quelle delle ricerche per nome che si portano
44
+ * dietro — senza infilare un parametro in ogni firma che sta in mezzo. Una `signal` passata alla
45
+ * singola richiesta (`RequestOptions`) resta più specifica e vince su questa.
46
+ */
47
+ signal;
39
48
  constructor(config) {
40
49
  this.config = config;
41
50
  }
@@ -49,6 +58,7 @@ class CliApi {
49
58
  method,
50
59
  headers,
51
60
  body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
61
+ signal: opts.signal ?? this.signal,
52
62
  });
53
63
  }
54
64
  catch (err) {
@@ -103,7 +113,17 @@ class CliApi {
103
113
  }
104
114
  /** Parse the response body and map the error envelope to an ApiRequestError. Shared by request() and upload(). */
105
115
  async handleResponse(res) {
106
- const text = await res.text();
116
+ // The body can fail after the headers arrived: an abort that fires mid-read (RequestOptions.signal),
117
+ // a connection that drops. That is the same network failure as one on the first byte, so it must
118
+ // come out as an ApiRequestError — raw, it would escape every caller that maps failures by that
119
+ // type, and a command counting on a quiet failure would crash instead.
120
+ let text;
121
+ try {
122
+ text = await res.text();
123
+ }
124
+ catch (err) {
125
+ throw new ApiRequestError(0, error_codes_1.ErrorCodes.Network.requestFailed, `Reading the response failed: ${err.message}`);
126
+ }
107
127
  let json = {};
108
128
  if (text.length > 0) {
109
129
  try {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `owner/name` out of a git remote URL, in both shapes git writes them:
3
+ * git@github.com:bussolabs/closeyourit-cli.git
4
+ * https://github.com/bussolabs/closeyourit-cli
5
+ *
6
+ * Only the last two path segments are kept, so a nested group (gitlab) still yields the pair the
7
+ * project→repository map is keyed by. Anything that is not a pair — a local path, an empty remote —
8
+ * returns undefined: the caller then simply has no project, which is a legitimate state (CYCL-55,
9
+ * scenario 2), not an error.
10
+ */
11
+ export declare function parseRepoSlug(url: string): string | undefined;
12
+ /**
13
+ * The `origin` remote of `cwd` as `owner/name`, or undefined when there is none to read: outside a
14
+ * repository, without an `origin`, or without git installed. Never throws and never waits long —
15
+ * this runs at the start of a session, where a hang would be felt on every single start.
16
+ */
17
+ export declare function repoSlug(cwd: string, timeoutMs?: number): Promise<string | undefined>;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseRepoSlug = parseRepoSlug;
4
+ exports.repoSlug = repoSlug;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_util_1 = require("node:util");
7
+ const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
8
+ /**
9
+ * `owner/name` out of a git remote URL, in both shapes git writes them:
10
+ * git@github.com:bussolabs/closeyourit-cli.git
11
+ * https://github.com/bussolabs/closeyourit-cli
12
+ *
13
+ * Only the last two path segments are kept, so a nested group (gitlab) still yields the pair the
14
+ * project→repository map is keyed by. Anything that is not a pair — a local path, an empty remote —
15
+ * returns undefined: the caller then simply has no project, which is a legitimate state (CYCL-55,
16
+ * scenario 2), not an error.
17
+ */
18
+ function parseRepoSlug(url) {
19
+ const trimmed = url.trim();
20
+ if (trimmed === '')
21
+ return undefined;
22
+ // A repository cloned from disk (`file:///srv/git/acme/api.git`) has no owner: keeping its last
23
+ // two directories would invent a slug that could collide with a real `owner/name`.
24
+ if (/^file:\/\//i.test(trimmed))
25
+ return undefined;
26
+ // With a scheme the path starts after the host; the scp-like form has everything past the colon.
27
+ const marker = trimmed.indexOf('://');
28
+ const path = marker === -1
29
+ ? trimmed.slice(trimmed.indexOf(':') + 1)
30
+ : trimmed
31
+ .slice(marker + 3)
32
+ .split('/')
33
+ .slice(1)
34
+ .join('/');
35
+ if (marker === -1 && !trimmed.includes(':'))
36
+ return undefined;
37
+ const parts = path
38
+ .replace(/\.git$/i, '')
39
+ .split('/')
40
+ .filter((part) => part !== '');
41
+ return parts.length < 2 ? undefined : parts.slice(-2).join('/');
42
+ }
43
+ /**
44
+ * The `origin` remote of `cwd` as `owner/name`, or undefined when there is none to read: outside a
45
+ * repository, without an `origin`, or without git installed. Never throws and never waits long —
46
+ * this runs at the start of a session, where a hang would be felt on every single start.
47
+ */
48
+ async function repoSlug(cwd, timeoutMs = 2000) {
49
+ try {
50
+ const { stdout } = await run('git', ['remote', 'get-url', 'origin'], { cwd, timeout: timeoutMs });
51
+ return parseRepoSlug(stdout);
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
@@ -0,0 +1,27 @@
1
+ import { ApiRequestError, type CliApi, type Envelope, type RequestOptions } from './api';
2
+ /**
3
+ * Does this failure mean the server has no such route? A 404 says so outright; a 500 only when the
4
+ * body was not an error envelope the backend wrote, which is how a missing route surfaces here.
5
+ */
6
+ export declare function isMissingRoute(error: ApiRequestError): boolean;
7
+ /**
8
+ * Ask for the project's knowledge context, falling back to its plain page list on a server that
9
+ * does not expose the context route yet (CYRA-767): the same short list, one call later, instead
10
+ * of a session start that reports a failure the reader can do nothing about.
11
+ *
12
+ * A 500 counts as "route missing" alongside 404, but only when the body was not a backend error
13
+ * envelope: the CLI asks for `Accept: application/json`, a server without the route renders its
14
+ * not-found page, has no JSON template for it, and fails — which arrives here as a plain
15
+ * `Network.requestFailed`. A 500 the backend narrated itself (`R500-…`) is a real fault of a route
16
+ * that exists, and retrying elsewhere would only replace its message with a more confusing one.
17
+ */
18
+ export declare function fetchKnowledgeContext(api: CliApi, project: string, limit: number, opts?: RequestOptions): Promise<Envelope<unknown>>;
19
+ /**
20
+ * The project of `cwd`, matched on its `origin` remote through the project → repository map the
21
+ * backend already publishes (`projects github-map`).
22
+ *
23
+ * The remote was preferred over a local configuration file: it is already written in every
24
+ * repository, so nothing new has to be created, kept in sync, or forgotten in a fresh clone.
25
+ * No remote, no match, no repository at all → undefined, and the caller shows nothing.
26
+ */
27
+ export declare function projectFromGitRemote(api: CliApi, cwd: string, timeoutMs: number, opts?: RequestOptions): Promise<string | undefined>;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMissingRoute = isMissingRoute;
4
+ exports.fetchKnowledgeContext = fetchKnowledgeContext;
5
+ exports.projectFromGitRemote = projectFromGitRemote;
6
+ const api_1 = require("./api");
7
+ const error_codes_1 = require("../errors/error-codes");
8
+ const git_1 = require("./git");
9
+ const knowledge_1 = require("./knowledge");
10
+ /**
11
+ * Does this failure mean the server has no such route? A 404 says so outright; a 500 only when the
12
+ * body was not an error envelope the backend wrote, which is how a missing route surfaces here.
13
+ */
14
+ function isMissingRoute(error) {
15
+ if (error.status === 404)
16
+ return true;
17
+ return error.status === 500 && error.code === error_codes_1.ErrorCodes.Network.requestFailed;
18
+ }
19
+ /**
20
+ * Ask for the project's knowledge context, falling back to its plain page list on a server that
21
+ * does not expose the context route yet (CYRA-767): the same short list, one call later, instead
22
+ * of a session start that reports a failure the reader can do nothing about.
23
+ *
24
+ * A 500 counts as "route missing" alongside 404, but only when the body was not a backend error
25
+ * envelope: the CLI asks for `Accept: application/json`, a server without the route renders its
26
+ * not-found page, has no JSON template for it, and fails — which arrives here as a plain
27
+ * `Network.requestFailed`. A 500 the backend narrated itself (`R500-…`) is a real fault of a route
28
+ * that exists, and retrying elsewhere would only replace its message with a more confusing one.
29
+ */
30
+ async function fetchKnowledgeContext(api, project, limit, opts = {}) {
31
+ const query = new URLSearchParams({ project, per: String(limit) });
32
+ try {
33
+ return await api.get(`/cli/v1/knowledge/context?${query.toString()}`, opts);
34
+ }
35
+ catch (error) {
36
+ if (!(error instanceof api_1.ApiRequestError) || !isMissingRoute(error))
37
+ throw error;
38
+ const pages = (0, knowledge_1.buildPagesQuery)({ kinds: [], page: 1, per: limit, project });
39
+ return api.get(`/cli/v1/knowledge/pages?${pages}`, opts);
40
+ }
41
+ }
42
+ /**
43
+ * The project of `cwd`, matched on its `origin` remote through the project → repository map the
44
+ * backend already publishes (`projects github-map`).
45
+ *
46
+ * The remote was preferred over a local configuration file: it is already written in every
47
+ * repository, so nothing new has to be created, kept in sync, or forgotten in a fresh clone.
48
+ * No remote, no match, no repository at all → undefined, and the caller shows nothing.
49
+ */
50
+ async function projectFromGitRemote(api, cwd, timeoutMs, opts = {}) {
51
+ const slug = await (0, git_1.repoSlug)(cwd, timeoutMs);
52
+ if (slug === undefined)
53
+ return undefined;
54
+ const wanted = slug.toLowerCase();
55
+ let page = 1;
56
+ let totalPages = 1;
57
+ do {
58
+ const query = new URLSearchParams({ page: String(page), per: '100', connected_only: 'true' });
59
+ // eslint-disable-next-line no-await-in-loop
60
+ const res = await api.get(`/cli/v1/projects/github-map?${query.toString()}`, opts);
61
+ const match = (res.data ?? []).find((row) => (row.repository?.full_name ?? '').toLowerCase() === wanted);
62
+ if (match)
63
+ return match.project_key ?? match.project_id;
64
+ totalPages = Number(res.meta?.total_pages ?? 1);
65
+ page += 1;
66
+ } while (page <= totalPages);
67
+ return undefined;
68
+ }
@@ -0,0 +1,127 @@
1
+ import type { CliApi } from './api';
2
+ /**
3
+ * Il controllo di salute della conoscenza (`cyi kb doctor`, CYCL-57).
4
+ *
5
+ * Le verifiche girano QUI, sul client, sopra l'elenco delle pagine che il servizio già espone: è
6
+ * lo stesso canale su cui gira oggi lo script di audit tenuto fuori dal prodotto
7
+ * (`knowledge-base/scripts/audit_closeyourit_kb.rb`, che chiama `cyi kb list --status all --json`),
8
+ * e portarlo dentro il comando è tutto il punto del ticket — un controllo che nessuno lancia non
9
+ * accorge nessuno di niente. Il controllo del file versionato, poi, può girare solo da questa
10
+ * parte: il repo dei documenti sta sul disco di chi lavora, il servizio non lo vede.
11
+ *
12
+ * Nessuna verifica scrive: il modulo legge pagine e restituisce un referto. La riparazione resta
13
+ * un gesto esplicito di chi legge, ed è per questo che ogni verifica porta con sé il comando da
14
+ * dare — non lo esegue.
15
+ */
16
+ /** Esito di una verifica. Le stringhe sono quelle che `statusDot` sa già colorare. */
17
+ export type DoctorCheckStatus = 'fail' | 'ok' | 'skipped' | 'warning';
18
+ /** Una pagina segnalata da una verifica: id e titolo per ritrovarla, `detail` per il perché. */
19
+ export interface DoctorFinding {
20
+ id: string;
21
+ title: string;
22
+ detail?: string;
23
+ }
24
+ /** Il referto di una singola verifica: cosa guarda, com'è andata, cosa fare adesso. */
25
+ export interface DoctorCheck {
26
+ code: string;
27
+ status: DoctorCheckStatus;
28
+ count: number;
29
+ summary: string;
30
+ next_step: string;
31
+ pages: DoctorFinding[];
32
+ }
33
+ /** Una pagina come la serializza il servizio (KnowledgePageSerializer), per quel che serve qui. */
34
+ export interface DoctorPage extends Record<string, unknown> {
35
+ id?: unknown;
36
+ title?: unknown;
37
+ body?: unknown;
38
+ status?: unknown;
39
+ projects?: unknown;
40
+ groups?: unknown;
41
+ source_path?: unknown;
42
+ consolidated_at?: unknown;
43
+ updated_at?: unknown;
44
+ created_at?: unknown;
45
+ }
46
+ /** Il referto completo, nella forma che finisce sia a schermo sia in `--json`. */
47
+ export interface DoctorReport {
48
+ scanned: number;
49
+ truncated: boolean;
50
+ stale_days: number;
51
+ checks: DoctorCheck[];
52
+ }
53
+ /**
54
+ * I codici delle verifiche, nell'ordine in cui vengono mostrate: prima ciò che rende una pagina
55
+ * introvabile, poi ciò che spezza i collegamenti, infine le code ferme e il repo dei documenti.
56
+ * Codici stabili: chi ne legge l'esito da un programma può filtrare per codice senza inseguire
57
+ * il testo, che è per le persone.
58
+ */
59
+ export declare const DOCTOR_CHECK_CODES: readonly ["empty-body", "orphan-scope", "duplicate-title", "dangling-link", "stale-review", "stale-consolidation", "inconsistent-consolidation", "missing-source-file"];
60
+ /**
61
+ * La chiave con cui il servizio risolve un wikilink verso una pagina: titolo senza spazi ai bordi,
62
+ * minuscolo (`LOWER(BTRIM(title))` in Knowledge::Links::Sync). Deliberatamente NON normalizza gli
63
+ * spazi interni né gli accenti: due titoli che il servizio considera diversi devono restare diversi
64
+ * anche qui, o il referto segnalerebbe collegamenti rotti che rotti non sono.
65
+ */
66
+ export declare function titleKey(value: unknown): string;
67
+ /**
68
+ * Il markdown senza le parti di codice: blocchi recintati (``` / ~~~) e code span inline.
69
+ * Una guida che documenta la sintassi scrivendo `[[Titolo]]` fra i backtick non sta collegando
70
+ * niente, e il servizio infatti non la collega (Knowledge::Links::Segments).
71
+ */
72
+ export declare function stripCode(markdown: string): string;
73
+ /**
74
+ * I titoli citati come wikilink nel corpo, deduplicati senza distinzione di maiuscole e nell'ordine
75
+ * di apparizione — la stessa lettura che fa il servizio quando costruisce il grafo dei collegamenti.
76
+ */
77
+ export declare function wikilinkTitles(body: unknown): string[];
78
+ export interface DoctorInput {
79
+ /** Le pagine visibili, in ogni stato. */
80
+ pages: DoctorPage[];
81
+ /** Le accettate non ancora archiviate, come le elenca il servizio (scope `awaiting_consolidation`). */
82
+ awaitingConsolidation: DoctorPage[];
83
+ /** Da quanti giorni una coda ferma va segnalata. */
84
+ staleDays: number;
85
+ now: Date;
86
+ /** La radice del repo dei documenti versionati; assente → il controllo del file non si fa. */
87
+ docsRoot?: string;
88
+ /** Come si guarda se un file c'è. Iniettabile perché le verifiche restino pure e testabili. */
89
+ fileExists?: (path: string) => boolean;
90
+ /**
91
+ * L'elenco letto è quello di UN progetto. Il servizio però risolve i wikilink e l'unicità dei
92
+ * titoli su TUTTA l'organizzazione (Knowledge::Links::Sync), quindi da un elenco ristretto quelle
93
+ * due verifiche direbbero il falso in entrambi i versi: collegamenti sani segnalati come appesi
94
+ * perché la destinazione sta in un altro progetto, e doppioni cross-progetto mai visti.
95
+ */
96
+ scopedToProject?: boolean;
97
+ }
98
+ /**
99
+ * Tutte le verifiche, sempre tutte e sempre nello stesso ordine: un referto che tace sulle prove
100
+ * superate non si distingue da un referto che non le ha fatte.
101
+ */
102
+ export declare function runDoctorChecks(input: DoctorInput): DoctorCheck[];
103
+ /** Quante verifiche sono fallite e quante hanno solo avvisato. */
104
+ export declare function doctorTotals(checks: DoctorCheck[]): {
105
+ failures: number;
106
+ warnings: number;
107
+ };
108
+ /**
109
+ * Il referto a schermo: prima la tabella di tutte le verifiche con il loro esito, poi un blocco per
110
+ * ciascuna di quelle che hanno qualcosa da dire — la spiegazione, il passo da fare e le pagine.
111
+ */
112
+ export declare function renderDoctorReport(report: DoctorReport): string;
113
+ /**
114
+ * Legge le pagine da esaminare, fermandosi a `limit` righe.
115
+ *
116
+ * Il tetto c'è perché un controllo che scandisce tutto il parco non deve poter diventare una
117
+ * scansione infinita lanciata in linea: quando si ferma prima della fine lo DICE (`truncated`), così
118
+ * il referto non fa passare per "tutto a posto" ciò che semplicemente non ha guardato.
119
+ */
120
+ export declare function fetchDoctorPages(api: CliApi, opts: {
121
+ limit: number;
122
+ project?: string;
123
+ awaitingConsolidation?: boolean;
124
+ }): Promise<{
125
+ pages: DoctorPage[];
126
+ truncated: boolean;
127
+ }>;