@vellumai/assistant 0.10.6-staging.1 → 0.10.6

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 (58) hide show
  1. package/openapi.yaml +43 -6
  2. package/package.json +1 -1
  3. package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
  4. package/src/__tests__/attachments-store.test.ts +37 -1
  5. package/src/__tests__/attachments.test.ts +53 -0
  6. package/src/__tests__/channel-reply-delivery.test.ts +214 -5
  7. package/src/__tests__/config-loader-backfill.test.ts +16 -36
  8. package/src/__tests__/image-source-path-reinject.test.ts +155 -14
  9. package/src/__tests__/managed-profile-guard.test.ts +2 -0
  10. package/src/__tests__/mtime-cache.test.ts +265 -80
  11. package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
  12. package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
  13. package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
  14. package/src/agent/attachments.ts +55 -13
  15. package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
  16. package/src/cli/commands/platform/index.ts +86 -4
  17. package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
  18. package/src/cli/lib/diff-plugin.ts +7 -3
  19. package/src/cli/lib/inspect-plugin.ts +28 -10
  20. package/src/cli/lib/install-from-github.ts +69 -24
  21. package/src/cli/lib/ipc-params.ts +3 -3
  22. package/src/cli/lib/list-installed-plugins.ts +11 -5
  23. package/src/cli/lib/merge-plugin-tree.ts +15 -9
  24. package/src/cli/lib/plugin-fingerprint.ts +0 -6
  25. package/src/cli/lib/publish-plugin.ts +1 -1
  26. package/src/cli/lib/unknown-command.ts +3 -1
  27. package/src/cli/lib/upgrade-plugin.ts +2 -5
  28. package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
  29. package/src/config/seed-inference-profiles.ts +5 -4
  30. package/src/daemon/conversation-lifecycle.ts +37 -15
  31. package/src/daemon/conversation-messaging.ts +56 -28
  32. package/src/daemon/conversation.ts +2 -2
  33. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  34. package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
  35. package/src/hooks/hook-loader.ts +57 -144
  36. package/src/hooks/registry.ts +13 -9
  37. package/src/persistence/attachments-store.ts +2 -1
  38. package/src/persistence/conversation-crud.ts +24 -0
  39. package/src/plugin-api/types.ts +4 -1
  40. package/src/plugins/mtime-cache.ts +347 -33
  41. package/src/plugins/pipeline.ts +3 -2
  42. package/src/providers/__tests__/inference.test.ts +42 -31
  43. package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
  44. package/src/providers/inference/auth.ts +1 -2
  45. package/src/providers/inference/backfill.ts +7 -5
  46. package/src/providers/inference/connections.ts +54 -30
  47. package/src/runtime/channel-reply-delivery.ts +83 -24
  48. package/src/runtime/channel-retry-sweep.ts +4 -0
  49. package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
  50. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
  51. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
  52. package/src/runtime/routes/conversation-query-routes.ts +18 -1
  53. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
  54. package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
  55. package/src/runtime/routes/platform-routes.ts +82 -1
  56. package/src/runtime/slack-reply-session.ts +1 -1
  57. package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
  58. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,140 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { repointManagedConnectionsToVellumMigration } from "../workspace/migrations/125-repoint-managed-connections-to-vellum.js";
