prjct-cli 0.20.1 → 0.22.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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Notion Client
3
+ * Wrapper for Notion MCP tools with fallback to direct API.
4
+ *
5
+ * Uses MCP tools when available (via Claude), falls back to fetch for CLI usage.
6
+ */
7
+
8
+ import type { NotionIntegrationConfig } from '../../types/integrations'
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ export interface NotionDatabase {
15
+ id: string
16
+ title: string
17
+ url: string
18
+ }
19
+
20
+ export interface NotionPage {
21
+ id: string
22
+ url: string
23
+ }
24
+
25
+ export interface NotionProperty {
26
+ type: string
27
+ [key: string]: unknown
28
+ }
29
+
30
+ export interface NotionDatabaseSchema {
31
+ title: string
32
+ properties: Record<string, NotionProperty>
33
+ }
34
+
35
+ export interface NotionQueryFilter {
36
+ property: string
37
+ rich_text?: { equals: string }
38
+ title?: { equals: string }
39
+ date?: { equals: string }
40
+ }
41
+
42
+ // Notion API response types
43
+ interface NotionApiResponse {
44
+ id?: string
45
+ url?: string
46
+ bot?: {
47
+ workspace_name?: string
48
+ owner?: {
49
+ workspace?: {
50
+ id?: string
51
+ }
52
+ }
53
+ }
54
+ results?: Array<{ id: string; properties: Record<string, unknown> }>
55
+ }
56
+
57
+ // =============================================================================
58
+ // Notion Client Class
59
+ // =============================================================================
60
+
61
+ export class NotionClient {
62
+ private config: NotionIntegrationConfig | null = null
63
+ private apiToken: string | null = null
64
+
65
+ /**
66
+ * Initialize client with config
67
+ */
68
+ initialize(config: NotionIntegrationConfig, apiToken?: string): void {
69
+ this.config = config
70
+ this.apiToken = apiToken || process.env.NOTION_TOKEN || null
71
+ }
72
+
73
+ /**
74
+ * Check if client is ready
75
+ */
76
+ isReady(): boolean {
77
+ return this.config?.enabled === true && this.apiToken !== null
78
+ }
79
+
80
+ /**
81
+ * Get workspace info
82
+ */
83
+ async getWorkspace(): Promise<{ id: string; name: string } | null> {
84
+ if (!this.apiToken) return null
85
+
86
+ try {
87
+ const response = await this.apiRequest('/users/me')
88
+ if (response.bot?.workspace_name) {
89
+ return {
90
+ id: response.bot.owner?.workspace?.id || 'unknown',
91
+ name: response.bot.workspace_name,
92
+ }
93
+ }
94
+ return null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Create a database in Notion
102
+ */
103
+ async createDatabase(
104
+ parentPageId: string,
105
+ schema: NotionDatabaseSchema
106
+ ): Promise<NotionDatabase | null> {
107
+ try {
108
+ const response = await this.apiRequest('/databases', 'POST', {
109
+ parent: { type: 'page_id', page_id: parentPageId },
110
+ title: [{ type: 'text', text: { content: schema.title } }],
111
+ properties: schema.properties,
112
+ })
113
+
114
+ if (!response.id || !response.url) {
115
+ return null
116
+ }
117
+
118
+ return {
119
+ id: response.id,
120
+ title: schema.title,
121
+ url: response.url,
122
+ }
123
+ } catch (error) {
124
+ console.error('[notion] Failed to create database:', (error as Error).message)
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Create a page in a database
131
+ */
132
+ async createPage(
133
+ databaseId: string,
134
+ properties: Record<string, unknown>
135
+ ): Promise<NotionPage | null> {
136
+ try {
137
+ const response = await this.apiRequest('/pages', 'POST', {
138
+ parent: { type: 'database_id', database_id: databaseId },
139
+ properties,
140
+ })
141
+
142
+ if (!response.id || !response.url) {
143
+ return null
144
+ }
145
+
146
+ return {
147
+ id: response.id,
148
+ url: response.url,
149
+ }
150
+ } catch (error) {
151
+ console.error('[notion] Failed to create page:', (error as Error).message)
152
+ return null
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Update a page
158
+ */
159
+ async updatePage(
160
+ pageId: string,
161
+ properties: Record<string, unknown>
162
+ ): Promise<NotionPage | null> {
163
+ try {
164
+ const response = await this.apiRequest(`/pages/${pageId}`, 'PATCH', {
165
+ properties,
166
+ })
167
+
168
+ if (!response.id || !response.url) {
169
+ return null
170
+ }
171
+
172
+ return {
173
+ id: response.id,
174
+ url: response.url,
175
+ }
176
+ } catch (error) {
177
+ console.error('[notion] Failed to update page:', (error as Error).message)
178
+ return null
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Update page content (for dashboard)
184
+ */
185
+ async updatePageContent(pageId: string, content: string): Promise<boolean> {
186
+ try {
187
+ // Convert markdown content to Notion blocks
188
+ const blocks = this.markdownToBlocks(content)
189
+
190
+ // Clear existing content and add new blocks
191
+ // First, get existing blocks to delete them
192
+ const existingBlocks = await this.apiRequest(
193
+ `/blocks/${pageId}/children`,
194
+ 'GET'
195
+ )
196
+
197
+ // Delete existing blocks
198
+ for (const block of existingBlocks.results || []) {
199
+ await this.apiRequest(`/blocks/${block.id}`, 'DELETE')
200
+ }
201
+
202
+ // Add new blocks
203
+ await this.apiRequest(`/blocks/${pageId}/children`, 'PATCH', {
204
+ children: blocks,
205
+ })
206
+
207
+ return true
208
+ } catch (error) {
209
+ console.error('[notion] Failed to update page content:', (error as Error).message)
210
+ return false
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Convert simple markdown to Notion blocks
216
+ */
217
+ private markdownToBlocks(content: string): unknown[] {
218
+ const blocks: unknown[] = []
219
+ const lines = content.split('\n')
220
+
221
+ for (const line of lines) {
222
+ if (line.startsWith('# ')) {
223
+ blocks.push({
224
+ type: 'heading_1',
225
+ heading_1: {
226
+ rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
227
+ },
228
+ })
229
+ } else if (line.startsWith('## ')) {
230
+ blocks.push({
231
+ type: 'heading_2',
232
+ heading_2: {
233
+ rich_text: [{ type: 'text', text: { content: line.slice(3) } }],
234
+ },
235
+ })
236
+ } else if (line.startsWith('- ')) {
237
+ blocks.push({
238
+ type: 'bulleted_list_item',
239
+ bulleted_list_item: {
240
+ rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
241
+ },
242
+ })
243
+ } else if (line.startsWith('|') && line.includes('|')) {
244
+ // Table row - skip header separator
245
+ if (!line.match(/^\|[-|]+\|$/)) {
246
+ const cells = line.split('|').filter(Boolean).map((c) => c.trim())
247
+ if (cells.length > 0) {
248
+ blocks.push({
249
+ type: 'paragraph',
250
+ paragraph: {
251
+ rich_text: [{ type: 'text', text: { content: cells.join(' | ') } }],
252
+ },
253
+ })
254
+ }
255
+ }
256
+ } else if (line.trim() === '---') {
257
+ blocks.push({ type: 'divider', divider: {} })
258
+ } else if (line.trim()) {
259
+ blocks.push({
260
+ type: 'paragraph',
261
+ paragraph: {
262
+ rich_text: [{ type: 'text', text: { content: line } }],
263
+ },
264
+ })
265
+ }
266
+ }
267
+
268
+ return blocks
269
+ }
270
+
271
+ /**
272
+ * Query a database
273
+ */
274
+ async queryDatabase(
275
+ databaseId: string,
276
+ filter?: NotionQueryFilter
277
+ ): Promise<Array<{ id: string; properties: Record<string, unknown> }>> {
278
+ try {
279
+ const body: Record<string, unknown> = {}
280
+ if (filter) {
281
+ body.filter = filter
282
+ }
283
+
284
+ const response = await this.apiRequest(
285
+ `/databases/${databaseId}/query`,
286
+ 'POST',
287
+ body
288
+ )
289
+
290
+ return (response.results || []).map(
291
+ (page: { id: string; properties: Record<string, unknown> }) => ({
292
+ id: page.id,
293
+ properties: page.properties,
294
+ })
295
+ )
296
+ } catch (error) {
297
+ console.error('[notion] Failed to query database:', (error as Error).message)
298
+ return []
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Find page by name (for upsert)
304
+ * Note: Since each project has its own database, we don't need to filter by project
305
+ */
306
+ async findPageByProjectAndName(
307
+ databaseId: string,
308
+ _projectId: string,
309
+ name: string
310
+ ): Promise<string | null> {
311
+ try {
312
+ const response = await this.apiRequest(
313
+ `/databases/${databaseId}/query`,
314
+ 'POST',
315
+ {
316
+ filter: {
317
+ or: [
318
+ { property: 'Name', title: { equals: name } },
319
+ { property: 'Idea', title: { equals: name } },
320
+ ],
321
+ },
322
+ }
323
+ )
324
+
325
+ const results = response.results || []
326
+ return results.length > 0 ? results[0].id : null
327
+ } catch {
328
+ return null
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Create a page (for dashboard)
334
+ */
335
+ async createDashboardPage(
336
+ parentPageId: string,
337
+ title: string,
338
+ content: string
339
+ ): Promise<NotionPage | null> {
340
+ try {
341
+ const response = await this.apiRequest('/pages', 'POST', {
342
+ parent: { type: 'page_id', page_id: parentPageId },
343
+ properties: {
344
+ title: [{ type: 'text', text: { content: title } }],
345
+ },
346
+ children: [
347
+ {
348
+ object: 'block',
349
+ type: 'paragraph',
350
+ paragraph: {
351
+ rich_text: [{ type: 'text', text: { content } }],
352
+ },
353
+ },
354
+ ],
355
+ })
356
+
357
+ if (!response.id || !response.url) {
358
+ return null
359
+ }
360
+
361
+ return {
362
+ id: response.id,
363
+ url: response.url,
364
+ }
365
+ } catch (error) {
366
+ console.error('[notion] Failed to create page:', (error as Error).message)
367
+ return null
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Make API request to Notion
373
+ */
374
+ private async apiRequest(
375
+ endpoint: string,
376
+ method = 'GET',
377
+ body?: Record<string, unknown>
378
+ ): Promise<NotionApiResponse> {
379
+ if (!this.apiToken) {
380
+ throw new Error('Notion API token not configured')
381
+ }
382
+
383
+ const url = `https://api.notion.com/v1${endpoint}`
384
+ const headers: Record<string, string> = {
385
+ Authorization: `Bearer ${this.apiToken}`,
386
+ 'Content-Type': 'application/json',
387
+ 'Notion-Version': '2022-06-28',
388
+ }
389
+
390
+ const options: RequestInit = {
391
+ method,
392
+ headers,
393
+ }
394
+
395
+ if (body) {
396
+ options.body = JSON.stringify(body)
397
+ }
398
+
399
+ const response = await fetch(url, options)
400
+
401
+ if (!response.ok) {
402
+ const errorData = (await response.json().catch(() => ({}))) as { message?: string }
403
+ throw new Error(
404
+ `Notion API error: ${response.status} - ${errorData.message || 'Unknown error'}`
405
+ )
406
+ }
407
+
408
+ return (await response.json()) as NotionApiResponse
409
+ }
410
+ }
411
+
412
+ // Singleton instance
413
+ export const notionClient = new NotionClient()
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Notion Integration Module
3
+ * Optional integration with Notion for syncing prjct data.
4
+ */
5
+
6
+ // Client
7
+ export { NotionClient, notionClient } from './client'
8
+ export type {
9
+ NotionDatabase,
10
+ NotionPage,
11
+ NotionProperty,
12
+ NotionDatabaseSchema,
13
+ NotionQueryFilter,
14
+ } from './client'
15
+
16
+ // Templates
17
+ export {
18
+ SHIPPED_DATABASE_SCHEMA,
19
+ ROADMAP_DATABASE_SCHEMA,
20
+ IDEAS_DATABASE_SCHEMA,
21
+ TASKS_DATABASE_SCHEMA,
22
+ ALL_DATABASE_SCHEMAS,
23
+ getDashboardContent,
24
+ } from './templates'
25
+
26
+ // Sync
27
+ export {
28
+ syncShippedFeature,
29
+ syncIdea,
30
+ fullSync,
31
+ pullShippedFeatures,
32
+ pullIdeas,
33
+ bidirectionalSync,
34
+ } from './sync'
35
+ export type { SyncResult, PullResult } from './sync'
36
+
37
+ // Setup
38
+ export {
39
+ checkNotionMCPAvailable,
40
+ validateToken,
41
+ createDatabases,
42
+ setupNotion,
43
+ getSetupInstructions,
44
+ parseNotionPageUrl,
45
+ } from './setup'
46
+ export type { SetupResult, ValidationResult } from './setup'
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Notion Setup Flow
3
+ * Interactive setup for Notion integration.
4
+ */
5
+
6
+ import type { NotionIntegrationConfig } from '../../types/integrations'
7
+ import { DEFAULT_NOTION_CONFIG } from '../../types/integrations'
8
+ import { notionClient } from './client'
9
+ import {
10
+ ALL_DATABASE_SCHEMAS,
11
+ getDashboardContent,
12
+ } from './templates'
13
+
14
+ // =============================================================================
15
+ // Types
16
+ // =============================================================================
17
+
18
+ export interface SetupResult {
19
+ success: boolean
20
+ config?: NotionIntegrationConfig
21
+ error?: string
22
+ message?: string
23
+ }
24
+
25
+ export interface ValidationResult {
26
+ valid: boolean
27
+ workspaceName?: string
28
+ error?: string
29
+ }
30
+
31
+ // =============================================================================
32
+ // Setup Functions
33
+ // =============================================================================
34
+
35
+ /**
36
+ * Check if Notion MCP is available
37
+ * Returns true if the MCP server is configured
38
+ */
39
+ export function checkNotionMCPAvailable(): boolean {
40
+ // Check if running in Claude environment with MCP
41
+ if (typeof globalThis !== 'undefined') {
42
+ const g = globalThis as Record<string, unknown>
43
+ if (g.mcp || g.notion || process.env.NOTION_TOKEN) {
44
+ return true
45
+ }
46
+ }
47
+ return !!process.env.NOTION_TOKEN
48
+ }
49
+
50
+ /**
51
+ * Validate Notion API token
52
+ */
53
+ export async function validateToken(token: string): Promise<ValidationResult> {
54
+ try {
55
+ // Temporarily initialize client with token
56
+ const tempConfig: NotionIntegrationConfig = {
57
+ ...DEFAULT_NOTION_CONFIG,
58
+ enabled: true,
59
+ }
60
+ notionClient.initialize(tempConfig, token)
61
+
62
+ const workspace = await notionClient.getWorkspace()
63
+ if (workspace) {
64
+ return {
65
+ valid: true,
66
+ workspaceName: workspace.name,
67
+ }
68
+ }
69
+
70
+ return {
71
+ valid: false,
72
+ error: 'Could not connect to Notion workspace',
73
+ }
74
+ } catch (error) {
75
+ return {
76
+ valid: false,
77
+ error: (error as Error).message,
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Create all prjct databases in Notion
84
+ */
85
+ export async function createDatabases(
86
+ parentPageId: string,
87
+ projectName: string
88
+ ): Promise<{
89
+ success: boolean
90
+ databases: NotionIntegrationConfig['databases']
91
+ dashboardPageId?: string
92
+ error?: string
93
+ }> {
94
+ const databases: NotionIntegrationConfig['databases'] = {}
95
+
96
+ try {
97
+ // Create each database with project-specific names
98
+ for (const [key, schema] of Object.entries(ALL_DATABASE_SCHEMAS)) {
99
+ // Override title with project name prefix
100
+ const projectSchema = {
101
+ ...schema,
102
+ title: schema.title.replace('prjct:', `${projectName}:`),
103
+ }
104
+ const db = await notionClient.createDatabase(parentPageId, projectSchema)
105
+ if (db) {
106
+ databases[key as keyof typeof databases] = db.id
107
+ } else {
108
+ return {
109
+ success: false,
110
+ databases,
111
+ error: `Failed to create ${projectSchema.title} database`,
112
+ }
113
+ }
114
+ }
115
+
116
+ // Create dashboard page
117
+ const dashboardContent = getDashboardContent(projectName, databases)
118
+ const dashboard = await notionClient.createDashboardPage(
119
+ parentPageId,
120
+ `${projectName} Dashboard`,
121
+ dashboardContent
122
+ )
123
+
124
+ return {
125
+ success: true,
126
+ databases,
127
+ dashboardPageId: dashboard?.id,
128
+ }
129
+ } catch (error) {
130
+ return {
131
+ success: false,
132
+ databases,
133
+ error: (error as Error).message,
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Full setup flow
140
+ * Called by /p:init or /p:notion setup
141
+ */
142
+ export async function setupNotion(params: {
143
+ token: string
144
+ parentPageId: string
145
+ projectId: string
146
+ projectName: string
147
+ }): Promise<SetupResult> {
148
+ const { token, parentPageId, projectId, projectName } = params
149
+
150
+ // Step 1: Validate token
151
+ const validation = await validateToken(token)
152
+ if (!validation.valid) {
153
+ return {
154
+ success: false,
155
+ error: validation.error || 'Invalid token',
156
+ }
157
+ }
158
+
159
+ // Step 2: Initialize client
160
+ const config: NotionIntegrationConfig = {
161
+ ...DEFAULT_NOTION_CONFIG,
162
+ enabled: true,
163
+ workspaceName: validation.workspaceName,
164
+ }
165
+ notionClient.initialize(config, token)
166
+
167
+ // Step 3: Create databases
168
+ const result = await createDatabases(parentPageId, projectName)
169
+ if (!result.success) {
170
+ return {
171
+ success: false,
172
+ error: result.error,
173
+ }
174
+ }
175
+
176
+ // Step 4: Build final config
177
+ const finalConfig: NotionIntegrationConfig = {
178
+ enabled: true,
179
+ workspaceName: validation.workspaceName,
180
+ databases: result.databases,
181
+ dashboardPageId: result.dashboardPageId,
182
+ syncOn: {
183
+ ship: true,
184
+ done: false,
185
+ idea: true,
186
+ },
187
+ setupAt: new Date().toISOString(),
188
+ }
189
+
190
+ return {
191
+ success: true,
192
+ config: finalConfig,
193
+ message: `Notion connected to "${validation.workspaceName}". Created 4 databases.`,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Get setup instructions for user
199
+ */
200
+ export function getSetupInstructions(): string[] {
201
+ return [
202
+ '1. Go to https://www.notion.so/my-integrations',
203
+ '2. Click "New integration"',
204
+ '3. Name it "prjct-cli" and select your workspace',
205
+ '4. Copy the Internal Integration Secret (starts with ntn_)',
206
+ '5. Share a Notion page with the integration (this will be the parent)',
207
+ '6. Copy the page ID from the URL (the 32-character string)',
208
+ ]
209
+ }
210
+
211
+ /**
212
+ * Parse Notion page URL to get page ID
213
+ */
214
+ export function parseNotionPageUrl(url: string): string | null {
215
+ // Handle various Notion URL formats
216
+ // https://www.notion.so/workspace/Page-Title-abc123def456
217
+ // https://notion.so/abc123def456
218
+ // abc123def456 (just the ID)
219
+
220
+ const cleanUrl = url.trim()
221
+
222
+ // If it's already a 32-char hex string
223
+ if (/^[a-f0-9]{32}$/i.test(cleanUrl)) {
224
+ return cleanUrl
225
+ }
226
+
227
+ // If it's a UUID format (with dashes)
228
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(cleanUrl)) {
229
+ return cleanUrl.replace(/-/g, '')
230
+ }
231
+
232
+ // Try to extract from URL
233
+ const match = cleanUrl.match(/([a-f0-9]{32})/i)
234
+ return match ? match[1] : null
235
+ }