@gitpagedocs/cli 1.1.65 → 1.1.67
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/ai/application/resolve-chat-credentials.ts +62 -0
- package/ai/core/chat-session.ts +44 -0
- package/contracts/doc-versions.mjs +1 -1
- package/package.json +3 -3
- package/presentation/commands/chat.ts +259 -0
- package/presentation/commands/run-command.ts +2 -0
- package/presentation/index.ts +3 -2
- package/presentation/ui/terminal-stream.ts +97 -0
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gitpagedocs/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.67",
|
|
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.
|
|
33
|
-
"@gitpagedocs/tools": "^1.1.
|
|
32
|
+
"@gitpagedocs/mcp": "^1.1.67",
|
|
33
|
+
"@gitpagedocs/tools": "^1.1.67",
|
|
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
|
+
}
|
|
@@ -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). */
|
package/presentation/index.ts
CHANGED
|
@@ -45,8 +45,9 @@ const params: CliRuntimeParams = {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
async function main(): Promise<void> {
|
|
48
|
-
// `mcp start`
|
|
49
|
-
|
|
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;
|