Skip to content
This repository was archived by the owner on Jul 18, 2026. It is now read-only.
Draft
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
808 changes: 808 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

75 changes: 74 additions & 1 deletion packages/engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,82 @@ Includes offline queue, conflict resolution, privacy filtering, and merge strate

## CLI

The `nomark` CLI imports your AI conversation exports into your NOMARK preference ledger.

### Install

```bash
npm install -g @nomark-ai/engine
# or use without installing:
npx nomark <command>
```

Requires Node.js 18+.

### Authenticate

Get an API key from [nomark.ai](https://nomark.ai) and run:

```bash
npx nomark login
```

You will be prompted for your key (`nomark_sk_...`). Alternatively, set the `NOMARK_TOKEN` environment variable:

```bash
export NOMARK_TOKEN=nomark_sk_...
npx nomark login
```

The key is saved to `~/.nomark/config.json` with mode 0600.

Verify authentication:

```bash
npx nomark whoami
```

### Import

Export your conversation history from ChatGPT, Claude, or Gemini, then import:

```bash
# ChatGPT — export from Settings → Data Controls → Export Data
npx nomark import --platform chatgpt --file conversations.json

# Claude — export from Settings → Privacy → Export Data
npx nomark import --platform claude --file claude-export.json

# Gemini — export via Google Takeout
npx nomark import --platform gemini --file takeout.json
```

Use `--dry-run` to preview without writing changes (local mode only):

```bash
npx nomark import --platform chatgpt --file conversations.json --dry-run
```

Use `--ledger` to specify a custom ledger path (default: `./nomark-ledger.jsonl`):

```bash
npx nomark import --platform chatgpt --file conversations.json --ledger ~/my-ledger.jsonl
```

### Managing API Keys

| Action | Command |
|--------|---------|
| Save key interactively | `npx nomark login` |
| Save key from env var | `NOMARK_TOKEN=nomark_sk_... npx nomark login` |
| Check current user | `npx nomark whoami` |

The API key is stored in `~/.nomark/config.json` under the `api_key` field with file permissions set to 0600 (owner read/write only). It is never logged or echoed.

To remove the key, delete the `api_key` field from `~/.nomark/config.json`.

```bash
npx nomark profile # Show resolved preference profile
npx nomark import # Import from ChatGPT/Claude exports
npx nomark review # Review and manage ledger entries
```

Expand Down
1 change: 1 addition & 0 deletions packages/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
},
"devDependencies": {
"@types/node": "^25.5.2",
"@vitest/coverage-v8": "^2.0.0",
"tsup": "^8.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
Expand Down
55 changes: 55 additions & 0 deletions packages/engine/src/cli/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'

function getConfigDir(): string {
return path.join(os.homedir(), '.nomark')
}

function getConfigPath(): string {
return path.join(getConfigDir(), 'config.json')
}

export function loadApiKey(): string | undefined {
const envKey = process.env['NOMARK_TOKEN']
if (envKey) return envKey
const configPath = getConfigPath()
if (!fs.existsSync(configPath)) return undefined
try {
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record<string, unknown>
if (typeof raw['api_key'] === 'string') return raw['api_key']
} catch {
// malformed JSON or read error — treat as missing
}
return undefined
}

export function saveApiKey(key: string): void {
const configDir = getConfigDir()
const configPath = getConfigPath()
fs.mkdirSync(configDir, { recursive: true })
let existing: Record<string, unknown> = {}
if (fs.existsSync(configPath)) {
try {
existing = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record<string, unknown>
} catch {
// malformed — start fresh but keep file
}
}
existing['api_key'] = key
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n')
fs.chmodSync(configPath, 0o600)
}

export function clearApiKey(): void {
const configPath = getConfigPath()
if (!fs.existsSync(configPath)) return
try {
const existing = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record<string, unknown>
delete existing['api_key']
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n')
fs.chmodSync(configPath, 0o600)
} catch {
// ignore read/parse errors
}
}
65 changes: 65 additions & 0 deletions packages/engine/src/cli/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const EDGE_FUNCTION_URL =
'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import'

export type ImportResult = {
platform: string
conversationsAnalyzed: number
signalsExtracted: number
signalsPromoted: number
byConfidence: { high: number; medium: number; low: number }
}

export type AuthProbeResult = {
authenticated: boolean
uid?: string
email?: string
}

export async function cloudImport(
apiKey: string,
platform: string,
data: unknown
): Promise<ImportResult> {
const res = await fetch(EDGE_FUNCTION_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ platform, data }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`HTTP ${res.status}: ${body}`)
}
return res.json() as Promise<ImportResult>
}

