@vellumai/assistant 0.9.0-dev.202606170806.f7bee44 → 0.9.0-dev.202606171417.d2192ab

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/openapi.yaml CHANGED
@@ -13241,7 +13241,7 @@ paths:
13241
13241
  type: string
13242
13242
  description:
13243
13243
  "Filter by provider. One of: anthropic, openai, gemini, ollama, fireworks, openrouter, openai-compatible,
13244
- minimax"
13244
+ minimax, atlascloud"
13245
13245
  responses:
13246
13246
  "200":
13247
13247
  description: Successful response
@@ -19573,6 +19573,35 @@ paths:
19573
19573
  - type: string
19574
19574
  - type: "null"
19575
19575
  description: Marketplace fetch error message, when the catalog could not be read.
19576
+ surfaces:
19577
+ anyOf:
19578
+ - type: object
19579
+ properties:
19580
+ skills:
19581
+ type: array
19582
+ items:
19583
+ type: string
19584
+ description: Skill ids shipped at `skills/<id>/SKILL.md`.
19585
+ hooks:
19586
+ type: array
19587
+ items:
19588
+ type: string
19589
+ description: Lifecycle hook names from `hooks/<name>.{ts,js}` (e.g. `pre-model-call`).
19590
+ tools:
19591
+ type: array
19592
+ items:
19593
+ type: string
19594
+ description:
19595
+ Registered tool names from `tools/<name>.{ts,js}` (filenames derived to tool names, e.g. `create-issue` →
19596
+ `create_issue`).
19597
+ required:
19598
+ - skills
19599
+ - hooks
19600
+ - tools
19601
+ additionalProperties: false
19602
+ description: Surfaces the installed copy contributes, read from its on-disk tree.
19603
+ - type: "null"
19604
+ description: Surfaces the installed copy contributes (skills, hooks, tools); null when the plugin is not installed.
19576
19605
  required:
19577
19606
  - name
19578
19607
  - installed
@@ -19580,6 +19609,7 @@ paths:
19580
19609
  - local
19581
19610
  - remote
19582
19611
  - remoteError
19612
+ - surfaces
19583
19613
  additionalProperties: false
19584
19614
  "400":
19585
19615
  description: The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).
@@ -19596,11 +19626,12 @@ paths:
19596
19626
  commit already equals the pin; pass `dryRun` to preview the move (`outcome: "would-upgrade"`) without touching
19597
19627
  the install. Installs lacking provenance are re-pinned to the current SHA. The assistant must be restarted to
19598
19628
  load the upgraded code. `strategy` controls how local edits are reconciled: `overwrite` (default) discards them
19599
- and re-installs the pin wholesale; `ours`/`theirs` perform a three-way merge against the re-materialized install
19600
- commit, carrying non-conflicting edits from both sides forward and resolving conflicting hunks toward the local
19601
- edit or the pin respectively; `assistant` is not yet supported (400). A merge strategy whose install-time
19602
- baseline cannot be reconstructed returns 409. Mirrors the CLI''s `assistant plugins upgrade <name> [--strategy
19603
- <s>]`.'
19629
+ and re-installs the pin wholesale; `ours`/`theirs`/`assistant` perform a three-way merge against the
19630
+ re-materialized install commit, carrying non-conflicting edits from both sides forward and resolving conflicting
19631
+ hunks toward the local edit (`ours`) or the pin (`theirs`), or writing git conflict markers into the file and
19632
+ reporting them in `conflicts`/`binaryConflicts` for the assistant to resolve (`assistant`). A merge strategy
19633
+ whose install-time baseline cannot be reconstructed returns 409. Mirrors the CLI''s `assistant plugins upgrade
19634
+ <name> [--strategy <s>]`.'
19604
19635
  tags:
19605
19636
  - plugins
19606
19637
  parameters:
@@ -19689,6 +19720,20 @@ paths:
19689
19720
  - overwrite
19690
19721
  - assistant
19691
19722
  description: Conflict-resolution strategy the upgrade applied.
19723
+ conflicts:
19724
+ type: array
19725
+ items:
19726
+ type: string
19727
+ description:
19728
+ "Paths left for the assistant to resolve under the `assistant` strategy: text files carry git conflict
19729
+ markers, modify/delete divergences keep the surviving content. Empty for other strategies."
19730
+ binaryConflicts:
19731
+ type: array
19732
+ items:
19733
+ type: string
19734
+ description:
19735
+ Binary files that conflicted under the `assistant` strategy; the local copy was kept since markers cannot
19736
+ be written into binary content. Empty for other strategies.
19692
19737
  provenanceWasUnknown:
