diff --git a/README.md b/README.md index 1da0f3b..ebeca6f 100644 --- a/README.md +++ b/README.md @@ -342,11 +342,12 @@ try { ## CLI -Every product is a `microlink` subcommand — `npx microlink.io` works without a global install. Run `microlink buy` to purchase an API key, then `microlink login` to save it. +Every product is a `microlink` subcommand — `npx microlink.io` works without a global install. Run `microlink buy` to purchase an API key, then `microlink login` to save it. `microlink setup` detects the coding agents on this machine and installs the Microlink skill for them. ```bash npx microlink.io buy npx microlink.io login +npx microlink.io setup npx microlink.io markdown https://example.com npx microlink.io screenshot https://example.com --fullPage npx microlink.io logo https://github.com --square diff --git a/packages/core/bin/help.js b/packages/core/bin/help.js index cd10479..87a9306 100755 --- a/packages/core/bin/help.js +++ b/packages/core/bin/help.js @@ -106,6 +106,15 @@ const COMMANDS = { flags: [], cli: [], examples: [['logout', 'forget the saved API key']] + }, + setup: { + usage: 'setup', + desc: 'Install the Microlink skill for detected coding agents', + flags: [], + cli: [], + examples: [ + ['setup', 'detect coding agents and install the Microlink skill'] + ] } } @@ -316,6 +325,7 @@ ${cmd('help')} ${cmd('buy')} ${cmd('login')} ${cmd('logout')} +${cmd('setup')} Commands ${commandList} @@ -329,6 +339,7 @@ ${rows(CLI)} Examples ${cmd('buy', 'buy an API key')} ${cmd('login', 'save an API key from your account')} +${cmd('setup', 'install the Microlink skill for detected coding agents')} ${cmd('markdown docs', 'print the markdown docs page')} ${cmd('https://example.com', 'unified metadata (default)')} ${cmd( diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index 2311625..1277973 100644 --- a/packages/core/bin/run.js +++ b/packages/core/bin/run.js @@ -7,6 +7,7 @@ const parseArgv = require('./argv') const helpText = require('./help') const { asUrl } = require('./url') const docs = require('./docs') +const setup = require('./setup') const create = require('../src') const run = async (argvInput, host) => { @@ -66,6 +67,30 @@ const run = async (argvInput, host) => { } } + if (command === 'setup') { + if (help) return showHelp(command) + if (target) { + writeLine( + stderr, + '`setup` does not take arguments. Run `microlink setup`.' + ) + return finish(1) + } + try { + await setup({ + stderr, + env, + home: host.home, + fetch: host.fetch, + which: host.which + }) + return finish(0) + } catch (error) { + writeLine(stderr, error.message) + return finish(1) + } + } + const apiKey = apiKeyFlag || apiKeyCamel || env.MICROLINK_API_KEY || host.readApiKey() const endpoint = endpointFlag diff --git a/packages/core/bin/setup.js b/packages/core/bin/setup.js new file mode 100644 index 0000000..2278079 --- /dev/null +++ b/packages/core/bin/setup.js @@ -0,0 +1,259 @@ +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { green, gray, red } = require('./style') + +const SKILL_URL = + 'https://raw.githubusercontent.com/microlinkhq/skills/master/microlink/SKILL.md' +const SKILL_NAME = 'microlink' +const MARKER = '.managed-by-microlink' + +// Codex and Cursor read ~/.agents/skills. Claude Code, OpenCode, and +// Copilot keep their own skills directory, so those get a link — the same +// split `hey setup` uses for Claude vs Codex. +const AGENTS = [ + { + name: 'Claude Code', + binary: 'claude', + dir: ({ home, env }) => env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), + skillsDir: dir => path.join(dir, 'skills') + }, + { + name: 'Codex', + binary: 'codex', + dir: ({ home, env }) => env.CODEX_HOME || path.join(home, '.codex') + }, + { + name: 'Cursor', + binary: 'cursor', + dir: ({ home }) => path.join(home, '.cursor') + }, + { + name: 'OpenCode', + dir: ({ home, env }) => + path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'), + skillsDir: dir => path.join(dir, 'skills') + }, + { + name: 'GitHub Copilot', + dir: ({ home }) => path.join(home, '.copilot'), + skillsDir: dir => path.join(dir, 'skills') + } +] + +const writeLine = (stream, text) => { + stream.write(`${text}\n`) +} + +const isDir = file => { + try { + return fs.statSync(file).isDirectory() + } catch { + return false + } +} + +const which = (name, env) => { + const dirs = (env.PATH || '').split(path.delimiter).filter(Boolean) + const exts = + process.platform === 'win32' + ? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') + : [''] + for (const dir of dirs) { + for (const ext of exts) { + const candidate = path.join(dir, name + ext) + try { + if (fs.statSync(candidate).isFile()) return candidate + } catch {} + } + } + return '' +} + +const display = (home, file) => + file.startsWith(home) ? `~${file.slice(home.length)}` : file + +const writeRegular = (file, data) => { + try { + if (!fs.lstatSync(file).isFile()) { + throw new Error(`${file} is not a regular file`) + } + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + fs.writeFileSync(file, data) +} + +const owned = dir => { + try { + return fs.lstatSync(path.join(dir, MARKER)).isFile() + } catch { + return false + } +} + +// A directory microlink did not write is left alone. An empty one, or one +// that already carries the marker, is claimed. +const claim = dir => { + let info + try { + info = fs.lstatSync(dir) + } catch (error) { + if (error.code !== 'ENOENT') throw error + fs.mkdirSync(dir, { recursive: true }) + writeRegular( + path.join(dir, MARKER), + 'This skill is managed by microlink. Manual edits will be overwritten on upgrade.\n' + ) + return + } + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error( + `${dir} exists but was not written by microlink. Move it aside, then run \`microlink setup\` again.` + ) + } + if (owned(dir)) return + const entries = fs.readdirSync(dir) + if (entries.length > 0) { + throw new Error( + `${dir} exists but was not written by microlink. Move it aside, then run \`microlink setup\` again.` + ) + } + writeRegular( + path.join(dir, MARKER), + 'This skill is managed by microlink. Manual edits will be overwritten on upgrade.\n' + ) +} + +const installSkill = (home, body) => { + const dir = path.join(home, '.agents', 'skills', SKILL_NAME) + claim(dir) + writeRegular(path.join(dir, 'SKILL.md'), body) + return dir +} + +const linkSkill = (skillsDir, canonicalDir) => { + if (path.resolve(skillsDir) === path.dirname(path.resolve(canonicalDir))) { + return + } + fs.mkdirSync(skillsDir, { recursive: true }) + const link = path.join(skillsDir, SKILL_NAME) + const target = + path.relative(fs.realpathSync(skillsDir), fs.realpathSync(canonicalDir)) || + canonicalDir + let info + try { + info = fs.lstatSync(link) + } catch (error) { + if (error.code !== 'ENOENT') throw error + try { + fs.symlinkSync(target, link, 'dir') + } catch { + claim(link) + for (const name of ['SKILL.md', MARKER]) { + const from = path.join(canonicalDir, name) + if (fs.existsSync(from)) { + writeRegular(path.join(link, name), fs.readFileSync(from)) + } + } + } + return + } + if (info.isSymbolicLink()) { + if (fs.readlinkSync(link) === target) return + throw new Error( + `${link} points somewhere else. Move it aside, then run \`microlink setup\` again.` + ) + } + if (info.isDirectory() && owned(link)) { + for (const name of ['SKILL.md', MARKER]) { + const from = path.join(canonicalDir, name) + if (fs.existsSync(from)) { + writeRegular(path.join(link, name), fs.readFileSync(from)) + } + } + return + } + throw new Error( + `${link} exists but was not written by microlink. Move it aside, then run \`microlink setup\` again.` + ) +} + +const detect = ({ home, env, find }) => + AGENTS.filter(agent => { + const dir = agent.dir({ home, env }) + return isDir(dir) || (agent.binary && find(agent.binary)) + }) + +const fetchSkill = async fetchFn => { + const res = await fetchFn(SKILL_URL, { signal: AbortSignal.timeout(10_000) }) + if (!res.ok) { + throw new Error(`Failed to fetch the Microlink skill (${res.status})`) + } + const body = await res.text() + if (!body.startsWith('---\nname: microlink\n')) { + throw new Error('The downloaded file is not the Microlink skill') + } + return body +} + +const finish = stderr => { + writeLine(stderr, '') + writeLine( + stderr, + gray('Start by typing ') + '/microlink' + gray(' to use it.') + ) +} + +const setup = async ({ + stderr, + env = {}, + home = os.homedir(), + fetch: fetchFn = fetch, + which: find = name => which(name, env) +} = {}) => { + writeLine(stderr, '') + writeLine(stderr, gray("Let's get you set up. It'll only take a moment")) + writeLine(stderr, '') + + const agents = detect({ home, env, find }) + const body = await fetchSkill(fetchFn) + const dir = installSkill(home, body) + const issues = [] + + if (agents.length === 0) { + writeLine(stderr, 'No coding agents detected.') + writeLine( + stderr, + `Installed the Microlink skill to ${display( + home, + path.join(dir, 'SKILL.md') + )}` + ) + finish(stderr) + return + } + + for (const agent of agents) { + try { + if (agent.skillsDir) { + linkSkill(agent.skillsDir(agent.dir({ home, env })), dir) + } + writeLine(stderr, `${green('✓')} ${agent.name} ${gray('connected')}`) + } catch (error) { + issues.push(error.message) + writeLine(stderr, red(`✗ ${agent.name}`)) + writeLine(stderr, gray(error.message)) + } + } + + if (issues.length > 0) { + throw new Error('Setup incomplete') + } + finish(stderr) +} + +module.exports = setup +module.exports.SKILL_URL = SKILL_URL diff --git a/packages/core/test/cli.mjs b/packages/core/test/cli.mjs index 33006cf..5c23e97 100644 --- a/packages/core/test/cli.mjs +++ b/packages/core/test/cli.mjs @@ -1,7 +1,17 @@ import { createRequire } from 'module' import { fileURLToPath } from 'url' import { spawn } from 'child_process' -import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync } from 'fs' +import { + mkdirSync, + mkdtempSync, + writeFileSync, + existsSync, + readFileSync, + lstatSync, + readlinkSync, + realpathSync, + symlinkSync +} from 'fs' import { tmpdir } from 'os' import http from 'http' import path from 'path' @@ -24,6 +34,7 @@ test('prints help with no arguments', async t => { t.true(stdout.includes('buy')) t.true(stdout.includes('login')) t.true(stdout.includes('logout')) + t.true(stdout.includes('setup')) t.true(stdout.includes(' docs')) }) @@ -721,6 +732,126 @@ test('run reports missing --file through the host', async t => { t.true(host.stderrText().includes('Missing `--file`')) }) +const skillBody = '---\nname: microlink\n---\n# microlink\n' + +const setupHost = (home, overrides = {}) => + memoryHost({ + home, + env: { PATH: '' }, + fetch: async url => { + if (!url.endsWith('/microlink/SKILL.md')) { + throw new Error(`unexpected skill url ${url}`) + } + return { ok: true, status: 200, text: async () => skillBody } + }, + ...overrides + }) + +test('prints command help for setup', async t => { + const { stdout } = await $('node', [bin, 'setup', '--help']) + t.true(stdout.includes('setup')) + t.true(stdout.includes('Install the Microlink skill')) + t.false(stdout.includes('Products')) +}) + +test('setup installs the skill and connects detected agents', async t => { + const home = mkdtempSync(path.join(tmpdir(), 'microlink-setup-')) + mkdirSync(path.join(home, '.claude')) + mkdirSync(path.join(home, '.codex')) + mkdirSync(path.join(home, '.cursor')) + mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true }) + const host = setupHost(home) + t.is(await run(['setup'], host), 0) + + const canonical = path.join(home, '.agents', 'skills', 'microlink', 'SKILL.md') + t.is(readFileSync(canonical, 'utf8'), skillBody) + t.true( + lstatSync(path.join(home, '.agents', 'skills', 'microlink', '.managed-by-microlink')).isFile() + ) + + const claude = path.join(home, '.claude', 'skills', 'microlink') + t.true(lstatSync(claude).isSymbolicLink()) + t.is(readlinkSync(claude), path.join('..', '..', '.agents', 'skills', 'microlink')) + + const opencode = path.join(home, '.config', 'opencode', 'skills', 'microlink') + t.true(lstatSync(opencode).isSymbolicLink()) + + t.false(existsSync(path.join(home, '.codex', 'skills', 'microlink'))) + t.false(existsSync(path.join(home, '.cursor', 'skills', 'microlink'))) + + const stderr = host.stderrText() + t.true(stderr.includes('Claude Code connected')) + t.true(stderr.includes('Codex connected')) + t.true(stderr.includes('Cursor connected')) + t.true(stderr.includes('OpenCode connected')) + t.false(stderr.includes('GitHub Copilot')) + t.true(stderr.includes('Start by typing /microlink to use it.')) + t.false(stderr.includes('Step 1')) + + const again = setupHost(home, { + fetch: async () => ({ + ok: true, + status: 200, + text: async () => skillBody.replace('# microlink', '# updated') + }) + }) + t.is(await run(['setup'], again), 0) + t.true(readFileSync(canonical, 'utf8').includes('# updated')) + t.is(readlinkSync(claude), path.join('..', '..', '.agents', 'skills', 'microlink')) +}) + +test('setup links through a symlinked agent directory', async t => { + const home = mkdtempSync(path.join(tmpdir(), 'microlink-setup-')) + const real = mkdtempSync(path.join(tmpdir(), 'microlink-dotfiles-')) + symlinkSync(real, path.join(home, '.claude')) + const host = setupHost(home) + t.is(await run(['setup'], host), 0) + const link = path.join(real, 'skills', 'microlink') + t.true(lstatSync(link).isSymbolicLink()) + t.is( + realpathSync(link), + realpathSync(path.join(home, '.agents', 'skills', 'microlink')) + ) +}) + +test('setup links an agent found by its binary', async t => { + const home = mkdtempSync(path.join(tmpdir(), 'microlink-setup-')) + const host = setupHost(home, { + which: name => (name === 'claude' ? '/usr/local/bin/claude' : '') + }) + t.is(await run(['setup'], host), 0) + t.true(lstatSync(path.join(home, '.claude', 'skills', 'microlink')).isSymbolicLink()) + t.true(host.stderrText().includes('Claude Code connected')) + t.false(host.stderrText().includes('Codex connected')) +}) + +test('setup installs the shared skill when no agent is installed', async t => { + const home = mkdtempSync(path.join(tmpdir(), 'microlink-setup-')) + const host = setupHost(home) + t.is(await run(['setup'], host), 0) + t.true(existsSync(path.join(home, '.agents', 'skills', 'microlink', 'SKILL.md'))) + t.true(host.stderrText().includes('No coding agents detected.')) + t.true(host.stderrText().includes('Start by typing /microlink to use it.')) +}) + +test('setup refuses to overwrite a skill it did not write', async t => { + const home = mkdtempSync(path.join(tmpdir(), 'microlink-setup-')) + const dir = path.join(home, '.agents', 'skills', 'microlink') + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, 'SKILL.md'), 'mine') + const host = setupHost(home) + t.is(await run(['setup'], host), 1) + t.is(readFileSync(path.join(dir, 'SKILL.md'), 'utf8'), 'mine') + t.true(host.stderrText().includes('was not written by microlink')) + t.false(host.stderrText().includes('Installed!')) +}) + +test('setup rejects extra arguments', async t => { + const host = memoryHost() + t.is(await run(['setup', 'claude'], host), 1) + t.true(host.stderrText().includes('does not take arguments')) +}) + test('run unsubscribes interrupt after the request finishes', async t => { const { endpoint } = await listenSuccess(t) let listeners = 0