@gitpagedocs/cli 1.1.66 → 1.1.68

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,62 @@
1
+ import { legacyProviderToCatalogId, resolveApiKeyFromEnv } from "@gitpagedocs/tools/ai";
2
+ import type { AiProviderId } from "@gitpagedocs/tools/ports";
3
+ import { PROVIDER_CATALOG } from "@gitpagedocs/tools";
4
+ import { AiConfigFileRepository } from "../infrastructure/ai-config-file";
5
+
6
+ export interface ResolvedChatCredentials {
7
+ readonly providerId: AiProviderId;
8
+ readonly model: string;
9
+ readonly apiKey?: string;
10
+ readonly baseUrl?: string;
11
+ }
12
+
13
+ export interface ResolveChatCredentialsInput {
14
+ readonly cwd: string;
15
+ /** Overrides the stored/detected provider (e.g. a `--provider` flag). */
16
+ readonly providerOverride?: string;
17
+ /** Overrides the stored/detected model (e.g. a `--model` flag). */
18
+ readonly modelOverride?: string;
19
+ /** Injectable for tests; defaults to a real config file repository. */
20
+ readonly configRepo?: Pick<AiConfigFileRepository, "read">;
21
+ /** Injectable for tests; defaults to process.env. */
22
+ readonly env?: Readonly<Record<string, string | undefined>>;
23
+ }
24
+
25
+ /**
26
+ * Resolves the provider, model and credentials for a chat turn, layering
27
+ * sources by priority: explicit overrides > stored .gitpagedocsconfig > env
28
+ * vars declared by the catalog. Returns null when no provider can be
29
+ * determined and no key/base URL is available, so the caller can guide setup.
30
+ */
31
+ export async function resolveChatCredentials(
32
+ input: ResolveChatCredentialsInput,
33
+ ): Promise<ResolvedChatCredentials | null> {
34
+ const env = input.env ?? process.env;
35
+ const configRepo = input.configRepo ?? new AiConfigFileRepository({ cwd: input.cwd });
36
+ const stored = await configRepo.read().catch(() => null);
37
+
38
+ const providerId = input.providerOverride
39
+ ? legacyProviderToCatalogId(input.providerOverride)
40
+ : stored
41
+ ? legacyProviderToCatalogId(stored.ai.provider)
42
+ : firstProviderFromEnv(env) ?? "openai";
43
+
44
+ const spec = PROVIDER_CATALOG[providerId];
45
+ const model = input.modelOverride?.trim() || stored?.ai.model?.trim() || spec.defaultModel;
46
+ const baseUrl = stored?.ai.baseUrl?.trim() || undefined;
47
+ const apiKey = stored?.ai.apiKey?.trim() || resolveApiKeyFromEnv(providerId, env);
48
+
49
+ const keyless = spec.auth === "none";
50
+ if (!keyless && !apiKey) return null;
51
+
52
+ return { providerId, model, apiKey, baseUrl };
53
+ }
54
+
55
+ function firstProviderFromEnv(
56
+ env: Readonly<Record<string, string | undefined>>,
57
+ ): AiProviderId | undefined {
58
+ for (const id of Object.keys(PROVIDER_CATALOG) as AiProviderId[]) {
59
+ if (resolveApiKeyFromEnv(id, env)) return id;
60
+ }
61
+ return undefined;
62
+ }
@@ -0,0 +1,44 @@
1
+ import type { AiMessage } from "@gitpagedocs/tools/ports";
2
+
3
+ /**
4
+ * @file chat-session.ts
5
+ * @description Pure conversation state for the interactive terminal chat. Holds
6
+ * the running message history and an optional system prompt, and builds the
7
+ * message array sent to a provider. No IO, no streaming, no terminal concerns —
8
+ * so the conversation logic is unit-testable without a provider or a TTY.
9
+ */
10
+ export class ChatSession {
11
+ private readonly history: AiMessage[] = [];
12
+
13
+ constructor(private readonly systemPrompt?: string) {}
14
+
15
+ /** Records a user turn. */
16
+ addUser(content: string): void {
17
+ this.history.push({ role: "user", content });
18
+ }
19
+
20
+ /** Records an assistant turn (the fully streamed reply). */
21
+ addAssistant(content: string): void {
22
+ this.history.push({ role: "assistant", content });
23
+ }
24
+
25
+ /** The user/assistant messages to send for the next completion. */
26
+ buildRequestMessages(): AiMessage[] {
27
+ return [...this.history];
28
+ }
29
+
30
+ /** The system prompt for the request's dedicated `system` field, if any. */
31
+ get system(): string | undefined {
32
+ return this.systemPrompt?.trim() || undefined;
33
+ }
34
+
35
+ /** Number of recorded turns (excluding the system prompt). */
36
+ get turnCount(): number {
37
+ return this.history.length;
38
+ }
39
+
40
+ /** Drops the conversation history, keeping the system prompt. */
41
+ clear(): void {
42
+ this.history.length = 0;
43
+ }
44
+ }
@@ -1,9 +1,16 @@
1
1
  /** Config-only command output messages - SRP: single place for all report messages */
2
2
  import { resolveRenderingUrl } from "../../builders/project-links.mjs";
3
3
  import { normalizeLayoutsDir } from "../../contracts/layouts-paths.mjs";
4
+ import { languageArtifactPaths } from "../../contracts/langs-paths.mjs";
5
+ import { SUPPORTED_LANGUAGES } from "../../contracts/languages.mjs";
4
6
 
5
7
  export function reportConfigOnlySuccess(options) {
6
- return [`Generated: ${options.outputDir}/ (config-only)`, "No index.html/index.js generated."];
8
+ const langs = languageArtifactPaths(options.outputDir);
9
+ return [
10
+ `Generated: ${options.outputDir}/ (config-only)`,
11
+ `UI strings: ${langs.manifest} + ${langs.dir}/{${SUPPORTED_LANGUAGES.join(",")}}.json`,
12
+ "No index.html/index.js generated.",
13
+ ];
7
14
  }
