@cueloop/integration-obsidian 0.1.0-alpha.9 → 0.1.0-alpha.90

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/package.json CHANGED
@@ -1,33 +1,34 @@
1
1
  {
2
2
  "name": "@cueloop/integration-obsidian",
3
- "version": "0.1.0-alpha.9",
3
+ "version": "0.1.0-alpha.90",
4
+ "description": "Export reviewed cueloop plans into an Obsidian vault",
5
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/mmurakaru/cueloop/issues"
8
+ },
4
9
  "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mmurakaru/cueloop.git",
13
+ "directory": "packages/integrations/obsidian"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
5
19
  "type": "module",
6
20
  "exports": {
7
21
  ".": "./src/index.ts"
8
22
  },
9
- "dependencies": {
10
- "@cueloop/extension-api": "0.1.0-alpha.9",
11
- "@cueloop/schema": "0.1.0-alpha.9"
12
- },
13
23
  "publishConfig": {
14
24
  "access": "public"
15
25
  },
16
- "files": [
17
- "src",
18
- "README.md"
19
- ],
26
+ "dependencies": {
27
+ "@cueloop/extension-api": "0.1.0-alpha.90",
28
+ "@cueloop/schema": "0.1.0-alpha.90",
29
+ "valibot": "^1.5.0"
30
+ },
20
31
  "engines": {
21
32
  "bun": ">=1.3.0"
22
- },
23
- "repository": {
24
- "type": "git",
25
- "url": "git+https://github.com/mmurakaru/cueloop.git",
26
- "directory": "packages/integrations/obsidian"
27
- },
28
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
29
- "bugs": {
30
- "url": "https://github.com/mmurakaru/cueloop/issues"
31
- },
32
- "description": "Export reviewed cueloop plans into an Obsidian vault"
33
- }
33
+ }
34
+ }
@@ -15,10 +15,13 @@ afterEach(() => {
15
15
 
16
16
  describe("detectVaults", () => {
17
17
  test("returns registered vault paths that exist on disk", () => {
18
+ // Arrange
18
19
  const alive = join(dir, "notes");
20
+
19
21
  mkdirSync(alive);
20
22
  const gone = join(dir, "deleted-vault");
21
23
  const configPath = join(dir, "obsidian.json");
24
+
22
25
  writeFileSync(
23
26
  configPath,
24
27
  JSON.stringify({
@@ -28,15 +31,19 @@ describe("detectVaults", () => {
28
31
  },
29
32
  }),
30
33
  );
34
+
35
+ // Assert
31
36
  expect(detectVaults(configPath)).toEqual([alive]);
32
37
  });
33
38
 
34
39
  test("missing or malformed config yields no vaults", () => {
35
40
  expect(detectVaults(join(dir, "nope.json"))).toEqual([]);
36
41
  const broken = join(dir, "broken.json");
42
+
37
43
  writeFileSync(broken, "not json");
38
44
  expect(detectVaults(broken)).toEqual([]);
39
45
  const empty = join(dir, "empty.json");
46
+
40
47
  writeFileSync(empty, "{}");
41
48
  expect(detectVaults(empty)).toEqual([]);
42
49
  });
@@ -44,7 +51,9 @@ describe("detectVaults", () => {
44
51
 
45
52
  describe("obsidianConfigPath", () => {
46
53
  test("resolves per platform", () => {
47
- expect(obsidianConfigPath("darwin")).toContain(join("Library", "Application Support", "obsidian", "obsidian.json"));
54
+ expect(obsidianConfigPath("darwin")).toContain(
55
+ join("Library", "Application Support", "obsidian", "obsidian.json"),
56
+ );
48
57
  expect(obsidianConfigPath("linux")).toContain(join("obsidian", "obsidian.json"));
49
58
  expect(obsidianConfigPath("win32")).toContain(join("obsidian", "obsidian.json"));
50
59
  });
package/src/detect.ts CHANGED
@@ -7,6 +7,11 @@
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
+ import * as v from "valibot";
11
+
12
+ const ObsidianRegistrySchema = v.object({
13
+ vaults: v.optional(v.record(v.string(), v.object({ path: v.optional(v.string()) }))),
14
+ });
10
15
 
11
16
  /** Platform-specific location of Obsidian's own obsidian.json. */
12
17
  export function obsidianConfigPath(platform: NodeJS.Platform = process.platform): string {
@@ -14,20 +19,28 @@ export function obsidianConfigPath(platform: NodeJS.Platform = process.platform)
14
19
  return join(homedir(), "Library", "Application Support", "obsidian", "obsidian.json");
15
20
  }
16
21
  if (platform === "win32") {
17
- return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "obsidian", "obsidian.json");
22
+ return join(
23
+ process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
24
+ "obsidian",
25
+ "obsidian.json",
26
+ );
18
27
  }
19
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "obsidian", "obsidian.json");
28
+
29
+ return join(
30
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
31
+ "obsidian",
32
+ "obsidian.json",
33
+ );
20
34
  }
21
35
 
22
36
  /** Vault paths registered with Obsidian that exist on disk. */
23
37
  export function detectVaults(configPath: string = obsidianConfigPath()): string[] {
24
38
  try {
25
- const raw = JSON.parse(readFileSync(configPath, "utf8")) as {
26
- vaults?: Record<string, { path?: string }>;
27
- };
39
+ const raw = v.parse(ObsidianRegistrySchema, JSON.parse(readFileSync(configPath, "utf8")));
40
+
28
41
  return Object.values(raw.vaults ?? {})
29
- .map((v) => v.path)
30
- .filter((p): p is string => typeof p === "string" && existsSync(p));
42
+ .map((vault) => vault.path)
43
+ .filter((path): path is string => typeof path === "string" && existsSync(path));
31
44
  } catch {
32
45
  // no Obsidian install, or an unreadable registry: no vaults
33
46
  return [];
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import type { ReviewSession } from "@cueloop/schema";
5
+ import type { Thread } from "@cueloop/schema";
6
6
  import { Registry } from "@cueloop/extension-api";
7
7
  import { OBSIDIAN_DEFAULTS, exportSession, shouldExport, type ObsidianConfig } from "./export";
8
8
  import { createObsidianExtension } from "./extension";
@@ -10,7 +10,7 @@ import { frontmatter } from "./frontmatter";
10
10
 
11
11
  const NOW = new Date("2026-08-07T12:34:56.000Z");
12
12
 
13
- function resolvedSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
13
+ function resolvedSession(overrides: Partial<Thread> = {}): Thread {
14
14
  return {
15
15
  schemaVersion: "1",
16
16
  id: "s_test1",
@@ -20,9 +20,21 @@ function resolvedSession(overrides: Partial<ReviewSession> = {}): ReviewSession
20
20
  content: "# Migration Plan\n\nMove the store.\n",
21
21
  meta: { title: "Migration Plan" },
22
22
  },
23
- revisions: [{ revision: 1, content: "# Migration Plan\n\nMove the store.\n", submittedAt: NOW.toISOString() }],
23
+ revisions: [
24
+ {
25
+ revision: 1,
26
+ content: "# Migration Plan\n\nMove the store.\n",
27
+ submittedAt: NOW.toISOString(),
28
+ },
29
+ ],
24
30
  annotations: [],
25
- verdict: { kind: "approve", summary: "ship it", feedback: "", resolvedAt: NOW.toISOString() },
31
+ message: {
32
+ id: "msg_1",
33
+ outcome: "approved",
34
+ summary: "ship it",
35
+ body: "",
36
+ sentAt: NOW.toISOString(),
37
+ },
26
38
  status: "resolved",
27
39
  createdAt: NOW.toISOString(),
28
40
  ...overrides,
@@ -44,53 +56,94 @@ function config(overrides: Partial<ObsidianConfig> = {}): ObsidianConfig {
44
56
 
45
57
  describe("exportSession", () => {
46
58
  test("writes the final plan with frontmatter into vault/folder", () => {
59
+ // Arrange
47
60
  const session = resolvedSession();
61
+
62
+ // Act
48
63
  const result = exportSession(session, config(), NOW);
64
+
65
+ // Assert
49
66
  expect(result.success).toBe(true);
50
67
  expect(result.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan.md"));
51
68
  const written = readFileSync(result.path!, "utf8");
69
+
52
70
  expect(written).toBe(`${frontmatter(session, NOW)}\n\n# Migration Plan\n\nMove the store.\n`);
53
71
  expect(written).toContain("created: 2026-08-07T12:34:56.000Z");
54
72
  expect(written).toContain("source: cueloop");
55
73
  expect(written).toContain("session: s_test1");
56
- expect(written).toContain("verdict: approve");
74
+ expect(written).toContain("message: approved");
57
75
  });
58
76
 
59
77
  test("the working copy wins over the submitted content", () => {
60
- const session = resolvedSession({ workingCopy: "# Migration Plan\n\nMove the store carefully.\n" });
78
+ // Arrange
79
+ const session = resolvedSession({
80
+ workingCopy: "# Migration Plan\n\nMove the store carefully.\n",
81
+ });
82
+
83
+ // Act
61
84
  const result = exportSession(session, config(), NOW);
85
+
86
+ // Assert
62
87
  expect(readFileSync(result.path!, "utf8")).toContain("Move the store carefully.");
63
88
  });
64
89
 
65
90
  test("never overwrites: collisions get counting suffixes", () => {
91
+ // Arrange
66
92
  const session = resolvedSession();
93
+
94
+ // Act
67
95
  const first = exportSession(session, config(), NOW);
68
96
  const second = exportSession(session, config(), NOW);
69
97
  const third = exportSession(session, config(), NOW);
98
+
99
+ // Assert
70
100
  expect(second.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 2.md"));
71
101
  expect(third.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 3.md"));
72
102
  expect(existsSync(first.path!)).toBe(true);
73
103
  });
74
104
 
75
105
  test("no vault configured and none detected fails without writing", () => {
106
+ // Arrange
76
107
  const emptyConfigPath = join(vault, "obsidian.json");
77
- const result = exportSession(resolvedSession(), config({ vault: undefined, obsidianConfigPath: emptyConfigPath }), NOW);
108
+
109
+ // Act
110
+ const result = exportSession(
111
+ resolvedSession(),
112
+ config({ vault: undefined, obsidianConfigPath: emptyConfigPath }),
113
+ NOW,
114
+ );
115
+
116
+ // Assert
78
117
  expect(result.success).toBe(false);
79
118
  expect(result.error).toContain("no Obsidian vault");
80
119
  });
81
120
 
82
121
  test("a configured vault path that does not exist fails", () => {
122
+ // Act
83
123
  const result = exportSession(resolvedSession(), config({ vault: join(vault, "missing") }), NOW);
124
+
125
+ // Assert
84
126
  expect(result.success).toBe(false);
85
127
  expect(result.error).toContain("vault not found");
86
128
  });
87
129
 
88
130
  test("falls back to the first auto-detected vault", () => {
131
+ // Arrange
89
132
  const detected = join(vault, "detected-vault");
133
+
90
134
  mkdirSync(detected);
91
135
  const obsidianJson = join(vault, "obsidian.json");
136
+
92
137
  writeFileSync(obsidianJson, JSON.stringify({ vaults: { v1: { path: detected } } }));
93
- const result = exportSession(resolvedSession(), config({ vault: undefined, obsidianConfigPath: obsidianJson }), NOW);
138
+
139
+ // Act
140
+ const result = exportSession(
141
+ resolvedSession(),
142
+ config({ vault: undefined, obsidianConfigPath: obsidianJson }),
143
+ NOW,
144
+ );
145
+
146
+ // Assert
94
147
  expect(result.success).toBe(true);
95
148
  expect(result.path).toBe(join(detected, "cueloop", "2026-08-07 - Migration Plan.md"));
96
149
  });
@@ -98,12 +151,22 @@ describe("exportSession", () => {
98
151
 
99
152
  describe("obsidian extension", () => {
100
153
  test("registers the exporter through the extension API and exports a resolved session", async () => {
154
+ // Arrange
101
155
  const registry = new Registry();
156
+
157
+ // Act
102
158
  const record = await registry.load("obsidian", createObsidianExtension(config()));
159
+
160
+ // Assert
103
161
  expect(record.errors).toEqual([]);
104
162
  const exporter = record.exporters.get("obsidian");
163
+
105
164
  expect(exporter).toBeDefined();
165
+
166
+ // Act
106
167
  const result = await exporter!(resolvedSession());
168
+
169
+ // Assert
107
170
  expect(result.success).toBe(true);
108
171
  expect(existsSync(result.path!)).toBe(true);
109
172
  });
@@ -111,19 +174,17 @@ describe("obsidian extension", () => {
111
174
 
112
175
  describe("shouldExport", () => {
113
176
  test("approve exports only approvals", () => {
114
- expect(shouldExport("approve", "approve")).toBe(true);
115
- expect(shouldExport("approve", "request_changes")).toBe(false);
116
- expect(shouldExport("approve", "comment")).toBe(false);
177
+ expect(shouldExport("approved", "approved")).toBe(true);
178
+ expect(shouldExport("approved", "changes_requested")).toBe(false);
117
179
  });
118
180
 
119
- test("resolve exports any verdict", () => {
120
- expect(shouldExport("resolve", "approve")).toBe(true);
121
- expect(shouldExport("resolve", "request_changes")).toBe(true);
122
- expect(shouldExport("resolve", "comment")).toBe(true);
181
+ test("message exports any outcome", () => {
182
+ expect(shouldExport("message", "approved")).toBe(true);
183
+ expect(shouldExport("message", "changes_requested")).toBe(true);
123
184
  });
124
185
 
125
186
  test("manual never auto-exports", () => {
126
- expect(shouldExport("manual", "approve")).toBe(false);
127
- expect(shouldExport("manual", "request_changes")).toBe(false);
187
+ expect(shouldExport("manual", "approved")).toBe(false);
188
+ expect(shouldExport("manual", "changes_requested")).toBe(false);
128
189
  });
129
190
  });
package/src/export.ts CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
- import type { ReviewSession, VerdictKind } from "@cueloop/schema";
9
+ import type { Thread, MessageOutcome } from "@cueloop/schema";
10
10
  import { detectVaults } from "./detect";
11
11
  import { formatFilename, titleFrom, uniquePath, type Separator } from "./filename";
12
12
  import { frontmatter } from "./frontmatter";
13
13
 
14
- export type ExportOn = "approve" | "resolve" | "manual";
14
+ export type ExportOn = "approved" | "message" | "manual";
15
15
 
16
16
  export interface ObsidianConfig {
17
17
  /** Vault path; when unset, the first auto-detected vault is used. */
@@ -31,10 +31,11 @@ export const OBSIDIAN_DEFAULTS: ObsidianConfig = {
31
31
  exportOn: "manual",
32
32
  };
33
33
 
34
- /** approve exports only approvals; resolve exports any verdict; manual never auto-exports. */
35
- export function shouldExport(exportOn: ExportOn, verdict: VerdictKind): boolean {
36
- if (exportOn === "approve") return verdict === "approve";
37
- return exportOn === "resolve";
34
+ /** approved exports only approvals; message exports any Message; manual never auto-exports. */
35
+ export function shouldExport(exportOn: ExportOn, message: MessageOutcome): boolean {
36
+ if (exportOn === "approved") return message === "approved";
37
+
38
+ return exportOn === "message";
38
39
  }
39
40
 
40
41
  export interface ExportResult {
@@ -43,17 +44,25 @@ export interface ExportResult {
43
44
  error?: string;
44
45
  }
45
46
 
46
- export function exportSession(session: ReviewSession, config: ObsidianConfig, now: Date = new Date()): ExportResult {
47
+ export function exportSession(
48
+ session: Thread,
49
+ config: ObsidianConfig,
50
+ now: Date = new Date(),
51
+ ): ExportResult {
47
52
  const vault = config.vault ?? detectVaults(config.obsidianConfigPath)[0];
53
+
48
54
  if (!vault) return { success: false, error: "no Obsidian vault configured or detected" };
49
55
  if (!existsSync(vault)) return { success: false, error: `vault not found: ${vault}` };
50
56
 
51
57
  const content = session.workingCopy ?? session.artifact.content;
52
58
  const title = titleFrom(content, session.artifact.meta.title);
53
59
  const dir = join(vault, config.folder);
60
+
54
61
  mkdirSync(dir, { recursive: true });
55
62
  const base = formatFilename(config.filenameFormat, title, now, config.separator);
56
63
  const path = uniquePath(dir, base);
64
+
57
65
  writeFileSync(path, `${frontmatter(session, now)}\n\n${content}`);
66
+
58
67
  return { success: true, path };
59
68
  }
@@ -23,7 +23,10 @@ describe("titleFrom", () => {
23
23
  });
24
24
 
25
25
  test("caps the title at 50 characters", () => {
26
+ // Arrange
26
27
  const long = "# " + "word ".repeat(20);
28
+
29
+ // Assert
27
30
  expect(titleFrom(long).length).toBeLessThanOrEqual(50);
28
31
  expect(titleFrom(long).endsWith(" ")).toBe(false);
29
32
  });
@@ -35,17 +38,22 @@ describe("titleFrom", () => {
35
38
 
36
39
  describe("formatFilename", () => {
37
40
  test("renders date tokens and the title with the space separator", () => {
38
- expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "space")).toBe("2026-08-07 - My Plan");
41
+ expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "space")).toBe(
42
+ "2026-08-07 - My Plan",
43
+ );
39
44
  });
40
45
 
41
46
  test("dash and underscore separators replace whitespace runs", () => {
42
- expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "dash")).toBe("2026-08-07---My-Plan");
47
+ expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "dash")).toBe(
48
+ "2026-08-07---My-Plan",
49
+ );
43
50
  expect(formatFilename("{title}", "My Plan", DATE, "underscore")).toBe("My_Plan");
44
51
  });
45
52
  });
46
53
 
47
54
  describe("uniquePath", () => {
48
55
  let dir: string;
56
+
49
57
  beforeEach(() => {
50
58
  dir = mkdtempSync(join(tmpdir(), "cueloop-obsidian-name-"));
51
59
  });
@@ -54,10 +62,19 @@ describe("uniquePath", () => {
54
62
  });
55
63
 
56
64
  test("appends counting suffixes instead of overwriting", () => {
65
+ // Assert
57
66
  expect(uniquePath(dir, "plan")).toBe(join(dir, "plan.md"));
67
+
68
+ // Act
58
69
  writeFileSync(join(dir, "plan.md"), "");
70
+
71
+ // Assert
59
72
  expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 2.md"));
73
+
74
+ // Act
60
75
  writeFileSync(join(dir, "plan 2.md"), "");
76
+
77
+ // Assert
61
78
  expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 3.md"));
62
79
  });
63
80
  });
package/src/filename.ts CHANGED
@@ -18,12 +18,14 @@ const FORBIDDEN = /[<>:"/\\|?*(){}[\]#~`]/g;
18
18
  export function sanitizeTitle(raw: string): string {
19
19
  const clean = raw.replace(FORBIDDEN, "").replace(/\s+/g, " ").trim();
20
20
  const capped = clean.slice(0, 50).trim();
21
+
21
22
  return capped || "untitled";
22
23
  }
23
24
 
24
25
  /** Title = first H1 of the content, else the fallback, sanitized. */
25
26
  export function titleFrom(content: string, fallback?: string): string {
26
27
  const h1 = content.match(/^#[ \t]+(.+)$/m)?.[1];
28
+
27
29
  return sanitizeTitle(h1 ?? fallback ?? "untitled");
28
30
  }
29
31
 
@@ -31,21 +33,29 @@ export function titleFrom(content: string, fallback?: string): string {
31
33
  * Render the filename pattern ({YYYY}, {MM}, {DD}, {title}); each whitespace
32
34
  * run in the result becomes the configured separator.
33
35
  */
34
- export function formatFilename(format: string, title: string, date: Date, separator: Separator = "space"): string {
35
- const pad = (n: number) => String(n).padStart(2, "0");
36
+ export function formatFilename(
37
+ format: string,
38
+ title: string,
39
+ date: Date,
40
+ separator: Separator = "space",
41
+ ): string {
42
+ const pad = (value: number) => String(value).padStart(2, "0");
36
43
  const name = format
37
44
  .replaceAll("{YYYY}", String(date.getFullYear()))
38
45
  .replaceAll("{MM}", pad(date.getMonth() + 1))
39
46
  .replaceAll("{DD}", pad(date.getDate()))
40
47
  .replaceAll("{title}", title);
48
+
41
49
  return name.replace(/\s+/g, SEPARATOR_CHAR[separator]);
42
50
  }
43
51
 
44
52
  /** First free path for base.md in dir: base.md, base 2.md, base 3.md, ... */
45
53
  export function uniquePath(dir: string, base: string): string {
46
54
  let candidate = join(dir, `${base}.md`);
55
+
47
56
  for (let n = 2; existsSync(candidate); n++) {
48
57
  candidate = join(dir, `${base} ${n}.md`);
49
58
  }
59
+
50
60
  return candidate;
51
61
  }
@@ -1,14 +1,14 @@
1
1
  /** Frontmatter for exported notes: provenance the vault can query. */
2
2
 
3
- import type { ReviewSession } from "@cueloop/schema";
3
+ import type { Thread } from "@cueloop/schema";
4
4
 
5
- export function frontmatter(session: ReviewSession, created: Date): string {
5
+ export function frontmatter(session: Thread, created: Date): string {
6
6
  return [
7
7
  "---",
8
8
  `created: ${created.toISOString()}`,
9
9
  "source: cueloop",
10
10
  `session: ${session.id}`,
11
- `verdict: ${session.verdict?.kind ?? "pending"}`,
11
+ `message: ${session.message?.outcome ?? "pending"}`,
12
12
  "---",
13
13
  ].join("\n");
14
14
  }