Skip to content

Commit d240171

Browse files
rubenmarcusclaude
andauthored
feat: notion interactive wizard command (#272)
* feat(wizard): add shared wizard utilities (credential prompt, browse-or-url) Shared utilities for integration wizards: - ensureCredentials(): check for existing auth, prompt for API key if missing - askBrowseOrUrl(): common "browse or paste URL" prompt - askForUrl(): URL input with domain validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(wizard): add Notion interactive wizard command Interactive wizard for working with Notion pages: - Auth check (Notion API token) - Search pages by query with "search again" loop, or paste URL - Extracts page titles from Notion's property structure - Delegates to runCommand() for execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): register ralph-starter notion command - Add notion wizard to CLI as top-level command - Add wizard fallback in run.ts for --from notion without project Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Notion wizard documentation and examples - Add interactive wizard section to Notion source docs - Add ralph-starter notion to README commands table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use type over interface, anchor regex, add apiKey fallback - Change interface to type for plain data structures (Greptile) - Anchor Notion URL regex pattern (CodeQL fix) - Add apiKey fallback when retrieving Notion token (Greptile) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 460270a commit d240171

6 files changed

Lines changed: 414 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ This creates:
471471
| `ralph-starter fix [task]` | Fix build errors, lint issues, or design problems |
472472
| `ralph-starter auto` | Batch-process issues from GitHub/Linear |
473473
| `ralph-starter task <action>` | Manage tasks across GitHub and Linear (list, create, update, close, comment) |
474+
| `ralph-starter notion` | Interactive Notion pages wizard |
474475
| `ralph-starter integrations <action>` | Manage integrations (list, help, test, fetch) |
475476
| `ralph-starter plan` | Create implementation plan from specs |
476477
| `ralph-starter init` | Initialize Ralph Playbook in a project |

docs/docs/sources/notion.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,38 @@ In Notion:
3939
2. Click "..." menu → "Add connections"
4040
3. Select "ralph-starter"
4141

42+
## Interactive Wizard
43+
44+
The easiest way to get started:
45+
46+
```bash
47+
ralph-starter notion
48+
```
49+
50+
This will:
51+
1. Check your authentication (prompt for token if needed)
52+
2. Let you search for pages by name and select one
53+
3. Start the build loop automatically
54+
55+
You can also paste a Notion page URL directly when prompted.
56+
57+
### Wizard Options
58+
59+
```bash
60+
ralph-starter notion --commit # Auto-commit after tasks
61+
ralph-starter notion --push # Push commits to remote
62+
ralph-starter notion --pr # Create PR when done
63+
ralph-starter notion --agent claude-code # Use a specific agent
64+
```
65+
66+
### Fallback
67+
68+
If you run `--from notion` without specifying a project, the wizard launches automatically:
69+
70+
```bash
71+
ralph-starter run --from notion # Launches wizard
72+
```
73+
4274
## Public Pages (No Auth Required)
4375

4476
For **public** Notion pages, you can use the URL source directly without any API key:

src/cli.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { figmaCommand } from './commands/figma.js';
1010
import { fixCommand } from './commands/fix.js';
1111
import { initCommand } from './commands/init.js';
1212
import { integrationsCommand } from './commands/integrations.js';
13+
import { notionCommand } from './commands/notion.js';
1314
import { pauseCommand } from './commands/pause.js';
1415
import { planCommand } from './commands/plan.js';
1516
import { resumeCommand } from './commands/resume.js';
@@ -187,6 +188,28 @@ program
187188
});
188189
});
189190

191+
// ralph-starter notion - Notion pages wizard
192+
program
193+
.command('notion')
194+
.description('Build from Notion pages with an interactive wizard')
195+
.option('--commit', 'Auto-commit after tasks')
196+
.option('--push', 'Push to remote')
197+
.option('--pr', 'Create PR when done')
198+
.option('--validate', 'Run validation', true)
199+
.option('--no-validate', 'Skip validation')
200+
.option('--max-iterations <n>', 'Max loop iterations')
201+
.option('--agent <name>', 'Agent to use')
202+
.action(async (options) => {
203+
await notionCommand({
204+
commit: options.commit,
205+
push: options.push,
206+
pr: options.pr,
207+
validate: options.validate,
208+
maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined,
209+
agent: options.agent,
210+
});
211+
});
212+
190213
// ralph-starter init - Initialize Ralph in a project
191214
program
192215
.command('init')

