#!/usr/bin/env bare
await import('bare-node-runtime/global')
/**
* hn - Hypernote CLI
*
* Usage:
* hn <command> [args] [--flags]
*
* Commands:
* init Generate device identity (keypair + encrypted mnemonic)
* restore Restore identity from mnemonic
* whoami Show current identity
* config Get/set/list configuration
* help Show help
* version Show version
*/
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { HnClient } from '../lib/cli/client.js'
import { loadConfig, saveConfig, hasDeviceIdentity, getServer } from '../lib/cli/config.js'
import { log, error } from '../lib/cli/output.js'
import { IdentityManager } from '../lib/identity/identity-manager.js'
import { notebookCommand } from '../lib/cli/commands/notebook.js'
import { noteCommand } from '../lib/cli/commands/note.js'
import { searchCommand } from '../lib/cli/commands/search.js'
import { shareCommand } from '../lib/cli/commands/share.js'
import { linksCommand, backlinksCommand, graphCommand, mergeCommand, splitCommand } from '../lib/cli/commands/links.js'
import { syncCommand } from '../lib/cli/commands/sync.js'
import { linkCommand } from '../lib/cli/commands/link.js'
const VERSION = '0.1.0'
const CONFIG_DIR = path.join(os.homedir(), '.config', 'hypernote')
// Parse args
const rawArgs = process.argv.slice(2)
const flags = {}
const positional = []
const booleanFlags = new Set([
'--json', '--help', '-h', '--version', '-v', '--verbose', '--offline', '--force', '--encrypted'
])
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i]
if (booleanFlags.has(arg)) {
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1)
flags[key] = true
} else if (arg.startsWith('--') && rawArgs[i + 1] && !rawArgs[i + 1].startsWith('--')) {
flags[arg.slice(2)] = rawArgs[++i]
} else if (arg.startsWith('-') && arg.length === 2 && rawArgs[i + 1] && !rawArgs[i + 1].startsWith('-')) {
flags[arg.slice(1)] = rawArgs[++i]
} else if (!arg.startsWith('-')) {
positional.push(arg)
}
}
const command = positional[0]
const subcommand = positional[1]
const cmdArgs = positional.slice(2)
if (flags.version || flags.v) {
log(`hn ${VERSION}`)
process.exit(0)
}
if (!command || flags.help || flags.h) {
showHelp()
process.exit(0)
}
// First-run guard — require device identity for most commands
const skipGuardCommands = new Set(['init', 'restore', 'config', 'help', 'version'])
if (command && !skipGuardCommands.has(command) && !hasDeviceIdentity()) {
log('Hypernote is not configured. Run: hn init')
process.exit(1)
}
try {
switch (command) {
case 'init':
await initCommand()
break
case 'restore':
await restoreCommand()
break
case 'whoami':
await whoamiCommand()
break
case 'config':
await configCommand()
break
case 'notebook':
case 'nb':
await notebookCommand(subcommand, cmdArgs, flags)
break
case 'note':
await noteCommand(subcommand, cmdArgs, flags)
break
case 'share':
await shareCommand(subcommand, cmdArgs, flags)
break
case 'links':
await linksCommand(positional.slice(1), flags)
break
case 'backlinks':
await backlinksCommand(positional.slice(1), flags)
break
case 'graph':
await graphCommand(positional.slice(1), flags)
break
case 'merge':
await mergeCommand(positional.slice(1), flags)
break
case 'split':
await splitCommand(positional.slice(1), flags)
break
case 'search':
await searchCommand(positional.slice(1), flags)
break
case 'sync':
await syncCommand(subcommand, cmdArgs, flags)
break
case 'link':
await linkCommand(subcommand, cmdArgs, flags)
break
case 'help':
showHelp()
break
case 'version':
log(`hn ${VERSION}`)
break
default:
error(`Unknown command: ${command}\nRun 'hn help' for available commands`)
}
} catch (err) {
error(err.message)
}
// ==================== COMMANDS ====================
async function initCommand() {
if (hasDeviceIdentity()) {
log('Device identity already exists.')
log('Use "hn restore" to restore from mnemonic on a new device.')
return
}
// Ensure config dir
await fs.promises.mkdir(CONFIG_DIR, { recursive: true })
const mgr = new IdentityManager({
storagePath: CONFIG_DIR,
identityFile: path.join(CONFIG_DIR, 'device-identity.json')
})
// Generate keypair
await mgr.ensureKeypair()
// Prompt for password
let securePrompt
try {
securePrompt = (await import('secure-prompt')).default
} catch {
error('secure-prompt not available. Install with: npm install secure-prompt')
return
}
log('Creating device identity...')
log('')
const password = await securePrompt('Enter encryption password: ')
if (!password || password.length === 0) {
error('Password cannot be empty')
return
}
const confirm = await securePrompt('Confirm password: ')
if (password.toString() !== confirm.toString()) {
error('Passwords do not match')
return
}
// Generate mnemonic + proof + encrypt vault
const result = await mgr.setup(password)
log('')
log('=== BACKUP YOUR MNEMONIC ===')
log('')
log(result.mnemonic)
log('')
log('Write these words down and store them safely.')
log('You will need them to restore your identity on another device.')
log('')
log(`Identity public key: ${result.identityPublicKey.substring(0, 16)}...`)
log('')
log('Device identity created successfully!')
// Prompt for display name
let name = 'Anonymous'
const readline = await import('bare-readline')
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
try {
name = await new Promise((resolve) => {
rl.question('Display name (optional): ', (answer) => {
resolve(answer || 'Anonymous')
})
})
} finally {
rl.close()
}
// Save config with name
const cfg = loadConfig()
cfg.name = name
cfg.identityPublicKey = result.identityPublicKey
saveConfig(cfg)
log(`Welcome, ${name}!`)
}
async function restoreCommand() {
// Ensure config dir
await fs.promises.mkdir(CONFIG_DIR, { recursive: true })
const mgr = new IdentityManager({
storagePath: CONFIG_DIR,
identityFile: path.join(CONFIG_DIR, 'device-identity.json')
})
// Ensure a device keypair exists (new key for this device)
await mgr.ensureKeypair()
let securePrompt
try {
securePrompt = (await import('secure-prompt')).default
} catch {
error('secure-prompt not available. Install with: npm install secure-prompt')
return
}
log('Restoring identity from mnemonic...')
log('')
const readline = await import('bare-readline')
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
let mnemonic
try {
mnemonic = await new Promise((resolve) => {
rl.question('Enter mnemonic (12 or 24 words): ', resolve)
})
} finally {
rl.close()
}
if (!mnemonic || mnemonic.trim().split(/\s+/).length < 12) {
error('Invalid mnemonic (must be 12 or 24 words)')
return
}
const password = await securePrompt('Enter encryption password for this device: ')
if (!password || password.length === 0) {
error('Password cannot be empty')
return
}
const confirm = await securePrompt('Confirm password: ')
if (password.toString() !== confirm.toString()) {
error('Passwords do not match')
return
}
const result = await mgr.setupFromMnemonic(mnemonic.trim(), password)
log('')
log(`Identity restored! Public key: ${result.identityPublicKey.substring(0, 16)}...`)
// Save to config
const cfg = loadConfig()
cfg.identityPublicKey = result.identityPublicKey
saveConfig(cfg)
log('Identity restored successfully!')
}
async function whoamiCommand() {
const cfg = loadConfig()
if (flags.offline) {
// Offline mode — read from config
log(`Name: ${cfg.name || 'Not set'}`)
log(`Server: ${cfg.server}`)
if (cfg.identityPublicKey) {
log(`Identity: ${cfg.identityPublicKey.substring(0, 16)}...`)
}
if (cfg.token) {
log(`Token: ${cfg.token.substring(0, 8)}...`)
}
return
}
// Try server
try {
const client = new HnClient(flags)
const data = await client.get('/api/whoami')
if (data.authenticated) {
log(`Authenticated: yes`)
log(`Method: ${data.method}`)
if (data.username) log(`Username: ${data.username}`)
if (data.userId) log(`User ID: ${data.userId}`)
log(`Permissions: ${JSON.stringify(data.permissions)}`)
} else {
log('Not authenticated with server')
}
} catch {
// Fall back to offline
log(`Name: ${cfg.name || 'Not set'}`)
log(`Server: ${cfg.server} (unreachable)`)
if (cfg.identityPublicKey) {
log(`Identity: ${cfg.identityPublicKey.substring(0, 16)}...`)
}
}
}
async function configCommand() {
const cfg = loadConfig()
if (!subcommand || subcommand === 'list') {
// List all config
for (const [key, value] of Object.entries(cfg)) {
log(`${key} = ${value !== null ? value : '(not set)'}`)
}
return
}
if (subcommand === 'get') {
const key = cmdArgs[0]
if (!key) {
error('Usage: hn config get <key>')
return
}
const value = cfg[key]
log(value !== undefined && value !== null ? String(value) : '(not set)')
return
}
if (subcommand === 'set') {
const key = cmdArgs[0]
const value = cmdArgs[1]
if (!key || value === undefined) {
error('Usage: hn config set <key> <value>')
return
}
cfg[key] = value
saveConfig(cfg)
log(`Set ${key} = ${value}`)
return
}
error(`Unknown config subcommand: ${subcommand}`)
}
function showHelp() {
log(`hn ${VERSION} — Hypernote CLI
Usage:
hn <command> [args] [--flags]
Commands:
init Generate device identity (keypair + encrypted mnemonic)
restore Restore identity from mnemonic backup
whoami Show current identity (--offline for local only)
config Manage configuration
list List all config values
get <key> Get a config value
set <k> <v> Set a config value
notebook (nb) Manage notebooks
list List all notebooks
create <slug> [--name "..."] [--description "..."] [--encrypted --password "..."]
info <slug> Show notebook details
rename <old> <new> Rename a notebook
delete <slug> [--force] Delete notebook + all notes
note Manage notes
list <notebook> List notes in a notebook
create <notebook> --title "..." [--content "..." | --file path] [--tags "a,b"]
show <notebook> <id> Show note content
edit <notebook> <id> [--title | --content | --file | --tags]
delete <notebook> <id>
search <query> [--notebook <slug>] Search notes
links <notebook> <id> Show outgoing [[wiki-links]] from a note
backlinks <notebook> <id> Show notes that link TO this note
graph [notebook] Show link graph (all or one notebook)
merge <notebook> <id1> <id2> [--title "..."] Merge two notes
split <notebook> <id> [--delimiter "## "] Split note at headings
share Manage notebook shares
create <notebook> [--permissions read|write] [--label "..."]
list <notebook> List active shares
revoke <notebook> <tokenHash> Revoke a share
sync P2P replication control
status Show peers + announced notebooks
announce <slug> Share notebook on swarm
unannounce <slug> Stop sharing
add <driveKey> [--slug name] Add remote notebook by drive key
remove <slug> Remove remote notebook
info <slug> Show drive key + sync status
link Multi-writer device linking
create Create link invite
join <invite-hex> Join with invite from another device
status Show multiwriter status
sync Trigger manual mirror sync
add-writer <key> Manual writer key add (fallback)
help Show this help
version Show version
Flags:
--server <url> Override server URL
--token <tok> Override auth token
--offline Don't contact server
--json Output as JSON
--force Skip confirmation prompts
-h, --help Show help
-v, --version Show version
Examples:
hn init # First-time setup
hn config set server http://localhost:4040
hn config set token <admin-token>
hn whoami # Check identity
hn notebook create work --name "Work Notes"
hn note create work --title "Meeting" --content "# Notes\\n..."
hn note list work
hn search "meeting"
`)
}