Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ When bridge contracts change, validate all three repos.
### External CLI Command Surface

- Daemon lifecycle: `daemon start`, `daemon stop`, `daemon status`
- Bridge commands: `create`, `search`, `search-tag`, `read`, `update`, `journal`, `status`
- Bridge commands: `create`, `create-md`, `search`, `search-tag`, `read`, `update`, `journal`, `status`

### Bridge Mapping and Compatibility

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- Enhanced `create` command with flashcard support (`-b/--back-text`, `--concept`, `--descriptor`).
- Added `create-md` command to allow creating native hierarchical Rem structures (including flashcards via `::`) from indented markdown strings.

### Changed

- Renamed the local OpenClaw skill package directory to `skills/` and updated live repository references, including
Expand Down
34 changes: 34 additions & 0 deletions docs/guides/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ remnote-cli create <title> [options]
| `--content-file <path>` | none | Read initial content from UTF-8 file (`-` for stdin) |
| `--parent-id <id>` | none | Parent Rem ID |
| `-t, --tags <tag...>` | none | One or more tags |
| `-b, --back-text <text>`| none | Back text for flashcard |
| `--concept` | false | Create as a Concept card |
| `--descriptor` | false | Create as a Descriptor card |

Behavior rules:

Expand All @@ -148,6 +151,37 @@ remnote-cli create "Meeting Notes"
remnote-cli create "Project Plan" --content "Phase 1" --tags planning work --text
remnote-cli create "Weekly Summary" --content-file /tmp/weekly-summary.md --text
cat /tmp/weekly-summary.md | remnote-cli create "Weekly Summary" --content-file - --text
remnote-cli create "Photosynthesis" --back-text "Process by which plants make food" --concept --text
```

## create-md

Create a hierarchical note tree in RemNote from a markdown string (indented bullets). Automatically parses indentations into parent-child Rem relationships using RemNote's native capabilities.

```bash
remnote-cli create-md [options]
```

| Option | Default | Description |
|--------|---------|-------------|
| `-c, --content <text>` | none | Markdown text containing the bulleted tree, flashcards inline supported |
| `--content-file <path>`| none | Read markdown content from UTF-8 file (`-` for stdin) |
| `--title <text>` | none | Optional root Rem title to enclose the entire tree |
| `--parent-id <id>` | none | Parent Rem ID where the tree will be created |
| `-t, --tags <tag...>` | none | Array of tags to apply to the root/title Rem |

Behavior rules:

- `--content` or `--content-file` is required.
- Provide a `title` if you want all items nested logically under a single new Rem.
- Use `- ` or `* ` for each bullet and use leading spaces for nesting levels
- You can batch create flashcards inline using `::` for Concept cards and `;;` for Descriptor cards. Refer to https://help.remnote.com/en/articles/9252072-how-to-import-flashcards-from-text#h_fc1588b3b7 for more information.

Examples:

```bash
remnote-cli create-md --content "- Programming Languages\n - Python\n - JavaScript"
remnote-cli create-md --title "Biology Terms" --content-file /tmp/biology.md --text
```

## search
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
import { DEFAULT_CONTROL_PORT } from './config.js';
import { registerDaemonCommand } from './commands/daemon.js';
import { registerCreateCommand } from './commands/create.js';
import { registerCreateMdCommand } from './commands/create-md.js';
import { registerSearchByTagCommand, registerSearchCommand } from './commands/search.js';
import { registerReadCommand } from './commands/read.js';
import { registerUpdateCommand } from './commands/update.js';
Expand All @@ -26,6 +27,7 @@ export function createProgram(version: string): Command {

registerDaemonCommand(program);
registerCreateCommand(program);
registerCreateMdCommand(program);
registerSearchCommand(program);
registerSearchByTagCommand(program);
registerReadCommand(program);
Expand Down
4 changes: 3 additions & 1 deletion src/commands/content-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ export async function resolveOptionalInlineOrFileContent({
if (filePath !== undefined) {
return readContentFileOrStdin(filePath, stdin);
}
return inlineText;

// convert literal \n strings into actual newline characters
return inlineText?.replace(/\\n/g, '\n');
}

interface UpdateContentArgs {
Expand Down
52 changes: 52 additions & 0 deletions src/commands/create-md.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Command } from 'commander';
import { DaemonClient } from '../client/daemon-client.js';
import { formatResult, formatError, type OutputFormat } from '../output/formatter.js';
import { EXIT } from '../config.js';
import { resolveOptionalInlineOrFileContent } from './content-input.js';

export function registerCreateMdCommand(program: Command): void {
program
.command('create-md')
.description('Create a hierarchical note tree in RemNote from a markdown string')
.option('-c, --content <text>', 'Markdown content')
.option('--content-file <path>', 'Read markdown content from UTF-8 file ("-" for stdin)')
.option('--title <text>', 'Optional root Rem title to enclose the entire tree')
.option('--parent-id <id>', 'Parent Rem ID')
.option('-t, --tags <tags...>', 'Tags to add')
.action(async (opts) => {
const globalOpts = program.opts();
const format: OutputFormat = globalOpts.text ? 'text' : 'json';
const client = new DaemonClient(parseInt(globalOpts.controlPort, 10));

try {
const content = await resolveOptionalInlineOrFileContent({
inlineText: opts.content as string | undefined,
filePath: opts.contentFile as string | undefined,
inlineFlag: '--content',
fileFlag: '--content-file',
});

if (content === undefined) {
throw new Error('Markdown content is required via --content or --content-file');
}

const payload: Record<string, unknown> = { content };
if (opts.title) payload.title = opts.title;
if (opts.parentId) payload.parentId = opts.parentId;
if (opts.tags) payload.tags = opts.tags;

const result = await client.execute('create_note_md', payload);
console.log(
formatResult(result, format, (data) => {
const r = data as Record<string, unknown>;
const ids = Array.isArray(r.remIds) ? r.remIds.join(', ') : 'unknown';
return `Created markdown tree: ${opts.title || '(root)'} (IDs: ${ids})`;
})
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(formatError(message, format));
process.exit(EXIT.ERROR);
}
});
}
6 changes: 6 additions & 0 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export function registerCreateCommand(program: Command): void {
.option('--content-file <path>', 'Read note content from UTF-8 file ("-" for stdin)')
.option('--parent-id <id>', 'Parent Rem ID')
.option('-t, --tags <tags...>', 'Tags to add')
.option('-b, --back-text <text>', 'Back text for creating a flashcard')
.option('--concept', 'Create as a Concept card (::)', false)
.option('--descriptor', 'Create as a Descriptor card (;;)', false)
.action(async (title: string, opts) => {
const globalOpts = program.opts();
const format: OutputFormat = globalOpts.text ? 'text' : 'json';
Expand All @@ -29,6 +32,9 @@ export function registerCreateCommand(program: Command): void {
if (content !== undefined) payload.content = content;
if (opts.parentId) payload.parentId = opts.parentId;
if (opts.tags) payload.tags = opts.tags;
if (opts.backText !== undefined) payload.backText = opts.backText;
if (opts.concept) payload.isConcept = true;
if (opts.descriptor) payload.isDescriptor = true;

const result = await client.execute('create_note', payload);
console.log(
Expand Down
2 changes: 2 additions & 0 deletions test/integration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface SharedState {
searchByTagTag?: string;
noteAId?: string;
noteBId?: string;
noteCId?: string;
mdTreeIds?: string[];
acceptWriteOperations?: boolean;
acceptReplaceOperation?: boolean;
}
Expand Down
98 changes: 90 additions & 8 deletions test/integration/workflows/03-create-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,16 +236,98 @@ export async function createSearchWorkflow(
}
}

// Step 3: Create flashcard note (with back-text)
{
const start = Date.now();
try {
const result = (await ctx.cli.runExpectSuccess([
'create',
`[CLI-TEST] Flashcard Note ${ctx.runId}`,
'--parent-id',
state.integrationParentRemId as string,
'--back-text',
'This is the back of the flashcard',
'--concept',
'--tags',
state.searchByTagTag as string,
])) as Record<string, unknown>;
assertHasField(result, 'remId', 'create flashcard note');
state.noteCId = result.remId as string;
steps.push({ label: 'Create flashcard note', passed: true, durationMs: Date.now() - start });
} catch (e) {
steps.push({
label: 'Create flashcard note',
passed: false,
durationMs: Date.now() - start,
error: (e as Error).message,
});
}
}

// Step 4: Create markdown tree with various flashcard types
{
const start = Date.now();
try {
const markdownContent = [
`- Flashcard Tree`,
` - Basic Forward >> Answer`,
` - Basic Backward << Answer`,
` - Two-way :: Answer`,
` - Disabled >- Answer`,
` - Cloze with {{hidden}}{({hint text})} text`,
` - Concept :: Definition`,
` - Concept Forward :> Definition`,
` - Concept Backward :< Definition`,
` - Descriptor ;; Detail`,
` - Multi-line >>>`,
` - Card Item 1`,
` - Card Item 2`,
` - List-answer >>1.`,
` - First list item`,
` - Second list item`,
` - Multiple-choice >>A)`,
` - Correct option`,
` - Wrong option`
].join('\n')

const result = (await withTempContentFile(markdownContent, async (contentPath) => {
return (await ctx.cli.runExpectSuccess([
'create-md',
'--parent-id',
state.integrationParentRemId as string,
'--content-file',
contentPath,
'--title',
`[CLI-TEST] Flashcard Tree ${ctx.runId}`,
'--tags',
state.searchByTagTag as string,
])) as Record<string, unknown>;
})) as Record<string, unknown>;

assertHasField(result, 'remIds', 'create markdown tree');
assertIsArray(result.remIds, 'markdown tree remIds');
state.mdTreeIds = result.remIds as string[];
steps.push({ label: 'Create md tree with flashcards', passed: true, durationMs: Date.now() - start });
} catch (e) {
steps.push({
label: 'Create md tree with flashcards',
passed: false,
durationMs: Date.now() - start,
error: (e as Error).message,
});
}
}

// Wait for indexing
await new Promise((r) => setTimeout(r, INDEXING_DELAY_MS));

// Step 3: Search for created notes
// Step 5: Search for created notes
{
const start = Date.now();
try {
const result = (await ctx.cli.runExpectSuccess([
'search',
`[CLI-TEST] ${ctx.runId}`,
`${ctx.runId}`,
])) as Record<string, unknown>;
assertHasField(result, 'results', 'search results');
assertIsArray(result.results, 'search results');
Expand All @@ -269,11 +351,11 @@ export async function createSearchWorkflow(
}
}

// Step 4-6: Search with includeContent modes
// Step 6-8: Search with includeContent modes
for (const mode of ['markdown', 'structured', 'none'] as const) {
const start = Date.now();
const label = `Search includeContent=${mode} returns expected shape`;
const query = `[CLI-TEST] ${ctx.runId}`;
const query = `${ctx.runId}`;
let debugResults: Array<Record<string, unknown>> | null = null;
try {
const result = (await ctx.cli.runExpectSuccess([
Expand All @@ -287,8 +369,8 @@ export async function createSearchWorkflow(
const results = result.results as Array<Record<string, unknown>>;
debugResults = results;
assertTruthy(results.length >= 1, `search ${mode} should find rich note`);
assertTruthy(typeof state.noteBId === 'string', 'rich note remId should be recorded');
const match = findMatchingSearchResult(results, state.noteBId as string);
assertTruthy(typeof state.mdTreeIds?.[0] === 'string', 'md tree root remId should be recorded');
const match = findMatchingSearchResult(results, state.mdTreeIds?.[0] as string);
assertSearchContentModeShape(match, mode);
assertParentContext(match, state, `search ${mode} parent context`);
steps.push({
Expand All @@ -303,7 +385,7 @@ export async function createSearchWorkflow(
durationMs: Date.now() - start,
error:
`${(e as Error).message} | query=${JSON.stringify(query)} expectedRemId=${JSON.stringify(
state.noteBId ?? null
state.mdTreeIds?.[0] ?? null
)}` +
(debugResults
? ` resultCount=${debugResults.length} topResults=${JSON.stringify(
Expand All @@ -314,7 +396,7 @@ export async function createSearchWorkflow(
}
}

// Step 7-9: Search by tag with includeContent modes
// Step 9-11: Search by tag with includeContent modes
let expectedTagTarget: ExpectedTagTarget | undefined;
{
const start = Date.now();
Expand Down
1 change: 1 addition & 0 deletions test/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('createProgram', () => {

expect(commandNames).toContain('daemon');
expect(commandNames).toContain('create');
expect(commandNames).toContain('create-md');
expect(commandNames).toContain('search');
expect(commandNames).toContain('search-tag');
expect(commandNames).toContain('read');
Expand Down
43 changes: 43 additions & 0 deletions test/unit/command-action-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,49 @@ describe('command bridge action mapping', () => {
executeSpy.mockRestore();
});

it('maps create command with flashcard options to create_note', async () => {
const executeSpy = await runCommand([
'create',
'Front',
'--back-text',
'Back',
'--concept',
]);
expect(executeSpy).toHaveBeenCalledWith('create_note', {
title: 'Front',
backText: 'Back',
isConcept: true,
});
executeSpy.mockRestore();
});

it('maps create-md command to create_note_md', async () => {
const executeSpy = await runCommand([
'create-md',
'--title',
'Root',
'--content',
'- Item 1\n - Item 2\n',
'--tags',
'md-tag',
]);
expect(executeSpy).toHaveBeenCalledWith('create_note_md', {
title: 'Root',
content: '- Item 1\n - Item 2\n',
tags: ['md-tag'],
});
executeSpy.mockRestore();
});

it('maps create-md --content-file to create_note_md', async () => {
const filePath = await createTempContentFile('- Item from file');
const executeSpy = await runCommand(['create-md', '--content-file', filePath]);
expect(executeSpy).toHaveBeenCalledWith('create_note_md', {
content: '- Item from file',
});
executeSpy.mockRestore();
});

it('maps read command to read_note', async () => {
const executeSpy = await runCommand(['read', 'abc123', '--depth', '2']);
expect(executeSpy).toHaveBeenCalledWith('read_note', { remId: 'abc123', depth: 2 });
Expand Down
Loading