19693
19738
  type: boolean
19694
19739
  description:
@@ -19705,12 +19750,12 @@ paths:
19705
19750
  - fileCount
19706
19751
  - dryRun
19707
19752
  - strategy
19753
+ - conflicts
19754
+ - binaryConflicts
19708
19755
  - provenanceWasUnknown
19709
19756
  additionalProperties: false
19710
19757
  "400":
19711
- description:
19712
- The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters), or the
19713
- `assistant` strategy was requested (not yet supported).
19758
+ description: The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).
19714
19759
  "404":
19715
19760
  description: No copy of the plugin is installed, or its source resolves to nothing.
19716
19761
  "409":
@@ -28199,6 +28244,7 @@ components:
28199
28244
  - openrouter
28200
28245
  - openai-compatible
28201
28246
  - minimax
28247
+ - atlascloud
28202
28248
  ProfilePatchEntry:
28203
28249
  type: object
28204
28250
  properties:
@@ -28556,6 +28602,7 @@ components:
28556
28602
  - openrouter
28557
28603
  - openai-compatible
28558
28604
  - minimax
28605
+ - atlascloud
28559
28606
  Auth:
28560
28607
  oneOf:
28561
28608
  - type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606170806.f7bee44",
3
+ "version": "0.9.0-dev.202606171417.d2192ab",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -210,10 +210,13 @@ describe("scheduler conversation reuse", () => {
210
210
  expect(runs2[0].conversationId).toBe(firstConversationId);
211
211
  });
212
212
 
