|
1 | 1 | #!/usr/bin/env node |
2 | | -import { jwtExpiryMs, checkJwt, handleApiError, diagnoseAuth, planAuthHeal } from './auth.js'; |
| 2 | +import { jwtExpiryMs, checkJwt, handleApiError, diagnoseAuth, planAuthHeal, |
| 3 | + readClientCredentialsFromEnv, buildClientCredentialsBody, clientCredentialsTokenUrl } from './auth.js'; |
3 | 4 | import type { FunnelEventName, DashboardMetrics } from './telemetry-types.js'; |
4 | 5 | import { handleTcoTool } from './handlers/tco.js'; |
5 | 6 | import { notify } from './notifier.js'; |
@@ -75,7 +76,7 @@ import { Redis } from 'ioredis'; |
75 | 76 | let API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev'; |
76 | 77 | let JWT = process.env.CACHLY_JWT ?? ''; |
77 | 78 | const _EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? ''; |
78 | | -const CURRENT_VERSION = '0.10.83'; |
| 79 | +const CURRENT_VERSION = '0.10.84'; |
79 | 80 |
|
80 | 81 | // Max time to wait for a freshly-provisioned instance to become "running" before |
81 | 82 | // giving up. Free-tier provisioning in high-latency regions can take 45–90s, so the |
@@ -2051,6 +2052,38 @@ if (process.argv[2] === 'publish') { |
2051 | 2052 | process.exit(0); |
2052 | 2053 | } |
2053 | 2054 |
|
| 2055 | +// ── CLI: cachly tool-specs / openapi ─────────────────────────────────────────── |
| 2056 | +// Emit the 114-tool surface in any agent framework's dialect, derived from the |
| 2057 | +// single TOOLS source of truth. Lets OpenAI Assistants, the Anthropic Messages |
| 2058 | +// API, LangChain, CrewAI and AutoGen wrap cachly without hand-written glue. |
| 2059 | +// npx @cachly-dev/mcp-server tool-specs --format=openai > cachly.openai.json |
| 2060 | +// npx @cachly-dev/mcp-server openapi > cachly.openapi.json |
| 2061 | +if (process.argv[2] === 'tool-specs' || process.argv[2] === 'openapi') { |
| 2062 | + const { renderToolSpecs } = await import('./toolspecs.js'); |
| 2063 | + // Accept both "--format openai" and "--format=openai". |
| 2064 | + let format = 'openapi'; |
| 2065 | + if (process.argv[2] !== 'openapi') { |
| 2066 | + const eqArg = process.argv.find(a => a.startsWith('--format=')); |
| 2067 | + if (eqArg) { |
| 2068 | + format = eqArg.slice('--format='.length); |
| 2069 | + } else { |
| 2070 | + const fmtFlag = process.argv.indexOf('--format'); |
| 2071 | + if (fmtFlag !== -1) format = process.argv[fmtFlag + 1] ?? 'openapi'; |
| 2072 | + } |
| 2073 | + } |
| 2074 | + const valid = ['openapi', 'openai', 'anthropic', 'langchain']; |
| 2075 | + if (!valid.includes(format)) { |
| 2076 | + process.stderr.write(`\n❌ Unknown format "${format}". Use one of: ${valid.join(', ')}\n\n`); |
| 2077 | + process.exit(1); |
| 2078 | + } |
| 2079 | + // Exit only after stdout has fully flushed — on a pipe, write() is async and an |
| 2080 | + // immediate process.exit() truncates large payloads (~64KB) mid-flush. |
| 2081 | + process.stdout.write( |
| 2082 | + renderToolSpecs(TOOLS as unknown as Parameters<typeof renderToolSpecs>[0], format as 'openapi' | 'openai' | 'anthropic' | 'langchain', CURRENT_VERSION) + '\n', |
| 2083 | + () => process.exit(0), |
| 2084 | + ); |
| 2085 | +} |
| 2086 | + |
2054 | 2087 | // ── CLI: cachly badge ───────────────────────────────────────────────────────── |
2055 | 2088 | // Outputs the Markdown + HTML snippet for embedding a live Brain lesson-count |
2056 | 2089 | // badge in any README or website. Badge SVG served by cachly API (public, no auth). |
@@ -3176,14 +3209,47 @@ if (process.argv[2] === 'learn-git') { |
3176 | 3209 |
|
3177 | 3210 | // ── Start ───────────────────────────────────────────────────────────────────── |
3178 | 3211 |
|
| 3212 | +// M2M / headless auth: if no JWT but client credentials are present, run the |
| 3213 | +// OAuth2 client_credentials grant before any credential-missing handling. This |
| 3214 | +// makes cachly fully non-interactive for CI runners, agent orchestrators and |
| 3215 | +// AI-to-AI pipelines — no device flow, no human, no browser. |
| 3216 | +if (!JWT) { |
| 3217 | + const creds = readClientCredentialsFromEnv(); |
| 3218 | + if (creds) { |
| 3219 | + try { |
| 3220 | + const res = await fetch(clientCredentialsTokenUrl(creds), { |
| 3221 | + method: 'POST', |
| 3222 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 3223 | + body: buildClientCredentialsBody(creds), |
| 3224 | + signal: AbortSignal.timeout(8000), |
| 3225 | + }); |
| 3226 | + if (res.ok) { |
| 3227 | + const tok = await res.json() as { access_token?: string }; |
| 3228 | + if (tok.access_token) { |
| 3229 | + JWT = tok.access_token; |
| 3230 | + setEmbedJwt(tok.access_token); |
| 3231 | + sendFunnelEvent('m2m_auth_completed', { grant: 'client_credentials', client_id: creds.clientId }); |
| 3232 | + process.stderr.write('🤖 cachly: authenticated via client_credentials (M2M, headless).\n'); |
| 3233 | + } |
| 3234 | + } else { |
| 3235 | + process.stderr.write(`⚠️ cachly: client_credentials auth failed (${res.status}). Check CACHLY_CLIENT_ID / CACHLY_CLIENT_SECRET.\n`); |
| 3236 | + sendFunnelEvent('m2m_auth_failed', { status: res.status }); |
| 3237 | + } |
| 3238 | + } catch (err) { |
| 3239 | + process.stderr.write(`⚠️ cachly: client_credentials auth error: ${(err as Error).message}\n`); |
| 3240 | + sendFunnelEvent('m2m_auth_failed', { reason: 'network' }); |
| 3241 | + } |
| 3242 | + } |
| 3243 | +} |
| 3244 | + |
3179 | 3245 | // Credential-missing handling at startup. The critical distinction is HOW we were |
3180 | 3246 | // launched, because writing anything to stdout in stdio-MCP mode corrupts the |
3181 | 3247 | // JSON-RPC stream and exiting kills the zero-credential device-flow onboarding. |
3182 | 3248 | // • Human in a real terminal (TTY), no args → show the setup banner, exit 0. |
3183 | 3249 | // • Editor as an MCP stdio server (non-TTY) → ONE stderr hint, then KEEP RUNNING |
3184 | 3250 | // so tools/list works and the first tool call starts the browser sign-in. |
3185 | 3251 | // Skip entirely for CLI subcommands that intentionally run without credentials. |
3186 | | -const _cliNoAuthCommands = ['demo', 'share', 'publish', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade']; |
| 3252 | +const _cliNoAuthCommands = ['demo', 'share', 'publish', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade', 'tool-specs', 'openapi']; |
3187 | 3253 | if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '')) { |
3188 | 3254 | const runningInTerminal = !process.argv[2] && process.stdout.isTTY === true && _isMain; |
3189 | 3255 | if (runningInTerminal) { |
|
0 commit comments