#!/usr/bin/env node /** * community-asks.mjs — decides whether career-ops asks the user for support, * and which ask. * * career-ops never phones home, so the only signals the maintainers get are * the ones people choose to give. It asks for them in words, rarely, and at * the right moment (AGENTS.md → "Community asks"): * * star after the first saved evaluation report * manifesto after five reports, at least seven days after the star ask * (the Hired Wall ask lives in hired-share.mjs, at a hire) * * Each ask is shown at most once, and never two in one run. * * node community-asks.mjs next prints one line or nothing; always exits 0 * node community-asks.mjs open opens the ask's page, after the user said yes * node community-asks.mjs status the state as JSON * node community-asks.mjs off never ask again * CAREER_OPS_NO_ASKS=1 the same, without writing anything * * Guarantees (tests/community-asks.test.mjs): * - At most once. `next` claims data/.community-asks/ with an exclusive * create BEFORE it prints. When the marker exists or cannot be written, it * prints nothing. The markers are user-layer data (DATA_CONTRACT.md): an * update, or a reinstall that keeps the data, never asks again. * - Never unattended: silent under CI, batch workers (CAREER_OPS_HEADLESS, * CAREER_OPS_BATCH_STATE) and Claude Code's headless entrypoints. Never * decided by isTTY: an agent's shell has no TTY, so a TTY check would hide * the ask from almost every user. * - No network. The script never makes a request. `open` hands one fixed URL * to the system's browser opener, and the user still clicks the star or * signs themselves: nothing is ever starred or signed for them (GitHub * Acceptable Use Policies, section 4, forbid automated starring). */ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, writeSync } from 'fs'; import { join, resolve } from 'path'; import { execFileSync } from 'child_process'; import { isMainModule } from './lib/is-main-module.mjs'; export const STAR_URL = 'https://github.com/career-ops-hq/career-ops'; export const MANIFESTO_URL = 'https://career-ops.org/manifesto?utm_source=ask'; export const MANIFESTO_MIN_REPORTS = 5; export const MANIFESTO_MIN_DAYS_AFTER_STAR = 7; export const ASKS = Object.freeze(['star', 'manifesto']); /** From this many reports the star ask names the count: the user has run career-ops for a while. */ export const VETERAN_MIN_REPORTS = 5; // The drawing for the star ask ("yours is missing"). `next` prints it in a fenced // block of its own so the agent copies it exactly. Star ask only, never the manifesto. // Each emoji closes its row: a terminal that draws it one column wider shifts nothing. // Never U+2B50 (⭐): some terminals (Claude Code on macOS among them) draw it as a // text "*" plus a space. 🌟, ✨ and 💫 draw as emoji. export const STAR_ART = Object.freeze([ " + . '", " o 🌟", " / \\", "*-----* *-----* ✨", " `. .'", " * *", " / .-*-. \\", " *-' '-* 💫", ]); const REPORT_FILE = /^\d{3,}-.+\.md$/; const DAY_MS = 86_400_000; const CI_VARS = Object.freeze([ 'GITHUB_ACTIONS', 'GITLAB_CI', 'BUILDKITE', 'CIRCLECI', 'TRAVIS', 'TF_BUILD', 'JENKINS_URL', 'TEAMCITY_VERSION', 'BITBUCKET_BUILD_NUMBER', 'CODEBUILD_BUILD_ID', 'DRONE', 'APPVEYOR', ]); const USAGE = `Usage: node community-asks.mjs next print the one ask that is due, or nothing (always exits 0) node community-asks.mjs open open the page of an ask the user said yes to (star | manifesto) node community-asks.mjs status show the state as JSON node community-asks.mjs off never ask again (CAREER_OPS_NO_ASKS=1 does the same without writing) Options: --root data root (default: the configured career-ops data root) --now clock override, for tests`; /** * The line the agent says. The star line says career-ops "collects nothing * about you": that holds while LEGAL_DISCLAIMER.md says it collects no * analytics, telemetry or usage data, and the test ties the two together. A * change that starts sending any data, even opt-in, revisits this sentence in * the same PR. * * @param {string} ask - 'star' or 'manifesto'. * @param {{reports?: number}} [context] * @returns {string} */ export function askText(ask, { reports = 0 } = {}) { if (ask === 'star') { const opening = reports >= VETERAN_MIN_REPORTS ? `You've run ${reports} evaluations with career-ops so far. It's free and collects nothing about you, ` : 'If this report saved you some time: career-ops is free and collects nothing about you, '; return opening + "so we can't tell whether it helped. A Star on GitHub 🌟 (the button at the top right of the page) " + 'is one of the few ways we find out, ' + 'and it helps the next job seeker find career-ops. ' + `Say "yes" and I'll open the page (${STAR_URL}). I won't ask for a star again.`; } if (ask === 'manifesto') { return "The way you've been searching has a name: CareerOps, and it has a manifesto. " + 'If it speaks for you, sign it (your name goes on the public wall). ' + `Say "yes" and I'll open it (${MANIFESTO_URL}). I won't ask about the manifesto again.`; } return ''; } /** * What `next` prints for an ask: the star ask comes with its drawing in a * fenced block of its own, for the agent to copy exactly; the manifesto ask is * the sentence alone. * * @param {string} ask * @param {{reports?: number}} [context] * @returns {string} */ export function askOutput(ask, context = {}) { const text = askText(ask, context); if (!text) return ''; return ask === 'star' ? `\`\`\`\n${STAR_ART.join('\n')}\n\`\`\`\n${text}` : text; } const isOn = (value) => value !== undefined && value !== null && value !== '' && value !== '0' && String(value).toLowerCase() !== 'false'; /** * Why this run must not ask, or null when it may. * * @param {NodeJS.ProcessEnv} [env] * @returns {string|null} */ export function unattendedReason(env = process.env) { if (isOn(env.CAREER_OPS_NO_ASKS)) return 'CAREER_OPS_NO_ASKS is set'; if (isOn(env.CAREER_OPS_HEADLESS)) return 'CAREER_OPS_HEADLESS is set'; if (env.CAREER_OPS_BATCH_STATE) return 'this is a batch worker (CAREER_OPS_BATCH_STATE is set)'; if (isOn(env.CI)) return 'CI is set'; const ci = CI_VARS.find((name) => isOn(env[name])); if (ci) return `${ci} is set`; const entrypoint = String(env.CLAUDE_CODE_ENTRYPOINT || ''); if (entrypoint.startsWith('sdk-') || entrypoint === 'mcp') return `headless Claude Code (${entrypoint})`; return null; } /** * Number of evaluation reports (`{###}-{slug}.md`) in a reports directory. * * @param {string} reportsDir * @returns {number} */ export function countReports(reportsDir) { try { return readdirSync(reportsDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && REPORT_FILE.test(entry.name)).length; } catch { return 0; } } /** * When an ask was shown, or null when it never was. A marker that exists but * cannot be read still counts as shown: unreadable must never mean "ask again". * * @param {string} asksDir * @param {string} name * @returns {Date|null} */ function shownAt(asksDir, name) { const file = join(asksDir, name); if (!existsSync(file)) return null; try { const at = Date.parse(readFileSync(file, 'utf8').trim()); if (Number.isFinite(at)) return new Date(at); } catch { /* fall through to the file time */ } try { return statSync(file).mtime; } catch { return new Date(0); } } /** * The ask that is due, or null. Pure: the caller reads the state. * * @param {{reports?: number, star?: Date|null, manifesto?: Date|null, off?: boolean, * legacyManifesto?: boolean, now?: Date}} state * @returns {'star'|'manifesto'|null} */ export function decideAsk({ reports = 0, star = null, manifesto = null, off = false, legacyManifesto = false, now = new Date() } = {}) { if (off) return null; if (!star) return reports >= 1 ? 'star' : null; if (manifesto || legacyManifesto) return null; const daysSinceStar = (now.getTime() - star.getTime()) / DAY_MS; return reports >= MANIFESTO_MIN_REPORTS && daysSinceStar >= MANIFESTO_MIN_DAYS_AFTER_STAR ? 'manifesto' : null; } /** * Claim an ask: create its marker with an exclusive create. Only the process * that creates it may print, so two sessions at once still ask once. * * @param {string} asksDir * @param {string} name * @param {Date} [now] * @returns {boolean} true when this call created the marker. */ export function claim(asksDir, name, now = new Date()) { try { mkdirSync(asksDir, { recursive: true }); const fd = openSync(join(asksDir, name), 'wx'); try { writeSync(fd, `${now.toISOString()}\n`); } finally { closeSync(fd); } return true; } catch { return false; } } /** * Everything decideAsk needs, read from a data root. * * @param {string} root * @param {NodeJS.ProcessEnv} env * @param {Date} now */ export function readState(root, env = process.env, now = new Date()) { const asksDir = join(root, 'data', '.community-asks'); const reportsDir = resolve(env.CAREER_OPS_REPORTS_DIR || join(root, 'reports')); // scan.mjs used to show the manifesto once in a terminal and leave this file // in the folder it ran from. Whoever saw it is not asked again. const legacyManifesto = [root, process.cwd()].some((dir) => existsSync(join(dir, '.manifesto-noted'))); return { asksDir, reportsDir, reports: countReports(reportsDir), star: shownAt(asksDir, 'star'), manifesto: shownAt(asksDir, 'manifesto'), off: existsSync(join(asksDir, 'off')), legacyManifesto, now, }; } function openInBrowser(url) { const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; // Bounded: on Linux xdg-open can hand the URL to a handler that waits, and the // agent's shell would hang after the user said yes. On timeout the URL is printed. try { execFileSync(cmd, args, { stdio: 'ignore', timeout: 10_000, windowsHide: true }); return true; } catch { return false; } } function parseArgs(argv) { const flags = { root: null, now: null, help: false }; const positional = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--help' || arg === '-h') flags.help = true; else if (arg === '--root') flags.root = argv[++i] ?? null; else if (arg.startsWith('--root=')) flags.root = arg.slice('--root='.length); else if (arg === '--now') flags.now = argv[++i] ?? null; else if (arg.startsWith('--now=')) flags.now = arg.slice('--now='.length); else positional.push(arg); } return { flags, positional }; } async function defaultRoot() { // Loaded lazily: `next` must stay silent and exit 0 even if the resolver // throws (for example, an unsafe data root), and the tests pass --root. const { getCareerOpsRoot } = await import('./path-resolver.mjs'); const { resolveWorkspaceRootFor } = await import('./tracker-utils.mjs'); return resolveWorkspaceRootFor(getCareerOpsRoot()); } /** * @param {string[]} argv * @param {NodeJS.ProcessEnv} env * @returns {Promise} exit code */ export async function main(argv = process.argv.slice(2), env = process.env) { const { flags, positional } = parseArgs(argv); const [command = 'next', target] = positional; if (flags.help) { process.stdout.write(`${USAGE}\n`); return 0; } const parsedNow = flags.now ? new Date(flags.now) : new Date(); const now = Number.isFinite(parsedNow.getTime()) ? parsedNow : new Date(); if (command === 'next') { try { if (unattendedReason(env)) return 0; const root = resolve(flags.root || await defaultRoot()); const state = readState(root, env, now); const ask = decideAsk(state); if (ask && claim(state.asksDir, ask, now)) process.stdout.write(`${askOutput(ask, state)}\n`); } catch { /* an ask is never worth breaking the user's run */ } return 0; } if (command === 'open') { const url = target === 'star' ? STAR_URL : target === 'manifesto' ? MANIFESTO_URL : null; if (!url) { process.stderr.write(`community-asks: open needs star or manifesto\n${USAGE}\n`); return 1; } const opened = !unattendedReason(env) && openInBrowser(url); process.stdout.write(opened ? `Opened ${url} in your browser.\n` : `Open this page: ${url}\n`); return 0; } if (command === 'status') { const root = resolve(flags.root || await defaultRoot()); const state = readState(root, env, now); const iso = (date) => (date ? date.toISOString() : null); process.stdout.write(`${JSON.stringify({ root, reportsDir: state.reportsDir, reports: state.reports, shown: { star: iso(state.star), manifesto: iso(state.manifesto) }, off: state.off, legacyManifesto: state.legacyManifesto, unattended: unattendedReason(env), due: state.off ? null : decideAsk(state), }, null, 2)}\n`); return 0; } if (command === 'off') { const root = resolve(flags.root || await defaultRoot()); const asksDir = join(root, 'data', '.community-asks'); if (claim(asksDir, 'off', now) || existsSync(join(asksDir, 'off'))) { process.stdout.write("Community asks are off: career-ops won't ask for a star or a signature again. " + `Delete ${join(asksDir, 'off')} to undo.\n`); return 0; } process.stderr.write(`community-asks: could not write ${join(asksDir, 'off')}; set CAREER_OPS_NO_ASKS=1 instead.\n`); return 1; } process.stderr.write(`community-asks: unknown command "${command}"\n${USAGE}\n`); return 1; } if (isMainModule(import.meta.url)) { main().then( (code) => { process.exitCode = code; }, (err) => { process.stderr.write(`community-asks: ${err.message}\n`); process.exitCode = 1; }, ); }