13
+
14
+ let workspaceDir: string;
15
+
16
+ function writeConfig(data: Record<string, unknown>): void {
17
+ writeFileSync(
18
+ join(workspaceDir, "config.json"),
19
+ JSON.stringify(data, null, 2) + "\n",
20
+ );
21
+ }
22
+
23
+ function readConfig(): Record<string, unknown> {
24
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
25
+ }
26
+
27
+ function readLlm(): Record<string, unknown> {
28
+ return readConfig().llm as Record<string, unknown>;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ workspaceDir = join(
33
+ tmpdir(),
34
+ `vellum-migration-125-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
35
+ );
36
+ mkdirSync(workspaceDir, { recursive: true });
37
+ });
38
+
39
+ afterEach(() => {
40
+ if (existsSync(workspaceDir)) {
41
+ rmSync(workspaceDir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ describe("125-repoint-managed-connections-to-vellum migration", () => {
46
+ test("no-op when config.json does not exist", () => {
47
+ repointManagedConnectionsToVellumMigration.run(workspaceDir);
48
+ expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
49
+ });
50
+
51
+ test("gracefully handles invalid JSON", () => {
52
+ writeFileSync(join(workspaceDir, "config.json"), "not-valid-json");
53
+ repointManagedConnectionsToVellumMigration.run(workspaceDir);
54
+ expect(readFileSync(join(workspaceDir, "config.json"), "utf-8")).toBe(
55
+ "not-valid-json",
56
+ );
57
+ });
58
+
59
+ test("repoints legacy managed connections across default, profiles, and callSites", () => {
60
+ writeConfig({
61
+ llm: {
62
+ default: {
63
+ provider: "anthropic",
64
+ provider_connection: "anthropic-managed",
65
+ },
66
+ profiles: {
67
+ balanced: {
68
+ source: "managed",
69
+ provider: "fireworks",
70
+ provider_connection: "fireworks-managed",
71
+ },
72
+ "quality-optimized": {
73
+ source: "managed",
74
+ provider: "anthropic",
75
+ provider_connection: "anthropic-managed",
76
+ },
77
+ "os-beta": {
78
+ source: "managed",
79
+ provider: "together",
80
+ provider_connection: "together-managed",
81
+ },
82
+ // User-owned copy referencing a managed connection is repointed too.
83
+ "my-quality": {
84
+ source: "user",
85
+ provider: "openai",
86
+ provider_connection: "openai-managed",
87
+ },
88
+ },
89
+ callSites: {
90
+ replySuggestion: {
91
+ provider: "gemini",
92
+ provider_connection: "gemini-managed",
93
+ },
94
+ },
95
+ },
96
+ });
97
+
98
+ repointManagedConnectionsToVellumMigration.run(workspaceDir);
99
+
100
+ const llm = readLlm();
101
+ const profiles = llm.profiles as Record<string, Record<string, unknown>>;
102
+ const callSites = llm.callSites as Record<string, Record<string, unknown>>;
103
+
104
+ expect((llm.default as Record<string, unknown>).provider_connection).toBe(
105
+ "vellum",
106
+ );
107
+ expect(profiles.balanced.provider_connection).toBe("vellum");
108
+ expect(profiles["quality-optimized"].provider_connection).toBe("vellum");
109
+ expect(profiles["os-beta"].provider_connection).toBe("vellum");
110
+ expect(profiles["my-quality"].provider_connection).toBe("vellum");
111
+ expect(callSites.replySuggestion.provider_connection).toBe("vellum");
112
+
113
+ // The provider field is left intact — that is how vellum recovers the
114
+ // upstream at dispatch time.
115
+ expect(profiles.balanced.provider).toBe("fireworks");
116
+ expect(profiles["quality-optimized"].provider).toBe("anthropic");
117
+ });
118
+
119
+ test("leaves non-managed connections (personal, openrouter) untouched", () => {
120
+ const original = {
121
+ llm: {
122
+ profiles: {
123
+ "custom-balanced": {
124
+ source: "user",
125
+ provider: "anthropic",
126
+ provider_connection: "anthropic-personal",
127
+ },
128
+ "my-router": {
129
+ source: "user",
130
+ provider: "openrouter",
131
+ provider_connection: "openrouter-personal",
132
+ },
133
+ },
134
+ },
135
+ };
136
+ writeConfig(original);
137
+ repointManagedConnectionsToVellumMigration.run(workspaceDir);
138
+ expect(readConfig()).toEqual(original);
139
+ });
140
+ });
@@ -8,6 +8,13 @@ export interface MessageAttachmentInput {
8
8
  data: string;
9
9
  extractedText?: string;
10
10
  filePath?: string;
11
+ /**
12
+ * Path of the canonical copy in the conversation's attachments/ directory,
13
+ * known once the attachment is linked to a message. Name collisions are
14
+ * resolved with a -2/-3 suffix, so this may differ from `filename` — and
15
+ * from `filePath`, which is where the upload originally came from.
16
+ */
17
+ storedPath?: string;
11
18
  }
12
19
 
13
20
  export function attachmentsToContentBlocks(
@@ -44,26 +51,61 @@ export function attachmentsToContentBlocks(
44
51
  }
45
52
 
46
53
  /**
47
- * Return a copy of the message with text annotations for image source paths.
48
- * The annotations are appended as a text content block so the LLM knows where
49
- * the images came from on disk. The caller should persist the ORIGINAL message
50
- * (without annotations) so the UI stays clean.
54
+ * Annotation line for where an attached image originally came from (e.g. a
55
+ * desktop client path). Shared with the history-reload reinjection so the
56
+ * reloaded content block stays byte-identical to the one built at persist
57
+ * time (prefix-cache parity).
58
+ */
59
+ export function formatImageSourceAnnotation(filePath: string): string {
60
+ return `[Attached image source: ${filePath}]`;
61
+ }
62
+
63
+ /**
64
+ * Annotation line binding an attachment's user-facing filename to its
65
+ * canonical on-disk copy. The stored path is the one the model must use to
66
+ * read the attachment — the plain filename in the attachments/ directory may
67
+ * belong to an older upload with the same name. Shared with the
68
+ * history-reload reinjection (prefix-cache parity).
69
+ */
70
+ export function formatStoredPathAnnotation(
71
+ filename: string,
72
+ storedPath: string,
73
+ ): string {
74
+ return `[Attachment "${filename}" is stored at: ${storedPath}]`;
75
+ }
76
+
77
+ /**
78
+ * Return a copy of the message with text annotations for attachment paths:
79
+ * source paths for images (where the file came from) and stored paths for
80
+ * any attachment whose canonical copy in the conversation's attachments/
81
+ * directory is known. The annotations are appended as a text content block
82
+ * so the LLM knows where to find the files on disk. The caller should
83
+ * persist the ORIGINAL message (without annotations) so the UI stays clean.
51
84
  */
52
85
  export function enrichMessageWithSourcePaths(
53
86
  message: Message,
54
87
  attachments: MessageAttachmentInput[],
55
88
  ): Message {
56
- const imageAttachments = attachments.filter(
57
- (a) => a.mimeType.toLowerCase().startsWith("image/") && a.filePath,
58
- );
59
- if (imageAttachments.length === 0) return message;
60
-
61
- const annotation = imageAttachments
62
- .map((a) => `[Attached image source: ${a.filePath}]`)
63
- .join("\n");
89
+ const lines: string[] = [];
90
+ for (const a of attachments) {
91
+ if (a.mimeType.toLowerCase().startsWith("image/") && a.filePath) {
92
+ lines.push(formatImageSourceAnnotation(a.filePath));
93
+ }
94
+ }
95
+ for (const a of attachments) {
96
+ if (a.storedPath) {
97
+ lines.push(formatStoredPathAnnotation(a.filename, a.storedPath));
98
+ }
99
+ }
100
+ if (lines.length === 0) {
101
+ return message;
102
+ }
64
103
 
65
104
  return {
66
105
  ...message,
67
- content: [...message.content, { type: "text" as const, text: annotation }],
106
+ content: [
107
+ ...message.content,
108
+ { type: "text" as const, text: lines.join("\n") },
109
+ ],
68
110
  };
69
111
  }
@@ -0,0 +1,111 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { Command } from "commander";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock state
7
+ // ---------------------------------------------------------------------------
8
+
9
+ let mockCalls: Array<[string, Record<string, unknown>]> = [];
10
+ let mockResponse: unknown = {
11
+ ok: true,
12
+ result: {
13
+ remaining: 42.17,
14
+ settled: 50,
15
+ pending: 7.83,
16
+ unit: "USD",
17
+ stale: false,
18
+ as_of: "2026-07-06T00:00:00.000Z",
19
+ },
20
+ };
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mocks
24
+ // ---------------------------------------------------------------------------
25
+
26
+ mock.module("../../../../ipc/cli-client.js", () => ({
27
+ cliIpcCall: async (method: string, params: Record<string, unknown>) => {
28
+ mockCalls.push([method, params]);
29
+ return mockResponse;
30
+ },
31
+ exitFromIpcResult: (_r: unknown, _cmd: unknown) => {
32
+ throw new Error("exitFromIpcResult called");
33
+ },
34
+ }));
35
+
36
+ const { registerPlatformCommand } = await import("../index.js");
37
+
38
+ function buildProgram(): Command {
39
+ const program = new Command();
40
+ program.exitOverride();
41
+ registerPlatformCommand(program);
42
+ return program;
43
+ }
44
+
45
+ function captureStdout(fn: () => Promise<void>): Promise<string[]> {
46
+ const chunks: string[] = [];
47
+ const origWrite = process.stdout.write.bind(process.stdout);
48
+ process.stdout.write = ((chunk: unknown) => {
49
+ chunks.push(typeof chunk === "string" ? chunk : String(chunk));
50
+ return true;
51
+ }) as typeof process.stdout.write;
52
+ return fn()
53
+ .then(() => chunks)
54
+ .finally(() => {
55
+ process.stdout.write = origWrite;
56
+ });
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Tests
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe("assistant platform credits", () => {
64
+ beforeEach(() => {
65
+ mockCalls = [];
66
+ mockResponse = {
67
+ ok: true,
68
+ result: {
69
+ remaining: 42.17,
70
+ settled: 50,
71
+ pending: 7.83,
72
+ unit: "USD",
73
+ stale: false,
74
+ as_of: "2026-07-06T00:00:00.000Z",
75
+ },
76
+ };
77
+ process.exitCode = 0;
78
+ });
79
+
80
+ test("calls platform_credits and emits balance JSON with --json", async () => {
81
+ const out = await captureStdout(async () => {
82
+ const program = buildProgram();
83
+ await program.parseAsync([
84
+ "node",
85
+ "assistant",
86
+ "platform",
87
+ "credits",
88
+ "--json",
89
+ ]);
90
+ });
91
+
92
+ expect(mockCalls[0][0]).toBe("platform_credits");
93
+
94
+ const parsed = JSON.parse(out.join(""));
95
+ expect(parsed.remaining).toBe(42.17);
96
+ expect(parsed.settled).toBe(50);
97
+ expect(parsed.pending).toBe(7.83);
98
+ expect(parsed.unit).toBe("USD");
99
+ expect(parsed.stale).toBe(false);
100
+ });
101
+
102
+ test("plain text mode does not emit JSON to stdout", async () => {
103
+ const out = await captureStdout(async () => {
104
+ const program = buildProgram();
105
+ await program.parseAsync(["node", "assistant", "platform", "credits"]);
106
+ });
107
+
108
+ // Plain-text mode logs via log.info — verify writeOutput (JSON) was NOT called
109
+ expect(() => JSON.parse(out.join("").trim())).toThrow();
110
+ });
111
+ });
@@ -25,6 +25,15 @@ interface PlatformStatusResult {
25
25
  velayTunnel: { connected: boolean; publicUrl: string | null } | null;
26
26
  }
27
27
 
28
+ interface PlatformCreditsResult {
29
+ remaining: number;
30
+ settled: number;
31
+ pending: number;
32
+ unit: "USD";
33
+ stale: boolean;
34
+ as_of: string;
35
+ }
36
+
28
37
  export function registerPlatformCommand(program: Command): void {
29
38
  registerCommand(program, {
30
39
  name: "platform",
@@ -47,6 +56,7 @@ assistants.
47
56
 
48
57
  Examples:
49
58
  $ assistant platform status --json
59
+ $ assistant platform credits --json
50
60
  $ assistant platform connect
51
61
  $ assistant platform disconnect
52
62
  $ assistant platform callback-routes register --path webhooks/telegram --type telegram --json`,
@@ -95,7 +105,11 @@ Examples:
95
105
  "platform_status",
96
106
  {},
97
107
  );
98
- if (!r.ok) return exitFromIpcResult({ ok: false, error: r.error, statusCode: r.statusCode }, cmd);
108
+ if (!r.ok)
109
+ return exitFromIpcResult(
110
+ { ok: false, error: r.error, statusCode: r.statusCode },
111
+ cmd,
112
+ );
99
113
 
100
114
  const result = r.result!;
101
115
 
@@ -114,7 +128,9 @@ Examples:
114
128
  log.info(
115
129
  `Callback registration available: ${result.available ? "yes" : "no"}`,
116
130
  );
117
- log.info(`Organization ID: ${result.organizationId || "(not set)"}`);
131
+ log.info(
132
+ `Organization ID: ${result.organizationId || "(not set)"}`,
133
+ );
118
134
  log.info(`User ID: ${result.userId || "(not set)"}`);
119
135
  if (result.velayTunnel !== null) {
120
136
  const tunnelState = result.velayTunnel.connected
@@ -127,6 +143,64 @@ Examples:
127
143
  }
128
144
  });
