@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
256 lines (224 loc) • 9.47 kB
JavaScript
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createHash } from 'node:crypto'
import { performance } from 'node:perf_hooks'
const AGENT = "claude"
const CATALOG_COMMAND = "pnpm exec intent list --json --no-notices"
const EDIT_TOOLS = new Set(["Edit","MultiEdit","NotebookEdit","Write"])
// MESH-2162: name the exact runnable commands, not a vague "follow the setup".
// An agent that hits this deny WITHOUT the SessionStart catalog in view (subagent,
// post-compaction, headless) otherwise has no command to run — the "guess a bare
// `intent`, fail, give up" loop MESH-2160 closed for the catalog. Reuse
// CATALOG_COMMAND so the listing invocation stays in one place.
const GATE_DENY_REASON = `Blocked: load matching TanStack guidance before editing. Run \`${CATALOG_COMMAND}\` to see available skills, then \`pnpm exec intent load <package>#<skill>\` for the matching one, then retry the edit. (No bare \`intent\` on PATH — use \`pnpm exec intent\`.)`
const INTENT_COMMAND_PATTERN = /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+@tanstack\/intent(?:@latest)?)|(?:pnpm\s+exec\s+intent)|(?:pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:npx\s+@tanstack\/intent(?:@latest)?)|(?:yarn\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i
// LOCAL HARDENING (MESH-2020): this file started as `intent install` output, but it
// is committed and CLI-managed — `mesh skills sync` (and `mesh init app-tenant --fix`)
// overwrite it from the mesh-cli asset (libs/mesh-cli/assets/intent/intent-claude-gate.mjs
// in mesh-platform). Never fix one copy without the other; in mesh-platform,
// scripts/check-intent-hook-sync.mjs fails CI when they diverge. The gate
// deliberately fails OPEN — it must never block a session or an edit on a bug in
// the gate tooling itself. But the original blanket `catch {}` made a broken gate
// SILENT: it would stop enforcing with zero signal. We keep fail-open, but surface
// the failure on stderr so it's diagnosable.
try {
await main()
} catch (err) {
console.error(
`[intent-${AGENT}-gate] hook error — continuing (gate open), but enforcement/catalog may be degraded: ${err?.stack ?? err}`,
)
}
process.exit(0)
async function main() {
const event = readEventFromStdin()
if (isSessionStartEvent(event)) {
const additionalContext = await createSessionCatalogContext(rootForEvent(event))
if (additionalContext) {
process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext)))
}
return
}
const stateFile = stateFileForEvent(event)
const observation = observationFromEvent(event)
if (observation) {
appendObservation(stateFile, observation)
}
const toolName = event?.tool_name ?? event?.toolName
if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) {
process.stdout.write(JSON.stringify(denyOutput()))
}
}
function readEventFromStdin() {
try {
return JSON.parse(readFileSync(0, 'utf8'))
} catch {
return {}
}
}
function isSessionStartEvent(event) {
return (event?.hook_event_name ?? event?.hookEventName) === 'SessionStart'
}
function rootForEvent(event) {
return typeof event?.cwd === 'string' ? event.cwd : process.cwd()
}
async function createSessionCatalogContext(root) {
try {
const start = performance.now()
const result = readIntentList(root)
const durationMs = performance.now() - start
console.error(
`[intent-${AGENT}-session-catalog] listIntentSkills found ${result.skills.length} skills from ${result.packages.length} packages in ${formatDuration(durationMs)} (packageJsonReadCount=${result.debug?.scan.packageJsonReadCount ?? 'unknown'})`,
)
return formatSessionCatalog(result)
} catch (err) {
// Fail open (no catalog) but visibly — a silent empty catalog looked identical
// to "no skills", hiding a broken `intent list` (e.g. version skew, parse error).
console.error(
`[intent-${AGENT}-session-catalog] failed to load skill catalog, continuing without it: ${err?.message ?? err}`,
)
return ''
}
}
function readIntentList(root) {
const output = execFileSync(CATALOG_COMMAND, {
cwd: root,
encoding: 'utf8',
env: { ...process.env, INTENT_AUDIENCE: 'agent' },
maxBuffer: 1024 * 1024,
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 9000,
})
return JSON.parse(output)
}
function formatDuration(durationMs) {
return `${durationMs.toFixed(1)}ms`
}
function formatSessionCatalog(result) {
if (!Array.isArray(result.skills) || result.skills.length === 0) return ''
return [
'TanStack Intent skills are available in this repository.',
'',
'Before substantial work, check whether a listed skill matches the task or package you are about to work in. If one clearly matches, load its full guidance BEFORE you reason about that package by running the exact command shown for it below (always `pnpm exec intent load ...` — there is no bare `intent` on PATH, so `intent ...`/`npx intent ...` will fail).',
'',
'If no skill clearly matches, continue normally. Do not load a skill just to improve phrasing or gather nonessential context.',
'',
'Available local Intent skills:',
formatSkillCatalog(result.skills),
formatWarnings(result),
]
.filter(Boolean)
.join('\n')
}
function formatSkillCatalog(skills) {
// Render each skill as a copy-paste load command (MESH-2160). `skill.use` is the
// exact `<package>#<skill>` id, so agents don't have to guess the invocation — the
// vague "load with the Intent CLI" wording made them try a non-existent bare
// `intent` and give up. LOCAL CUSTOMIZATION: re-apply if you re-run `intent install`.
return skills
.map((skill) => `- \`pnpm exec intent load ${skill.use}\` — ${normalizeDescription(skill.description)}`)
.join('\n')
}
function normalizeDescription(description) {
return typeof description === 'string' ? description.replace(/\s+/g, ' ').trim() : ''
}
function formatWarnings(result) {
const warnings = [
...(Array.isArray(result.warnings) ? result.warnings : []),
...(Array.isArray(result.conflicts)
? result.conflicts.map(
(conflict) =>
`Version conflict for ${conflict.packageName}; using ${conflict.chosen.version}`,
)
: []),
]
if (warnings.length === 0) return ''
return `\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join('\n')}`
}
function sessionStartOutput(additionalContext) {
if (AGENT === 'copilot') {
return { additionalContext }
}
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext,
},
}
}
function stateFileForEvent(event) {
const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown'
const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd()
const key = createHash('sha256').update(AGENT + '\0' + cwd + '\0' + sessionId).digest('hex')
return join(tmpdir(), 'tanstack-intent-hooks', key + '.jsonl')
}
function observationFromEvent(event) {
if (!event || typeof event !== 'object') return undefined
const toolName = event.tool_name ?? event.toolName
const toolInput = event.tool_input ?? event.toolArgs
if (toolName !== 'Bash') return undefined
const command = typeof toolInput === 'string' ? safeCommandFromString(toolInput) : commandFromObject(toolInput)
const parsed = parseIntentInvocation(command)
if (!parsed || typeof command !== 'string') return undefined
return { action: parsed.action, skillUse: parsed.skillUse, raw: command }
}
function parseIntentInvocation(command) {
if (typeof command !== 'string') return undefined
const match = command.match(INTENT_COMMAND_PATTERN)
if (!match?.[1] || !match[2]) return undefined
const action = match[2].toLowerCase()
if (action !== 'list' && action !== 'load') return undefined
const skillUse = action === 'load' ? match[3] : undefined
if (action === 'load' && !skillUse) return undefined
return action === 'load' ? { action, skillUse } : { action }
}
function commandFromObject(value) {
return value && typeof value === 'object' ? value.command : undefined
}
function safeCommandFromString(value) {
try {
const command = commandFromObject(JSON.parse(value))
return typeof command === 'string' ? command : value
} catch {
return value
}
}
function appendObservation(stateFile, observation) {
try {
mkdirSync(dirname(stateFile), { recursive: true })
appendFileSync(stateFile, JSON.stringify({ ts: new Date().toISOString(), ...observation }) + '\n')
} catch {
}
}
function hasLoad(stateFile) {
if (!existsSync(stateFile)) return false
try {
return readFileSync(stateFile, 'utf8')
.split('\n')
.filter(Boolean)
.some((line) => {
try {
return JSON.parse(line).action === 'load'
} catch {
return false
}
})
} catch {
return false
}
}
function denyOutput() {
if (AGENT === 'copilot') {
return { permissionDecision: 'deny', permissionDecisionReason: GATE_DENY_REASON }
}
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: GATE_DENY_REASON,
},
}
}