prjct-cli 0.20.1 → 0.21.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,323 @@
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
+ * Query a database
184
+ */
185
+ async queryDatabase(
186
+ databaseId: string,
187
+ filter?: NotionQueryFilter
188
+ ): Promise<Array<{ id: string; properties: Record<string, unknown> }>> {
189
+ try {
190
+ const body: Record<string, unknown> = {}
191
+ if (filter) {
192
+ body.filter = filter
193
+ }
194
+
195
+ const response = await this.apiRequest(
196
+ `/databases/${databaseId}/query`,
197
+ 'POST',
198
+ body
199
+ )
200
+
201
+ return (response.results || []).map(
202
+ (page: { id: string; properties: Record<string, unknown> }) => ({
203
+ id: page.id,
204
+ properties: page.properties,
205
+ })
206
+ )
207
+ } catch (error) {
208
+ console.error('[notion] Failed to query database:', (error as Error).message)
209
+ return []
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Find page by project ID and name (for upsert)
215
+ */
216
+ async findPageByProjectAndName(
217
+ databaseId: string,
218
+ projectId: string,
219
+ name: string
220
+ ): Promise<string | null> {
221
+ try {
222
+ const response = await this.apiRequest(
223
+ `/databases/${databaseId}/query`,
224
+ 'POST',
225
+ {
226
+ filter: {
227
+ and: [
228
+ { property: 'Project', rich_text: { equals: projectId } },
229
+ { property: 'Name', title: { equals: name } },
230
+ ],
231
+ },
232
+ }
233
+ )
234
+
235
+ const results = response.results || []
236
+ return results.length > 0 ? results[0].id : null
237
+ } catch {
238
+ return null
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Create a page (for dashboard)
244
+ */
245
+ async createDashboardPage(
246
+ parentPageId: string,
247
+ title: string,
248
+ content: string
249
+ ): Promise<NotionPage | null> {
250
+ try {
251
+ const response = await this.apiRequest('/pages', 'POST', {
252
+ parent: { type: 'page_id', page_id: parentPageId },
253
+ properties: {
254
+ title: [{ type: 'text', text: { content: title } }],
255
+ },
256
+ children: [
257
+ {
258
+ object: 'block',
259
+ type: 'paragraph',
260
+ paragraph: {
261
+ rich_text: [{ type: 'text', text: { content } }],
262
+ },
263
+ },
264
+ ],
265
+ })
266
+
267
+ if (!response.id || !response.url) {
268
+ return null
269
+ }
270
+
271
+ return {
272
+ id: response.id,
273
+ url: response.url,
274
+ }
275
+ } catch (error) {
276
+ console.error('[notion] Failed to create page:', (error as Error).message)
277
+ return null
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Make API request to Notion
283
+ */
284
+ private async apiRequest(
285
+ endpoint: string,
286
+ method = 'GET',
287
+ body?: Record<string, unknown>
288
+ ): Promise<NotionApiResponse> {
289
+ if (!this.apiToken) {
290
+ throw new Error('Notion API token not configured')
291
+ }
292
+
293
+ const url = `https://api.notion.com/v1${endpoint}`
294
+ const headers: Record<string, string> = {
295
+ Authorization: `Bearer ${this.apiToken}`,
296
+ 'Content-Type': 'application/json',
297
+ 'Notion-Version': '2022-06-28',
298
+ }
299
+
300
+ const options: RequestInit = {
301
+ method,
302
+ headers,
303
+ }
304
+
305
+ if (body) {
306
+ options.body = JSON.stringify(body)
307
+ }
308
+
309
+ const response = await fetch(url, options)
310
+
311
+ if (!response.ok) {
312
+ const errorData = (await response.json().catch(() => ({}))) as { message?: string }
313
+ throw new Error(
314
+ `Notion API error: ${response.status} - ${errorData.message || 'Unknown error'}`
315
+ )
316
+ }
317
+
318
+ return (await response.json()) as NotionApiResponse
319
+ }
320
+ }
321
+
322
+ // Singleton instance
323
+ export const notionClient = new NotionClient()
@@ -0,0 +1,43 @@
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
+ } from './sync'
32
+ export type { SyncResult } from './sync'
33
+
34
+ // Setup
35
+ export {
36
+ checkNotionMCPAvailable,
37
+ validateToken,
38
+ createDatabases,
39
+ setupNotion,
40
+ getSetupInstructions,
41
+ parseNotionPageUrl,
42
+ } from './setup'
43
+ export type { SetupResult, ValidationResult } from './setup'
@@ -0,0 +1,230 @@
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
98
+ for (const [key, schema] of Object.entries(ALL_DATABASE_SCHEMAS)) {
99
+ const db = await notionClient.createDatabase(parentPageId, schema)
100
+ if (db) {
101
+ databases[key as keyof typeof databases] = db.id
102
+ } else {
103
+ return {
104
+ success: false,
105
+ databases,
106
+ error: `Failed to create ${schema.title} database`,
107
+ }
108
+ }
109
+ }
110
+
111
+ // Create dashboard page
112
+ const dashboardContent = getDashboardContent(projectName, databases)
113
+ const dashboard = await notionClient.createDashboardPage(
114
+ parentPageId,
115
+ `${projectName} Dashboard`,
116
+ dashboardContent
117
+ )
118
+
119
+ return {
120
+ success: true,
121
+ databases,
122
+ dashboardPageId: dashboard?.id,
123
+ }
124
+ } catch (error) {
125
+ return {
126
+ success: false,
127
+ databases,
128
+ error: (error as Error).message,
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Full setup flow
135
+ * Called by /p:init or /p:notion setup
136
+ */
137
+ export async function setupNotion(params: {
138
+ token: string
139
+ parentPageId: string
140
+ projectId: string
141
+ projectName: string
142
+ }): Promise<SetupResult> {
143
+ const { token, parentPageId, projectId, projectName } = params
144
+
145
+ // Step 1: Validate token
146
+ const validation = await validateToken(token)
147
+ if (!validation.valid) {
148
+ return {
149
+ success: false,
150
+ error: validation.error || 'Invalid token',
151
+ }
152
+ }
153
+
154
+ // Step 2: Initialize client
155
+ const config: NotionIntegrationConfig = {
156
+ ...DEFAULT_NOTION_CONFIG,
157
+ enabled: true,
158
+ workspaceName: validation.workspaceName,
159
+ }
160
+ notionClient.initialize(config, token)
161
+
162
+ // Step 3: Create databases
163
+ const result = await createDatabases(parentPageId, projectName)
164
+ if (!result.success) {
165
+ return {
166
+ success: false,
167
+ error: result.error,
168
+ }
169
+ }
170
+
171
+ // Step 4: Build final config
172
+ const finalConfig: NotionIntegrationConfig = {
173
+ enabled: true,
174
+ workspaceName: validation.workspaceName,
175
+ databases: result.databases,
176
+ dashboardPageId: result.dashboardPageId,
177
+ syncOn: {
178
+ ship: true,
179
+ done: false,
180
+ idea: true,
181
+ },
182
+ setupAt: new Date().toISOString(),
183
+ }
184
+
185
+ return {
186
+ success: true,
187
+ config: finalConfig,
188
+ message: `Notion connected to "${validation.workspaceName}". Created 4 databases.`,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Get setup instructions for user
194
+ */
195
+ export function getSetupInstructions(): string[] {
196
+ return [
197
+ '1. Go to https://www.notion.so/my-integrations',
198
+ '2. Click "New integration"',
199
+ '3. Name it "prjct-cli" and select your workspace',
200
+ '4. Copy the Internal Integration Secret (starts with ntn_)',
201
+ '5. Share a Notion page with the integration (this will be the parent)',
202
+ '6. Copy the page ID from the URL (the 32-character string)',
203
+ ]
204
+ }
205
+
206
+ /**
207
+ * Parse Notion page URL to get page ID
208
+ */
209
+ export function parseNotionPageUrl(url: string): string | null {
210
+ // Handle various Notion URL formats
211
+ // https://www.notion.so/workspace/Page-Title-abc123def456
212
+ // https://notion.so/abc123def456
213
+ // abc123def456 (just the ID)
214
+
215
+ const cleanUrl = url.trim()
216
+
217
+ // If it's already a 32-char hex string
218
+ if (/^[a-f0-9]{32}$/i.test(cleanUrl)) {
219
+ return cleanUrl
220
+ }
221
+
222
+ // If it's a UUID format (with dashes)
223
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(cleanUrl)) {
224
+ return cleanUrl.replace(/-/g, '')
225
+ }
226
+
227
+ // Try to extract from URL
228
+ const match = cleanUrl.match(/([a-f0-9]{32})/i)
229
+ return match ? match[1] : null
230
+ }