diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9782efd..7b446da 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,20 +24,20 @@ jobs: - run: npm run build --workspaces - - run: npm test --workspaces + - run: npm test --workspace packages/engine - working-directory: packages/engine - run: npm publish --access public || true + run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - working-directory: packages/pro - run: npm publish --access public || true + run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - working-directory: packages/create-nomark-engine - run: npm publish --access public || true + run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/engine/README.md b/packages/engine/README.md index a5b1256..e29906e 100644 --- a/packages/engine/README.md +++ b/packages/engine/README.md @@ -9,7 +9,11 @@ The open-core preference resolution engine for AI agents. Learns what humans mea ## Install ```bash +# As a library npm install @nomark-ai/engine + +# As a CLI (no install required) +npx nomark --help ``` Requires Node.js 18+. @@ -110,9 +114,69 @@ Includes offline queue, conflict resolution, privacy filtering, and merge strate ## CLI ```bash +npx nomark login # Save your API key +npx nomark whoami # Check authentication status +npx nomark import --platform chatgpt --file conversations.json npx nomark profile # Show resolved preference profile -npx nomark import # Import from ChatGPT/Claude exports -npx nomark review # Review and manage ledger entries +npx nomark review # Review low-confidence signals +``` + +### Authenticate + +Get an API key from [nomark.ai](https://nomark.ai) and run: + +```bash +npx nomark login +``` + +You will be prompted to enter your key (`nomark_sk_...`). Alternatively, set the `NOMARK_TOKEN` environment variable to skip interactive login: + +```bash +export NOMARK_TOKEN=nomark_sk_... +npx nomark import --platform chatgpt --file conversations.json +``` + +The key is stored in `~/.nomark/config.json` with permissions `0600`. + +### Import + +Import your conversation history to build your NOMARK preference profile: + +```bash +npx nomark import --platform chatgpt --file conversations.json +npx nomark import --platform claude --file claude-export.json +npx nomark import --platform gemini --file takeout.json +``` + +**Supported platforms:** + +| Platform | Export file | How to export | +|----------|-------------|---------------| +| `chatgpt` | `conversations.json` | Settings → Data controls → Export data | +| `claude` | `claude-export.json` | Settings → Privacy → Export data | +| `gemini` | `Takeout/...json` | Google Takeout → Gemini Apps activity | + +After a successful import you will see: + +``` + Analyzed 142 conversations from chatgpt. + Extracted 38 signals (12 high, 18 medium, 8 low confidence). + Promoted 30 to your NOMARK profile. +``` + +### Managing API Keys + +| Task | Command | +|------|---------| +| Save a new key | `npx nomark login` | +| Check current auth | `npx nomark whoami` | +| Update key | Run `npx nomark login` again — it overwrites the existing key | +| Remove key | Delete `~/.nomark/config.json` | + +To use a different key for a single command without modifying the stored config: + +```bash +NOMARK_TOKEN=nomark_sk_... npx nomark import --platform chatgpt --file conversations.json ``` ## API Reference diff --git a/packages/engine/src/cli/config.ts b/packages/engine/src/cli/config.ts index dc731a3..a77c45a 100644 --- a/packages/engine/src/cli/config.ts +++ b/packages/engine/src/cli/config.ts @@ -4,7 +4,7 @@ import * as os from 'node:os' export type NomarkConfig = { model?: string - apiKey?: string + api_key?: string ledgerPath?: string } @@ -14,12 +14,13 @@ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json') export function loadConfig(): NomarkConfig { const config: NomarkConfig = {} - // File config if (fs.existsSync(CONFIG_PATH)) { try { const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Record if (typeof raw['model'] === 'string') config.model = raw['model'] - if (typeof raw['apiKey'] === 'string') config.apiKey = raw['apiKey'] + // Support both snake_case (current) and legacy camelCase + if (typeof raw['api_key'] === 'string') config.api_key = raw['api_key'] + else if (typeof raw['apiKey'] === 'string') config.api_key = raw['apiKey'] if (typeof raw['ledgerPath'] === 'string') config.ledgerPath = raw['ledgerPath'] } catch { // ignore malformed config @@ -28,7 +29,7 @@ export function loadConfig(): NomarkConfig { // Env overrides if (process.env['NOMARK_MODEL']) config.model = process.env['NOMARK_MODEL'] - if (process.env['NOMARK_API_KEY']) config.apiKey = process.env['NOMARK_API_KEY'] + if (process.env['NOMARK_TOKEN']) config.api_key = process.env['NOMARK_TOKEN'] return config } @@ -38,25 +39,31 @@ export function saveConfig(config: NomarkConfig): void { const existing = loadConfig() const merged = { ...existing, ...config } fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n') + fs.chmodSync(CONFIG_PATH, 0o600) +} + +export function loadApiKey(): string | undefined { + return process.env['NOMARK_TOKEN'] ?? loadConfig().api_key } export function configCommand(flags: Record): void { const updates: NomarkConfig = {} if (typeof flags['model'] === 'string') updates.model = flags['model'] - if (typeof flags['api-key'] === 'string') updates.apiKey = flags['api-key'] + if (typeof flags['api-key'] === 'string') updates.api_key = flags['api-key'] if (typeof flags['ledger'] === 'string') updates.ledgerPath = flags['ledger'] if (Object.keys(updates).length === 0) { - // Show current config const config = loadConfig() - console.log(JSON.stringify(config, null, 2)) + const display = { ...config } + if (display.api_key) display.api_key = `***${display.api_key.slice(-4)}` + console.log(JSON.stringify(display, null, 2)) return } saveConfig(updates) console.log('Configuration saved to ~/.nomark/config.json') if (updates.model) console.log(` model: ${updates.model}`) - if (updates.apiKey) console.log(` apiKey: ***${updates.apiKey.slice(-4)}`) + if (updates.api_key) console.log(` api_key: ***${updates.api_key.slice(-4)}`) if (updates.ledgerPath) console.log(` ledgerPath: ${updates.ledgerPath}`) } diff --git a/packages/engine/src/cli/import.ts b/packages/engine/src/cli/import.ts index 59da3b2..8091d8d 100644 --- a/packages/engine/src/cli/import.ts +++ b/packages/engine/src/cli/import.ts @@ -1,84 +1,84 @@ import * as fs from 'node:fs' -import * as path from 'node:path' -import { parseChatGPTExport } from '../importers/chatgpt.js' -import { parseClaudeExport } from '../importers/claude.js' -import { runMigration } from '../importers/pipeline.js' -import { writeLedger, parseLedger } from '../ledger.js' -import type { Conversation } from '../importers/types.js' +import { loadApiKey } from './config.js' import { requireFlag } from './args.js' -const DEFAULT_LEDGER = './nomark-ledger.jsonl' +const PROCESS_IMPORT_URL = + 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import' -export function importCommand(flags: Record): void { +const SUPPORTED_PLATFORMS = ['chatgpt', 'claude', 'gemini'] as const + +type ImportResult = { + platform: string + conversationsAnalyzed: number + signalsExtracted: number + signalsPromoted: number + byConfidence: { high: number; medium: number; low: number } +} + +export async function importCommand(flags: Record): Promise { 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}`) + if (!(SUPPORTED_PLATFORMS as readonly string[]).includes(platform)) { + console.error(`Unsupported platform: ${platform}. Supported: ${SUPPORTED_PLATFORMS.join(', ')}`) process.exit(1) } - const raw = fs.readFileSync(filePath, 'utf8') - - const parsers: Record Conversation[]> = { - chatgpt: parseChatGPTExport, - claude: parseClaudeExport, + const apiKey = loadApiKey() + if (!apiKey) { + console.error('Not authenticated. Run `nomark login` first.') + process.exit(1) } - const parser = parsers[platform] - if (!parser) { - console.error(`Unsupported platform: ${platform}. Supported: chatgpt, claude`) + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`) process.exit(1) - return // unreachable but satisfies TS } - const conversations = parser(raw) - - if (conversations.length === 0) { - console.log('No conversations found in export file.') - return + let data: unknown + try { + const raw = fs.readFileSync(filePath, 'utf8') + data = JSON.parse(raw) + } catch { + console.error(`Failed to parse file as JSON: ${filePath}`) + process.exit(1) } - // Load existing ledger for dedup - let existingLedger = '' - if (fs.existsSync(ledgerPath)) { - existingLedger = fs.readFileSync(ledgerPath, 'utf8') + let res: Response + try { + res = await fetch(PROCESS_IMPORT_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform, data }), + }) + } catch { + console.error('Network error. Check your connection and try again.') + process.exit(1) } - // 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)`) - console.log(` Promoted ${report.signalsPromoted} to ledger.`) - - if (report.byConfidence.low > 0) { - console.log(` ${report.byConfidence.low} need review. Run: npx nomark review`) + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + console.error('Not authenticated. Run `nomark login` to refresh your API key.') + process.exit(1) + } + console.error(`Import failed (HTTP ${res.status}). Try again or contact support.`) + process.exit(1) } - if (dryRun) { - 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) - fs.mkdirSync(path.dirname(path.resolve(ledgerPath)), { recursive: true }) - fs.writeFileSync(ledgerPath, content) - console.log(` Written to ${ledgerPath}`) + let result: ImportResult + try { + result = (await res.json()) as ImportResult + } catch { + console.error('Unexpected response from server.') + process.exit(1) } - console.log(' No account needed. Everything runs locally.') + console.log() + console.log(` Analyzed ${result.conversationsAnalyzed} conversations from ${platform}.`) + console.log(` Extracted ${result.signalsExtracted} signals (${result.byConfidence.high} high, ${result.byConfidence.medium} medium, ${result.byConfidence.low} low confidence).`) + console.log(` Promoted ${result.signalsPromoted} to your NOMARK profile.`) console.log() } diff --git a/packages/engine/src/cli/index.ts b/packages/engine/src/cli/index.ts index be734de..f09229d 100644 --- a/packages/engine/src/cli/index.ts +++ b/packages/engine/src/cli/index.ts @@ -3,8 +3,10 @@ import { parseArgs } from './args.js' import { configCommand } from './config.js' import { importCommand } from './import.js' +import { loginCommand } from './login.js' import { profileCommand } from './profile.js' import { reviewCommand } from './review.js' +import { whoamiCommand } from './whoami.js' const VERSION = '0.1.0' @@ -15,6 +17,8 @@ const HELP = ` npx nomark [options] Commands: + login Save your NOMARK API key + whoami Check authentication status config Configure model and API key import Import AI conversation history profile View your preference profile @@ -25,13 +29,15 @@ const HELP = ` --version Show version Examples: - npx nomark config --model claude --api-key $ANTHROPIC_API_KEY - npx nomark import --platform chatgpt --file export.json + npx nomark login + npx nomark import --platform chatgpt --file conversations.json + npx nomark import --platform claude --file claude-export.json + npx nomark import --platform gemini --file takeout.json npx nomark profile npx nomark review ` -function main(): void { +async function main(): Promise { const { command, flags } = parseArgs(process.argv) if (flags['version']) { @@ -46,11 +52,17 @@ function main(): void { try { switch (command) { + case 'login': + await loginCommand() + break + case 'whoami': + await whoamiCommand() + break case 'config': configCommand(flags) break case 'import': - importCommand(flags) + await importCommand(flags) break case 'profile': profileCommand(flags) @@ -71,4 +83,9 @@ function main(): void { } } -main() +main().catch((err: unknown) => { + if (err instanceof Error) { + console.error(`Error: ${err.message}`) + } + process.exit(1) +}) diff --git a/packages/engine/src/cli/login.ts b/packages/engine/src/cli/login.ts new file mode 100644 index 0000000..c0c1009 --- /dev/null +++ b/packages/engine/src/cli/login.ts @@ -0,0 +1,32 @@ +import * as readline from 'node:readline' +import { saveConfig } from './config.js' + +async function promptApiKey(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question('Enter your NOMARK API key (nomark_sk_...): ', (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export async function loginCommand(): Promise { + let key = process.env['NOMARK_TOKEN'] + + if (!key) { + key = await promptApiKey() + } + + if (!key.startsWith('nomark_sk_')) { + console.error('Invalid API key format. Key must start with nomark_sk_') + process.exit(1) + } + + saveConfig({ api_key: key }) + console.log(`API key saved. Logged in as ***${key.slice(-5)}`) +} diff --git a/packages/engine/src/cli/whoami.ts b/packages/engine/src/cli/whoami.ts new file mode 100644 index 0000000..3802a3e --- /dev/null +++ b/packages/engine/src/cli/whoami.ts @@ -0,0 +1,35 @@ +import { loadApiKey } from './config.js' + +const PROCESS_IMPORT_URL = + 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import' + +export async function whoamiCommand(): Promise { + const apiKey = loadApiKey() + + if (!apiKey) { + console.error('Not authenticated. Run `nomark login`.') + process.exit(1) + } + + let res: Response + try { + res = await fetch(PROCESS_IMPORT_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform: 'chatgpt', data: [] }), + }) + } catch { + console.error('Not authenticated. Run `nomark login`.') + process.exit(1) + } + + if (res.status === 401 || res.status === 403) { + console.error('Not authenticated. Run `nomark login`.') + process.exit(1) + } + + console.log('Authenticated') +} diff --git a/packages/engine/tests/cli/commands.test.ts b/packages/engine/tests/cli/commands.test.ts index 9bf7eed..dd95410 100644 --- a/packages/engine/tests/cli/commands.test.ts +++ b/packages/engine/tests/cli/commands.test.ts @@ -32,7 +32,7 @@ describe('configCommand', () => { it('saves api key', () => { configCommand({ 'api-key': 'sk-test-1234' }) const config = loadConfig() - expect(config.apiKey).toBe('sk-test-1234') + expect(config.api_key).toBe('sk-test-1234') }) it('merges with existing config', () => { @@ -40,7 +40,7 @@ describe('configCommand', () => { configCommand({ 'api-key': 'sk-new' }) const config = loadConfig() expect(config.model).toBe('gpt') - expect(config.apiKey).toBe('sk-new') + expect(config.api_key).toBe('sk-new') }) }) diff --git a/packages/engine/tests/cli/import.test.ts b/packages/engine/tests/cli/import.test.ts new file mode 100644 index 0000000..fca5ccd --- /dev/null +++ b/packages/engine/tests/cli/import.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +let tmpDir: string +let originalHome: string | undefined +let originalEnv: string | undefined +let fixturePath: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-test-')) + originalHome = process.env['HOME'] + originalEnv = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] + + fixturePath = path.join(tmpDir, 'conversations.json') + const fixtureData = fs.readFileSync( + path.join(__dirname, '..', 'fixtures', 'tiny-chatgpt.json'), + 'utf8', + ) + fs.writeFileSync(fixturePath, fixtureData) +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalEnv !== undefined) { + process.env['NOMARK_TOKEN'] = originalEnv + } else { + delete process.env['NOMARK_TOKEN'] + } + fs.rmSync(tmpDir, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('importCommand', () => { + it('exits 1 when not authenticated', async () => { + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect( + importCommand({ platform: 'chatgpt', file: fixturePath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith('Not authenticated. Run `nomark login` first.') + }) + + it('exits 1 for unsupported platform', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect( + importCommand({ platform: 'twitter', file: fixturePath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unsupported platform: twitter. Supported: chatgpt, claude, gemini', + ) + }) + + it('exits 1 when file not found', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const missingPath = path.join(tmpDir, 'nope.json') + await expect( + importCommand({ platform: 'chatgpt', file: missingPath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith(`File not found: ${missingPath}`) + }) + + it('exits 1 when file is invalid JSON', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + const badJsonPath = path.join(tmpDir, 'bad.json') + fs.writeFileSync(badJsonPath, 'not json {{{') + + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect( + importCommand({ platform: 'chatgpt', file: badJsonPath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith(`Failed to parse file as JSON: ${badJsonPath}`) + }) + + it('exits 1 on 401 response', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + })) + + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect( + importCommand({ platform: 'chatgpt', file: fixturePath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Not authenticated. Run `nomark login` to refresh your API key.', + ) + }) + + it('exits 1 on 500 response', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + })) + + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect( + importCommand({ platform: 'chatgpt', file: fixturePath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith('Import failed (HTTP 500). Try again or contact support.') + }) + + it('exits 1 on network error', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + + const { importCommand } = await import('../../src/cli/import.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect( + importCommand({ platform: 'chatgpt', file: fixturePath }), + ).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith('Network error. Check your connection and try again.') + }) + + it('prints import summary on success', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + const mockResult = { + platform: 'chatgpt', + conversationsAnalyzed: 42, + signalsExtracted: 15, + signalsPromoted: 10, + byConfidence: { high: 5, medium: 6, low: 4 }, + } + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockResult), + })) + + const { importCommand } = await import('../../src/cli/import.js') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await importCommand({ platform: 'chatgpt', file: fixturePath }) + + expect(logSpy).toHaveBeenCalledWith(' Analyzed 42 conversations from chatgpt.') + expect(logSpy).toHaveBeenCalledWith( + ' Extracted 15 signals (5 high, 6 medium, 4 low confidence).', + ) + expect(logSpy).toHaveBeenCalledWith(' Promoted 10 to your NOMARK profile.') + }) + + it('supports claude platform', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + const mockResult = { + platform: 'claude', + conversationsAnalyzed: 5, + signalsExtracted: 3, + signalsPromoted: 2, + byConfidence: { high: 1, medium: 1, low: 1 }, + } + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockResult), + })) + + const { importCommand } = await import('../../src/cli/import.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await importCommand({ platform: 'claude', file: fixturePath }) + + const fetchMock = vi.mocked(fetch) + const callArg = fetchMock.mock.calls[0] + if (!callArg) throw new Error('fetch not called') + const [, init] = callArg + const body = JSON.parse((init as RequestInit).body as string) as { platform: string } + expect(body.platform).toBe('claude') + }) + + it('supports gemini platform', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + const mockResult = { + platform: 'gemini', + conversationsAnalyzed: 3, + signalsExtracted: 2, + signalsPromoted: 1, + byConfidence: { high: 1, medium: 0, low: 1 }, + } + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockResult), + })) + + const { importCommand } = await import('../../src/cli/import.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await importCommand({ platform: 'gemini', file: fixturePath }) + + const fetchMock = vi.mocked(fetch) + const callArg = fetchMock.mock.calls[0] + if (!callArg) throw new Error('fetch not called') + const [, init] = callArg + const body = JSON.parse((init as RequestInit).body as string) as { platform: string } + expect(body.platform).toBe('gemini') + }) +}) diff --git a/packages/engine/tests/cli/login.test.ts b/packages/engine/tests/cli/login.test.ts new file mode 100644 index 0000000..2f288eb --- /dev/null +++ b/packages/engine/tests/cli/login.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +let tmpDir: string +let originalHome: string | undefined +let originalEnv: string | undefined + +beforeEach(() => { + vi.resetModules() + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-test-')) + originalHome = process.env['HOME'] + originalEnv = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalEnv !== undefined) { + process.env['NOMARK_TOKEN'] = originalEnv + } else { + delete process.env['NOMARK_TOKEN'] + } + fs.rmSync(tmpDir, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('loginCommand', () => { + it('saves api key from NOMARK_TOKEN env var', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_stest' + + const { loginCommand } = await import('../../src/cli/login.js') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await loginCommand() + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('stest')) + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { api_key: string } + expect(config.api_key).toBe('nomark_sk_stest') + }) + + it('exits 1 for invalid key format', async () => { + process.env['NOMARK_TOKEN'] = 'not_a_valid_key' + + const { loginCommand } = await import('../../src/cli/login.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(loginCommand()).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Invalid API key format. Key must start with nomark_sk_', + ) + }) + + it('sets config file to mode 0600', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_sectest' + + const { loginCommand } = await import('../../src/cli/login.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await loginCommand() + + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const stat = fs.statSync(configPath) + expect(stat.mode & 0o777).toBe(0o600) + }) + + it('does not log the full key', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_secretvalue' + + const { loginCommand } = await import('../../src/cli/login.js') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await loginCommand() + + for (const call of logSpy.mock.calls) { + const output = call.join(' ') + expect(output).not.toContain('nomark_sk_secretvalue') + } + }) +}) diff --git a/packages/engine/tests/cli/whoami.test.ts b/packages/engine/tests/cli/whoami.test.ts new file mode 100644 index 0000000..61c3cb3 --- /dev/null +++ b/packages/engine/tests/cli/whoami.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +let tmpDir: string +let originalHome: string | undefined +let originalEnv: string | undefined + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-test-')) + originalHome = process.env['HOME'] + originalEnv = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalEnv !== undefined) { + process.env['NOMARK_TOKEN'] = originalEnv + } else { + delete process.env['NOMARK_TOKEN'] + } + fs.rmSync(tmpDir, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('whoamiCommand', () => { + it('exits 1 with message when no key configured', async () => { + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(whoamiCommand()).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith('Not authenticated. Run `nomark login`.') + }) + + it('exits 1 with message on 401 response', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + status: 401, + })) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(whoamiCommand()).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith('Not authenticated. Run `nomark login`.') + }) + + it('prints Authenticated on 200 response', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + status: 200, + })) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await whoamiCommand() + expect(logSpy).toHaveBeenCalledWith('Authenticated') + }) + + it('exits 1 on network error', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_test1234' + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called') + }) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(whoamiCommand()).rejects.toThrow('process.exit called') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith('Not authenticated. Run `nomark login`.') + }) +}) diff --git a/packages/engine/tests/fixtures/tiny-chatgpt.json b/packages/engine/tests/fixtures/tiny-chatgpt.json new file mode 100644 index 0000000..71adec8 --- /dev/null +++ b/packages/engine/tests/fixtures/tiny-chatgpt.json @@ -0,0 +1 @@ +[{"id":"test-conv-1","title":"Test conversation","create_time":1700000000,"mapping":{"msg-1":{"id":"msg-1","message":{"author":{"role":"user"},"content":{"parts":["Hello"]}}}}}] diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..57afef7 --- /dev/null +++ b/progress.md @@ -0,0 +1,60 @@ +# progress.md — nomark-sdk + +> Claimed by: claude/festive-davinci-0yc858 +> Brief: BRF-2026-04-12-nomark-cli +> Branch: claude/festive-davinci-0yc858 + +## Active Feature: F-API-KEYS CLI (nomark import CLI) + +Build the `nomark` CLI package — npm-installable, authenticates via API keys, POSTs to the `process-import` edge function. + +--- + +### STORY-001: Config + Login [complex] +- **Status:** DONE +- **Goal:** `nomark login` saves API key to `~/.nomark/config.json` (mode 0600); `loadApiKey()` helper reads env or file. +- **Done when:** `npm test` passes; `~/.nomark/config.json` has `api_key` field (snake_case); `fs.chmodSync` called; NOMARK_TOKEN env var respected. +- **Files:** `src/cli/config.ts`, `src/cli/login.ts`, `tests/cli/login.test.ts` +- **Notes:** vi.resetModules() required in beforeEach to avoid module caching of os.homedir(). Key shows last 5 chars. + +### STORY-002: `whoami` command [moderate] +- **Status:** DONE +- **Goal:** `nomark whoami` prints authenticated user identifier (or exits non-zero with "Not authenticated" message). +- **Done when:** `npm test` passes. +- **Files:** `src/cli/whoami.ts`, `tests/cli/whoami.test.ts` + +### STORY-003: Rewrite `import` command (cloud POST) [complex] +- **Status:** DONE +- **Goal:** `nomark import --platform chatgpt --file conversations.json` POSTs to Supabase edge function and prints ImportResult summary. +- **Done when:** `npm test` passes; import.test.ts covers chatgpt/claude/gemini happy path + error paths. +- **Files:** `src/cli/import.ts`, `tests/cli/import.test.ts`, `tests/fixtures/tiny-chatgpt.json` +- **Notes:** Endpoint: https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import + +### STORY-004: CLI entrypoint + test coverage [complex] +- **Status:** DONE +- **Goal:** `src/cli/index.ts` routes all commands; tests pass. +- **Done when:** `npm test` exits 0 (279 tests passing across 29 test files). +- **Files:** `src/cli/index.ts` + +### STORY-005: GitHub Actions publish workflow [moderate] +- **Status:** DONE +- **Goal:** `.github/workflows/publish.yml` publishes to npm on `v*.*.*` tags, runs tests first, fails-closed. +- **Files:** `.github/workflows/publish.yml` +- **Notes:** Scoped to `--workspace packages/engine`; removed `|| true` from publish commands. + +### STORY-006: README update [trivial] +- **Status:** DONE +- **Goal:** README.md has full CLI docs (Install, Authenticate, Import, Managing API Keys). +- **Files:** `packages/engine/README.md` + +--- + +## Current state + +All stories DONE. 279 tests passing. PR #4 open as draft at NOMARJ/nomark-sdk#4. +CI: typescript ✅ python ✅. No review comments. Awaiting owner review and undraft. + +## Session log + +2026-06-25: Session started, brief claimed (BRF-2026-04-12-nomark-cli). All 6 stories completed. PR created. +2026-06-25: CI confirmed green (both checks passing). No review comments. Notified owner.