129
145
 
146
+ // -----------------------------------------------------------------------
147
+ // credits
148
+ // -----------------------------------------------------------------------
149
+
150
+ platform
151
+ .command("credits")
152
+ .description("Show the organization's remaining credit balance")
153
+ .addHelpText(
154
+ "after",
155
+ `
156
+ Fetches the org's credit balance from the platform billing summary.
157
+
158
+ Fields:
159
+ remaining Effective balance (settled minus pending charges) in USD
160
+ settled On-ledger balance in USD
161
+ pending Estimated pending compute charges not yet settled, in USD
162
+ unit Balance currency (USD)
163
+ stale True when pending-charge data may be stale or unavailable
164
+ as_of When this balance was read (response receipt time)
165
+
166
+ Combine with 'assistant usage daily' to compute runway (remaining divided
167
+ by rolling daily average) and warn before credits run out.
168
+
169
+ Requires platform credentials (run 'assistant platform connect' first or
170
+ ensure VELLUM_PLATFORM_URL is set and credentials are stored).
171
+
172
+ Examples:
173
+ $ assistant platform credits
174
+ $ assistant platform credits --json`,
175
+ )
176
+ .action(async (_opts: Record<string, unknown>, cmd: Command) => {
177
+ const r = await cliIpcCall<PlatformCreditsResult>(
178
+ "platform_credits",
179
+ {},
180
+ );
181
+ if (!r.ok)
182
+ return exitFromIpcResult(
183
+ { ok: false, error: r.error, statusCode: r.statusCode },
184
+ cmd,
185
+ );
186
+
187
+ const result = r.result!;
188
+
189
+ if (shouldOutputJson(cmd)) {
190
+ writeOutput(cmd, result);
191
+ } else {
192
+ const staleNote = result.stale
193
+ ? " (pending data may be stale)"
194
+ : "";
195
+ log.info(
196
+ `Remaining: $${result.remaining.toFixed(2)} ${result.unit} (as of ${result.as_of})${staleNote}`,
197
+ );
198
+ log.info(
199
+ `Settled: $${result.settled.toFixed(2)} Pending: $${result.pending.toFixed(2)}`,
200
+ );
201
+ }
202
+ });
203
+
130
204
  // -----------------------------------------------------------------------