213
- test("recurring schedule defaults to reuseConversation=true", async () => {
213
+ test("recurring schedule defaults to reuseConversation=false", async () => {
214
214
  /**
215
215
  * When no explicit reuseConversation is provided, recurring schedules
216
- * default to true — subsequent runs reuse the same conversation.
216
+ * default to false — each run gets a fresh conversation. This keeps a
217
+ * weak model's per-run context bounded instead of accumulating a long,
218
+ * self-similar transcript that primes it to repeat or drift; durable
219
+ * cross-run state belongs in workspace files and memory, not history.
217
220
  */
218
221
 
219
222
  // GIVEN a recurring schedule with no explicit reuseConversation
@@ -224,7 +227,7 @@ describe("scheduler conversation reuse", () => {
224
227
  message: "Default reuse message",
225
228
  syntax: "rrule",
226
229
  expression: rruleExpr,
227
- // no explicit reuseConversation — should default to true for recurring
230
+ // no explicit reuseConversation — should default to false
228
231
  });
229
232
 
230
233
  // WHEN the schedule fires for the first time
@@ -250,9 +253,9 @@ describe("scheduler conversation reuse", () => {
250
253
  await new Promise((resolve) => setTimeout(resolve, 500));
251
254
  scheduler2.stop();
252
255
 
253
- // THEN the same conversation is reused
256
+ // THEN a fresh conversation is created instead of reusing the first
254
257
  expect(processedMessages).toHaveLength(1);
255
- expect(processedMessages[0].conversationId).toBe(firstConversationId);
258
+ expect(processedMessages[0].conversationId).not.toBe(firstConversationId);
256
259
  });
257
260
 
258
261
  test("recurring schedule with reuseConversation=false creates new conversation each run", async () => {
@@ -46,7 +46,6 @@ import {
46
46
  PluginNotUpgradableError,
47
47
  type PluginUpgradeResult,
48
48
  type PluginUpgradeStrategy,
49
- PluginUpgradeStrategyUnsupportedError,
50
49
  upgradePlugin,
51
50
  } from "../lib/upgrade-plugin.js";
52
51
  import { getCliLogger } from "../logger.js";
@@ -76,6 +75,7 @@ Examples:
76
75
  $ assistant plugins upgrade example --dry-run
77
76
  $ assistant plugins upgrade example --strategy ours
78
77
  $ assistant plugins upgrade example --strategy theirs
78
+ $ assistant plugins upgrade example --strategy assistant
79
79
  $ assistant plugins search example
80
80
  $ assistant plugins search "^example"
81
81
  $ assistant plugins search example --json
@@ -180,7 +180,7 @@ Examples:
180
180
  plugins
181
181
  .command("inspect <name>")
182
182
  .description(
183
- "Show a plugin's local install metadata and the marketplace pin, and whether an update is available",
183
+ "Show a plugin's local install metadata, the marketplace pin, whether an update is available, and the surfaces (skills, hooks, tools) it contributes",
184
184
  )
185
185
  .option("--json", "Emit machine-readable JSON instead of a summary")
186
186
  .action(async (name: string, opts: { json?: boolean }) => {
@@ -465,7 +465,6 @@ Examples:
465
465
  if (
466
466
  err instanceof PluginNotInstalledError ||
467
467
  err instanceof PluginNotUpgradableError ||
468
- err instanceof PluginUpgradeStrategyUnsupportedError ||
469
468
  err instanceof PluginMergeBaselineError ||
470
469
  err instanceof InvalidPluginNameError
471
470
  ) {
@@ -550,12 +549,20 @@ function remoteLocation(remote: PluginRemoteInfo): string {
550
549
  * `location`, with a `drift` line under the installed copy.
551
550
  */
552
551
  function formatInspection(inspection: PluginInspection): string[] {
553
- const { name, status, local, remote, remoteError } = inspection;
552
+ const { name, status, local, remote, remoteError, surfaces } = inspection;
554
553
  const lines: string[] = [name, "─".repeat(44)];
555
554
  const topRow = (label: string, value: string) =>
556
555
  lines.push(`${label.padEnd(11)} ${value}`);
557
556
  const blockRow = (label: string, value: string) =>
558
557
  lines.push(` ${label.padEnd(9)} ${value}`);
558
+ // A surface block: the surface type as a heading, then its items indented
559
+ // under it. Omitted entirely when the plugin contributes none of that type,
560
+ // so the listing only ever shows what the plugin actually contributes.
561
+ const surfaceBlock = (label: string, items: readonly string[]) => {
562
+ if (items.length === 0) return;
563
+ lines.push(label);
564
+ for (const item of items) lines.push(` ${item}`);
565
+ };
559
566
 
560
567
  topRow("status", statusLine(status));
561
568
 
@@ -590,6 +597,12 @@ function formatInspection(inspection: PluginInspection): string[] {
590
597
  const description = remote?.description ?? local?.description ?? null;
591
598
  if (description) topRow("description", description);
592
599
 
600
+ if (surfaces) {
601
+ surfaceBlock("skills", surfaces.skills);
602
+ surfaceBlock("hooks", surfaces.hooks);
603
+ surfaceBlock("tools", surfaces.tools);
604
+ }
605
+
593
606
  for (const issue of local?.issues ?? []) topRow("issue", issue);
594
607
 
595
608
  return lines;
@@ -628,10 +641,49 @@ function formatUpgrade(result: PluginUpgradeResult): string[] {
628
641
  result.fileCount === null
629
642
  ? ""
630
643
  : `(${result.fileCount} file${result.fileCount === 1 ? "" : "s"}) `;
644
+ const hasConflicts =
645
+ result.conflicts.length > 0 || result.binaryConflicts.length > 0;
646
+
647
+ // Under `assistant`, an unresolved merge leaves conflicts in the tree —
648
+ // the plugin would fail to load while any remain, so the usual "restart
649
+ // now" guidance is replaced with resolution instructions. Most conflicts
650
+ // carry git markers, but a modify/delete divergence (a file edited on one
651
+ // side and deleted on the other) keeps the surviving content with no
652
+ // markers, so the guidance covers both rather than assuming markers.
653
+ if (result.strategy === "assistant" && hasConflicts) {
654
+ const lines = [
655
+ `Merged "${name}" ${move} with conflicts`,
656
+ "",
657
+ `${count}→ ${result.target}`,
658
+ ];
659
+ if (result.conflicts.length > 0) {
660
+ lines.push(
661
+ "",
662
+ `Resolve ${result.conflicts.length} conflicted file${result.conflicts.length === 1 ? "" : "s"} (open each: resolve its git conflict markers, or — if a modify/delete divergence kept the file with none — decide whether to keep or remove it):`,
663
+ ...result.conflicts.map((p) => ` ${p}`),
664
+ );
665
+ }
666
+ if (result.binaryConflicts.length > 0) {
667
+ lines.push(
668
+ "",
669
+ `Binary conflict${result.binaryConflicts.length === 1 ? "" : "s"} (kept the local copy — choose a version manually):`,
670
+ ...result.binaryConflicts.map((p) => ` ${p}`),
671
+ );
672
+ }
673
+ lines.push(
674
+ "",
675
+ "Resolve the conflicts, then restart the assistant to pick up the upgrade.",
676
+ );
677
+ if (provenanceNote) lines.push(provenanceNote);
678
+ return lines;
679
+ }
680
+
631
681
  const mergeNote =
632
682
  result.strategy === "ours" || result.strategy === "theirs"
633
683
  ? `Local edits were merged (--strategy ${result.strategy}).`
634
- : null;
684
+ : result.strategy === "assistant"
685
+ ? "Local edits were merged with no conflicts (--strategy assistant)."
686
+ : null;
635
687
  const lines = [
636
688
  `Upgraded "${name}" ${move}`,
637
689
  "",
@@ -90,6 +90,8 @@ function installPlugin(
90
90
  } | null;
91
91
  /** Embed a content fingerprint of the materialized tree in the sidecar. */
92
92
  fingerprint?: boolean;
93
+ /** Materialize surface directories (`hooks/<name>.ts`, `skills/<id>/SKILL.md`). */
94
+ surfaces?: { hooks?: string[]; tools?: string[]; skills?: string[] };
93
95
  } = {},
94
96
  ): void {
95
97
  const dir = join(workspace, name);
@@ -102,6 +104,18 @@ function installPlugin(
102
104
  description: "Installed copy.",
103
105
  }),
104
106
  );
107
+ for (const hook of opts.surfaces?.hooks ?? []) {
108
+ mkdirSync(join(dir, "hooks"), { recursive: true });
109
+ writeFileSync(join(dir, "hooks", `${hook}.ts`), "export default () => {};");
110
+ }
111
+ for (const tool of opts.surfaces?.tools ?? []) {
112
+ mkdirSync(join(dir, "tools"), { recursive: true });
113
+ writeFileSync(join(dir, "tools", `${tool}.ts`), "export default {};");
114
+ }
115
+ for (const skill of opts.surfaces?.skills ?? []) {
116
+ mkdirSync(join(dir, "skills", skill), { recursive: true });
117
+ writeFileSync(join(dir, "skills", skill, "SKILL.md"), `# ${skill}`);
118
+ }
105
119
  if (opts.sidecar !== null) {
106
120
  const sidecar = opts.sidecar ?? { commit: SHA_A };
107
121
  // Mirror install: fingerprint the tree before the sidecar is written so it
@@ -367,6 +381,46 @@ describe("inspectPlugin", () => {
367
381
  expect(result.local?.localChanges).toBeNull();
368
382
  });
369
383
 
384
+ test("reports the surfaces an installed copy contributes", async () => {
385
+ // GIVEN an installed plugin shipping hooks and skills but no tools
386
+ installPlugin(workspace, "level-up", {
387
+ sidecar: { commit: SHA_A },
388
+ surfaces: {
389
+ hooks: ["post-model-call", "init"],
390
+ skills: ["second-skill", "first-skill"],
391
+ },
392
+ });
393
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
394
+
395
+ // WHEN it is inspected
396
+ const result = await inspectPlugin(
397
+ { name: "level-up" },
398
+ { fetch, workspacePluginsDir: workspace },
399
+ );
400
+
401
+ // THEN the contributed surfaces are listed (sorted) and tools is empty
402
+ expect(result.surfaces).toEqual({
403
+ skills: ["first-skill", "second-skill"],
404
+ hooks: ["init", "post-model-call"],
405
+ tools: [],
406
+ });
407
+ });
408
+
409
+ test("leaves surfaces null when the plugin is not installed", async () => {
410
+ // GIVEN a marketplace entry with no local copy to walk
411
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_B) });
412
+
413
+ // WHEN it is inspected
414
+ const result = await inspectPlugin(
415
+ { name: "level-up" },
416
+ { fetch, workspacePluginsDir: workspace },
417
+ );
418
+
419
+ // THEN there is no tree to read surfaces from
420
+ expect(result.installed).toBe(false);
421
+ expect(result.surfaces).toBeNull();
422
+ });
423
+
370
424
  test("throws when the plugin is neither installed nor in the marketplace", async () => {
371
425
  // GIVEN no local copy and an empty catalog
372
426
  const fetch = makeFetch({ marketplace: undefined });
@@ -4,8 +4,9 @@
4
4
  * The merge is exercised against three real temp directories (base/ours/theirs)
5
5
  * and the merged tree is read back from a fourth (dest). `git merge-file` runs
6
6
  * for real — these tests assert the line-level merge behavior (non-conflicting
7
- * hunks from both sides survive; conflicts resolve toward the strategy), the
8
- * file-level add/delete resolution, and binary whole-file handling.
7
+ * hunks from both sides survive; conflicts resolve toward `ours`/`theirs` or
8
+ * get conflict markers under `assistant`), the file-level add/delete
9
+ * resolution, and binary whole-file handling.
9
10
  */
10
11
 
11
12
  import {
@@ -97,7 +98,9 @@ describe("mergePluginTree", () => {
97
98
  expect(read("local-only.txt")).toBe("local\n");
98
99
  expect(read("remote-only.txt")).toBe("remote\n");
99
100
  expect(read("conflict.txt")).toBe("ours\n");
100
- expect(count).toBe(4);
101
+ expect(count.fileCount).toBe(4);
102
+ expect(count.conflicts).toEqual([]);
103
+ expect(count.binaryConflicts).toEqual([]);
101
104
  });
102
105
 
103
106
  test("resolves conflicts toward the pin under the theirs strategy", async () => {
@@ -289,7 +292,7 @@ describe("mergePluginTree", () => {
289
292
 
290
293
  // THEN the sidecar is never carried through (the caller rewrites it)
291
294
  expect(read("install-meta.json")).toBeNull();
292
- expect(count).toBe(1);
295
+ expect(count.fileCount).toBe(1);
293
296
  });
294
297
 
295
298
  test("merges files in nested directories", async () => {
@@ -310,4 +313,131 @@ describe("mergePluginTree", () => {
310
313
  // THEN the nested path is preserved and resolved
311
314
  expect(read("src/a/b.txt")).toBe("ours\n");
312
315
  });
316
+
317
+ test("writes git conflict markers and reports the path under the assistant strategy", async () => {
318
+ // GIVEN a file edited on disjoint lines by each side plus one true conflict
319
+ writeTree(baseDir, { "common.txt": "a\nb\nc\n", "conflict.txt": "base\n" });
320
+ writeTree(oursDir, { "common.txt": "A\nb\nc\n", "conflict.txt": "ours\n" });
321
+ writeTree(theirsDir, {
322
+ "common.txt": "a\nb\nC\n",
323
+ "conflict.txt": "theirs\n",
324
+ });
325
+
326
+ // WHEN merged with the `assistant` strategy
327
+ const result = await mergePluginTree({
328
+ baseDir,
329
+ oursDir,
330
+ theirsDir,
331
+ destDir,
332
+ strategy: "assistant",
333
+ });
334
+
335
+ // THEN the non-conflicting edits still auto-merge and only the true
336
+ // conflict carries markers naming both sides, and its path is reported
337
+ expect(read("common.txt")).toBe("A\nb\nC\n");
338
+ const conflict = read("conflict.txt") ?? "";
339
+ expect(conflict).toContain("<<<<<<<");
340
+ expect(conflict).toContain("=======");
341
+ expect(conflict).toContain(">>>>>>>");
342
+ expect(conflict).toContain("ours\n");
343
+ expect(conflict).toContain("theirs\n");
344
+ expect(result.conflicts).toEqual(["conflict.txt"]);
345
+ expect(result.binaryConflicts).toEqual([]);
346
+ });
347
+
348
+ test("uses the supplied conflict labels in the markers", async () => {
349
+ // GIVEN a true conflict and custom marker labels
350
+ writeTree(baseDir, { "f.txt": "base\n" });
351
+ writeTree(oursDir, { "f.txt": "ours\n" });
352
+ writeTree(theirsDir, { "f.txt": "theirs\n" });
353
+
354
+ // WHEN merged with the `assistant` strategy and explicit labels
355
+ await mergePluginTree({
356
+ baseDir,
357
+ oursDir,
358
+ theirsDir,
359
+ destDir,
360
+ strategy: "assistant",
361
+ conflictLabels: {
362
+ ours: "local edits (was abc1234)",
363
+ base: "install baseline abc1234",
364
+ theirs: "upgrade pin def5678",
365
+ },
366
+ });
367
+
368
+ // THEN the marker lines carry those labels
369
+ const merged = read("f.txt") ?? "";
370
+ expect(merged).toContain("<<<<<<< local edits (was abc1234)");
371
+ expect(merged).toContain(">>>>>>> upgrade pin def5678");
372
+ });
373
+
374
+ test("auto-merges with no markers when the assistant strategy hits no true conflict", async () => {
375
+ // GIVEN edits on disjoint lines of the same file (no overlapping hunk)
376
+ writeTree(baseDir, { "f.txt": "a\nb\nc\n" });
377
+ writeTree(oursDir, { "f.txt": "A\nb\nc\n" });
378
+ writeTree(theirsDir, { "f.txt": "a\nb\nC\n" });
379
+
380
+ // WHEN merged with the `assistant` strategy
381
+ const result = await mergePluginTree({
382
+ baseDir,
383
+ oursDir,
384
+ theirsDir,
385
+ destDir,
386
+ strategy: "assistant",
387
+ });
388
+
389
+ // THEN both edits merge cleanly with no markers and nothing is reported
390
+ const merged = read("f.txt") ?? "";
391
+ expect(merged).toBe("A\nb\nC\n");
392
+ expect(merged).not.toContain("<<<<<<<");
393
+ expect(result.conflicts).toEqual([]);
394
+ expect(result.binaryConflicts).toEqual([]);
395
+ });
396
+
397
+ test("keeps the local copy and reports a binary conflict under the assistant strategy", async () => {
398
+ // GIVEN a binary file (NUL bytes) diverged on both sides
399
+ const base = Buffer.from([0x00, 0x01, 0x02]);
400
+ const ours = Buffer.from([0x00, 0xaa, 0xbb]);
401
+ const theirs = Buffer.from([0x00, 0xcc, 0xdd]);
402
+ writeTree(baseDir, { "img.bin": base });
403
+ writeTree(oursDir, { "img.bin": ours });
404
+ writeTree(theirsDir, { "img.bin": theirs });
405
+
406
+ // WHEN merged with the `assistant` strategy
407
+ const result = await mergePluginTree({
408
+ baseDir,
409
+ oursDir,
410
+ theirsDir,
411
+ destDir,
412
+ strategy: "assistant",
413
+ });
414
+
415
+ // THEN the local copy is kept byte-for-byte (markers are impossible) and
416
+ // the path is surfaced as a binary conflict, not a marker conflict
417
+ expect(readFileSync(join(destDir, "img.bin")).equals(ours)).toBe(true);
418
+ expect(result.binaryConflicts).toEqual(["img.bin"]);
419
+ expect(result.conflicts).toEqual([]);
420
+ });
421
+
422
+ test("keeps the surviving content and reports a modify/delete conflict under the assistant strategy", async () => {
423
+ // GIVEN a file edited locally but deleted at the pin
424
+ writeTree(baseDir, { "f.txt": "base\n" });
425
+ writeTree(oursDir, { "f.txt": "local edit\n" });
426
+ writeTree(theirsDir, {});
427
+
428
+ // WHEN merged with the `assistant` strategy
429
+ const result = await mergePluginTree({
430
+ baseDir,
431
+ oursDir,
432
+ theirsDir,
433
+ destDir,
434
+ strategy: "assistant",
435
+ });
436
+
437
+ // THEN the local edit is preserved (never silently dropped) and the
438
+ // modify/delete divergence is reported for resolution
439
+ expect(read("f.txt")).toBe("local edit\n");
440
+ expect(result.conflicts).toEqual(["f.txt"]);
441
+ expect(result.binaryConflicts).toEqual([]);
442
+ });
313
443
  });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tests for {@link detectPluginSurfaces}.
3
+ *
4
+ * Surface detection is a pure walk of an installed plugin's on-disk tree, so
5
+ * the fixtures materialize the `hooks/`, `tools/`, and `skills/` directory
6
+ * conventions in a real temp dir and assert the derived listing matches what
7
+ * the runtime loader / skills catalog would discover.
8
+ */
9
+
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+
15
+ import { detectPluginSurfaces } from "../plugin-surfaces.js";
16
+
17
+ let pluginDir: string;
18
+
19
+ beforeEach(() => {
20
+ pluginDir = mkdtempSync(join(tmpdir(), "plugin-surfaces-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(pluginDir, { recursive: true, force: true });
25
+ });
26
+
27
+ /** Create `<pluginDir>/<rel>` with empty contents, making parents as needed. */
28
+ function touch(rel: string): void {
29
+ const path = join(pluginDir, rel);
30
+ mkdirSync(join(path, ".."), { recursive: true });
31
+ writeFileSync(path, "");
32
+ }
33
+
34
+ describe("detectPluginSurfaces", () => {
35
+ test("lists hooks, tools, and skills from the directory conventions", () => {
36
+ // GIVEN a plugin shipping two hooks, one tool, and two skills
37
+ touch("hooks/post-model-call.ts");
38
+ touch("hooks/init.ts");
39
+ touch("tools/summarize.ts");
40
+ touch("skills/first-skill/SKILL.md");
41
+ touch("skills/second-skill/SKILL.md");
42
+
43
+ // WHEN its surfaces are detected
44
+ const surfaces = detectPluginSurfaces(pluginDir);
45
+
46
+ // THEN each surface type lists its items, sorted
47
+ expect(surfaces.hooks).toEqual(["init", "post-model-call"]);
48
+ expect(surfaces.tools).toEqual(["summarize"]);
49
+ expect(surfaces.skills).toEqual(["first-skill", "second-skill"]);
50
+ });
51
+
52
+ test("reports tools under their registered name, not the raw filename", () => {
53
+ // GIVEN a tool whose filename is not already a valid tool-name segment
54
+ touch("tools/create-issue.ts");
55
+
56
+ // WHEN its surfaces are detected
57
+ const surfaces = detectPluginSurfaces(pluginDir);
58
+
59
+ // THEN the derived (registered) name is reported, matching the loader
60
+ expect(surfaces.tools).toEqual(["create_issue"]);
61
+ });
62
+
63
+ test("omits surface types the plugin does not contribute", () => {
64
+ // GIVEN a plugin that ships only a hook
65
+ touch("hooks/post-model-call.ts");
66
+
67
+ // WHEN its surfaces are detected
68
+ const surfaces = detectPluginSurfaces(pluginDir);
69
+
70
+ // THEN the contributed type is listed and the others are empty
71
+ expect(surfaces.hooks).toEqual(["post-model-call"]);
72
+ expect(surfaces.tools).toEqual([]);
73
+ expect(surfaces.skills).toEqual([]);
74
+ });
75
+
76
+ test("prefers .js over .ts for the same basename and skips .d.ts declarations", () => {
77
+ // GIVEN a compiled hook shipping both .ts and .js plus a .d.ts declaration
78
+ touch("hooks/post-model-call.ts");
79
+ touch("hooks/post-model-call.js");
80
+ touch("hooks/post-model-call.d.ts");
81
+
82
+ // WHEN its surfaces are detected
83
+ const surfaces = detectPluginSurfaces(pluginDir);
84
+
85
+ // THEN the basename appears once and the declaration file is ignored
86
+ expect(surfaces.hooks).toEqual(["post-model-call"]);
87
+ });
88
+
89
+ test("ignores skill directories without a SKILL.md", () => {
90
+ // GIVEN a skills dir with one real skill and one stray subdirectory
91
+ touch("skills/real-skill/SKILL.md");
92
+ touch("skills/not-a-skill/README.md");
93
+
94
+ // WHEN its surfaces are detected
95
+ const surfaces = detectPluginSurfaces(pluginDir);
96
+
97
+ // THEN only the directory carrying a SKILL.md is reported
98
+ expect(surfaces.skills).toEqual(["real-skill"]);
99
+ });
100
+
101
+ test("returns empty surfaces for a plugin with no surface directories", () => {
102
+ // GIVEN a plugin tree with only a package.json
103
+ touch("package.json");
104
+
105
+ // WHEN its surfaces are detected
106
+ const surfaces = detectPluginSurfaces(pluginDir);
107
+
108
+ // THEN every surface type is empty
109
+ expect(surfaces).toEqual({ skills: [], hooks: [], tools: [] });
110
+ });
111
+ });