@takara-ai/miru-code 1.0.5 → 1.1.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/src/mcp/server.ts CHANGED
@@ -87,12 +87,10 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
87
87
  file_path: z
88
88
  .string()
89
89
  .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
90
- line: z
90
+ anchor_line: z
91
91
  .number()
92
92
  .int()
93
- .describe(
94
- "Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
95
- ),
93
+ .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
96
94
  repo: z.string().describe(REPO_DESCRIPTION),
97
95
  before: z
98
96
  .number()
@@ -108,7 +106,7 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
108
106
  .describe("Extra chunks after the anchor (default 1)."),
109
107
  },
110
108
  },
111
- async ({ file_path: filePath, line, repo, before, after }) => {
109
+ async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
112
110
  try {
113
111
  const index = await getIndexForRepo(repo, cache);
114
112
  const repoRoot = localRepoRoot(repo);
@@ -117,19 +115,19 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
117
115
  const { anchor, chunks: expanded } = expandChunksAtLine(
118
116
  index.chunks,
119
117
  filePath,
120
- line,
118
+ anchorLine,
121
119
  repoRoot,
122
120
  beforeCount,
123
121
  afterCount,
124
122
  );
125
123
  if (!anchor) {
126
124
  return toolText(
127
- `No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
125
+ `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
128
126
  );
129
127
  }
130
128
  return toolText(
131
129
  JSON.stringify(
132
- formatExpandResults(filePath, line, anchor, expanded, {
130
+ formatExpandResults(filePath, anchorLine, anchor, expanded, {
133
131
  repoRoot,
134
132
  before: beforeCount,
135
133
  after: afterCount,
@@ -150,12 +148,10 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
150
148
  file_path: z
151
149
  .string()
152
150
  .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
153
- line: z
151
+ anchor_line: z
154
152
  .number()
155
153
  .int()
156
- .describe(
157
- "Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
158
- ),
154
+ .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
159
155
  repo: z.string().describe(REPO_DESCRIPTION),
160
156
  top_k: z
161
157
  .number()
@@ -168,25 +164,25 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
168
164
  ),
169
165
  },
170
166
  },
171
- async ({ file_path: filePath, line, repo, top_k: topK }) => {
167
+ async ({ file_path: filePath, anchor_line: anchorLine, repo, top_k: topK }) => {
172
168
  try {
173
169
  const index = await getIndexForRepo(repo, cache);
174
170
  const repoRoot = localRepoRoot(repo);
175
- const chunk = resolveChunk(index.chunks, filePath, line, repoRoot);
171
+ const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
176
172
  if (!chunk) {
177
173
  return toolText(
178
- `No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
174
+ `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
179
175
  );
180
176
  }
181
177
  const results = await index.findRelated(chunk, clampMcpTopK(topK));
182
178
  if (results.length === 0) {
183
179
  return toolText(
184
- JSON.stringify({ error: `No related chunks found for ${filePath}:${line}.` }),
180
+ JSON.stringify({ error: `No related chunks found for ${filePath}:${anchorLine}.` }),
185
181
  );
186
182
  }
187
183
  return toolText(
188
184
  JSON.stringify(
189
- formatResults(`Chunks related to ${filePath}:${line}`, results, {
185
+ formatResults(`Chunks related to ${filePath}:${anchorLine}`, results, {
190
186
  repoRoot,
191
187
  snippet: true,
192
188
  }),
package/src/miru-index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "./embeddings/openai.ts";
10
10
  import { cloneGitRepository } from "./git.ts";
11
11
  import type { BM25Index } from "./index/bm25.ts";
12
+ import { buildChunkSelector } from "./index/chunk-selector.ts";
12
13
  import { createIndexFromPath } from "./index/create.ts";
13
14
  import { applyIncrementalFileChanges } from "./index/incremental.ts";
14
15
  import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
@@ -19,27 +20,71 @@ import { chunkKey, chunkToDict } from "./types.ts";
19
20
  import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
20
21
  import { indexCacheEpoch } from "./version.ts";
21
22
 
23
+ /**
24
+ * In-memory search index over a codebase: BM25 keyword scores plus semantic
25
+ * embeddings, with optional disk cache for reuse across runs.
26
+ *
27
+ * Construct via `fromPath`, `fromGit`, or `fromSource`; load a saved bundle
28
+ * with `loadFromDisk`. Search combines both signals; `findRelated` is
29
+ * semantic-only similarity from an existing chunk or hit.
30
+ *
31
+ * Lifecycle:
32
+ * - **Build** — parse files, chunk, embed, score with BM25
33
+ * - **Cache** — `save` writes a bundle keyed by source path or git URL + ref
34
+ * - **Query** — `search` blends BM25 and vectors; filters use prebuilt mappings
35
+ * - **Update** — `applyFileChanges` re-indexes only touched paths (local roots)
36
+ */
22
37
  export class MiruIndex {
38
+ /** OpenAI (or configured) client used for query embeddings and reranking. */
23
39
  readonly embeddings: EmbeddingBackend;
40
+
41
+ /** Flat list of all chunks; index position is the stable chunk id. */
24
42
  private chunksInternal: Chunk[];
43
+
44
+ /** Inverted index for lexical (BM25) retrieval. */
25
45
  private bm25Index: BM25Index;
46
+
47
+ /** Vector store for semantic nearest-neighbor search. */
26
48
  private semanticIndex: SemanticIndex;
49
+
50
+ /**
51
+ * True when hydrated from disk via `loadFromDisk`.
52
+ * Affects `saveToCache`, which skips re-saving already-cached indexes unless forced.
53
+ */
27
54
  private loadedFromDiskFlag: boolean;
28
55
 
56
+ /** Model id stored in cache metadata and used for new embeddings. */
29
57
  readonly embeddingModel: string;
58
+
59
+ /**
60
+ * Absolute path to the indexed tree on disk, or `null` for git-only indexes
61
+ * (clone is deleted after indexing; incremental updates need a local root).
62
+ */
30
63
  private readonly root: string | null;
64
+
65
+ /** Content kinds indexed at build time (e.g. `code`, `markdown`). */
31
66
  private readonly content: ContentType[];
67
+
68
+ /** Repo-relative file path → chunk indices in `chunksInternal`. */
32
69
  private fileMapping: Map<string, number[]>;
70
+
71
+ /** Language tag → chunk indices, for `filterLanguages` in search. */
33
72
  private languageMapping: Map<string, number[]>;
34
73
 
74
+ /** Read-only view of indexed chunks. */
35
75
  get chunks(): Chunk[] {
36
76
  return this.chunksInternal;
37
77
  }
38
78
 
79
+ /** Whether this instance was loaded from a cache bundle rather than freshly built. */
39
80
  get loadedFromDisk(): boolean {
40
81
  return this.loadedFromDiskFlag;
41
82
  }
42
83
 
84
+ /**
85
+ * Prefer static factories (`fromPath`, `fromGit`, `loadFromDisk`) over calling
86
+ * directly; the constructor is public for tests and advanced composition.
87
+ */
43
88
  constructor(options: {
44
89
  embeddings: EmbeddingBackend;
45
90
  bm25Index: BM25Index;
@@ -63,6 +108,10 @@ export class MiruIndex {
63
108
  this.rebuildMappings();
64
109
  }
65
110
 
111
+ /**
112
+ * Rebuild file/language → chunk-index maps after chunks change.
113
+ * Called from the constructor and after incremental updates.
114
+ */
66
115
  private rebuildMappings(): void {
67
116
  this.fileMapping = new Map();
68
117
  this.languageMapping = new Map();
@@ -85,6 +134,12 @@ export class MiruIndex {
85
134
  }
86
135
  }
87
136
 
137
+ /**
138
+ * Clone a remote repo, index it, persist to cache, and return the in-memory index.
139
+ *
140
+ * Cache key is derived from URL + ref. On hit, skips clone and embedding work.
141
+ * The temporary clone directory is always removed in `finally`.
142
+ */
88
143
  static async fromGit(
89
144
  url: string,
90
145
  content: ContentType[] = ["code"],
@@ -113,6 +168,7 @@ export class MiruIndex {
113
168
  semanticIndex: semantic,
114
169
  chunks,
115
170
  embeddingModel: model,
171
+ // No durable local root — clone is deleted; incremental updates unavailable.
116
172
  root: null,
117
173
  content,
118
174
  });
@@ -123,6 +179,7 @@ export class MiruIndex {
123
179
  }
124
180
  }
125
181
 
182
+ /** Resolve a local path or git URL and delegate to `fromPath` or `fromGit`. */
126
183
  static async fromSource(
127
184
  source: string,
128
185
  content: ContentType[] = ["code"],
@@ -135,6 +192,13 @@ export class MiruIndex {
135
192
  return MiruIndex.fromPath(source, content, embeddingModel);
136
193
  }
137
194
 
195
+ /**
196
+ * Index a directory on disk, reusing a validated cache hit when available.
197
+ *
198
+ * `root` is set to the resolved path so `applyFileChanges` can re-chunk files
199
+ * later. Unlike `fromGit`, this does not auto-save to cache; callers use
200
+ * `saveToCache` when they want persistence.
201
+ */
138
202
  static async fromPath(
139
203
  path: string,
140
204
  content: ContentType[] = ["code"],
@@ -178,6 +242,12 @@ export class MiruIndex {
178
242
  });
179
243
  }
180
244
 
245
+ /**
246
+ * Hydrate BM25, semantic vectors, and chunks from a saved index bundle.
247
+ *
248
+ * Validates embedding model compatibility via cache metadata. Sets
249
+ * `loadedFromDisk` so callers can avoid redundant cache writes.
250
+ */
181
251
  static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
182
252
  const model = embeddingModel ?? resolveEmbeddingModel();
183
253
  const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
@@ -196,6 +266,12 @@ export class MiruIndex {
196
266
  });
197
267
  }
198
268
 
269
+ /**
270
+ * Write BM25, semantic vectors, chunk metadata, and index metadata to `path`.
271
+ *
272
+ * Metadata includes `index_epoch` for invalidation when Miru's on-disk format
273
+ * changes, plus embedding model/dimensions for cache validation on reload.
274
+ */
199
275
  async save(path: string): Promise<void> {
200
276
  const paths = persistencePaths(path);
201
277
  await mkdir(paths.root, { recursive: true });
@@ -218,17 +294,24 @@ export class MiruIndex {
218
294
  });
219
295
  }
220
296
 
221
- async saveToDefaultCache(sourcePath: string): Promise<void> {
222
- if (!this.loadedFromDiskFlag) {
297
+ /**
298
+ * Persist to the default cache location for `sourcePath`.
299
+ *
300
+ * By default this is a no-op for unchanged indexes loaded from disk, avoiding
301
+ * redundant cache writes. Pass `force: true` after mutating a cached index.
302
+ */
303
+ async saveToCache(sourcePath: string, options: { force?: boolean } = {}): Promise<void> {
304
+ if (!this.loadedFromDiskFlag || options.force) {
223
305
  await this.save(findIndexCachePath(sourcePath));
224
306
  }
225
307
  }
226
308
 
227
- async persistToCache(sourcePath: string): Promise<void> {
228
- await this.save(findIndexCachePath(sourcePath));
229
- }
230
-
231
- /** Re-chunk and re-embed only the given repo-relative paths; drop their old chunks. */
309
+ /**
310
+ * Re-chunk and re-embed only the given repo-relative paths; drop their old chunks.
311
+ *
312
+ * Requires `root` (local `fromPath` indexes only). Updates BM25, semantic
313
+ * index, and mappings in place; clears `loadedFromDisk` since data changed.
314
+ */
232
315
  async applyFileChanges(relativePaths: readonly string[]): Promise<void> {
233
316
  if (!this.root) {
234
317
  throw new Error("Incremental update requires a local index with root_path set.");
@@ -253,20 +336,27 @@ export class MiruIndex {
253
336
  this.rebuildMappings();
254
337
  }
255
338
 
256
- private getSelector(filterLanguages?: string[], filterPaths?: string[]): number[] | undefined {
257
- const selector: number[] = [];
258
- for (const lang of filterLanguages ?? []) {
259
- selector.push(...(this.languageMapping.get(lang) ?? []));
260
- }
261
- for (const fp of filterPaths ?? []) {
262
- selector.push(...(this.fileMapping.get(fp) ?? []));
263
- }
264
- if (selector.length === 0) {
265
- return undefined;
266
- }
267
- return [...new Set(selector)];
339
+ private getSelector(
340
+ filterLanguages?: string[],
341
+ filterPaths?: string[],
342
+ ): readonly number[] | undefined {
343
+ return buildChunkSelector(
344
+ {
345
+ fileMapping: this.fileMapping,
346
+ languageMapping: this.languageMapping,
347
+ },
348
+ filterLanguages,
349
+ filterPaths,
350
+ );
268
351
  }
269
352
 
353
+ /**
354
+ * Hybrid BM25 + semantic search over indexed chunks.
355
+ *
356
+ * `alpha` blends keyword vs vector scores (default depends on content type).
357
+ * `filterLanguages` / `filterPaths` restrict candidates before ranking.
358
+ * `rerank` defaults to on for code indexes (cross-encoder style reranking).
359
+ */
270
360
  async search(options: {
271
361
  query: string;
272
362
  topK?: number;
@@ -296,6 +386,13 @@ export class MiruIndex {
296
386
  });
297
387
  }
298
388
 
389
+ /**
390
+ * Semantic neighbors of a chunk or search hit, excluding the source itself.
391
+ *
392
+ * Uses the chunk's text as the query embedding. When the source has a
393
+ * language, search is scoped to same-language chunks. Requests `topK + 1`
394
+ * hits so filtering out the source still yields `topK` results.
395
+ */
299
396
  async findRelated(source: Chunk | SearchResult, topK = 5): Promise<SearchResult[]> {
300
397
  const target = "chunk" in source ? source.chunk : source;
301
398
  const selector = target.language ? this.getSelector([target.language]) : undefined;
package/src/search.ts CHANGED
@@ -35,7 +35,7 @@ function semanticFromQueryVector(
35
35
  semanticIndex: SemanticIndex,
36
36
  chunks: Chunk[],
37
37
  topK: number,
38
- selector?: number[],
38
+ selector?: readonly number[],
39
39
  ): SearchResult[] {
40
40
  const { indices, distances } = semanticIndex.query(queryVec, topK, selector);
41
41
  return indices.flatMap((index, i) => {
@@ -52,7 +52,7 @@ async function searchBm25(
52
52
  bm25Index: BM25Index,
53
53
  chunks: Chunk[],
54
54
  topK: number,
55
- selector?: number[],
55
+ selector?: readonly number[],
56
56
  ): Promise<SearchResult[]> {
57
57
  const tokens = tokenize(query);
58
58
  if (tokens.length === 0) {
@@ -79,7 +79,7 @@ export async function hybridSearch(options: {
79
79
  chunks: Chunk[];
80
80
  topK: number;
81
81
  alpha?: number | null;
82
- selector?: number[];
82
+ selector?: readonly number[];
83
83
  rerank?: boolean;
84
84
  }): Promise<SearchResult[]> {
85
85
  const {
@@ -170,7 +170,7 @@ export async function searchSemanticOnly(options: {
170
170
  semanticIndex: SemanticIndex;
171
171
  chunks: Chunk[];
172
172
  topK: number;
173
- selector?: number[];
173
+ selector?: readonly number[];
174
174
  }): Promise<SearchResult[]> {
175
175
  const queryVec = await options.embeddings.embedQuery(options.query);
176
176
  return semanticFromQueryVector(
package/src/setup.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { brandTitle, divider, fail, hint, info, success, writeStderr } from "./cli-ui.ts";
1
+ import { divider, fail, hint, info, printBrandBanner, success, writeStderr } from "./cli-ui.ts";
2
2
  import {
3
3
  clearStoredCredentials,
4
4
  loadStoredCredentials,
@@ -55,7 +55,8 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<RunSetupR
55
55
  }
56
56
 
57
57
  writeStderr("");
58
- writeStderr(`${brandTitle()} setup`);
58
+ // stderr banner: setup runs before stdout may be a TTY (e.g. piped miru search).
59
+ printBrandBanner(process.stderr);
59
60
  divider("─", 48, process.stderr);
60
61
  writeStderr("Miru needs a Takara API key for code embeddings.");
61
62
  hint("Get a bearer token from Takara, then enter it below.");
@@ -129,6 +130,11 @@ export async function ensureCredentials(options?: { interactive?: boolean }): Pr
129
130
  return;
130
131
  }
131
132
 
133
+ writeStderr("");
134
+ // Brand on stderr when credentials are missing in non-interactive mode (stdout may not be a TTY).
135
+ printBrandBanner(process.stderr);
136
+ writeStderr("");
137
+
132
138
  throw new Error(
133
139
  "Takara API key required. Run `miru setup` in a terminal, or set TAKARA_API_KEY " +
134
140
  "in your MCP server env (Cursor mcp.json) or .env.local.",
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Terminal helpers: ANSI styling, display-width layout, and brand RGB colors.
3
+ * Brand colors use truecolor when COLORTERM says so; otherwise 256-color palette
4
+ * (Terminal.app often omits COLORTERM but still renders 38;5 correctly).
5
+ */
6
+ const ANSI = {
7
+ reset: "\x1b[0m",
8
+ bold: "\x1b[1m",
9
+ dim: "\x1b[2m",
10
+ cyan: "\x1b[36m",
11
+ green: "\x1b[32m",
12
+ red: "\x1b[31m",
13
+ yellow: "\x1b[33m",
14
+ magenta: "\x1b[35m",
15
+ } as const;
16
+
17
+ export function colorEnabled(stream: NodeJS.WriteStream = process.stdout): boolean {
18
+ // https://no-color.org — any NO_COLOR value disables styling.
19
+ return stream.isTTY && process.env.NO_COLOR === undefined;
20
+ }
21
+
22
+ function paint(text: string, code: string, stream?: NodeJS.WriteStream): string {
23
+ if (!colorEnabled(stream)) {
24
+ return text;
25
+ }
26
+ return `${code}${text}${ANSI.reset}`;
27
+ }
28
+
29
+ const style =
30
+ (code: string) =>
31
+ (text: string, stream?: NodeJS.WriteStream): string =>
32
+ paint(text, code, stream);
33
+
34
+ export const bold = style(ANSI.bold);
35
+ export const dim = style(ANSI.dim);
36
+ export const cyan = style(ANSI.cyan);
37
+ export const green = style(ANSI.green);
38
+ export const red = style(ANSI.red);
39
+ export const yellow = style(ANSI.yellow);
40
+ export const magenta = style(ANSI.magenta);
41
+
42
+ function charWidth(codePoint: number): number {
43
+ // East Asian and emoji runes count as two columns in most terminals.
44
+ if (codePoint === 0) {
45
+ return 0;
46
+ }
47
+ if (
48
+ (codePoint >= 0x0300 && codePoint <= 0x036f) ||
49
+ (codePoint >= 0x200b && codePoint <= 0x200f) ||
50
+ codePoint === 0xfeff
51
+ ) {
52
+ return 0;
53
+ }
54
+ if (
55
+ (codePoint >= 0x1100 && codePoint <= 0x115f) ||
56
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) ||
57
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) ||
58
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
59
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
60
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) ||
61
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
62
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
63
+ (codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
64
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
65
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
66
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
67
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)
68
+ ) {
69
+ return 2;
70
+ }
71
+ return 1;
72
+ }
73
+
74
+ export function displayWidth(text: string): number {
75
+ // Ignore ANSI SGR sequences when measuring layout width.
76
+ let width = 0;
77
+ let inEscape = false;
78
+ for (const char of text) {
79
+ if (inEscape) {
80
+ if (char === "m") {
81
+ inEscape = false;
82
+ }
83
+ continue;
84
+ }
85
+ if (char === "\x1b") {
86
+ inEscape = true;
87
+ continue;
88
+ }
89
+ width += charWidth(char.codePointAt(0) ?? 0);
90
+ }
91
+ return width;
92
+ }
93
+
94
+ export function padLineToWidth(text: string, width: number): string {
95
+ const pad = Math.max(0, width - displayWidth(text));
96
+ return `${text}${" ".repeat(pad)}`;
97
+ }
98
+
99
+ /** Parse `#rrggbb` into `[r, g, b]` for terminal truecolor/256-color SGR. */
100
+ export function hexToRgb(hex: string): readonly [number, number, number] {
101
+ const normalized = hex.trim().replace(/^#/, "");
102
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
103
+ throw new Error(`Invalid hex color: ${hex}`);
104
+ }
105
+ return [
106
+ Number.parseInt(normalized.slice(0, 2), 16),
107
+ Number.parseInt(normalized.slice(2, 4), 16),
108
+ Number.parseInt(normalized.slice(4, 6), 16),
109
+ ];
110
+ }
111
+
112
+ export function cropCommonLeading(rows: readonly string[]): string[] {
113
+ // Trim shared left margin from sampled art without shifting rows relative to each other.
114
+ const trimmed = rows.map((row) => row.trimEnd());
115
+ let minLead = trimmed[0]?.length ?? 0;
116
+ let hasContent = false;
117
+
118
+ for (const row of trimmed) {
119
+ if (!row.trim()) {
120
+ continue;
121
+ }
122
+ hasContent = true;
123
+ minLead = Math.min(minLead, row.length - row.trimStart().length);
124
+ }
125
+
126
+ if (!hasContent) {
127
+ return [...trimmed];
128
+ }
129
+ return trimmed.map((row) => row.slice(minLead));
130
+ }
131
+
132
+ function rgbToAnsi256(r: number, g: number, b: number): number {
133
+ const avg = Math.round((r + g + b) / 3);
134
+ if (Math.abs(r - g) <= 3 && Math.abs(g - b) <= 3) {
135
+ if (avg < 8) {
136
+ return 16;
137
+ }
138
+ if (avg > 248) {
139
+ return 231;
140
+ }
141
+ return Math.round(((avg - 8) / 247) * 24) + 232;
142
+ }
143
+ return (
144
+ 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5)
145
+ );
146
+ }
147
+
148
+ function brandSgr(rgb: readonly [number, number, number]): string {
149
+ const [r, g, b] = rgb;
150
+ const colorTerm = process.env.COLORTERM?.toLowerCase() ?? "";
151
+ // termstandard/colors: COLORTERM=truecolor|24bit → 24-bit SGR; else 256-color cube.
152
+ const truecolor =
153
+ process.env.FORCE_COLOR === "3" ||
154
+ colorTerm.includes("truecolor") ||
155
+ colorTerm.includes("24bit");
156
+ if (truecolor) {
157
+ return `\x1b[1;38;2;${r};${g};${b}m`;
158
+ }
159
+ return `\x1b[1;38;5;${rgbToAnsi256(r, g, b)}m`;
160
+ }
161
+
162
+ export function brandColor(
163
+ text: string,
164
+ rgb: readonly [number, number, number],
165
+ stream: NodeJS.WriteStream = process.stdout,
166
+ ): string {
167
+ if (!colorEnabled(stream)) {
168
+ return text;
169
+ }
170
+ return `${brandSgr(rgb)}${text}${ANSI.reset}`;
171
+ }
package/src/version.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
1
  import { join } from "node:path";
3
2
  import packageJson from "../package.json";
4
3
  import { resolveCacheFolder } from "./cache.ts";
@@ -49,13 +48,25 @@ export function isVersionNewer(latest: string, current: string): boolean {
49
48
  return latest !== current;
50
49
  }
51
50
 
52
- for (let i = 0; i < 3; i++) {
53
- if (a[i]! > b[i]!) {
54
- return true;
55
- }
56
- if (a[i]! < b[i]!) {
57
- return false;
58
- }
51
+ const [a0, a1, a2] = a;
52
+ const [b0, b1, b2] = b;
53
+ if (a0 > b0) {
54
+ return true;
55
+ }
56
+ if (a0 < b0) {
57
+ return false;
58
+ }
59
+ if (a1 > b1) {
60
+ return true;
61
+ }
62
+ if (a1 < b1) {
63
+ return false;
64
+ }
65
+ if (a2 > b2) {
66
+ return true;
67
+ }
68
+ if (a2 < b2) {
69
+ return false;
59
70
  }
60
71
  return false;
61
72
  }
@@ -66,7 +77,7 @@ function updateCheckPath(): string {
66
77
 
67
78
  async function readUpdateCheckCache(): Promise<UpdateCheckCache | null> {
68
79
  try {
69
- const raw = await readFile(updateCheckPath(), "utf-8");
80
+ const raw = await Bun.file(updateCheckPath()).text();
70
81
  const parsed = JSON.parse(raw) as UpdateCheckCache;
71
82
  if (typeof parsed.checkedAt !== "number" || typeof parsed.latest !== "string") {
72
83
  return null;
@@ -79,7 +90,7 @@ async function readUpdateCheckCache(): Promise<UpdateCheckCache | null> {
79
90
 
80
91
  async function writeUpdateCheckCache(latest: string): Promise<void> {
81
92
  const payload: UpdateCheckCache = { checkedAt: Date.now(), latest };
82
- await writeFile(updateCheckPath(), `${JSON.stringify(payload)}\n`, "utf-8");
93
+ await Bun.write(updateCheckPath(), `${JSON.stringify(payload)}\n`);
83
94
  }
84
95
 
85
96
  export async function fetchLatestPublishedVersion(): Promise<string> {