131
205
  // disconnect
132
206
  // -----------------------------------------------------------------------
@@ -209,7 +283,11 @@ Examples:
209
283
  }>("platform_callback_routes_register", {
210
284
  body: { path: opts.path, type: opts.type },
211
285
  });
212
- if (!r.ok) return exitFromIpcResult({ ok: false, error: r.error, statusCode: r.statusCode }, cmd);
286
+ if (!r.ok)
287
+ return exitFromIpcResult(
288
+ { ok: false, error: r.error, statusCode: r.statusCode },
289
+ cmd,
290
+ );
213
291
 
214
292
  writeOutput(cmd, { ok: true, ...r.result });
215
293
 
@@ -248,7 +326,11 @@ Examples:
248
326
  callback_url: string;
249
327
  }>;
250
328
  }>("platform_callback_routes_list", {});
251
- if (!r.ok) return exitFromIpcResult({ ok: false, error: r.error, statusCode: r.statusCode }, cmd);
329
+ if (!r.ok)
330
+ return exitFromIpcResult(
331
+ { ok: false, error: r.error, statusCode: r.statusCode },
332
+ cmd,
333
+ );
252
334
 
253
335
  const routes = r.result!.routes;
254
336
 
@@ -23,7 +23,7 @@ function buildStreams(): Captured {
23
23
  }