src/commands/notion.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* ralph-starter notion — Interactive Notion pages wizard
3+
*
4+
* Guides the user through selecting Notion pages to work on:
5+
* 1. Authenticate (API token)
6+
* 2. Search for pages or paste a URL
7+
* 3. Select a page
8+
* 4. Delegate to run command
9+
*/
10+
11+
import chalk from 'chalk';
12+
import inquirer from 'inquirer';
13+
import { askBrowseOrUrl, askForUrl, ensureCredentials } from '../integrations/wizards/shared.js';
14+
import { type RunCommandOptions, runCommand } from './run.js';
15+
16+
export type NotionWizardOptions = {
17+
commit?: boolean;
18+
push?: boolean;
19+
pr?: boolean;
20+
validate?: boolean;
21+
maxIterations?: number;
22+
agent?: string;
23+
};
24+
25+
const NOTION_API_BASE = 'https://api.notion.com/v1';
26+
const NOTION_API_VERSION = '2022-06-28';
27+
28+
type NotionSearchResult = {
29+
id: string;
30+
object: 'page' | 'database';
31+
url: string;
32+
properties?: Record<string, unknown>;
33+
title?: Array<{ plain_text: string }>;
34+
parent?: {
35+
type: string;
36+
workspace?: boolean;
37+
page_id?: string;
38+
database_id?: string;
39+
};
40+
};
41+
42+
/** Search Notion pages via the API */
43+
async function searchPages(
44+
token: string,
45+
query: string,
46+
limit = 10
47+
): Promise<NotionSearchResult[]> {
48+
const response = await fetch(`${NOTION_API_BASE}/search`, {
49+
method: 'POST',
50+
headers: {
51+
Authorization: `Bearer ${token}`,
52+
'Notion-Version': NOTION_API_VERSION,
53+
'Content-Type': 'application/json',
54+
},
55+
body: JSON.stringify({
56+
query,
57+
filter: { property: 'object', value: 'page' },
58+
page_size: limit,
59+
}),
60+
});
61+
62+
if (!response.ok) {
63+
if (response.status === 401) {
64+
throw new Error('Invalid Notion token. Run: ralph-starter config set notion.token <token>');
65+
}
66+
throw new Error(`Notion API error: ${response.status} ${response.statusText}`);
67+
}
68+
69+
const data = (await response.json()) as {
70+
results: NotionSearchResult[];
71+
has_more: boolean;
72+
};
73+
74+
return data.results;
75+
}
76+
77+
/** Extract the title from a Notion page's properties */
78+
function getPageTitle(page: NotionSearchResult): string {
79+
// Direct title property (databases)
80+
if (page.title) {
81+
const titleText = page.title.map((t) => t.plain_text).join('');
82+
if (titleText) return titleText;
83+
}
84+
85+
// Page properties — look for the Title property
86+
if (page.properties) {
87+
for (const prop of Object.values(page.properties)) {
88+
const p = prop as { type?: string; title?: Array<{ plain_text: string }> };
89+
if (p.type === 'title' && p.title) {
90+
const titleText = p.title.map((t: { plain_text: string }) => t.plain_text).join('');
91+
if (titleText) return titleText;
92+
}
93+
}
94+
}
95+
96+
return 'Untitled';
97+
}
98+
99+
/** Get a short description of the page's parent */
100+
function getParentInfo(page: NotionSearchResult): string {
101+
if (!page.parent) return '';
102+
if (page.parent.workspace) return 'workspace';
103+
if (page.parent.page_id) return 'subpage';
104+
if (page.parent.database_id) return 'database item';
105+
return '';
106+
}
107+
108+
export async function notionCommand(options: NotionWizardOptions): Promise<void> {
109+
console.log();
110+
console.log(chalk.cyan.bold(' Notion Pages'));
111+
console.log(chalk.dim(' Build from Notion pages interactively'));
112+
console.log();
113+
114+
// Step 1: Ensure credentials
115+
await ensureCredentials('notion', 'Notion', {
116+
credKey: 'token',
117+
consoleUrl: 'https://www.notion.so/my-integrations',
118+
envVar: 'NOTION_API_KEY',
119+
});
120+
121+
// Step 2: Browse or URL?
122+
const mode = await askBrowseOrUrl('Notion');
123+
124+
if (mode === 'url') {
125+
const url = await askForUrl('Notion', /^https?:\/\/.*notion\.(so|site)\//);
126+
127+
const runOpts: RunCommandOptions = {
128+
from: 'notion',
129+
project: url,
130+
auto: true,
131+
commit: options.commit ?? false,
132+
push: options.push,
133+
pr: options.pr,
134+
validate: options.validate ?? true,
135+
maxIterations: options.maxIterations,
136+
agent: options.agent,
137+
};
138+
139+
await runCommand(undefined, runOpts);
140+
return;
141+
}
142+
143+
// Browse mode — search for pages
144+
// Get the actual token for API calls (ensureCredentials may have returned '__cli_auth__')
145+
const creds = await import('../sources/config.js').then((m) => m.getSourceCredentials('notion'));
146+
const token = process.env.NOTION_API_KEY || creds?.token || creds?.apiKey;
147+
148+
if (!token) {
149+
console.log(chalk.red(' Could not obtain Notion API token.'));
150+
console.log(chalk.dim(' Run: ralph-starter config set notion.token <token>'));
151+
return;
152+
}
153+
154+
// Search loop — let user search and refine until they find the right page
155+
let selectedUrl: string | undefined;
156+
157+
while (!selectedUrl) {
158+
const { searchQuery } = await inquirer.prompt([
159+
{
160+
type: 'input',
161+
name: 'searchQuery',
162+
message: 'Search for a page:',
163+
validate: (input: string) =>
164+
input.trim().length > 0 ? true : 'Please enter a search term',
165+
},
166+
]);
167+
168+
console.log(chalk.dim(' Searching...'));
169+
let results: NotionSearchResult[];
170+
try {
171+
results = await searchPages(token, searchQuery.trim());
172+
} catch (err) {
173+
console.log(chalk.red(' Failed to search Notion. Check your token.'));
174+
console.log(chalk.dim(` Error: ${err instanceof Error ? err.message : String(err)}`));
175+
return;
176+
}
177+
178+
if (results.length === 0) {
179+
console.log(chalk.yellow(' No pages found. Try a different search term.'));
180+
console.log();
181+
continue;
182+
}
183+
184+
const SEARCH_AGAIN = '__search_again__';
185+
const { selectedPage } = await inquirer.prompt([
186+
{
187+
type: 'select',
188+
name: 'selectedPage',
189+
message: 'Select a page:',
190+
choices: [
191+
...results.map((page) => {
192+
const title = getPageTitle(page);
193+
const parent = getParentInfo(page);
194+
const parentTag = parent ? chalk.dim(` (${parent})`) : '';
195+
return {
196+
name: `${title}${parentTag}`,
197+
value: page.url,
198+
};
199+
}),
200+
{ name: chalk.dim('Search again...'), value: SEARCH_AGAIN },
201+
],
202+
},
203+
]);
204+
205+
if (selectedPage !== SEARCH_AGAIN) {
206+
selectedUrl = selectedPage;
207+
}
208+
// Otherwise loop continues
209+
}
210+
211+
// Step 3: Run with the selected page URL
212+
console.log();
213+
console.log(chalk.green(' Starting build from Notion page...'));
214+
console.log();
215+
216+
const runOpts: RunCommandOptions = {
217+
from: 'notion',
218+
project: selectedUrl,
219+
auto: true,
220+
commit: options.commit ?? false,
221+
push: options.push,
222+
pr: options.pr,
223+
validate: options.validate ?? true,
224+
maxIterations: options.maxIterations,
225+
agent: options.agent,
226+
};
227+
228+
await runCommand(undefined, runOpts);
229+
}

src/commands/run.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,22 @@ export async function runCommand(
370370
}
371371
}
372372

373+
// If --from is used without --project/--issue for supported wizards, launch the wizard
374+
if (options.from && !options.project && !options.issue) {
375+
const source = options.from.toLowerCase();
376+
if (source === 'notion') {
377+
const { notionCommand: launchNotion } = await import('./notion.js');
378+
return launchNotion({
379+
commit: options.commit,
380+
push: options.push,
381+
pr: options.pr,
382+
validate: options.validate,
383+
maxIterations: options.maxIterations,
384+
agent: options.agent,
385+
});
386+
}
387+
}
388+
373389
// Handle --from source
374390
let sourceSpec: string | null = null;
375391
let sourceTitle: string | undefined;

0 commit comments

Comments
 (0)