@gmickel/gno 1.24.0 → 1.26.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.
Files changed (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +196 -4
  15. package/spec/db/schema.sql +102 -1
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -0,0 +1,490 @@
1
+ // node:fs/promises provides realpath/stat; Bun has no equivalent for symlink-safe path identity and regular-file metadata.
2
+ import { realpath, stat } from "node:fs/promises";
3
+ // node:path provides cross-platform path operations; Bun has no path utilities.
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ import type { Config, ModelPreset } from "../config/types";
7
+
8
+ import { normalizeContentTypes } from "../config/content-types";
9
+ import { createDefaultConfig } from "../config/defaults";
10
+ import {
11
+ PROJECT_PROFILE_FORCED_EXCLUDES,
12
+ PROJECT_PROFILE_SCHEMA_VERSION,
13
+ type ProjectProfile,
14
+ } from "../config/project-profile";
15
+ import { parseModelUri } from "../llm/cache";
16
+ import { getPreset } from "../llm/registry";
17
+ import { canonicalOperationalPath } from "./config-write-lock";
18
+ import { normalizePersistedContextText } from "./context-identity";
19
+ import { hasLikelySecretPath } from "./path-rules";
20
+ import {
21
+ canonicalProjectProfileJson,
22
+ compareCodeUnits,
23
+ fingerprintProjectProfileState,
24
+ normalizeLogicalPath,
25
+ projectProfileIncludePattern,
26
+ sha256,
27
+ sortedUnique,
28
+ } from "./project-profile-canonical";
29
+ import { parseProjectProfile } from "./project-profile-parser";
30
+
31
+ export {
32
+ canonicalProjectProfileJson,
33
+ fingerprintProjectProfileState,
34
+ projectProfileIncludePattern,
35
+ } from "./project-profile-canonical";
36
+ export type ProjectProfileDiagnosticCode =
37
+ | "COLLECTION_ROOT_NOT_DIRECTORY"
38
+ | "CONTEXT_FILE_INVALID"
39
+ | "CONTEXT_FILE_NOT_REGULAR"
40
+ | "CONTEXT_FILE_TOO_LARGE"
41
+ | "CONTEXT_FILE_UNREADABLE"
42
+ | "CONTEXT_TEXT_EMPTY"
43
+ | "INVALID_PROFILE"
44
+ | "MIGRATION_REQUIRED"
45
+ | "MODEL_CACHE_CHECK_FAILED"
46
+ | "MODEL_PATH_OVERLAP"
47
+ | "MODEL_PRESET_NOT_FOUND"
48
+ | "MODEL_PRESET_UNAVAILABLE_OFFLINE"
49
+ | "PATH_NOT_FOUND"
50
+ | "SYMLINK_ESCAPE"
51
+ | "UNSAFE_PATH"
52
+ | "UNSUPPORTED_SCHEMA_MAJOR"
53
+ | "UNSUPPORTED_SCHEMA_MINOR";
54
+
55
+ export interface ProjectProfileDiagnostic {
56
+ code: ProjectProfileDiagnosticCode;
57
+ severity: "error" | "warning";
58
+ path: string;
59
+ message: string;
60
+ }
61
+
62
+ export interface ProjectProfileContextState {
63
+ scopeType: "collection";
64
+ scopeKey: string;
65
+ text: string;
66
+ source:
67
+ | { kind: "inline"; sha256: string }
68
+ | { kind: "file"; path: string; sha256: string };
69
+ }
70
+
71
+ export interface ProjectProfileDesiredState {
72
+ schemaVersion: typeof PROJECT_PROFILE_SCHEMA_VERSION;
73
+ collection: {
74
+ name: string;
75
+ root: string;
76
+ include: string[];
77
+ exclude: string[];
78
+ languageHint?: string;
79
+ modelPreset?: string;
80
+ };
81
+ contexts: ProjectProfileContextState[];
82
+ contentTypes: Array<{
83
+ id: string;
84
+ prefixes: string[];
85
+ preset: string;
86
+ graphHints?: string[];
87
+ searchBoost?: number;
88
+ temporal?: boolean;
89
+ }>;
90
+ affinityDefaults: {
91
+ enabled: boolean;
92
+ contribution: number;
93
+ };
94
+ recommendedCapabilities: string[];
95
+ }
96
+
97
+ export interface ResolvedProjectProfilePaths {
98
+ profileRoot: string;
99
+ collectionRoot: string;
100
+ contextFiles: Array<{
101
+ logicalPath: string;
102
+ absolutePath: string;
103
+ }>;
104
+ }
105
+
106
+ export interface CompiledProjectProfile {
107
+ profile: ProjectProfile;
108
+ desiredState: ProjectProfileDesiredState;
109
+ canonicalJson: string;
110
+ fingerprint: string;
111
+ resolvedPaths: ResolvedProjectProfilePaths;
112
+ diagnostics: ProjectProfileDiagnostic[];
113
+ }
114
+
115
+ export type CompileProjectProfileResult =
116
+ | { ok: true; value: CompiledProjectProfile }
117
+ | { ok: false; diagnostics: ProjectProfileDiagnostic[] };
118
+
119
+ export interface ProjectProfileCompilerOptions {
120
+ profileRoot: string;
121
+ config?: Config;
122
+ isModelAvailableOffline?: (
123
+ modelUri: string,
124
+ modelType: "embed" | "rerank" | "expand" | "gen"
125
+ ) => Promise<boolean>;
126
+ }
127
+
128
+ export const PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES = 65_536;
129
+
130
+ const isContained = (parent: string, candidate: string): boolean => {
131
+ const pathFromParent = relative(parent, candidate);
132
+ return (
133
+ pathFromParent === "" ||
134
+ (pathFromParent !== ".." &&
135
+ !pathFromParent.startsWith(`..${sep}`) &&
136
+ !isAbsolute(pathFromParent))
137
+ );
138
+ };
139
+
140
+ const resolveContainedPath = async (
141
+ profileRoot: string,
142
+ logicalPath: string,
143
+ diagnosticPath: string
144
+ ): Promise<
145
+ | {
146
+ ok: true;
147
+ absolutePath: string;
148
+ metadata: Awaited<ReturnType<typeof stat>>;
149
+ }
150
+ | { ok: false; diagnostic: ProjectProfileDiagnostic }
151
+ > => {
152
+ const candidate = resolve(profileRoot, normalizeLogicalPath(logicalPath));
153
+ let absolutePath: string;
154
+ let metadata: Awaited<ReturnType<typeof stat>>;
155
+ try {
156
+ absolutePath = await realpath(candidate);
157
+ metadata = await stat(absolutePath);
158
+ } catch {
159
+ return {
160
+ ok: false,
161
+ diagnostic: {
162
+ code: "PATH_NOT_FOUND",
163
+ severity: "error",
164
+ path: diagnosticPath,
165
+ message: "Referenced project path does not exist.",
166
+ },
167
+ };
168
+ }
169
+ if (!isContained(profileRoot, absolutePath)) {
170
+ return {
171
+ ok: false,
172
+ diagnostic: {
173
+ code: "SYMLINK_ESCAPE",
174
+ severity: "error",
175
+ path: diagnosticPath,
176
+ message:
177
+ "Referenced project path resolves outside the trusted profile root.",
178
+ },
179
+ };
180
+ }
181
+ return { ok: true, absolutePath, metadata };
182
+ };
183
+
184
+ const modelUris = (
185
+ preset: ModelPreset
186
+ ): Array<["embed" | "rerank" | "expand" | "gen", string]> => [
187
+ ["embed", preset.embed],
188
+ ["rerank", preset.rerank],
189
+ ["expand", preset.expand ?? preset.gen],
190
+ ["gen", preset.gen],
191
+ ];
192
+
193
+ const diagnoseModelPreset = async (
194
+ profile: ProjectProfile,
195
+ options: ProjectProfileCompilerOptions,
196
+ profileRoot: string
197
+ ): Promise<ProjectProfileDiagnostic[]> => {
198
+ const presetId = profile.collection.modelPreset;
199
+ if (!presetId) return [];
200
+ const preset = getPreset(options.config ?? createDefaultConfig(), presetId);
201
+ if (!preset) {
202
+ return [
203
+ {
204
+ code: "MODEL_PRESET_NOT_FOUND",
205
+ severity: "error",
206
+ path: "collection.modelPreset",
207
+ message: `Model preset alias "${presetId}" is not configured.`,
208
+ },
209
+ ];
210
+ }
211
+ for (const [, uri] of modelUris(preset)) {
212
+ const parsed = parseModelUri(uri);
213
+ if (!parsed.ok || parsed.value.scheme !== "file") continue;
214
+ const modelPath = await canonicalOperationalPath(parsed.value.file);
215
+ if (isContained(profileRoot, modelPath)) {
216
+ return [
217
+ {
218
+ code: "MODEL_PATH_OVERLAP",
219
+ severity: "error",
220
+ path: "collection.modelPreset",
221
+ message:
222
+ "The selected model preset resolves runtime model state inside the project profile root.",
223
+ },
224
+ ];
225
+ }
226
+ }
227
+ if (!options.isModelAvailableOffline) return [];
228
+
229
+ const unavailable: string[] = [];
230
+ try {
231
+ for (const [modelType, uri] of modelUris(preset)) {
232
+ if (!(await options.isModelAvailableOffline(uri, modelType))) {
233
+ unavailable.push(modelType);
234
+ }
235
+ }
236
+ } catch {
237
+ return [
238
+ {
239
+ code: "MODEL_CACHE_CHECK_FAILED",
240
+ severity: "error",
241
+ path: "collection.modelPreset",
242
+ message: `Offline cache availability could not be verified for model preset alias "${presetId}".`,
243
+ },
244
+ ];
245
+ }
246
+ if (unavailable.length === 0) return [];
247
+ return [
248
+ {
249
+ code: "MODEL_PRESET_UNAVAILABLE_OFFLINE",
250
+ severity: "error",
251
+ path: "collection.modelPreset",
252
+ message: `Model preset alias "${presetId}" is unavailable offline for: ${unavailable.join(", ")}.`,
253
+ },
254
+ ];
255
+ };
256
+
257
+ export async function compileProjectProfileYaml(
258
+ yaml: string,
259
+ options: ProjectProfileCompilerOptions
260
+ ): Promise<CompileProjectProfileResult> {
261
+ const parsed = parseProjectProfile(yaml);
262
+ if ("ok" in parsed) return parsed;
263
+ const profile = parsed;
264
+ const diagnostics: ProjectProfileDiagnostic[] = [];
265
+
266
+ let profileRoot: string;
267
+ try {
268
+ profileRoot = await realpath(options.profileRoot);
269
+ } catch {
270
+ return {
271
+ ok: false,
272
+ diagnostics: [
273
+ {
274
+ code: "PATH_NOT_FOUND",
275
+ severity: "error",
276
+ path: "profileRoot",
277
+ message: "Trusted project profile root does not exist.",
278
+ },
279
+ ],
280
+ };
281
+ }
282
+
283
+ const collectionRoot = await resolveContainedPath(
284
+ profileRoot,
285
+ profile.collection.root,
286
+ "collection.root"
287
+ );
288
+ if (!collectionRoot.ok) diagnostics.push(collectionRoot.diagnostic);
289
+ else if (!collectionRoot.metadata.isDirectory()) {
290
+ diagnostics.push({
291
+ code: "COLLECTION_ROOT_NOT_DIRECTORY",
292
+ severity: "error",
293
+ path: "collection.root",
294
+ message: "The collection root must resolve to a directory.",
295
+ });
296
+ }
297
+
298
+ const contexts: ProjectProfileContextState[] = [];
299
+ const contextFiles: ResolvedProjectProfilePaths["contextFiles"] = [];
300
+ for (const [index, context] of profile.contexts.entries()) {
301
+ if ("text" in context) {
302
+ const normalizedText = normalizePersistedContextText(context.text);
303
+ if (!normalizedText) {
304
+ diagnostics.push({
305
+ code: "CONTEXT_TEXT_EMPTY",
306
+ severity: "error",
307
+ path: `contexts[${index}].text`,
308
+ message: "Context text must not be empty after normalization.",
309
+ });
310
+ continue;
311
+ }
312
+ contexts.push({
313
+ scopeType: "collection",
314
+ scopeKey: `${profile.collection.name}:`,
315
+ text: normalizedText,
316
+ source: { kind: "inline", sha256: sha256(context.text) },
317
+ });
318
+ continue;
319
+ }
320
+
321
+ const resolved = await resolveContainedPath(
322
+ profileRoot,
323
+ context.file,
324
+ `contexts[${index}].file`
325
+ );
326
+ if (!resolved.ok) {
327
+ diagnostics.push(resolved.diagnostic);
328
+ continue;
329
+ }
330
+ const resolvedLogicalPath = normalizeLogicalPath(
331
+ relative(profileRoot, resolved.absolutePath)
332
+ );
333
+ if (hasLikelySecretPath(resolvedLogicalPath)) {
334
+ diagnostics.push({
335
+ code: "UNSAFE_PATH",
336
+ severity: "error",
337
+ path: `contexts[${index}].file`,
338
+ message:
339
+ "Context input resolves to a likely credential or secret file.",
340
+ });
341
+ continue;
342
+ }
343
+ if (!resolved.metadata.isFile()) {
344
+ diagnostics.push({
345
+ code: "CONTEXT_FILE_NOT_REGULAR",
346
+ severity: "error",
347
+ path: `contexts[${index}].file`,
348
+ message: "Context input must resolve to a regular file.",
349
+ });
350
+ continue;
351
+ }
352
+ if (resolved.metadata.size > PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES) {
353
+ diagnostics.push({
354
+ code: "CONTEXT_FILE_TOO_LARGE",
355
+ severity: "error",
356
+ path: `contexts[${index}].file`,
357
+ message: `Context file exceeds ${PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES} bytes.`,
358
+ });
359
+ continue;
360
+ }
361
+ let bytes: Uint8Array;
362
+ try {
363
+ bytes = new Uint8Array(
364
+ await Bun.file(resolved.absolutePath)
365
+ .slice(0, PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES + 1)
366
+ .arrayBuffer()
367
+ );
368
+ } catch {
369
+ diagnostics.push({
370
+ code: "CONTEXT_FILE_UNREADABLE",
371
+ severity: "error",
372
+ path: `contexts[${index}].file`,
373
+ message: "Context file could not be read.",
374
+ });
375
+ continue;
376
+ }
377
+ if (bytes.byteLength > PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES) {
378
+ diagnostics.push({
379
+ code: "CONTEXT_FILE_TOO_LARGE",
380
+ severity: "error",
381
+ path: `contexts[${index}].file`,
382
+ message: `Context file exceeds ${PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES} bytes.`,
383
+ });
384
+ continue;
385
+ }
386
+ let text: string;
387
+ try {
388
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
389
+ } catch {
390
+ diagnostics.push({
391
+ code: "CONTEXT_FILE_INVALID",
392
+ severity: "error",
393
+ path: `contexts[${index}].file`,
394
+ message: "Context file is not valid UTF-8.",
395
+ });
396
+ continue;
397
+ }
398
+ const logicalPath = normalizeLogicalPath(context.file);
399
+ const normalizedText = normalizePersistedContextText(text);
400
+ if (!normalizedText) {
401
+ diagnostics.push({
402
+ code: "CONTEXT_TEXT_EMPTY",
403
+ severity: "error",
404
+ path: `contexts[${index}].file`,
405
+ message: "Context file must not be empty after normalization.",
406
+ });
407
+ continue;
408
+ }
409
+ contexts.push({
410
+ scopeType: "collection",
411
+ scopeKey: `${profile.collection.name}:`,
412
+ text: normalizedText,
413
+ source: { kind: "file", path: logicalPath, sha256: sha256(bytes) },
414
+ });
415
+ contextFiles.push({
416
+ logicalPath,
417
+ absolutePath: resolved.absolutePath,
418
+ });
419
+ }
420
+
421
+ diagnostics.push(
422
+ ...(await diagnoseModelPreset(profile, options, profileRoot))
423
+ );
424
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
425
+ return { ok: false, diagnostics };
426
+ }
427
+
428
+ const normalizedContentTypes = normalizeContentTypes(
429
+ Object.entries(profile.contentTypes).map(([id, rule]) => ({ id, ...rule }))
430
+ ).rules.map((rule) => ({
431
+ id: rule.id,
432
+ prefixes: sortedUnique(rule.prefixes.map(normalizeLogicalPath)),
433
+ preset: rule.preset,
434
+ ...(rule.graphHints ? { graphHints: sortedUnique(rule.graphHints) } : {}),
435
+ ...(rule.searchBoost === undefined
436
+ ? {}
437
+ : { searchBoost: rule.searchBoost }),
438
+ ...(rule.temporal === undefined ? {} : { temporal: rule.temporal }),
439
+ }));
440
+
441
+ const desiredState: ProjectProfileDesiredState = {
442
+ schemaVersion: PROJECT_PROFILE_SCHEMA_VERSION,
443
+ collection: {
444
+ name: profile.collection.name,
445
+ root: normalizeLogicalPath(profile.collection.root),
446
+ include: sortedUnique(
447
+ profile.collection.include.map(normalizeLogicalPath)
448
+ ),
449
+ exclude: sortedUnique([
450
+ ...profile.collection.exclude.map(normalizeLogicalPath),
451
+ ...PROJECT_PROFILE_FORCED_EXCLUDES,
452
+ ]),
453
+ ...(profile.collection.languageHint
454
+ ? { languageHint: profile.collection.languageHint }
455
+ : {}),
456
+ ...(profile.collection.modelPreset
457
+ ? { modelPreset: profile.collection.modelPreset }
458
+ : {}),
459
+ },
460
+ contexts: contexts.sort((left, right) =>
461
+ compareCodeUnits(
462
+ canonicalProjectProfileJson(left),
463
+ canonicalProjectProfileJson(right)
464
+ )
465
+ ),
466
+ contentTypes: normalizedContentTypes,
467
+ affinityDefaults: profile.affinityDefaults,
468
+ recommendedCapabilities: sortedUnique(profile.recommendedCapabilities),
469
+ };
470
+ const canonicalJson = canonicalProjectProfileJson(desiredState);
471
+ return {
472
+ ok: true,
473
+ value: {
474
+ profile,
475
+ desiredState,
476
+ canonicalJson,
477
+ fingerprint: fingerprintProjectProfileState(desiredState),
478
+ resolvedPaths: {
479
+ profileRoot,
480
+ collectionRoot: collectionRoot.ok
481
+ ? collectionRoot.absolutePath
482
+ : profileRoot,
483
+ contextFiles: contextFiles.sort((left, right) =>
484
+ compareCodeUnits(left.logicalPath, right.logicalPath)
485
+ ),
486
+ },
487
+ diagnostics,
488
+ },
489
+ };
490
+ }
@@ -20,6 +20,7 @@ import {
20
20
  import type { SkippedEntry, WalkConfig, WalkEntry, WalkerPort } from "./types";
21
21
 
22
22
  import { SUPPORTED_EXTENSIONS } from "../converters/mime";
23
+ import { matchesCollectionExclusion } from "../core/path-rules";
23
24
 
24
25
  /**
25
26
  * Regex to detect dangerous patterns with parent directory traversal.
@@ -51,6 +52,70 @@ function validatePattern(pattern: string): string | null {
51
52
  return null;
52
53
  }
53
54
 
55
+ /**
56
+ * Split GNO's canonical whole-pattern union into independently scannable Bun
57
+ * globs. Bun.Glob.match() accepts a leading `{a,b}` union, but scan() does not.
58
+ * Nested braces remain part of each child glob; escaped outer commas become
59
+ * literal commas again before scanning.
60
+ */
61
+ function scanPatterns(pattern: string): string[] {
62
+ if (!(pattern.startsWith("{") && pattern.endsWith("}"))) return [pattern];
63
+
64
+ const patterns: string[] = [];
65
+ let branch = "";
66
+ let depth = 0;
67
+ let bracketDepth = 0;
68
+ for (let index = 0; index < pattern.length; index += 1) {
69
+ const character = pattern[index];
70
+ if (
71
+ character === "\\" &&
72
+ pattern[index + 1] === "," &&
73
+ depth === 1 &&
74
+ bracketDepth === 0
75
+ ) {
76
+ branch += ",";
77
+ index += 1;
78
+ continue;
79
+ }
80
+ if (character === "[" && bracketDepth === 0) {
81
+ bracketDepth = 1;
82
+ branch += character;
83
+ continue;
84
+ }
85
+ if (character === "]" && bracketDepth > 0) {
86
+ bracketDepth = 0;
87
+ branch += character;
88
+ continue;
89
+ }
90
+ if (bracketDepth > 0) {
91
+ branch += character;
92
+ continue;
93
+ }
94
+ if (character === "{") {
95
+ depth += 1;
96
+ if (depth > 1) branch += character;
97
+ continue;
98
+ }
99
+ if (character === "}") {
100
+ depth -= 1;
101
+ if (depth < 0 || (depth === 0 && index !== pattern.length - 1)) {
102
+ return [pattern];
103
+ }
104
+ if (depth > 0) branch += character;
105
+ continue;
106
+ }
107
+ if (character === "," && depth === 1) {
108
+ patterns.push(branch);
109
+ branch = "";
110
+ continue;
111
+ }
112
+ branch += character;
113
+ }
114
+ if (depth !== 0 || bracketDepth !== 0) return [pattern];
115
+ patterns.push(branch);
116
+ return patterns;
117
+ }
118
+
54
119
  /**
55
120
  * Compute safe relative path from root to file.
56
121
  * Returns null if file is outside root (security check).
@@ -77,36 +142,6 @@ async function safeRelPath(
77
142
  }
78
143
  }
79
144
 
80
- /**
81
- * Check if a path matches any exclude pattern.
82
- *
83
- * Exclude semantics (component-based matching):
84
- * - Patterns match against path components (directory/file names)
85
- * - "node_modules" matches any path containing "node_modules" as a component
86
- * - ".git" matches ".git" directory at any level
87
- * - Patterns are NOT globs - they match exact component names
88
- *
89
- * Examples:
90
- * - exclude: [".git"] matches "foo/.git/bar" but not "foo/.github/..."
91
- * - exclude: ["dist"] matches "dist/bundle.js" and "src/dist/output.js"
92
- */
93
- function matchesExclude(relPath: string, excludes: string[]): boolean {
94
- const parts = relPath.split("/");
95
-
96
- for (const pattern of excludes) {
97
- // Check if any path component matches exactly
98
- if (parts.includes(pattern)) {
99
- return true;
100
- }
101
- // Check if path starts with pattern
102
- if (relPath.startsWith(`${pattern}/`)) {
103
- return true;
104
- }
105
- }
106
-
107
- return false;
108
- }
109
-
110
145
  /**
111
146
  * Check if a file extension matches the include list.
112
147
  * Include list contains extensions like ".md" or "md" (normalized).
@@ -145,10 +180,12 @@ export class FileWalker implements WalkerPort {
145
180
  const entries: WalkEntry[] = [];
146
181
  const skipped: SkippedEntry[] = [];
147
182
 
148
- // Validate pattern for security
149
- const patternError = validatePattern(config.pattern);
150
- if (patternError) {
151
- throw new Error(`Invalid glob pattern: ${patternError}`);
183
+ const patterns = scanPatterns(config.pattern);
184
+ for (const pattern of patterns) {
185
+ const patternError = validatePattern(pattern);
186
+ if (patternError) {
187
+ throw new Error(`Invalid glob pattern: ${patternError}`);
188
+ }
152
189
  }
153
190
 
154
191
  // Resolve root to real path for consistent comparison
@@ -161,16 +198,20 @@ export class FileWalker implements WalkerPort {
161
198
  return { entries: [], skipped: [] };
162
199
  }
163
200
 
164
- const glob = new Bun.Glob(config.pattern);
165
-
166
- for await (const match of glob.scan({
167
- cwd: rootReal,
168
- absolute: true,
169
- onlyFiles: true,
170
- followSymlinks: false,
171
- })) {
172
- const absPath = normalizePath(match);
201
+ const matches = new Set<string>();
202
+ for (const pattern of patterns) {
203
+ const glob = new Bun.Glob(pattern);
204
+ for await (const match of glob.scan({
205
+ cwd: rootReal,
206
+ absolute: true,
207
+ onlyFiles: true,
208
+ followSymlinks: false,
209
+ })) {
210
+ matches.add(normalizePath(match));
211
+ }
212
+ }
173
213
 
214
+ for (const absPath of [...matches].sort()) {
174
215
  // Security: Compute safe relative path (validates file is within root)
175
216
  const relPath = await safeRelPath(rootReal, absPath);
176
217
  if (relPath === null) {
@@ -179,7 +220,7 @@ export class FileWalker implements WalkerPort {
179
220
  }
180
221
 
181
222
  // Check exclude patterns
182
- if (matchesExclude(relPath, config.exclude)) {
223
+ if (matchesCollectionExclusion(relPath, config.exclude)) {
183
224
  skipped.push({
184
225
  absPath,
185
226
  relPath,
package/src/llm/cache.ts CHANGED
@@ -527,6 +527,27 @@ export class ModelCache {
527
527
  return cached !== null;
528
528
  }
529
529
 
530
+ /**
531
+ * Read-only availability probe for inspection commands.
532
+ *
533
+ * Unlike isCached(), this never repairs manifest entries or removes invalid
534
+ * files. It is safe for commands whose contract forbids local mutation.
535
+ */
536
+ async isCachedReadOnly(uri: string): Promise<boolean> {
537
+ const parsed = parseModelUri(uri);
538
+ if (!parsed.ok) return false;
539
+ if (parsed.value.scheme === "file") {
540
+ const validation = await validateGgufFile(parsed.value.file, uri, "user");
541
+ return validation.ok;
542
+ }
543
+
544
+ const manifest = await this.readManifestFromDisk();
545
+ const entry = manifest.models.find((model) => model.uri === uri);
546
+ if (!entry || !(await this.fileExists(entry.path))) return false;
547
+ const validation = await validateGgufFile(entry.path, uri, "cache");
548
+ return validation.ok;
549
+ }
550
+
530
551
  /**
531
552
  * Get cached/available path for a URI.
532
553
  * For file: URIs, returns path if file exists.