8
15
 
9
16
  export function reportLayoutConfig(options) {
@@ -5,16 +5,18 @@ import { DOCS } from "../content/docs.mjs";
5
5
  import { DOC_VERSIONS } from "../data/version-constants.mjs";
6
6
  import { buildRootConfig } from "./root-config-builder.mjs";
7
7
  import { buildVersionConfig } from "./version-config-builder.mjs";
8
+ import { buildLanguageArtifacts } from "./language-bundles-builder.mjs";
8
9
 
9
10
  /**
10
- * Build all config artifacts (root, layouts, versions, docs).
11
+ * Build all config artifacts (root, languages, layouts, versions, docs).
11
12
  * @param {object} options - { useLocalLayoutConfig, githubOwner, githubRepo, root }
12
- * @returns {object} { rootConfig, layoutsConfig, fallbackLayoutsConfig, docs, docsHtml, versionConfigs }
13
+ * @returns {object} { rootConfig, languageManifest, languageBundles, layoutsConfig, fallbackLayoutsConfig, docs, docsHtml, versionConfigs }
13
14
  */
14
15
  export function buildConfigArtifacts(options = {}) {
15
16
  const root = options.root ?? process.cwd();
16
17
 
17
18
  const rootConfig = buildRootConfig(options);
19
+ const { languageManifest, languageBundles } = buildLanguageArtifacts();
18
20
  const layoutsConfig = { layouts: LAYOUTS };
19
21
  const fallbackLayoutsConfig = { layouts: FALLBACK_LAYOUTS };
20
22
 
@@ -25,6 +27,8 @@ export function buildConfigArtifacts(options = {}) {
25
27
 
26
28
  return {
27
29
  rootConfig,
30
+ languageManifest,
31
+ languageBundles,
28
32
  layoutsConfig,
29
33
  fallbackLayoutsConfig,
30
34
  docs: DOCS,
@@ -0,0 +1,24 @@
1
+ /** Build the language manifest + per-language UI string bundles for gitpagedocs */
2
+ import { buildLanguageManifest, splitLanguageBundles } from "@gitpagedocs/tools/i18n";
3
+ import { SUPPORTED_LANGUAGES } from "../contracts/languages.mjs";
4
+ import { defaultLangMenu } from "../data/i18n-langmenu.mjs";
5
+ import { defaultTranslations } from "../data/i18n-translations.mjs";
6
+
7
+ function assertLanguageStrings(languages) {
8
+ const missing = languages.filter((language) => !defaultLangMenu[language]);
9
+ if (missing.length > 0) {
10
+ throw new Error(`No UI strings (i18n-langmenu) for supported language(s): ${missing.join(", ")}`);
11
+ }
12
+ }
13
+
14
+ /**
15
+ * @returns {{ languageManifest: { languages: string[] }, languageBundles: Record<string, object> }}
16
+ */
17
+ export function buildLanguageArtifacts() {
18
+ const languages = [...SUPPORTED_LANGUAGES];
19
+ assertLanguageStrings(languages);
20
+ return {
21
+ languageManifest: buildLanguageManifest(languages),
22
+ languageBundles: splitLanguageBundles(languages, defaultLangMenu, defaultTranslations),
23
+ };
24
+ }
@@ -6,10 +6,7 @@ import {
6
6
  resolveSourceViewerPath,
7
7
  resolveLayoutsLinks,
8
8
  } from "./project-links.mjs";
9
- import { DOCS } from "../content/docs.mjs";
10
9
  import { DOC_VERSIONS } from "../data/version-constants.mjs";
11
- import { defaultLangMenu } from "../data/i18n-langmenu.mjs";
12
- import { defaultTranslations } from "../data/i18n-translations.mjs";
13
10
  import { getDefaultSiteConfig } from "../data/default-site-config.mjs";
14
11
 
15
12
  export function buildRootConfig(options = {}) {
@@ -37,7 +34,7 @@ export function buildRootConfig(options = {}) {
37
34
  "source-viewer-path": sourceViewerPath,
38
35
  }));
39
36
 
40
- const baseSiteConfig = getDefaultSiteConfig(DOCS, projectLink);
37
+ const baseSiteConfig = getDefaultSiteConfig(projectLink);
41
38
 
42
39
  return {
43
40
  site: {
@@ -51,11 +48,9 @@ export function buildRootConfig(options = {}) {
51
48
  rendering: renderingUrl,
52
49
  AiChatEnabled: true,
53
50
  docsAccess: { enabled: false, publicKey: "" },
54
- langmenu: defaultLangMenu,
55
51
  },
56
52
  VersionControl: {
57
53
  versions: versionEntries,
58
54
  },
59
- translations: defaultTranslations,
60
55
  };
61
56
  }
@@ -172,9 +172,10 @@ The version config can render a **Source code** container through \`routes-sourc
172
172
 
173
173
  ## Config keys (site)
174
174
 
175
- - \`name\`, \`defaultLanguage\`, \`supportedLanguages\`
175
+ - \`name\`, \`defaultLanguage\`
176
176
  - \`docsVersion\`, \`rendering\`, \`ThemeDefault\`, \`ThemeModeDefault\`
177
177
  - \`ProjectLink\`, \`layoutsConfigPathOficial\`, \`layoutsConfigPath\`
178
+ - Languages and UI strings: \`gitpagedocs/langs.json\` + \`gitpagedocs/langs/<lang>.json\`
178
179
 
179
180
  ## Environment variables
180
181
 
@@ -316,7 +317,14 @@ Supported adapters:
316
317
  `,
317
318
  configuration: `# Configuration
318
319
 
319
- Runtime configuration lives in \`gitpagedocs/config.json\`.
320
+ Runtime configuration lives in \`gitpagedocs/config.json\`. UI text lives next to it, one file per language.
321
+
322
+ ## Languages (\`langs.json\` + \`langs/\`)
323
+
324
+ - \`gitpagedocs/langs.json\` lists the shipped languages in menu order: \`{ "languages": ["en", "pt", "es"] }\`.
325
+ - \`gitpagedocs/langs/<lang>.json\` holds that language's strings: \`langmenu\` (header, search, source viewer, audio player, AI chat and docs-access labels) and \`translations\` (\`notFound\`, \`navigation\`, \`footer\`).
326
+ - To add a language, create \`langs/<lang>.json\` and append its code to \`langs.json\`.
327
+ - Older \`config.json\` files that still inline \`site.langmenu\` / \`translations\` keep working; when both exist, the \`langs/\` files win and any missing key is backfilled from the current release.
320
328
 
321
329
  ## \`site\` section
322
330
 
@@ -324,7 +332,6 @@ Important keys:
324
332
 
325
333
  - \`name\`
326
334
  - \`defaultLanguage\`
327
- - \`supportedLanguages\`
328
335
  - \`docsVersion\`
329
336
  - \`rendering\`
330
337
  - \`ThemeDefault\`
@@ -453,7 +460,7 @@ A pnpm + turborepo monorepo where all business logic lives in one shared core (\
453
460
 
454
461
  - **frontend/** — Next.js viewer (Feature-Sliced: \`app / widgets / features / entities / shared\`); static export.
455
462
  - **cli/** — the published \`gitpagedocs\` bin (hexagonal: \`presentation / application / domain / infrastructure\`) + \`cli/ai/\` (the AI documentation CLI).
456
- - **tools/** — \`@gitpagedocs/tools\`: \`ai/ security/ crypto/ cache/ config/ logger/ errors/ filesystem/ documentation/ ports/\`.
463
+ - **tools/** — \`@gitpagedocs/tools\`: \`ai/ security/ crypto/ cache/ config/ logger/ errors/ filesystem/ documentation/ i18n/ ports/\`.
457
464
  - **mcp/** — \`@gitpagedocs/mcp\`: MCP tools + resources delegating to \`tools/\`.
458
465
 
459
466
  ## Main runtime modules (frontend)
@@ -156,9 +156,10 @@ El config de version puede renderizar un contenedor **Codigo fuente** via \`rout
156
156
 
157
157
  ## Claves de config (site)
158
158
 
159
- - \`name\`, \`defaultLanguage\`, \`supportedLanguages\`
159
+ - \`name\`, \`defaultLanguage\`
160
160
  - \`docsVersion\`, \`rendering\`, \`ThemeDefault\`, \`ThemeModeDefault\`
161
161
  - \`ProjectLink\`, \`layoutsConfigPathOficial\`, \`layoutsConfigPath\`
162
+ - Idiomas y textos de la UI: \`gitpagedocs/langs.json\` + \`gitpagedocs/langs/<lang>.json\`
162
163
 
163
164
  ## Variables de entorno
164
165
 
@@ -167,7 +168,14 @@ El config de version puede renderizar un contenedor **Codigo fuente** via \`rout
167
168
  `,
168
169
  configuration: `# Configuracion
169
170
 
170
- La configuracion de runtime esta en \`gitpagedocs/config.json\`.
171
+ La configuracion de runtime esta en \`gitpagedocs/config.json\`. Los textos de la UI viven al lado, un archivo por idioma.
172
+
173
+ ## Idiomas (\`langs.json\` + \`langs/\`)
174
+
175
+ - \`gitpagedocs/langs.json\` lista los idiomas disponibles en el orden del menu: \`{ "languages": ["en", "pt", "es"] }\`.
176
+ - \`gitpagedocs/langs/<lang>.json\` guarda los textos de ese idioma: \`langmenu\` (cabecera, busqueda, visor de codigo, reproductor de audio, chat de IA y acceso a los docs) y \`translations\` (\`notFound\`, \`navigation\`, \`footer\`).
177
+ - Para agregar un idioma, crea \`langs/<lang>.json\` y agrega su codigo a \`langs.json\`.
178
+ - Los \`config.json\` antiguos que aun traen \`site.langmenu\` / \`translations\` inline siguen funcionando; cuando existen ambos, los archivos de \`langs/\` prevalecen y cualquier clave ausente se completa desde la version actual.
171
179
 
172
180
  ## Seccion \`site\`
173
181
 
@@ -175,7 +183,6 @@ Claves principales:
175
183
 
176
184
  - \`name\`: titulo del proyecto en UI
177
185
  - \`defaultLanguage\`: idioma por defecto
178
- - \`supportedLanguages\`: lista de idiomas disponibles
179
186
  - \`HideThemeSelector\`: ocultar/mostrar selector de tema
180
187
  - \`ThemeDefault\`: id del tema inicial
181
188
  - \`ThemeModeDefault\`: modo inicial (\`light\` o \`dark\`)
@@ -293,7 +300,7 @@ El proyecto esta organizado por fronteras de feature y responsabilidades de runt
293
300
 
294
301
  - **frontend/** — visor Next.js (Feature-Sliced: app / widgets / features / entities / shared); export estatico.
295
302
  - **cli/** — el bin publicado \`gitpagedocs\` (hexagonal) + \`cli/ai/\` (CLI de documentacion con IA).
296
- - **tools/** — \`@gitpagedocs/tools\`: ai/ security/ crypto/ cache/ config/ logger/ errors/ filesystem/ documentation/ ports/.
303
+ - **tools/** — \`@gitpagedocs/tools\`: ai/ security/ crypto/ cache/ config/ logger/ errors/ filesystem/ documentation/ i18n/ ports/.
297
304
  - **mcp/** — \`@gitpagedocs/mcp\`: herramientas + recursos MCP que delegan a tools/.
298
305
 
299
306
  ## Modulos principales (frontend)
@@ -156,9 +156,10 @@ O config da versao pode renderizar um container **Codigo fonte** via \`routes-so
156
156
 
157
157
  ## Chaves de config (site)
158
158
 
159
- - \`name\`, \`defaultLanguage\`, \`supportedLanguages\`
159
+ - \`name\`, \`defaultLanguage\`
160
160
  - \`docsVersion\`, \`rendering\`, \`ThemeDefault\`, \`ThemeModeDefault\`
161
161
  - \`ProjectLink\`, \`layoutsConfigPathOficial\`, \`layoutsConfigPath\`
162
+ - Idiomas e textos da UI: \`gitpagedocs/langs.json\` + \`gitpagedocs/langs/<lang>.json\`
162
163
 
163
164
  ## Variaveis de ambiente
164
165
 
@@ -167,7 +168,14 @@ O config da versao pode renderizar um container **Codigo fonte** via \`routes-so
167
168
  `,
168
169
  configuration: `# Configuracao
169
170
 
170
- A configuracao de runtime fica em \`gitpagedocs/config.json\`.
171
+ A configuracao de runtime fica em \`gitpagedocs/config.json\`. Os textos da UI ficam ao lado, um arquivo por idioma.
172
+
173
+ ## Idiomas (\`langs.json\` + \`langs/\`)
174
+
175
+ - \`gitpagedocs/langs.json\` lista os idiomas disponiveis na ordem do menu: \`{ "languages": ["en", "pt", "es"] }\`.
176
+ - \`gitpagedocs/langs/<lang>.json\` guarda os textos daquele idioma: \`langmenu\` (cabecalho, busca, visualizador de codigo, player de audio, chat de IA e acesso aos docs) e \`translations\` (\`notFound\`, \`navigation\`, \`footer\`).
177
+ - Para adicionar um idioma, crie \`langs/<lang>.json\` e inclua o codigo em \`langs.json\`.
178
+ - Arquivos \`config.json\` antigos que ainda trazem \`site.langmenu\` / \`translations\` inline continuam funcionando; quando os dois existem, os arquivos de \`langs/\` prevalecem e qualquer chave ausente e preenchida a partir da versao atual.
171
179
 
172
180
  ## Secao \`site\`
173
181
 
@@ -175,7 +183,6 @@ Principais chaves:
175
183
 
176
184
  - \`name\`: titulo do projeto no UI
177
185
  - \`defaultLanguage\`: idioma padrao
178
- - \`supportedLanguages\`: lista de idiomas disponiveis
179
186
  - \`HideThemeSelector\`: esconde/mostra seletor de tema
180
187
  - \`ThemeDefault\`: id do tema inicial
181
188
  - \`ThemeModeDefault\`: modo inicial (\`light\` ou \`dark\`)
@@ -293,7 +300,7 @@ O projeto e organizado por fronteiras de feature e responsabilidades do runtime.
293
300
 
294
301
  - **frontend/** — visualizador Next.js (Feature-Sliced: app / widgets / features / entities / shared); export estatico.
295
302
  - **cli/** — o bin publicado \`gitpagedocs\` (hexagonal) + \`cli/ai/\` (CLI de documentacao com IA).
296
- - **tools/** — \`@gitpagedocs/tools\`: ai/ security/ crypto/ cache/ config/ logger/ errors/ filesystem/ documentation/ ports/.
303
+ - **tools/** — \`@gitpagedocs/tools\`: ai/ security/ crypto/ cache/ config/ logger/ errors/ filesystem/ documentation/ i18n/ ports/.
297
304
  - **mcp/** — \`@gitpagedocs/mcp\`: ferramentas + recursos MCP que delegam ao tools/.
298
305
 
299
306
  ## Modulos principais (frontend)
@@ -1,3 +1,3 @@
1
- export const PACKAGE_VERSION = "1.1.66";
1
+ export const PACKAGE_VERSION = "1.1.68";
2
2
 
3
3
  export const DOC_VERSIONS = [PACKAGE_VERSION];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Canonical locations for the language artifacts inside the docs output dir:
3
+ *
4
+ * <outputDir>/langs.json manifest listing the shipped languages
5
+ * <outputDir>/langs/<lang>.json UI strings (langmenu + translations) of one language
6
+ *
7
+ * The file names come from @gitpagedocs/tools so the generator and the viewer
8
+ * never disagree about where the bundles live.
9
+ */
10
+ import { LANGS_DIRNAME, LANGS_MANIFEST_FILENAME } from "@gitpagedocs/tools/i18n";
11
+
12
+ /**
13
+ * Repo-relative POSIX paths of the language artifacts for a docs output dir.
14
+ *
15
+ * @param {string} outputDir Docs output dir (e.g. `gitpagedocs`).
16
+ * @returns {{ manifest: string, dir: string, bundle: (language: string) => string }}
17
+ */
18
+ export function languageArtifactPaths(outputDir) {
19
+ const dir = `${outputDir}/${LANGS_DIRNAME}`;
20
+ return {
21
+ manifest: `${outputDir}/${LANGS_MANIFEST_FILENAME}`,
22
+ dir,
23
+ bundle: (language) => `${dir}/${language}.json`,
24
+ };
25
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Languages the generator ships UI strings and docs for, in menu order. This
3
+ * is the list written to `gitpagedocs/langs.json`; every entry must have a
4
+ * `langs/<lang>.json` bundle and a docs content module.
5
+ */
6
+ export const SUPPORTED_LANGUAGES = Object.freeze(["en", "pt", "es"]);
@@ -1,8 +1,8 @@
1
- export function getDefaultSiteConfig(DOCS, projectLink) {
1
+ /** Site chrome defaults. The language list and UI strings live in langs.json + langs/. */
2
+ export function getDefaultSiteConfig(projectLink) {
2
3
  return {
3
4
  name: "Git Pages Docs",
4
5
  defaultLanguage: "en",
5
- supportedLanguages: Object.keys(DOCS || { en: 1, pt: 1, es: 1 }),
6
6
  HideThemeSelector: false,
7
7
  ThemeDefault: "aurora-dark",
8
8
  ThemeModeDefault: "dark",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitpagedocs/cli",
3
- "version": "1.1.66",
3
+ "version": "1.1.68",
4
4
  "description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.",
5
5
  "bin": {
6
6
  "gitpagedocs": "index.mjs"
@@ -29,8 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.5.1",
32
- "@gitpagedocs/mcp": "^1.1.66",
33
- "@gitpagedocs/tools": "^1.1.66",
32
+ "@gitpagedocs/mcp": "^1.1.68",
33
+ "@gitpagedocs/tools": "^1.1.68",
34
34
  "tsx": "^4.21.0"
35
35
  },
36
36
  "repository": {
@@ -0,0 +1,259 @@
1
+ import readline from "node:readline";
2
+ import { createDefaultFactory } from "@gitpagedocs/tools/ai";
3
+ import { PROVIDER_CATALOG } from "@gitpagedocs/tools";
4
+ import type { AIProvider, ProviderConfig } from "@gitpagedocs/tools/ports";
5
+ import { ChatSession } from "../../ai/core/chat-session";
6
+ import { resolveChatCredentials, type ResolvedChatCredentials } from "../../ai/application/resolve-chat-credentials";
7
+ import {
8
+ ElapsedSpinner,
9
+ accent,
10
+ danger,
11
+ writeChrome,
12
+ writeContent,
13
+ } from "../ui/terminal-stream";
14
+ import type { CommandContext } from "./run-command";
15
+
16
+ /** Positions the model as a documentation assistant for the current project. */
17
+ const DEFAULT_CHAT_SYSTEM_PROMPT = [
18
+ "You are a concise documentation and coding assistant embedded in the gitpagedocs CLI.",
19
+ "Answer questions about the user's project, source code and documentation.",
20
+ "Prefer short, correct, actionable answers; use GitHub-flavored markdown for code.",
21
+ ].join(" ");
22
+
23
+ const EXIT_REARM_MS = 3000;
24
+
25
+ interface ChatFlags {
26
+ provider?: string;
27
+ model?: string;
28
+ system?: string;
29
+ question?: string;
30
+ }
31
+
32
+ function parseChatArgs(args: readonly string[]): ChatFlags {
33
+ // args[0] is the verb ("chat"); parse flags and collect the free-text question.
34
+ const flags: ChatFlags = {};
35
+ const positional: string[] = [];
36
+ for (let i = 1; i < args.length; i += 1) {
37
+ const arg = args[i];
38
+ if (arg === "--provider" || arg === "--model" || arg === "--system") {
39
+ const value = args[i + 1];
40
+ if (value !== undefined) {
41
+ if (arg === "--provider") flags.provider = value;
42
+ else if (arg === "--model") flags.model = value;
43
+ else flags.system = value;
44
+ i += 1;
45
+ }
46
+ continue;
47
+ }
48
+ if (arg.startsWith("--")) continue;
49
+ positional.push(arg);
50
+ }
51
+ const question = positional.join(" ").trim();
52
+ if (question) flags.question = question;
53
+ return flags;
54
+ }
55
+
56
+ async function readPipedStdin(): Promise<string> {
57
+ if (process.stdin.isTTY) return "";
58
+ const chunks: Buffer[] = [];
59
+ for await (const chunk of process.stdin) {
60
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
61
+ }
62
+ return Buffer.concat(chunks).toString("utf-8").trim();
63
+ }
64
+
65
+ /**
66
+ * Streams one assistant turn to stdout. Cancellation is a scoped concern: the
67
+ * caller owns the AbortController and the stream stops the moment it aborts
68
+ * (the async generator's cleanup tears down the HTTP request). Returns whether
69
+ * the turn completed (false = aborted or errored).
70
+ */
71
+ async function streamTurn(
72
+ provider: AIProvider,
73
+ config: ProviderConfig,
74
+ session: ChatSession,
75
+ userText: string,
76
+ signal: AbortSignal,
77
+ ): Promise<boolean> {
78
+ session.addUser(userText);
79
+ const spinner = new ElapsedSpinner("Thinking");
80
+ spinner.start();
81
+ let full = "";
82
+ let started = false;
83
+ try {
84
+ for await (const delta of provider.stream(
85
+ { messages: session.buildRequestMessages(), system: session.system, signal },
86
+ config,
87
+ )) {
88
+ if (!started) {
89
+ spinner.stop();
90
+ started = true;
91
+ }
92
+ full += delta;
93
+ writeContent(delta);
94
+ }
95
+ if (started) writeContent("\n");
96
+ session.addAssistant(full);
97
+ writeChrome(
98
+ `${PROVIDER_CATALOG[config.providerId].label} · ${config.model} · ${spinner.elapsedSeconds().toFixed(1)}s`,
99
+ );
100
+ return true;
101
+ } catch (error) {
102
+ spinner.stop();
103
+ if (signal.aborted || (error instanceof Error && error.name === "AbortError")) {
104
+ if (started) writeContent("\n");
105
+ writeChrome(danger("Interrupted."));
106
+ // Keep the partial reply in history so follow-ups have context.
107
+ session.addAssistant(full);
108
+ return false;
109
+ }
110
+ writeChrome(danger(`Error: ${error instanceof Error ? error.message : String(error)}`));
111
+ return false;
112
+ } finally {
113
+ spinner.stop();
114
+ }
115
+ }
116
+
117
+ /** One-shot mode: a single question in, the answer out, then exit. */
118
+ async function runOneShot(
119
+ provider: AIProvider,
120
+ config: ProviderConfig,
121
+ session: ChatSession,
122
+ question: string,
123
+ ): Promise<void> {
124
+ const controller = new AbortController();
125
+ const onSigint = () => controller.abort();
126
+ process.on("SIGINT", onSigint);
127
+ try {
128
+ const ok = await streamTurn(provider, config, session, question, controller.signal);
129
+ if (!ok) process.exitCode = 1;
130
+ } finally {
131
+ process.off("SIGINT", onSigint);
132
+ }
133
+ }
134
+
135
+ /** Interactive REPL: requires a TTY on both ends. */
136
+ async function runRepl(
137
+ provider: AIProvider,
138
+ config: ProviderConfig,
139
+ session: ChatSession,
140
+ ): Promise<void> {
141
+ const spec = PROVIDER_CATALOG[config.providerId];
142
+ writeChrome(`Chat — ${spec.label} · ${config.model}`);
143
+ writeChrome("Commands: /clear reset · /help · /exit (or Ctrl+C twice)");
144
+
145
+ const rl = readline.createInterface({
146
+ input: process.stdin,
147
+ output: process.stderr,
148
+ prompt: accent("› "),
149
+ });
150
+
151
+ let generating: AbortController | null = null;
152
+ let exitArmed = false;
153
+ let exitTimer: ReturnType<typeof setTimeout> | null = null;
154
+
155
+ rl.on("SIGINT", () => {
156
+ if (generating) {
157
+ generating.abort();
158
+ return;
159
+ }
160
+ if (exitArmed) {
161
+ rl.close();
162
+ return;
163
+ }
164
+ exitArmed = true;
165
+ writeChrome("Press Ctrl+C again to exit.");
166
+ exitTimer = setTimeout(() => {
167
+ exitArmed = false;
168
+ }, EXIT_REARM_MS);
169
+ rl.prompt();
170
+ });
171
+
172
+ rl.prompt();
173
+ for await (const line of rl) {
174
+ if (exitTimer) {
175
+ clearTimeout(exitTimer);
176
+ exitTimer = null;
177
+ }
178
+ exitArmed = false;
179
+ const input = line.trim();
180
+ if (!input) {
181
+ rl.prompt();
182
+ continue;
183
+ }
184
+ if (input === "/exit" || input === "/quit") break;
185
+ if (input === "/clear") {
186
+ session.clear();
187
+ writeChrome("Conversation cleared.");
188
+ rl.prompt();
189
+ continue;
190
+ }
191
+ if (input === "/help") {
192
+ writeChrome("Type a message to chat. /clear resets history, /exit quits, Ctrl+C interrupts a reply.");
193
+ rl.prompt();
194
+ continue;
195
+ }
196
+ generating = new AbortController();
197
+ await streamTurn(provider, config, session, input, generating.signal);
198
+ generating = null;
199
+ rl.prompt();
200
+ }
201
+ rl.close();
202
+ writeChrome("Bye.");
203
+ }
204
+
205
+ function printSetupHelp(providerHint: string): void {
206
+ writeChrome(danger("No AI credentials found."));
207
+ writeChrome(`Set an API key via environment variable (e.g. ${providerHint}) or run: gitpagedocs ai`);
208
+ writeChrome("Then: gitpagedocs chat \"your question\" · or just: gitpagedocs chat");
209
+ }
210
+
211
+ /**
212
+ * `gitpagedocs chat [question]` — interactive AI chat in the terminal.
213
+ * Flags: --provider <id>, --model <id>, --system <prompt>.
214
+ * One-shot when a question is given or stdin is piped; a REPL on a TTY.
215
+ */
216
+ export async function runChat(ctx: CommandContext): Promise<void> {
217
+ const flags = parseChatArgs(ctx.args);
218
+ const creds: ResolvedChatCredentials | null = await resolveChatCredentials({
219
+ cwd: ctx.cwd,
220
+ providerOverride: flags.provider,
221
+ modelOverride: flags.model,
222
+ });
223
+
224
+ if (!creds) {
225
+ const hintProvider = flags.provider ?? "openai";
226
+ const hint = PROVIDER_CATALOG[
227
+ (hintProvider in PROVIDER_CATALOG ? hintProvider : "openai") as keyof typeof PROVIDER_CATALOG
228
+ ].envVars[0] ?? "OPENAI_API_KEY";
229
+ printSetupHelp(hint);
230
+ process.exitCode = 1;
231
+ return;
232
+ }
233
+
234
+ const provider = createDefaultFactory().create(creds.providerId);
235
+ const config: ProviderConfig = {
236
+ providerId: creds.providerId,
237
+ model: creds.model,
238
+ apiKey: creds.apiKey,
239
+ baseUrl: creds.baseUrl,
240
+ };
241
+ const session = new ChatSession(flags.system ?? DEFAULT_CHAT_SYSTEM_PROMPT);
242
+
243
+ const piped = await readPipedStdin();
244
+ const oneShot = [flags.question, piped].filter(Boolean).join("\n\n").trim();
245
+
246
+ if (oneShot) {
247
+ await runOneShot(provider, config, session, oneShot);
248
+ return;
249
+ }
250
+
251
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
252
+ writeChrome(danger("No question given and no interactive terminal available."));
253
+ writeChrome('Usage: gitpagedocs chat "your question"');
254
+ process.exitCode = 1;
255
+ return;
256
+ }
257
+
258
+ await runRepl(provider, config, session);
259
+ }
@@ -1,20 +1,33 @@
1
1
  import path from "node:path";
2
- import { defaultConfigLoader, isAppError } from "@gitpagedocs/tools";
2
+ import { readFile } from "node:fs/promises";
3
+ import { DEFAULT_LANGS_MANIFEST_PATH, defaultConfigLoader, isAppError, parseLanguageManifest } from "@gitpagedocs/tools";
3
4
  import type { CommandContext } from "./run-command";
4
5
 
6
+ const UNKNOWN = "—";
7
+
8
+ /** Languages from `gitpagedocs/langs.json`, else the legacy inline `site.supportedLanguages`. */
9
+ async function resolveLanguages(cwd: string, site: Record<string, unknown>): Promise<string[]> {
10
+ try {
11
+ const manifest = parseLanguageManifest(JSON.parse(await readFile(path.join(cwd, DEFAULT_LANGS_MANIFEST_PATH), "utf-8")));
12
+ if (manifest) return manifest.languages;
13
+ } catch {
14
+ // No manifest: fall back to the inline list below.
15
+ }
16
+ return Array.isArray(site.supportedLanguages) ? (site.supportedLanguages as string[]) : [];
17
+ }
18
+
5
19
  /** `gitpagedocs config` — resolve and summarize the active gitpagedocs config. */
6
20
  export async function runConfig(ctx: CommandContext): Promise<void> {
7
21
  try {
8
22
  const { config, sourcePath, extension } = await defaultConfigLoader.loadGitPageDocsConfig(ctx.cwd);
9
23
  const site = (config as { site?: Record<string, unknown> }).site ?? {};
24
+ const languages = await resolveLanguages(ctx.cwd, site);
10
25
  // eslint-disable-next-line no-console
11
26
  console.log(
12
27
  `\n Config: ${path.relative(ctx.cwd, sourcePath)} (${extension})\n` +
13
- ` site name : ${String(site.name ?? "—")}\n` +
14
- ` default language : ${String(site.defaultLanguage ?? "—")}\n` +
15
- ` languages : ${
16
- Array.isArray(site.supportedLanguages) ? (site.supportedLanguages as string[]).join(", ") : "—"
17
- }\n`,
28
+ ` site name : ${String(site.name ?? UNKNOWN)}\n` +
29
+ ` default language : ${String(site.defaultLanguage ?? UNKNOWN)}\n` +
30
+ ` languages : ${languages.length > 0 ? languages.join(", ") : UNKNOWN}\n`,
18
31
  );
19
32
  } catch (error) {
20
33
  const message = isAppError(error) ? error.message : String(error);
@@ -23,6 +23,7 @@ import { runMcp } from "./mcp";
23
23
  import { runDocs } from "./docs";
24
24
  import { runPagesActions, runPagesDeploy } from "./pages";
25
25
  import { runPassword } from "./password";
26
+ import { runChat } from "./chat";
26
27
 
27
28
  const REGISTRY: Record<string, CommandHandler> = {
28
29
  version: runVersion,
@@ -37,6 +38,7 @@ const REGISTRY: Record<string, CommandHandler> = {
37
38
  mcp: runMcp,
38
39
  docs: runDocs,
39
40
  password: runPassword,
41
+ chat: runChat,
40
42
  };
41
43
 
42
44
  /** Returns true if a new command handled the invocation (skip legacy flow). */
@@ -45,8 +45,9 @@ const params: CliRuntimeParams = {
45
45
  };
46
46
 
47
47
  async function main(): Promise<void> {
48
- // `mcp start` owns stdout (the MCP stdio channel) — no banner/credits there.
49
- const stdoutOwnedByCommand = process.argv[2] === "mcp";
48
+ // Commands that own stdout get no banner/credits: `mcp start` (the MCP stdio
49
+ // channel) and `chat` (streamed model output must be the only thing on stdout).
50
+ const stdoutOwnedByCommand = process.argv[2] === "mcp" || process.argv[2] === "chat";
50
51
  if (!stdoutOwnedByCommand) printBanner();
51
52
 
52
53
  // New informational/utility commands (version, doctor, provider, models,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file terminal-stream.ts
3
+ * @description Terminal rendering for streamed AI output, non-TTY-first:
4
+ * - model text goes to STDOUT (so `chat "q" > out.txt` captures clean content)
5
+ * - all chrome (spinner, headers, hints) goes to STDERR
6
+ * - colours/spinner are emitted only on a TTY; a pipe gets plain text
7
+ * Keeps the streaming/rendering side effects in one place, apart from the pure
8
+ * ChatSession state.
9
+ */
10
+ const ANSI = {
11
+ reset: "\x1b[0m",
12
+ dim: "\x1b[2m",
13
+ cyan: "\x1b[36m",
14
+ green: "\x1b[32m",
15
+ red: "\x1b[31m",
16
+ } as const;
17
+
18
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
19
+ const SPINNER_INTERVAL_MS = 80;
20
+
21
+ function stdoutIsTty(): boolean {
22
+ return Boolean(process.stdout.isTTY);
23
+ }
24
+
25
+ function stderrIsTty(): boolean {
26
+ return Boolean(process.stderr.isTTY);
27
+ }
28
+
29
+ /** Writes model content to stdout (verbatim; the actual deliverable). */
30
+ export function writeContent(text: string): void {
31
+ process.stdout.write(text);
32
+ }
33
+
34
+ /** Writes one chrome line to stderr, dimmed on a TTY. */
35
+ export function writeChrome(line: string): void {
36
+ if (stderrIsTty()) {
37
+ process.stderr.write(`${ANSI.dim}${line}${ANSI.reset}\n`);
38
+ } else {
39
+ process.stderr.write(`${line}\n`);
40
+ }
41
+ }
42
+
43
+ /** Colour helpers that no-op when stderr is not a TTY. */
44
+ export function accent(text: string): string {
45
+ return stderrIsTty() ? `${ANSI.cyan}${text}${ANSI.reset}` : text;
46
+ }
47
+
48
+ export function danger(text: string): string {
49
+ return stderrIsTty() ? `${ANSI.red}${text}${ANSI.reset}` : text;
50
+ }
51
+
52
+ /**
53
+ * A spinner with elapsed-time readout on stderr. On a non-TTY it prints a
54
+ * single static line instead of animating, so logs stay clean. `stop()` clears
55
+ * the line and is idempotent.
56
+ */
57
+ export class ElapsedSpinner {
58
+ private timer: ReturnType<typeof setInterval> | null = null;
59
+ private frame = 0;
60
+ private readonly startedAt = Date.now();
61
+
62
+ constructor(private readonly label: string) {}
63
+
64
+ start(): void {
65
+ if (!stderrIsTty()) {
66
+ process.stderr.write(`${this.label}...\n`);
67
+ return;
68
+ }
69
+ this.render();
70
+ this.timer = setInterval(() => this.render(), SPINNER_INTERVAL_MS);
71
+ }
72
+
73
+ private render(): void {
74
+ const seconds = ((Date.now() - this.startedAt) / 1000).toFixed(1);
75
+ const glyph = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
76
+ this.frame += 1;
77
+ process.stderr.write(`\r${ANSI.cyan}${glyph}${ANSI.reset} ${ANSI.dim}${this.label} ${seconds}s${ANSI.reset}\x1b[K`);
78
+ }
79
+
80
+ stop(): void {
81
+ if (this.timer) {
82
+ clearInterval(this.timer);
83
+ this.timer = null;
84
+ }
85
+ if (stderrIsTty()) {
86
+ process.stderr.write("\r\x1b[K");
87
+ }
88
+ }
89
+
90
+ /** Elapsed seconds since construction. */
91
+ elapsedSeconds(): number {
92
+ return (Date.now() - this.startedAt) / 1000;
93
+ }
94
+ }
95
+
96
+ export const supportsColor = stderrIsTty;
97
+ export const isInteractiveOutput = stdoutIsTty;
@@ -9,6 +9,8 @@ import {
9
9
  normalizeToOutputPath,
10
10
  } from "./doc-path-resolver.mjs";
11
11
  import { layoutsArtifactPaths } from "../contracts/layouts-paths.mjs";
12
+ import { languageArtifactPaths } from "../contracts/langs-paths.mjs";
13
+ import { SUPPORTED_LANGUAGES } from "../contracts/languages.mjs";
12
14
 
13
15
  const DEFAULT_ICON_SVG = `<?xml version="1.0" encoding="utf-8"?>
14
16
  <svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -29,7 +31,7 @@ function listExistingVersionIds(root, outputDir) {
29
31
  function removeLegacySourceViewerFiles(root, outputDir) {
30
32
  const versionIds = listExistingVersionIds(root, outputDir);
31
33
  for (const versionId of versionIds) {
32
- for (const language of ["pt", "en", "es"]) {
34
+ for (const language of SUPPORTED_LANGUAGES) {
33
35
  for (const fileName of ["source-viewer", "source-viewer.html"]) {
34
36
  const legacyPath = path.join(root, outputDir, "docs", "versions", versionId, language, fileName);
35
37
  if (existsSync(legacyPath)) {
@@ -75,6 +77,15 @@ async function writeLayoutArtifacts(options) {
75
77
  }
76
78
  }
77
79
 
80
+ /** Write the language manifest and one UI-strings bundle per shipped language. */
81
+ async function writeLanguageArtifacts(root, outputDir, artifacts) {
82
+ const paths = languageArtifactPaths(outputDir);
83
+ await writeJson(root, paths.manifest, artifacts.languageManifest);
84
+ for (const [language, bundle] of Object.entries(artifacts.languageBundles)) {
85
+ await writeJson(root, paths.bundle(language), bundle);
86
+ }
87
+ }
88
+
78
89
  export async function writeConfigOnlyOutput(options) {
79
90
  const {
80
91
  root,
@@ -88,7 +99,7 @@ export async function writeConfigOnlyOutput(options) {
88
99
  } = options;
89
100
 
90
101
  // Keep only versioned docs output in docs/, removing legacy root language folders.
91
- for (const legacyLanguageDir of ["en", "pt", "es"]) {
102
+ for (const legacyLanguageDir of SUPPORTED_LANGUAGES) {
92
103
  const legacyPath = path.join(root, outputDir, "docs", legacyLanguageDir);
93
104
  if (existsSync(legacyPath)) {
94
105
  rmSync(legacyPath, { recursive: true, force: true });
@@ -96,6 +107,7 @@ export async function writeConfigOnlyOutput(options) {
96
107
  }
97
108
 
98
109
  await writeJson(root, `${outputDir}/config.json`, artifacts.rootConfig);
110
+ await writeLanguageArtifacts(root, outputDir, artifacts);
99
111
  await writeText(root, `${outputDir}/icon.svg`, DEFAULT_ICON_SVG);
100
112
  await writeLayoutArtifacts({
101
113
  root,
@@ -146,7 +158,7 @@ export async function writeConfigOnlyOutput(options) {
146
158
  }
147
159
 
148
160
  // Ensure version folders always include an index file.
149
- for (const language of ["pt", "en", "es"]) {
161
+ for (const language of SUPPORTED_LANGUAGES) {
150
162
  const fallbackIndex = artifacts.docs?.[language]?.index;
151
163
  if (!fallbackIndex) continue;
152
164
  const versionIndexPath = `${outputDir}/docs/versions/${versionId}/${language}/index.md`;