Skip to content
Merged
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
55 changes: 52 additions & 3 deletions src/mcp/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
buildEntryFromFlags,
type ParsedFlags,
} from './install/flags'
import { installSkill, type SkillInstallResult } from './install/skills'
import { getSkillContent } from './skills/thinkfleet-memory'

const HELP = `agentmark-mcp — Model Context Protocol server for AgentMark

Expand Down Expand Up @@ -61,13 +63,18 @@ OPTIONS (install / setup / uninstall)
MCP config file on disk. Prefer rotating secrets from
an OS keychain (ThinkFleet Desktop does this) rather
than passing long-lived keys on a shared machine.
--skill=<name> Also install a skill that teaches the agent when /
how to use the tools you just wired. Repeatable.
Known: thinkfleet-memory. Native-skill clients get a
skill.md file; rules-file clients get a marker block.
--dry-run Show what would change without writing

EXAMPLE — wire Claude Code to ThinkFleet memory:
EXAMPLE — wire Claude Code to ThinkFleet memory + install skill:
agentmark-mcp install --client=claude-code \\
--env=THINKFLEET_BASE_URL=https://app.thinkfleet.ai \\
--env=THINKFLEET_PROJECT_ID=proj_xxx \\
--env=THINKFLEET_API_KEY=sk-xxx
--env=THINKFLEET_API_KEY=sk-xxx \\
--skill=thinkfleet-memory
`

async function main(argv: string[]): Promise<number> {
Expand Down Expand Up @@ -108,7 +115,34 @@ async function runInstall(args: string[]): Promise<number> {
dryRun: flags.dryRun,
})
printInstallResult(result, flags.dryRun ? 'dry-run' : 'install')
return result.clients.every((r) => r.action !== 'error') ? 0 : 1

// After the MCP entry is wired, optionally install skill files
// that teach the agent when/how to use those tools. Opt-in via
// `--skill=<name>` so callers who only want the MCP wiring
// (and not opinions injected into their agent prompts) can
// still install just the server entry.
let skillsOk = true
if (flags.skill && flags.skill.length > 0) {
for (const name of flags.skill) {
const skill = getSkillContent(name)
if (!skill) {
process.stderr.write(`Unknown skill: ${name}. Skipping.\n`)
skillsOk = false
continue
}
const skillResult = await installSkill({
skillName: name,
content: skill.content,
clientIds: flags.client,
dryRun: flags.dryRun,
})
printSkillResult(name, skillResult, flags.dryRun ? 'dry-run' : 'install')
if (!skillResult.ok) skillsOk = false
}
}

const installOk = result.clients.every((r) => r.action !== 'error')
return installOk && skillsOk ? 0 : 1
}

async function runUninstall(args: string[]): Promise<number> {
Expand Down Expand Up @@ -183,6 +217,21 @@ function pkgVersion(): string {
}
}

function printSkillResult(skillName: string, result: SkillInstallResult, label: string): void {
process.stdout.write(`agentmark-mcp ${label} — skill "${skillName}":\n\n`)
for (const c of result.clients) {
const flag =
c.action === 'added' || c.action === 'updated' ? '✓'
: c.action === 'already_present' ? '·'
: c.action === 'skipped' ? '⏭'
: '✗'
const padded = `${c.clientName} [${c.action}]`.padEnd(40)
process.stdout.write(` ${flag} ${padded} ${c.path}\n`)
if (c.message) process.stdout.write(` ${c.message}\n`)
}
process.stdout.write('\n')
}

