diff --git a/AGENTS.md b/AGENTS.md
index 9602c13..1bf5552 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c50a9e..bc75648 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/guides/command-reference.md b/docs/guides/command-reference.md
index ef5d892..4c28482 100644
--- a/docs/guides/command-reference.md
+++ b/docs/guides/command-reference.md
@@ -134,6 +134,9 @@ remnote-cli create
[options]
| `--content-file ` | none | Read initial content from UTF-8 file (`-` for stdin) |
| `--parent-id ` | none | Parent Rem ID |
| `-t, --tags ` | none | One or more tags |
+| `-b, --back-text `| none | Back text for flashcard |
+| `--concept` | false | Create as a Concept card |
+| `--descriptor` | false | Create as a Descriptor card |
Behavior rules:
@@ -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 ` | none | Markdown text containing the bulleted tree, flashcards inline supported |
+| `--content-file `| none | Read markdown content from UTF-8 file (`-` for stdin) |
+| `--title ` | none | Optional root Rem title to enclose the entire tree |
+| `--parent-id ` | none | Parent Rem ID where the tree will be created |
+| `-t, --tags ` | 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
diff --git a/src/cli.ts b/src/cli.ts
index cffe2a2..c06b005 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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';
@@ -26,6 +27,7 @@ export function createProgram(version: string): Command {
registerDaemonCommand(program);
registerCreateCommand(program);
+ registerCreateMdCommand(program);
registerSearchCommand(program);
registerSearchByTagCommand(program);
registerReadCommand(program);
diff --git a/src/commands/content-input.ts b/src/commands/content-input.ts
index 366d579..c135fb3 100644
--- a/src/commands/content-input.ts
+++ b/src/commands/content-input.ts
@@ -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 {
diff --git a/src/commands/create-md.ts b/src/commands/create-md.ts
new file mode 100644
index 0000000..c87fa4d
--- /dev/null
+++ b/src/commands/create-md.ts
@@ -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 ', 'Markdown content')
+ .option('--content-file ', 'Read markdown content from UTF-8 file ("-" for stdin)')
+ .option('--title ', 'Optional root Rem title to enclose the entire tree')
+ .option('--parent-id ', 'Parent Rem ID')
+ .option('-t, --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 = { 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;
+ 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);
+ }
+ });
+}
diff --git a/src/commands/create.ts b/src/commands/create.ts
index 68cbaf1..20bd4d4 100644
--- a/src/commands/create.ts
+++ b/src/commands/create.ts
@@ -12,6 +12,9 @@ export function registerCreateCommand(program: Command): void {
.option('--content-file ', 'Read note content from UTF-8 file ("-" for stdin)')
.option('--parent-id ', 'Parent Rem ID')
.option('-t, --tags ', 'Tags to add')
+ .option('-b, --back-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';
@@ -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(
diff --git a/test/integration/types.ts b/test/integration/types.ts
index e5159de..4307d11 100644
--- a/test/integration/types.ts
+++ b/test/integration/types.ts
@@ -28,6 +28,8 @@ export interface SharedState {
searchByTagTag?: string;
noteAId?: string;
noteBId?: string;
+ noteCId?: string;
+ mdTreeIds?: string[];
acceptWriteOperations?: boolean;
acceptReplaceOperation?: boolean;
}
diff --git a/test/integration/workflows/03-create-search.ts b/test/integration/workflows/03-create-search.ts
index 2749080..58a2399 100644
--- a/test/integration/workflows/03-create-search.ts
+++ b/test/integration/workflows/03-create-search.ts
@@ -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;
+ 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;
+ })) as Record;
+
+ 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;
assertHasField(result, 'results', 'search results');
assertIsArray(result.results, 'search results');
@@ -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> | null = null;
try {
const result = (await ctx.cli.runExpectSuccess([
@@ -287,8 +369,8 @@ export async function createSearchWorkflow(
const results = result.results as Array>;
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({
@@ -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(
@@ -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();
diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts
index cf855d2..bb064a0 100644
--- a/test/unit/cli.test.ts
+++ b/test/unit/cli.test.ts
@@ -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');
diff --git a/test/unit/command-action-mapping.test.ts b/test/unit/command-action-mapping.test.ts
index e743947..6058716 100644
--- a/test/unit/command-action-mapping.test.ts
+++ b/test/unit/command-action-mapping.test.ts
@@ -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 });
diff --git a/test/unit/content-input.test.ts b/test/unit/content-input.test.ts
index fe19e35..01ae98e 100644
--- a/test/unit/content-input.test.ts
+++ b/test/unit/content-input.test.ts
@@ -75,6 +75,28 @@ with "quotes" and \`ticks\``;
).rejects.toThrow('Cannot use --content and --content-file together');
});
+ it('un-escapes literal \\n in inline content', async () => {
+ const inlineText = '- line 1\\n- line 2';
+ const resolved = await resolveOptionalInlineOrFileContent({
+ inlineText,
+ filePath: undefined,
+ inlineFlag: '--content',
+ fileFlag: '--content-file',
+ });
+ expect(resolved).toBe('- line 1\n- line 2');
+ });
+
+ it('un-escapes literal \\n in inline content', async () => {
+ const inlineText = "- line 1\n- line 2";
+ const resolved = await resolveOptionalInlineOrFileContent({
+ inlineText,
+ filePath: undefined,
+ inlineFlag: '--content',
+ fileFlag: '--content-file',
+ });
+ expect(resolved).toBe('- line 1\n- line 2');
+ });
+
it('resolves update replacement content from --replace-file', async () => {
const path = await createTempFile('replace body');
await expect(