export async function probeAuth(apiKey: string): Promise<AuthProbeResult> {
let res: Response
try {
res = await fetch(EDGE_FUNCTION_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ platform: 'probe', data: null }),
})
} catch (err) {
throw new Error(
`Network error: ${err instanceof Error ? err.message : String(err)}`
)
}
if (res.status === 401) return { authenticated: false }
try {
const body = (await res.json()) as Record<string, unknown>
return {
authenticated: true,
uid: typeof body['uid'] === 'string' ? body['uid'] : undefined,
email: typeof body['email'] === 'string' ? body['email'] : undefined,
}
} catch {
return { authenticated: true }
}
}
55 changes: 46 additions & 9 deletions packages/engine/src/cli/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,75 @@ import { runMigration } from '../importers/pipeline.js'
import { writeLedger, parseLedger } from '../ledger.js'
import type { Conversation } from '../importers/types.js'
import { requireFlag } from './args.js'
import { loadApiKey } from './auth.js'
import { cloudImport } from './http.js'

const DEFAULT_LEDGER = './nomark-ledger.jsonl'

export function importCommand(flags: Record<string, string | boolean>): void {
export async function importCommand(flags: Record<string, string | boolean>): Promise<void> {
const platform = requireFlag(flags, 'platform')
const filePath = requireFlag(flags, 'file')
const dryRun = flags['dry-run'] !== false && flags['dry-run'] !== 'false'
const ledgerPath = typeof flags['ledger'] === 'string' ? flags['ledger'] : DEFAULT_LEDGER
const maxConversations = typeof flags['max'] === 'string' ? parseInt(flags['max'], 10) : undefined

// Read file
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`)
process.exit(1)
}

const raw = fs.readFileSync(filePath, 'utf8')

let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (err) {
console.error(`Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
return
}

const apiKey = loadApiKey()

if (apiKey) {
// Cloud mode: POST to edge function
let result
try {
result = await cloudImport(apiKey, platform, parsed)
} catch (err) {
console.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
return
}
console.log()
console.log(`Import complete.`)
console.log(` Platform: ${result.platform}`)
console.log(` Conversations analyzed: ${result.conversationsAnalyzed}`)
console.log(` Signals extracted: ${result.signalsExtracted}`)
console.log(` Signals promoted: ${result.signalsPromoted}`)
console.log(` By confidence: ${result.byConfidence.high} high, ${result.byConfidence.medium} medium, ${result.byConfidence.low} low`)
console.log()
return
}

// Local mode: use parsers
const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true'

const parsers: Record<string, (data: string) => Conversation[]> = {
chatgpt: parseChatGPTExport,
claude: parseClaudeExport,
}

const parser = parsers[platform]
if (!parser) {
console.error(`Unsupported platform: ${platform}. Supported: chatgpt, claude`)
if (platform === 'gemini') {
console.error(
'Gemini imports require a NOMARK account. Run `nomark login` to authenticate, then retry.'
)
} else {
console.error(`Unsupported platform: ${platform}. Supported: chatgpt, claude`)
}
process.exit(1)
return // unreachable but satisfies TS
return
}

const conversations = parser(raw)
Expand All @@ -49,14 +90,12 @@ export function importCommand(flags: Record<string, string | boolean>): void {
existingLedger = fs.readFileSync(ledgerPath, 'utf8')
}

// Run pipeline
const report = runMigration(conversations, {
existingLedger: existingLedger || undefined,
dryRun,
maxConversations,
})

// Display report
console.log()
console.log(` Analyzing ${report.conversationsAnalyzed} conversations...`)
console.log(` Extracted ${report.signalsExtracted} signals (${report.byConfidence.high} high, ${report.byConfidence.medium} medium, ${report.byConfidence.low} low confidence)`)
Expand All @@ -70,7 +109,6 @@ export function importCommand(flags: Record<string, string | boolean>): void {
console.log()
console.log(' [DRY RUN] No changes written. Run with --dry-run=false to apply.')
} else {
// Write ledger
const existingEntries = existingLedger ? parseLedger(existingLedger) : []
const allEntries = [...existingEntries, ...report.ledgerEntries]
const content = writeLedger(allEntries)
Expand All @@ -79,6 +117,5 @@ export function importCommand(flags: Record<string, string | boolean>): void {
console.log(` Written to ${ledgerPath}`)
}

console.log(' No account needed. Everything runs locally.')
console.log()
}
Loading
Loading