Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ This creates:
| `ralph-starter fix [task]` | Fix build errors, lint issues, or design problems |
| `ralph-starter auto` | Batch-process issues from GitHub/Linear |
| `ralph-starter task <action>` | Manage tasks across GitHub and Linear (list, create, update, close, comment) |
| `ralph-starter notion` | Interactive Notion pages wizard |
| `ralph-starter integrations <action>` | Manage integrations (list, help, test, fetch) |
| `ralph-starter plan` | Create implementation plan from specs |
| `ralph-starter init` | Initialize Ralph Playbook in a project |
Expand Down
32 changes: 32 additions & 0 deletions docs/docs/sources/notion.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@ In Notion:
2. Click "..." menu → "Add connections"
3. Select "ralph-starter"

## Interactive Wizard

The easiest way to get started:

```bash
ralph-starter notion
```

This will:
1. Check your authentication (prompt for token if needed)
2. Let you search for pages by name and select one
3. Start the build loop automatically

You can also paste a Notion page URL directly when prompted.

### Wizard Options

```bash
ralph-starter notion --commit # Auto-commit after tasks
ralph-starter notion --push # Push commits to remote
ralph-starter notion --pr # Create PR when done
ralph-starter notion --agent claude-code # Use a specific agent
```

### Fallback

If you run `--from notion` without specifying a project, the wizard launches automatically:

```bash
ralph-starter run --from notion # Launches wizard
```

## Public Pages (No Auth Required)

For **public** Notion pages, you can use the URL source directly without any API key:
Expand Down
23 changes: 23 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { figmaCommand } from './commands/figma.js';
import { fixCommand } from './commands/fix.js';
import { initCommand } from './commands/init.js';
import { integrationsCommand } from './commands/integrations.js';
import { notionCommand } from './commands/notion.js';
import { pauseCommand } from './commands/pause.js';
import { planCommand } from './commands/plan.js';
import { resumeCommand } from './commands/resume.js';
Expand Down Expand Up @@ -159,6 +160,28 @@ program
});
});

// ralph-starter notion - Notion pages wizard
program
.command('notion')
.description('Build from Notion pages with an interactive wizard')
.option('--commit', 'Auto-commit after tasks')
.option('--push', 'Push to remote')
.option('--pr', 'Create PR when done')
.option('--validate', 'Run validation', true)
.option('--no-validate', 'Skip validation')
.option('--max-iterations <n>', 'Max loop iterations')
.option('--agent <name>', 'Agent to use')
.action(async (options) => {
await notionCommand({
commit: options.commit,
push: options.push,
pr: options.pr,
validate: options.validate,
maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined,
agent: options.agent,
});
});

// ralph-starter init - Initialize Ralph in a project
program
.command('init')
Expand Down
229 changes: 229 additions & 0 deletions src/commands/notion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/**
* ralph-starter notion — Interactive Notion pages wizard
*
* Guides the user through selecting Notion pages to work on:
* 1. Authenticate (API token)
* 2. Search for pages or paste a URL
* 3. Select a page
* 4. Delegate to run command
*/

import chalk from 'chalk';
import inquirer from 'inquirer';
import { askBrowseOrUrl, askForUrl, ensureCredentials } from '../integrations/wizards/shared.js';
import { type RunCommandOptions, runCommand } from './run.js';

export type NotionWizardOptions = {
commit?: boolean;
push?: boolean;
pr?: boolean;
validate?: boolean;
maxIterations?: number;
agent?: string;
};

const NOTION_API_BASE = 'https://api.notion.com/v1';
const NOTION_API_VERSION = '2022-06-28';

type NotionSearchResult = {
id: string;
object: 'page' | 'database';
url: string;
properties?: Record<string, unknown>;
title?: Array<{ plain_text: string }>;
parent?: {
type: string;
workspace?: boolean;
page_id?: string;
database_id?: string;
};
};

/** Search Notion pages via the API */
async function searchPages(
token: string,
query: string,
limit = 10
): Promise<NotionSearchResult[]> {
const response = await fetch(`${NOTION_API_BASE}/search`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json',
},
Comment on lines +50 to +54

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
body: JSON.stringify({
query,
filter: { property: 'object', value: 'page' },
page_size: limit,
}),
});

if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid Notion token. Run: ralph-starter config set notion.token <token>');
}
throw new Error(`Notion API error: ${response.status} ${response.statusText}`);
}

const data = (await response.json()) as {
results: NotionSearchResult[];
has_more: boolean;
};

return data.results;
}