function printInstallResult(result: { clients: Array<{ id: string; name: string; path: string; action: string; message?: string }> }, label: string): void {
process.stdout.write(`agentmark-mcp ${label} — results:\n\n`)
for (const c of result.clients) {
Expand Down
20 changes: 19 additions & 1 deletion src/mcp/install/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface ParsedFlags {
/** When supplied, the resolved env map. Empty object means
* `--env` was used but every value parsed empty (still valid). */
env: Record<string, string> | undefined
/** Skill names requested via `--skill=<name>`. Deduplicated. */
skill: string[] | undefined
dryRun: boolean
}

Expand All @@ -33,18 +35,23 @@ export interface ParsedFlags {
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
const ENV_VALUE_MAX_LEN = 4 * 1024

/** Skill names — same shape as env-var keys, restricted to a sane
* set so a malformed value can't be interpreted as a path on disk. */
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/

export function parseFlags(args: string[], emit?: (line: string) => void): ParsedFlags {
const client: string[] = []
const name: string[] = []
const command: string[] = []
const env: Record<string, string> = {}
const skills = new Set<string>()
let envSeen = false
let dryRun = false
const warn = emit ?? ((line: string) => process.stderr.write(`${line}\n`))

for (const arg of args) {
if (arg === '--dry-run' || arg === '-n') { dryRun = true; continue }
const m = arg.match(/^--(client|name|command|env)(?:=(.*))?$/)
const m = arg.match(/^--(client|name|command|env|skill)(?:=(.*))?$/)
if (!m) continue
const value = m[2]
if (value === undefined) continue
Expand All @@ -59,13 +66,24 @@ export function parseFlags(args: string[], emit?: (line: string) => void): Parse
env[key] = envValue
envSeen = true
}
if (m[1] === 'skill') {
const skillName = value.trim()
if (!SKILL_NAME_PATTERN.test(skillName)) {
throw new Error(
`--skill name "${skillName}" is invalid. Must match `
+ '[a-z0-9][a-z0-9-]* (lowercase letters / digits / hyphens).',
)
}
skills.add(skillName)
}
}

return {
client: client.length > 0 ? client : undefined,
name: name.length > 0 ? name : undefined,
command: command.length > 0 ? command : undefined,
env: envSeen ? env : undefined,
skill: skills.size > 0 ? Array.from(skills) : undefined,
dryRun,
}
}
Expand Down
225 changes: 225 additions & 0 deletions src/mcp/install/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* Per-AI-tool skill installer.
*
* Some clients (Claude Code, Claude Desktop) have a native "skills"
* concept — a directory of `skill.md` files the agent loads at
* session start. We write the canonical skill content directly to
* the known path.
*
* Other clients (Cursor, Windsurf, Codex CLI) don't have skills,
* but they DO load a rules / instructions file automatically every
* session. We render the skill content as a marker-wrapped block
* inside that file so re-running the installer surgically replaces
* just our section without disturbing the user's hand-written
* rules.
*
* Why this lives alongside the MCP-config installer:
* The skill is useless without the corresponding MCP tools, so
* the natural install moment is the same. The CLI's
* `--skill=<name>` flag opts in per-skill so users who only want
* the MCP wiring (no opinions injected into their agent) can
* still install just the server entry.
*/
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'

export const MANAGED_BLOCK_START = '<!-- thinkfleet:skill:start -->'
export const MANAGED_BLOCK_END = '<!-- thinkfleet:skill:end -->'

export interface SkillTarget {
/** Client id matching `ClientDescriptor.id`. */
clientId: string
/** Human-readable client name (for log output). */
clientName: string
/** Absolute path the renderer writes to. Returns null when the
* client doesn't ship on this OS. */
pathFor(skillName: string): string | null
/** When `true`, the file at the path is owned exclusively by the
* skill — we write the whole file. When `false`, the skill is
* one marker-delimited block inside a larger file the user
* also edits — we replace only the block. */
exclusive: boolean
}

export interface SkillInstallResult {
clients: Array<{
clientId: string
clientName: string
skillName: string
path: string
action: 'added' | 'updated' | 'already_present' | 'skipped' | 'error'
message?: string
}>
ok: boolean
}

export interface SkillInstallOptions {
skillName: string
/** Markdown body of the skill (frontmatter + content). */
content: string
/** When set, restrict to these client ids. */
clientIds?: string[]
/** Skill targets to use. Defaults to {@link DEFAULT_SKILL_TARGETS}. */
targets?: SkillTarget[]
/** Preview only — log paths + actions, don't write. */
dryRun?: boolean
}

/** Native-skill clients (write the whole file). */
function claudeCodeSkillTarget(): SkillTarget {
return {
clientId: 'claude-code',
clientName: 'Claude Code',
pathFor: (name) => path.join(os.homedir(), '.claude', 'skills', name, 'skill.md'),
exclusive: true,
}
}

function claudeDesktopSkillTarget(): SkillTarget {
return {
clientId: 'claude-desktop',
clientName: 'Claude Desktop',
pathFor: (name) => {
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'skills', name, 'skill.md')
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming')
return path.join(appData, 'Claude', 'skills', name, 'skill.md')
}
// Linux Claude Desktop isn't shipped today.
return null
},
exclusive: true,
}
}

