Skip to content

Commit 44f9a6b

Browse files
backnotpropclaude
andauthored
Feat: Add Bear Notes integration (#21)
- Add bear.ts utility for settings storage - Add Bear toggle to Settings UI (simple on/off) - Add saveToBear() using x-callback-url protocol - Reuse extractTags() for inline #hashtags - Change base tag from "plan" to "plannotator" - Add test-hook-2.sh with alternate plan for testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a4c968f commit 44f9a6b

7 files changed

Lines changed: 188 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Interactive Plan Review for AI Coding Agents. Mark up and refine your plans usin
2828

2929
**New:**
3030

31-
- We now support auto-saving approved plans to [Obsidian](https://obsidian.md/).
31+
- We now support auto-saving approved plans to [Obsidian](https://obsidian.md/) and [Bear Notes](https://bear.app/).
3232

3333
## Install for Claude Code
3434

apps/hook/server/index.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ interface ObsidianConfig {
2222
plan: string;
2323
}
2424

25+
// --- Bear Integration ---
26+
27+
interface BearConfig {
28+
plan: string;
29+
}
30+
2531
/**
2632
* Extract tags from markdown content using simple heuristics
2733
*/
2834
function extractTags(markdown: string): string[] {
29-
const tags = new Set<string>(["plan"]);
35+
const tags = new Set<string>(["plannotator"]);
3036

3137
const stopWords = new Set([
3238
"the", "and", "for", "with", "this", "that", "from", "into",
@@ -213,6 +219,37 @@ async function saveToObsidian(
213219
}
214220
}
215221

222+
/**
223+
* Save plan to Bear using x-callback-url
224+
* Returns { success: boolean, error?: string }
225+
*/
226+
async function saveToBear(
227+
config: BearConfig
228+
): Promise<{ success: boolean; error?: string }> {
229+
try {
230+
const { plan } = config;
231+
232+
// Extract title and tags
233+
const title = extractTitle(plan);
234+
const tags = extractTags(plan);
235+
const hashtags = tags.map(t => `#${t}`).join(' ');
236+
237+
// Append hashtags to content
238+
const content = `${plan}\n\n${hashtags}`;
239+
240+
// Build Bear URL
241+
const url = `bear://x-callback-url/create?title=${encodeURIComponent(title)}&text=${encodeURIComponent(content)}&open_note=no`;
242+
243+
// Open Bear via URL scheme
244+
await $`open ${url}`.quiet();
245+
246+
return { success: true };
247+
} catch (err) {
248+
const message = err instanceof Error ? err.message : "Unknown error";
249+
return { success: false, error: message };
250+
}
251+
}
252+
216253
// Embed the built HTML at compile time
217254
import indexHtml from "../dist/index.html" with { type: "text" };
218255

@@ -294,12 +331,14 @@ async function startServer(): Promise<ReturnType<typeof Bun.serve>> {
294331

295332
// API: Approve plan
296333
if (url.pathname === "/api/approve" && req.method === "POST") {
297-
// Check for Obsidian integration
334+
// Check for note integrations
298335
try {
299336
const body = (await req.json().catch(() => ({}))) as {
300337
obsidian?: ObsidianConfig;
338+
bear?: BearConfig;
301339
};
302340

341+
// Obsidian integration
303342
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
304343
const result = await saveToObsidian(body.obsidian);
305344
if (result.success) {
@@ -308,9 +347,19 @@ async function startServer(): Promise<ReturnType<typeof Bun.serve>> {
308347
console.error(`[Obsidian] Save failed: ${result.error}`);
309348
}
310349
}
350+
351+
// Bear integration
352+
if (body.bear?.plan) {
353+
const result = await saveToBear(body.bear);
354+
if (result.success) {
355+
console.error(`[Bear] Saved plan to Bear`);
356+
} else {
357+
console.error(`[Bear] Save failed: ${result.error}`);
358+
}
359+
}
311360
} catch (err) {
312-
// Don't block approval on Obsidian errors
313-
console.error(`[Obsidian] Error:`, err);
361+
// Don't block approval on integration errors
362+
console.error(`[Integration] Error:`, err);
314363
}
315364

316365
resolveDecision({ approved: true });

packages/editor/App.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useSharing } from '@plannotator/ui/hooks/useSharing';
1414
import { storage } from '@plannotator/ui/utils/storage';
1515
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
1616
import { getObsidianSettings } from '@plannotator/ui/utils/obsidian';
17+
import { getBearSettings } from '@plannotator/ui/utils/bear';
1718

1819
const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
1920
@@ -379,17 +380,22 @@ const App: React.FC = () => {
379380
setIsSubmitting(true);
380381
try {
381382
const obsidianSettings = getObsidianSettings();
383+
const bearSettings = getBearSettings();
382384

383-
// Build request body - include obsidian config if enabled
384-
const body = obsidianSettings.enabled && obsidianSettings.vaultPath
385-
? {
386-
obsidian: {
387-
vaultPath: obsidianSettings.vaultPath,
388-
folder: obsidianSettings.folder || 'plannotator',
389-
plan: markdown,
390-
},
391-
}
392-
: {};
385+
// Build request body - include integrations if enabled
386+
const body: { obsidian?: object; bear?: object } = {};
387+
388+
if (obsidianSettings.enabled && obsidianSettings.vaultPath) {
389+
body.obsidian = {
390+
vaultPath: obsidianSettings.vaultPath,
391+
folder: obsidianSettings.folder || 'plannotator',
392+
plan: markdown,
393+
};
394+
}
395+
396+
if (bearSettings.enabled) {
397+
body.bear = { plan: markdown };
398+
}
393399

394400
await fetch('/api/approve', {
395401
method: 'POST',

packages/ui/components/Settings.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
saveObsidianSettings,
88
type ObsidianSettings,
99
} from '../utils/obsidian';
10+
import {
11+
getBearSettings,
12+
saveBearSettings,
13+
type BearSettings,
14+
} from '../utils/bear';
1015

1116
interface SettingsProps {
1217
taterMode: boolean;
@@ -24,11 +29,13 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2429
});
2530
const [detectedVaults, setDetectedVaults] = useState<string[]>([]);
2631
const [vaultsLoading, setVaultsLoading] = useState(false);
32+
const [bear, setBear] = useState<BearSettings>({ enabled: false });
2733

2834
useEffect(() => {
2935
if (showDialog) {
3036
setIdentity(getIdentity());
3137
setObsidian(getObsidianSettings());
38+
setBear(getBearSettings());
3239
}
3340
}, [showDialog]);
3441

@@ -56,6 +63,12 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
5663
saveObsidianSettings(newSettings);
5764
};
5865

66+
const handleBearChange = (enabled: boolean) => {
67+
const newSettings = { enabled };
68+
setBear(newSettings);
69+
saveBearSettings(newSettings);
70+
};
71+
5972
const handleRegenerateIdentity = () => {
6073
const oldIdentity = identity;
6174
const newIdentity = regenerateIdentity();
@@ -235,6 +248,32 @@ tags: [plan, ...]
235248
</div>
236249
)}
237250
</div>
251+
252+
<div className="border-t border-border" />
253+
254+
{/* Bear Integration */}
255+
<div className="flex items-center justify-between">
256+
<div>
257+
<div className="text-sm font-medium">Bear Notes</div>
258+
<div className="text-xs text-muted-foreground">
259+
Auto-save approved plans to Bear
260+
</div>
261+
</div>
262+
<button
263+
role="switch"
264+
aria-checked={bear.enabled}
265+
onClick={() => handleBearChange(!bear.enabled)}
266+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
267+
bear.enabled ? 'bg-primary' : 'bg-muted'
268+
}`}
269+
>
270+
<span
271+
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
272+
bear.enabled ? 'translate-x-6' : 'translate-x-1'
273+
}`}
274+
/>
275+
</button>
276+
</div>
238277
</div>
239278
</div>
240279
</div>,

packages/ui/utils/bear.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Bear Notes Integration Utility
3+
*
4+
* Manages settings for auto-saving plans to Bear.
5+
* Uses x-callback-url protocol - no vault detection needed.
6+
*/
7+
8+
import { storage } from './storage';
9+
10+
const STORAGE_KEY_ENABLED = 'plannotator-bear-enabled';
11+
12+
/**
13+
* Bear integration settings
14+
*/
15+
export interface BearSettings {
16+
enabled: boolean;
17+
}
18+
19+
/**
20+
* Get current Bear settings from storage
21+
*/
22+
export function getBearSettings(): BearSettings {
23+
return {
24+
enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true',
25+
};
26+
}
27+
28+
/**
29+
* Save Bear settings to storage
30+
*/
31+
export function saveBearSettings(settings: BearSettings): void {
32+
storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled));
33+
}

packages/ui/utils/obsidian.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function isObsidianConfigured(): boolean {
6565
* @returns Array of lowercase tag strings (max 6)
6666
*/
6767
export function extractTags(markdown: string): string[] {
68-
const tags = new Set<string>(['plan']);
68+
const tags = new Set<string>(['plannotator']);
6969

7070
// Common words to exclude from title extraction
7171
const stopWords = new Set([

tests/manual/local/test-hook-2.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/bin/bash
2+
# Test script to simulate Claude Code hook locally (alternate plan)
3+
#
4+
# Usage:
5+
# ./test-hook-2.sh
6+
#
7+
# What it does:
8+
# 1. Builds the hook (ensures latest code)
9+
# 2. Pipes sample plan JSON to the server (simulating Claude Code)
10+
# 3. Opens browser for you to test the UI
11+
# 4. Prints the hook output (allow/deny decision)
12+
13+
set -e
14+
15+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
17+
18+
echo "=== Plannotator Hook Test (Plan 2) ==="
19+
echo ""
20+
21+
# Build first to ensure latest code
22+
echo "Building hook..."
23+
cd "$PROJECT_ROOT"
24+
bun run build:hook
25+
26+
echo ""
27+
echo "Starting hook server..."
28+
echo "Browser should open automatically. Approve or deny the plan."
29+
echo ""
30+
31+
# Different sample plan - API rate limiting feature
32+
PLAN_JSON=$(cat << 'EOF'
33+
{
34+
"tool_input": {
35+
"plan": "# Implementation Plan: API Rate Limiting\n\n## Overview\nAdd rate limiting to protect API endpoints from abuse using a sliding window algorithm with Redis.\n\n## Phase 1: Redis Setup\n\n```typescript\nimport Redis from 'ioredis';\n\nconst redis = new Redis({\n host: process.env.REDIS_HOST,\n port: 6379,\n password: process.env.REDIS_PASSWORD,\n});\n```\n\n## Phase 2: Rate Limiter Middleware\n\n```typescript\ninterface RateLimitConfig {\n windowMs: number; // Time window in milliseconds\n max: number; // Max requests per window\n}\n\nasync function rateLimiter(req: Request, config: RateLimitConfig) {\n const key = `ratelimit:${req.ip}`;\n const current = await redis.incr(key);\n \n if (current === 1) {\n await redis.pexpire(key, config.windowMs);\n }\n \n if (current > config.max) {\n throw new RateLimitError('Too many requests');\n }\n}\n```\n\n## Phase 3: Apply to Routes\n\n```typescript\n// Apply different limits per endpoint\napp.use('/api/auth/*', rateLimiter({ windowMs: 60000, max: 5 }));\napp.use('/api/public/*', rateLimiter({ windowMs: 60000, max: 100 }));\napp.use('/api/admin/*', rateLimiter({ windowMs: 60000, max: 30 }));\n```\n\n## Checklist\n\n- [ ] Set up Redis connection\n- [ ] Implement sliding window algorithm\n- [ ] Add rate limit headers to responses\n- [ ] Create bypass for internal services\n- [ ] Add monitoring/alerts for rate limit hits\n\n---\n\n**Note:** Consider using `X-RateLimit-Remaining` headers for client feedback."
36+
}
37+
}
38+
EOF
39+
)
40+
41+
# Run the hook server
42+
echo "$PLAN_JSON" | bun run "$PROJECT_ROOT/apps/hook/server/index.ts"
43+
44+
echo ""
45+
echo "=== Test Complete ==="

0 commit comments

Comments
 (0)