24
24
 
25
25
  describe("confirmPrompt", () => {
26
- test("returns \"non-interactive\" without reading when isTTY=false", async () => {
26
+ test('returns "non-interactive" without reading when isTTY=false', async () => {
27
27
  const { stdin, stdout, stderr, outChunks, errChunks } = buildStreams();
28
28
  const result = await confirmPrompt({
29
29
  question: "Delete? [y/N] ",
@@ -38,7 +38,7 @@ describe("confirmPrompt", () => {
38
38
  expect(outChunks.join("")).toBe("");
39
39
  });
40
40
 
41
- test("returns \"confirmed\" for \"y\\n\"", async () => {
41
+ test('returns "confirmed" for "y\\n"', async () => {
42
42
  const { stdin, stdout, stderr } = buildStreams();
43
43
  const pending = confirmPrompt({
44
44
  question: "Delete? [y/N] ",
@@ -52,7 +52,7 @@ describe("confirmPrompt", () => {
52
52
  expect(await pending).toBe("confirmed");
53
53
  });
54
54
 
55
- test("returns \"confirmed\" for \"yes\\n\" (case-insensitive, whitespace-tolerant)", async () => {
55
+ test('returns "confirmed" for "yes\\n" (case-insensitive, whitespace-tolerant)', async () => {
56
56
  const { stdin, stdout, stderr } = buildStreams();
57
57
  const pending = confirmPrompt({
58
58
  question: "Delete? [y/N] ",
@@ -66,7 +66,7 @@ describe("confirmPrompt", () => {
66
66
  expect(await pending).toBe("confirmed");
67
67
  });
68
68
 
69
- test("returns \"denied\" for \"n\\n\"", async () => {
69
+ test('returns "denied" for "n\\n"', async () => {
70
70
  const { stdin, stdout, stderr } = buildStreams();
71
71
  const pending = confirmPrompt({
72
72
  question: "Delete? [y/N] ",
@@ -80,7 +80,7 @@ describe("confirmPrompt", () => {
80
80
  expect(await pending).toBe("denied");
81
81
  });
82
82
 
83
- test("returns \"denied\" for empty input (just Enter)", async () => {
83
+ test('returns "denied" for empty input (just Enter)', async () => {
84
84
  const { stdin, stdout, stderr } = buildStreams();
85
85
  const pending = confirmPrompt({
86
86
  question: "Delete? [y/N] ",
@@ -94,7 +94,7 @@ describe("confirmPrompt", () => {
94
94
  expect(await pending).toBe("denied");
95
95
  });
96
96
 
97
- test("returns \"denied\" on EOF without any data (regression: would have hung)", async () => {
97
+ test('returns "denied" on EOF without any data (regression: would have hung)', async () => {
98
98
  const { stdin, stdout, stderr } = buildStreams();
99
99
  const pending = confirmPrompt({
100
100
  question: "Delete? [y/N] ",
@@ -108,7 +108,7 @@ describe("confirmPrompt", () => {
108
108
  expect(await pending).toBe("denied");
109
109
  });
110
110
 
111
- test("returns \"denied\" on EOF after a partial line with no newline", async () => {
111
+ test('returns "denied" on EOF after a partial line with no newline', async () => {
112
112
  const { stdin, stdout, stderr } = buildStreams();
113
113
  const pending = confirmPrompt({
114
114
  question: "Delete? [y/N] ",
@@ -131,7 +131,7 @@ describe("confirmPrompt", () => {
131
131
  test("writes the question to stdout when interactive", async () => {
132
132
  const { stdin, stdout, stderr, outChunks } = buildStreams();
133
133
  const pending = confirmPrompt({
134
- question: "Delete plugin \"foo\"? [y/N] ",
134
+ question: 'Delete plugin "foo"? [y/N] ',
135
135
  isTTY: true,
136
136
  refuseNonInteractiveMessage: "n/a",
137
137
  stdin,
@@ -140,7 +140,7 @@ describe("confirmPrompt", () => {
140
140
  });
141
141
  stdin.write("n\n");
142
142
  await pending;
143
- expect(outChunks.join("")).toContain("Delete plugin \"foo\"? [y/N] ");
143
+ expect(outChunks.join("")).toContain('Delete plugin "foo"? [y/N] ');
144
144
  });
145
145
 
146
146
  test("treats stray garbage as denial, never confirmation", async () => {
@@ -38,6 +38,7 @@ import { join } from "node:path";
38
38
 
39
39
  import { createTwoFilesPatch } from "diff";
40
40
 
41
+ import { PRESERVED_ENTRIES } from "../../plugins/plugin-tree-walk.js";
41
42
  import { getWorkspacePluginsDir } from "../../util/platform.js";
42
43
  import {
43
44
  DEFAULT_PLUGIN_REF,
@@ -55,7 +56,6 @@ import {
55
56
  compareFingerprint,
56
57
  computeFingerprint,
57
58
  type Fingerprint,
58
- PRESERVED_ENTRIES,
59
59
  } from "./plugin-fingerprint.js";
60
60
  import { PluginNotInstalledError } from "./uninstall-plugin.js";
61
61
 
@@ -141,7 +141,9 @@ export interface DiffPluginDeps {
141
141
  function isBinary(buf: Buffer): boolean {
142
142
  const len = Math.min(buf.length, 8000);
143
143
  for (let i = 0; i < len; i++) {
144
- if (buf[i] === 0) return true;
144
+ if (buf[i] === 0) {
145
+ return true;
146
+ }
145
147
  }
146
148
  return false;
147
149
  }
@@ -214,7 +216,9 @@ function baselineContent(
214
216
  recorded: Fingerprint,
215
217
  materialized: Fingerprint,
216
218
  ): FileContent | null {
217
- if (materialized.files[path] !== recorded.files[path]) return null;
219
+ if (materialized.files[path] !== recorded.files[path]) {
220
+ return null;
221
+ }
218
222
  return readContent(join(baselineRoot, path));
219
223
  }
220
224
 
@@ -16,6 +16,7 @@
16
16
  * is a thin wrapper that supplies production deps and formats the result.
17
17
  */
18
18
 
19
+ import { PRESERVED_ENTRIES } from "../../plugins/plugin-tree-walk.js";
19
20
  import {
20
21
  DEFAULT_PLUGIN_REF,
21
22
  type FetchLike,
@@ -30,7 +31,6 @@ import {
30
31
  import {
31
32
  compareFingerprint,
32
33
  type FingerprintComparison,
33
- PRESERVED_ENTRIES,
34
34
  } from "./plugin-fingerprint.js";
35
35
  import {
36
36
  fetchMarketplaceEntries,
@@ -238,15 +238,25 @@ async function fetchCommitDate(
238
238
  "User-Agent": "vellum-assistant-cli",
239
239
  },
240
240
  });
241
- if (!res.ok) return null;
241
+ if (!res.ok) {
242
+ return null;
243
+ }
242
244
  const json: unknown = JSON.parse(await res.text());
243
- if (typeof json !== "object" || json === null) return null;
245
+ if (typeof json !== "object" || json === null) {
246
+ return null;
247
+ }
244
248
  const commit = (json as Record<string, unknown>).commit;
245
- if (typeof commit !== "object" || commit === null) return null;
249
+ if (typeof commit !== "object" || commit === null) {
250
+ return null;
251
+ }
246
252
  const committer = (commit as Record<string, unknown>).committer;
247
- if (typeof committer !== "object" || committer === null) return null;
253
+ if (typeof committer !== "object" || committer === null) {
254
+ return null;
255
+ }
248
256
  const date = (committer as Record<string, unknown>).date;
249
- if (typeof date !== "string") return null;
257
+ if (typeof date !== "string") {
258
+ return null;
259
+ }
250
260
  const ms = Date.parse(date);
251
261
  return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
252
262
  } catch {
@@ -312,10 +322,18 @@ function classify(
312
322
  remote: PluginRemoteInfo | null,
313
323
  remoteError: string | null,
314
324
  ): PluginUpdateStatus {
315
- if (!installed) return "not-installed";
316
- if (remoteError && !remote) return "remote-unavailable";
317
- if (!remote) return "not-in-marketplace";
318
- if (!local?.commit) return "unknown-provenance";
325
+ if (!installed) {
326
+ return "not-installed";
327
+ }
328
+ if (remoteError && !remote) {
329
+ return "remote-unavailable";
330
+ }
331
+ if (!remote) {
332
+ return "not-in-marketplace";
333
+ }
334
+ if (!local?.commit) {
335
+ return "unknown-provenance";
336
+ }
319
337
  return local.commit.toLowerCase() === remote.commit.toLowerCase()
320
338
  ? "up-to-date"
321
339
  : "update-available";