/**
* The defaults the install CLI uses when the caller doesn't supply
* a custom list. Today: just the two Claude-family tools with
* native skill support. Cursor / Windsurf / Codex rules-file
* injection lands in a follow-up — they need a separate code path
* for the marker-block replacement.
*/
export const DEFAULT_SKILL_TARGETS: SkillTarget[] = [
claudeCodeSkillTarget(),
claudeDesktopSkillTarget(),
]

export async function installSkill(options: SkillInstallOptions): Promise<SkillInstallResult> {
const targets = options.targets ?? DEFAULT_SKILL_TARGETS
const filtered = options.clientIds
? targets.filter((t) => options.clientIds!.includes(t.clientId))
: targets

const out: SkillInstallResult['clients'] = []

for (const target of filtered) {
const dest = target.pathFor(options.skillName)
if (!dest) {
out.push({
clientId: target.clientId,
clientName: target.clientName,
skillName: options.skillName,
path: '',
action: 'skipped',
message: `Not supported on platform ${process.platform}.`,
})
continue
}

try {
const action = await writeSkillForTarget(target, dest, options.content, options.dryRun === true)
out.push({
clientId: target.clientId,
clientName: target.clientName,
skillName: options.skillName,
path: dest,
action,
message: options.dryRun ? '(dry run; nothing written)' : undefined,
})
}
catch (err) {
out.push({
clientId: target.clientId,
clientName: target.clientName,
skillName: options.skillName,
path: dest,
action: 'error',
message: (err as Error).message,
})
}
}

return { clients: out, ok: out.every((r) => r.action !== 'error') }
}

async function writeSkillForTarget(
target: SkillTarget,
dest: string,
content: string,
dryRun: boolean,
): Promise<'added' | 'updated' | 'already_present'> {
if (target.exclusive) {
// Whole-file write: read existing, decide action, replace.
const existing = await safeReadFile(dest)
if (existing !== null && existing === content) return 'already_present'
if (!dryRun) {
await mkdir(path.dirname(dest), { recursive: true })
await atomicWriteFile(dest, content)
}
return existing === null ? 'added' : 'updated'
}

// Marker-block write: replace just our section in a larger file.
const existing = (await safeReadFile(dest)) ?? ''
const block = `${MANAGED_BLOCK_START}\n${content}\n${MANAGED_BLOCK_END}\n`
const next = upsertManagedBlock(existing, block)
if (next === existing) return 'already_present'
if (!dryRun) {
await mkdir(path.dirname(dest), { recursive: true })
await atomicWriteFile(dest, next)
}
return existing.includes(MANAGED_BLOCK_START) ? 'updated' : 'added'
}

async function safeReadFile(p: string): Promise<string | null> {
try {
return await readFile(p, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
throw err
}
}

/**
* Atomic write — temp file in the same dir + rename. Sets `0644`
* on POSIX so the skill file is readable by the AI client but not
* writable by other users.
*/
async function atomicWriteFile(dest: string, content: string): Promise<void> {
const tmp = `${dest}.tmp.${process.pid}`
await writeFile(tmp, content, { encoding: 'utf8', mode: 0o644 })
await rename(tmp, dest)
}

/**
* Replace the existing managed block in `text`, or append a new
* block at the end. Pure function — easy to unit-test.
*/
export function upsertManagedBlock(text: string, block: string): string {
const startIdx = text.indexOf(MANAGED_BLOCK_START)
const endIdx = text.indexOf(MANAGED_BLOCK_END)
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
// Include trailing newline if present
const trailing = text.charAt(endIdx + MANAGED_BLOCK_END.length) === '\n' ? 1 : 0
const before = text.slice(0, startIdx)
const after = text.slice(endIdx + MANAGED_BLOCK_END.length + trailing)
return `${before}${block}${after}`
}
// Append (with separating newline if existing text doesn't end in one).
const sep = text.length === 0 ? '' : text.endsWith('\n') ? '\n' : '\n\n'
return `${text}${sep}${block}`
}
Loading