/** Extract the title from a Notion page's properties */
function getPageTitle(page: NotionSearchResult): string {
// Direct title property (databases)
if (page.title) {
const titleText = page.title.map((t) => t.plain_text).join('');
if (titleText) return titleText;
}

// Page properties — look for the Title property
if (page.properties) {
for (const prop of Object.values(page.properties)) {
const p = prop as { type?: string; title?: Array<{ plain_text: string }> };
if (p.type === 'title' && p.title) {
const titleText = p.title.map((t: { plain_text: string }) => t.plain_text).join('');
if (titleText) return titleText;
}
}
}

return 'Untitled';
}

/** Get a short description of the page's parent */
function getParentInfo(page: NotionSearchResult): string {
if (!page.parent) return '';
if (page.parent.workspace) return 'workspace';
if (page.parent.page_id) return 'subpage';
if (page.parent.database_id) return 'database item';
return '';
}

export async function notionCommand(options: NotionWizardOptions): Promise<void> {
console.log();
console.log(chalk.cyan.bold(' Notion Pages'));
console.log(chalk.dim(' Build from Notion pages interactively'));
console.log();

// Step 1: Ensure credentials
await ensureCredentials('notion', 'Notion', {
credKey: 'token',
consoleUrl: 'https://www.notion.so/my-integrations',
envVar: 'NOTION_API_KEY',
});

// Step 2: Browse or URL?
const mode = await askBrowseOrUrl('Notion');

if (mode === 'url') {
const url = await askForUrl('Notion', /^https?:\/\/.*notion\.(so|site)\//);

const runOpts: RunCommandOptions = {
from: 'notion',
project: url,
auto: true,
commit: options.commit ?? false,
push: options.push,
pr: options.pr,
validate: options.validate ?? true,
maxIterations: options.maxIterations,
agent: options.agent,
};

await runCommand(undefined, runOpts);
return;
}

// Browse mode — search for pages
// Get the actual token for API calls (ensureCredentials may have returned '__cli_auth__')
const creds = await import('../sources/config.js').then((m) => m.getSourceCredentials('notion'));
const token = process.env.NOTION_API_KEY || creds?.token || creds?.apiKey;

if (!token) {
console.log(chalk.red(' Could not obtain Notion API token.'));
console.log(chalk.dim(' Run: ralph-starter config set notion.token <token>'));
return;
}

// Search loop — let user search and refine until they find the right page
let selectedUrl: string | undefined;

while (!selectedUrl) {
const { searchQuery } = await inquirer.prompt([
{
type: 'input',
name: 'searchQuery',
message: 'Search for a page:',
validate: (input: string) =>
input.trim().length > 0 ? true : 'Please enter a search term',
},
]);

console.log(chalk.dim(' Searching...'));
let results: NotionSearchResult[];
try {
results = await searchPages(token, searchQuery.trim());
} catch (err) {
console.log(chalk.red(' Failed to search Notion. Check your token.'));
console.log(chalk.dim(` Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}

if (results.length === 0) {
console.log(chalk.yellow(' No pages found. Try a different search term.'));
console.log();
continue;
}

const SEARCH_AGAIN = '__search_again__';
const { selectedPage } = await inquirer.prompt([
{
type: 'select',
name: 'selectedPage',
message: 'Select a page:',
choices: [
...results.map((page) => {
const title = getPageTitle(page);
const parent = getParentInfo(page);
const parentTag = parent ? chalk.dim(` (${parent})`) : '';
return {
name: `${title}${parentTag}`,
value: page.url,
};
}),
{ name: chalk.dim('Search again...'), value: SEARCH_AGAIN },
],
},
]);

if (selectedPage !== SEARCH_AGAIN) {
selectedUrl = selectedPage;
}
// Otherwise loop continues
}

// Step 3: Run with the selected page URL
console.log();
console.log(chalk.green(' Starting build from Notion page...'));
console.log();

const runOpts: RunCommandOptions = {
from: 'notion',
project: selectedUrl,
auto: true,
commit: options.commit ?? false,
push: options.push,
pr: options.pr,
validate: options.validate ?? true,
maxIterations: options.maxIterations,
agent: options.agent,
};

await runCommand(undefined, runOpts);
}
16 changes: 16 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,22 @@ export async function runCommand(
}
}

// If --from is used without --project/--issue for supported wizards, launch the wizard
if (options.from && !options.project && !options.issue) {
const source = options.from.toLowerCase();
if (source === 'notion') {
const { notionCommand: launchNotion } = await import('./notion.js');
return launchNotion({
commit: options.commit,
push: options.push,
pr: options.pr,
validate: options.validate,
maxIterations: options.maxIterations,
agent: options.agent,
});
}
}

// Handle --from source
let sourceSpec: string | null = null;
let sourceTitle: string | undefined;
Expand Down
Loading
Loading