@contextq/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,95 +1,104 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * contextq — one-command installer.
4
- *
5
- * `npx @contextq/cli init` detects which surface(s) are present -- Claude Code
6
- * project, Codex CLI project (T665), or neither (Desktop/Cowork) -- and
7
- * scaffolds the matching wiring: for Claude Code, .mcp.json + the contextq
8
- * SessionStart hook + .contextq/tasks/; for Codex, a `.codex/hooks.json`
9
- * SessionStart hook (see cli/src/session-hook.ts for why the two harnesses
10
- * share one command builder and one merge implementation). A repo can be
11
- * (and often is) both at once -- both get wired in the same run unless
12
- * `--surface` pins exactly one. Neither detected -> print the manual
13
- * 3-step connect guide instead. Idempotent throughout: re-running reports
14
- * "already configured" / "[skip]" for anything already wired and writes
15
- * nothing new.
16
- */
17
- import { basename } from 'node:path';
18
- import { detectSurface } from './detect.js';
19
- import { decideCredentialWrite, KEY_RE, readExistingCreds } from './platform-cred.js';
20
- import { wireMcpJson } from './mcp-config.js';
21
- import { wireCodexSessionHook, wireSessionHook } from './session-hook.js';
22
- import { initTasksDir } from './tasks-init.js';
23
- import { printDesktopGuide } from './desktop-guide.js';
24
- import { promptHidden } from './prompt.js';
25
- import { SLUG_PATTERN_DESCRIPTION, sanitizeSlugFallback, validateSlug } from './slug.js';
26
- import { syncSkills } from './skills-sync.js';
27
- const DEFAULT_URL = 'https://app.contextq.dev';
28
- function parseArgs(argv) {
29
- const hasLeadingCommand = argv.length > 0 && !argv[0].startsWith('-');
30
- const args = { command: hasLeadingCommand ? argv[0] : '', embedKey: false, help: false };
31
- for (let i = hasLeadingCommand ? 1 : 0; i < argv.length; i++) {
32
- const a = argv[i];
33
- switch (a) {
34
- case '--key':
35
- args.key = argv[++i];
36
- break;
37
- case '--url':
38
- args.url = argv[++i];
39
- break;
40
- case '--workspace':
41
- args.workspace = argv[++i];
42
- break;
43
- case '--agent':
44
- args.agent = argv[++i];
45
- break;
46
- case '--embed-key':
47
- args.embedKey = true;
48
- break;
49
- case '--surface': {
50
- const v = argv[++i];
51
- if (v !== 'claude-code' && v !== 'codex') {
52
- throw new Error(`--surface value "${v}" is invalid -- allowed: claude-code, codex`);
53
- }
54
- args.surface = v;
55
- break;
56
- }
57
- case '-h':
58
- case '--help':
59
- args.help = true;
60
- break;
61
- default:
62
- throw new Error(`unknown argument: ${a}`);
63
- }
64
- }
65
- return args;
66
- }
67
- function printHelp() {
68
- console.log(`contextq — one-command installer for ContextQ
2
+ var ct=Object.defineProperty;var r=(e,t)=>ct(e,"name",{value:t,configurable:!0});import{homedir as st}from"os";import{basename as it}from"path";import{existsSync as D}from"fs";import{execSync as Ct}from"child_process";import{homedir as ye}from"os";import{join as W}from"path";var lt="CONTEXTQ_HOST";var dt="CLAUDE_CODE_ENABLE_FUNCTION_HOOKS",ut=["claude-code","claude-desktop","codex","opencode","cyberine","muse","unknown"],pt=[["claude-code","claude-code"],["claude-desktop","claude-desktop"],["claude-ai","claude-desktop"],["claude","claude-desktop"],["codex","codex"],["opencode","opencode"],["cyberine","cyberine"],["muse","muse"],["muse-code","muse"]];function pe(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}r(pe,"normalizeName");function ft(e){if(!e)return"unknown";let t=pe(e);if(!t)return"unknown";let o=[...pt].sort((n,s)=>s[0].length-n[0].length);for(let[n,s]of o)if(t===n||t.startsWith(`${n}-`))return s;return"unknown"}r(ft,"hostFromClientName");function gt(e){if(!e)return;let t=pe(e);return ut.find(o=>o===t)}r(gt,"parseHostOverride");function fe(e={}){let t=e.env??(typeof process<"u"?process.env:{}),o=t.CLAUDE_CODE_SESSION_ID||void 0,n=r((i,c)=>({host:i,supportsFunctionHooks:i==="claude-code"&&t[dt]==="1",...i==="claude-code"&&o?{sessionId:o}:{},via:c}),"finish");if(e.forcedHost)return n(e.forcedHost,"forced");let s=gt(t[lt]);return s?n(s,"env-override"):e.clientName!==void 0?n(ft(e.clientName),"client-info"):t.CLAUDECODE==="1"?n("claude-code","env"):n("unknown","none")}r(fe,"resolveHost");import{chmodSync as mt,existsSync as ht,mkdirSync as yt,readFileSync as kt,writeFileSync as xt}from"fs";import{execFileSync as ge}from"child_process";import{basename as wt,dirname as me,join as L}from"path";var T=["gemini","cursor","copilot"],b={gemini:{label:"Gemini CLI",configDir:r(e=>L(e,".gemini"),"configDir"),configFile:"settings.json",entry:r((e,t)=>({httpUrl:e,headers:{Authorization:`Bearer ${t}`}}),"entry"),listCommand:"gemini mcp list"},cursor:{label:"Cursor",configDir:r(e=>L(e,".cursor"),"configDir"),configFile:"mcp.json",entry:r((e,t)=>({url:e,headers:{Authorization:`Bearer ${t}`}}),"entry"),listCommand:"cursor-agent mcp list"},copilot:{label:"Copilot CLI",configDir:r((e,t)=>t.COPILOT_HOME||L(e,".copilot"),"configDir"),configFile:"mcp-config.json",entry:r((e,t)=>({type:"http",url:e,headers:{Authorization:`Bearer ${t}`},tools:["*"]}),"entry"),listCommand:"copilot mcp list"}};function H(e,t,o){return b[e].configDir(t,o)}r(H,"hostConfigDir");function St(e,t,o){return L(H(e,t,o),b[e].configFile)}r(St,"hostMcpConfigPath");function C(e){return`${e.replace(/\/+$/,"")}/mcp`}r(C,"mcpEndpoint");function V(e){let t=me(e);try{if(ge("git",["-C",t,"rev-parse","--is-inside-work-tree"],{stdio:["ignore","pipe","ignore"],encoding:"utf-8"}).trim()!=="true")return!1}catch{return!1}try{return ge("git",["-C",t,"check-ignore","-q","--",wt(e)],{stdio:"ignore"}),!1}catch(o){return o.status===1}}r(V,"gitTracksUnignored");function he(e,t){let o=b[e],n=St(e,t.homeDir,t.env),s=ht(n),i={};if(s){let a=kt(n,"utf-8");try{let d=a.trim()===""?{}:JSON.parse(a);if(!d||typeof d!="object"||Array.isArray(d))throw new Error("top level is not an object");i=d}catch(d){return{surface:e,path:n,action:"refused",detail:`not valid JSON (${d.message}); left untouched`}}}let c=i.mcpServers??{};return typeof c!="object"||Array.isArray(c)?{surface:e,path:n,action:"refused",detail:"mcpServers is not an object; left untouched"}:c.contextq?{surface:e,path:n,action:"skipped",detail:"contextq server entry already present"}:V(n)?{surface:e,path:n,action:"refused",detail:`sits in a git work tree and is not gitignored; writing the API key there would put it in a commit. Add it to that repo's .gitignore and re-run, or add the server by hand: ${o.label} -> ${C(t.apiUrl)}`}:(i.mcpServers={...c,contextq:o.entry(C(t.apiUrl),t.apiKey)},yt(me(n),{recursive:!0}),xt(n,`${JSON.stringify(i,null,2)}
3
+ `,{mode:384}),mt(n,384),{surface:e,path:n,action:s?"added":"created",detail:`contextq remote MCP (${C(t.apiUrl)}) with the API key in the header, file mode 600; check with \`${o.listCommand}\``})}r(he,"wireHostMcp");function bt(e){try{return Ct("git rev-parse --show-toplevel",{cwd:e,stdio:["ignore","pipe","ignore"],encoding:"utf-8"}).trim()}catch{return""}}r(bt,"gitRoot");function vt(e){return D(W(e,".codex"))}r(vt,"hasCodexProjectDir");function _t(e){return D(W(e,".codex"))}r(_t,"hasCodexHome");function ke(e,t={}){let o=bt(e),n=o||e,{host:s}=fe(t.hostSignals);if(t.forcedSurface)return{surfaces:[t.forcedSurface],root:n,host:s};let a=D(W(e,".claude"))||!!o&&!(s!=="unknown"&&s!=="claude-code"),d=vt(n)||_t(t.homeDir??ye()),l=[];a&&l.push("claude-code"),d&&l.push("codex");let p=t.homeDir??ye(),m=t.env??process.env;for(let f of T)D(H(f,p,m))&&l.push(f);return l.length===0&&l.push("desktop"),{surfaces:l,root:a||d?n:e,host:s}}r(ke,"detectSurface");import{chmodSync as z,copyFileSync as Et,existsSync as U,mkdirSync as $t,readFileSync as Tt,writeFileSync as Pt}from"fs";import{homedir as At}from"os";import{join as x}from"path";function Z(){let e=process.env.CTQ_CRED_DIR||process.env.CONTEXTQ_CRED_DIR;if(e)return e;let t=Rt.map(Ht);return t.find(o=>U(x(o,"credentials")))??t[0]}r(Z,"platformCredDir");var Rt=["contextq","ctq-brain"];function Ht(e){let t=At();if(process.platform==="darwin")return x(t,"Library","Application Support",e);if(process.platform==="win32"){let n=process.env.APPDATA??x(t,"AppData","Roaming");return x(n,e)}let o=process.env.XDG_CONFIG_HOME??x(t,".config");return x(o,e)}r(Ht,"platformCredDirFor");function h(){return x(Z(),"credentials")}r(h,"platformCredFile");function ee(){return U(h())}r(ee,"credsFileExists");function It(e,t){return e.apiKey===t.apiKey&&e.apiUrl===t.apiUrl}r(It,"credsEqual");function Ot(){let e=new Date,t=r(o=>String(o).padStart(2,"0"),"pad");return`${e.getUTCFullYear()}${t(e.getUTCMonth()+1)}${t(e.getUTCDate())}T${t(e.getUTCHours())}${t(e.getUTCMinutes())}${t(e.getUTCSeconds())}Z`}r(Ot,"utcTimestamp");function Nt(){let e=h();if(!U(e))return null;let t=Z(),o=x(t,`credentials.bak-${Ot()}`);return Et(e,o),z(o,384),o}r(Nt,"backupCredsFile");function j(e,t){if(!U(t))return"";try{let o=Tt(t,"utf-8");for(let n of o.split(`
4
+ `))if(n.startsWith(`${e}=`))return n.slice(e.length+1).trim()}catch{}return""}r(j,"readEnvVal");function w(){let e=h(),t=j("CTQ_API_KEY",e)||j("CONTEXT_API_KEY",e),o=j("CTQ_API_URL",e)||j("CONTEXT_API_URL",e);return{apiKey:t,apiUrl:o}}r(w,"readExistingCreds");function xe(e){let t=Z(),o=h();$t(t,{recursive:!0,mode:448}),z(t,448);let n=`CTQ_API_KEY=${e.apiKey}
5
+ CTQ_API_URL=${e.apiUrl}
6
+ CONTEXT_API_KEY=${e.apiKey}
7
+ CONTEXT_API_URL=${e.apiUrl}
8
+ `;return Pt(o,n,{mode:384}),z(o,384),o}r(xe,"writeCreds");var v=/^sk_live_[0-9a-f]{40}$/,we="[create]",Se="[rotate]",G="[skip] ";function Ce(e){let t=w(),o=ee(),n=r(s=>{if(!o)return xe(s),{action:"created",backupPath:null};if(It(t,s))return{action:"skipped",backupPath:null};let i=Nt();return xe(s),{action:"rotated",backupPath:i}},"writeSafely");if(e.explicitKey){if(!v.test(e.explicitKey))throw new Error("the provided API key does not look valid (expected sk_live_<40 hex chars>)");let s=e.explicitKey,i=n({apiKey:s,apiUrl:e.apiUrl}),c=i.action==="skipped"?`${G} credentials (unchanged)`:i.action==="rotated"?`${Se} credentials (${h()}; previous saved to ${i.backupPath})`:`${we} credentials (${h()}, mode 600)`;return{apiKey:s,credLine:c,action:i.action,backupPath:i.backupPath}}if(o&&t.apiKey){let s=e.ambientVarName?`${G} credentials (already configured at ${h()}; env ${e.ambientVarName} ignored, pass --key to rotate)`:`${G} credentials (already configured at ${h()}; pass --key to rotate)`;return{apiKey:t.apiKey,credLine:s,action:"skipped",backupPath:null}}if(e.ambientKey){if(!v.test(e.ambientKey))throw new Error("the provided API key does not look valid (expected sk_live_<40 hex chars>)");let s=e.ambientKey,i=n({apiKey:s,apiUrl:e.apiUrl}),c=i.action==="rotated"?`${Se} credentials (${h()}; previous saved to ${i.backupPath})`:`${we} credentials (${h()}, mode 600)`;return{apiKey:s,credLine:c,action:i.action,backupPath:i.backupPath}}return{apiKey:"",credLine:"",action:null,backupPath:null}}r(Ce,"decideCredentialWrite");var be="the provided API key does not look valid (expected sk_live_<40 hex chars>)",Kt="npx @contextq/cli init --key <sk_live_...>";function ve(e){return e.status===401||e.status===403}r(ve,"isRefused");function Lt(e,t){switch(e){case"explicit":return"The --key";case"ambient":return`The ${t??"environment"}`;case"stored":return`The stored (${h()})`;case"prompt":return"The typed"}}r(Lt,"describe");async function _e(e){let t=w(),o=ee()?t.apiKey:"",n,s;if(e.explicitKey){if(!v.test(e.explicitKey))throw new Error(be);n=e.explicitKey,s="explicit"}else if(o)n=o,s="stored";else if(e.ambientKey){if(!v.test(e.ambientKey))throw new Error(be);n=e.ambientKey,s="ambient"}else{let a=e.interactive?await e.prompt("ContextQ API key (sk_live_...): "):"";if(!v.test(a))throw new Error("no valid API key available (expected sk_live_<40 hex chars>). Re-run from an interactive terminal to be prompted, set CONTEXT_API_KEY, or pass --key.");n=a,s="prompt"}let i=await e.verify(n);if(ve(i)){let a=`${Lt(s,e.ambientVarName)} API key was refused by ${e.apiUrl} (HTTP ${i.status})`;if(!e.interactive)throw new Error(`${a}; nothing was written. Re-run with a working key: ${Kt}`);let d=await e.prompt(`${a}. Paste a new key (sk_live_...): `);if(!v.test(d))throw new Error(`${a}, and no valid replacement was entered; nothing was written.`);if(n=d,s="prompt",i=await e.verify(n),ve(i))throw new Error(`The typed API key was refused by ${e.apiUrl} (HTTP ${i.status}); nothing was written.`)}return{decision:Ce({explicitKey:s==="explicit"||s==="prompt"?n:void 0,ambientVarName:e.ambientVarName,ambientKey:e.ambientKey,apiUrl:e.apiUrl}),verify:i,source:s}}r(_e,"resolveVerifiedCredentials");import{existsSync as Dt,readFileSync as jt,writeFileSync as Ut}from"fs";import{execFileSync as Ft}from"child_process";import{join as qt}from"path";function Mt(e,t){try{return Ft("git",["check-ignore","-q","--",t],{cwd:e,stdio:"ignore"}),!0}catch(o){return o.status===1?!1:null}}r(Mt,"isPathGitIgnored");function Ee(e,t,o,n){let s=qt(e,".mcp.json"),i=Dt(s),c={};if(i){let a=jt(s,"utf-8");try{c=a.trim()===""?{}:JSON.parse(a)}catch(d){throw new Error(`.mcp.json exists but is not valid JSON (${d.message}) -- refusing to touch it`)}}if(c.mcpServers??={},c.mcpServers.contextq)return{path:s,action:"skipped",detail:"contextq server entry already present"};if(o){let a=Mt(e,".mcp.json");if(a===!1)throw new Error('.mcp.json is not covered by .gitignore in this repo -- refusing to write the raw API key into a file git would track. Add ".mcp.json" to .gitignore first, or re-run without --embed-key to use the safer ${CONTEXT_API_KEY} placeholder instead.');a===null&&console.log("[warn] could not verify .mcp.json is gitignored (no git repo detected at this path) -- writing the raw key anyway.")}return c.mcpServers.contextq={command:"npx",args:["-y","@contextq/mcp"],env:{CONTEXT_API_URL:t,CONTEXT_API_KEY:o?n:"${CONTEXT_API_KEY}"}},Ut(s,`${JSON.stringify(c,null,2)}
9
+ `),{path:s,action:i?"added":"created",detail:`contextq server wired (key=${o?"embedded literal":"${CONTEXT_API_KEY} placeholder"})`}}r(Ee,"wireMcpJson");import{appendFileSync as Qt,chmodSync as Yt,existsSync as F,mkdirSync as Xt,readFileSync as Pe,writeFileSync as Jt}from"fs";import{execFileSync as Ae}from"child_process";import{dirname as Bt,join as q}from"path";import{parse as $e,stringify as Vt}from"smol-toml";var M=".codex/config.toml";function Wt(e){return q(e,".codex","config.toml")}r(Wt,"codexConfigPath");function Gt(e,t){if(!F(q(e,".git")))return!1;try{return Ae("git",["-C",e,"ls-files","--error-unmatch","--",t],{stdio:"ignore"}),!0}catch{return!1}}r(Gt,"gitTracked");function zt(e,t){try{return Ae("git",["-C",e,"check-ignore","-q","--",t],{stdio:"ignore"}),!0}catch{return!1}}r(zt,"gitIgnored");function Zt(e){if(!F(q(e,".git"))||zt(e,M))return;let t=q(e,".gitignore"),o=F(t)?Pe(t,"utf-8"):"",n=o===""||o.endsWith(`
10
+ `)?"":`
11
+ `;Qt(t,`${n}${M}
12
+ `)}r(Zt,"ensureConfigGitignored");function eo(e,t){return Vt({mcp_servers:{contextq:{url:C(e),http_headers:{Authorization:`Bearer ${t}`}}}})}r(eo,"codexMcpBlock");function Te(e){let t=e.mcp_servers;if(!(!t||typeof t!="object"||Array.isArray(t)))return t.contextq}r(Te,"contextqEntry");function Re(e,t,o){let n=Wt(e),s=F(n),i=s?Pe(n,"utf-8"):"";if(s){let d;try{d=$e(i)}catch(l){return{path:n,action:"refused",detail:`not valid TOML (${l.message.split(`
13
+ `)[0]}); left untouched`}}if(Te(d)!==void 0)return{path:n,action:"skipped",detail:"mcp_servers.contextq already present"}}if(Gt(e,M))return{path:n,action:"refused",detail:`is tracked by git; writing the API key there would put it in a commit. Untrack it (git rm --cached ${M}) and re-run, or add the server by hand: codex -> ${C(t)}`};let c=i===""?"":i.endsWith(`
14
+ `)?`
15
+ `:`
16
+
17
+ `,a=`${i}${c}${eo(t,o)}`;try{let d=$e(a);if(Te(d)===void 0)throw new Error("mcp_servers.contextq missing after merge")}catch(d){return{path:n,action:"refused",detail:`adding [mcp_servers.contextq] would not parse (${d.message.split(`
18
+ `)[0]}), e.g. mcp_servers is an inline table; left untouched`}}return Xt(Bt(n),{recursive:!0}),Zt(e),Jt(n,a,{mode:384}),Yt(n,384),{path:n,action:s?"added":"created",detail:`contextq streamable HTTP MCP (${C(t)}) with the API key in http_headers, file mode 600, gitignored; Codex reads project config only in a trusted project, so run \`codex\` once here and accept the trust prompt, then check with \`codex mcp list\``}}r(Re,"wireCodexMcp");import{chmodSync as uo,existsSync as oe,mkdirSync as Le,readFileSync as po,writeFileSync as De}from"fs";import{dirname as je,join as Q}from"path";import{appendFileSync as to,existsSync as I,mkdirSync as oo,readFileSync as te,writeFileSync as no}from"fs";import{execFileSync as ro}from"child_process";import{join as S,relative as so}from"path";var io=/contextq-boot\.sh|contextq.*boot/;function He(e){return`'${e.split("'").join("'\\''")}'`}r(He,"shellQuote");var _="npx -y --prefer-offline @contextq/cli";function Ie(e,t,o,n=_){return`${n} hook boot --format ${o} --agent ${He(e)} --workspace ${He(t)}`}r(Ie,"buildBootCommand");function ao(e,t){if(!I(S(e,".git")))return!1;try{return ro("git",["-C",e,"ls-files","--error-unmatch","--",so(e,t)],{stdio:"ignore"}),!0}catch{return!1}}r(ao,"gitTracked");function Oe(e,t,o){let{path:n,dir:s,topLevelDefaults:i}=e,c=I(n),a={};if(c){let l=te(n,"utf-8");try{a=l.trim()===""?{}:JSON.parse(l)}catch(p){return{path:n,action:"refused",detail:`not valid JSON (${p.message}); left untouched`}}if(!a||typeof a!="object"||Array.isArray(a))return{path:n,action:"refused",detail:"top level is not a JSON object; left untouched"}}if(i)for(let[l,p]of Object.entries(i))a[l]===void 0&&(a[l]=p);return a.hooks??={},a.hooks.SessionStart??=[],a.hooks.SessionStart.some(l=>(l?.hooks??[]).some(p=>typeof p.command=="string"&&io.test(p.command)))?{path:n,action:"skipped",detail:"SessionStart contextq-boot hook already present"}:e.root&&ao(e.root,n)?{path:n,action:"refused",detail:"is tracked by git; a per-machine hook would be committed for every teammate. Untrack it and re-run"}:(a.hooks.SessionStart.push({hooks:[{type:"command",command:t}]}),oo(s,{recursive:!0}),no(n,`${JSON.stringify(a,null,2)}
19
+ `),{path:n,action:"wired",detail:o})}r(Oe,"mergeSessionStartHook");function Ne(e,t,o,n=_){let s=S(e,".claude","settings.local.json"),i=Ie(t,o,"claude",n);return Oe({path:s,dir:S(e,".claude"),root:e},i,`SessionStart boot hook wired (agent=${t}, workspace=${o}; runs \`${n} hook boot\`, key from the credentials file)`)}r(Ne,"wireSessionHook");function co(e){let t=S(e,".codex","config.toml");if(!I(t))return!1;let o=te(t,"utf-8");return/^\s*\[{1,2}hooks(\.|\])/m.test(o)}r(co,"hasInlineHooksTable");function lo(e,t){if(!I(S(e,".git")))return;let o=S(e,".gitignore"),n=I(o)?te(o,"utf-8"):"";if(new Set(n.split(`
20
+ `).map(c=>c.trim())).has(t))return;let i=`
21
+ # .codex/hooks.json embeds a per-machine absolute path (the contextq boot
22
+ # hook command) -- gitignored the same way .claude/settings.local.json is.
23
+ # Each teammate who wants it needs to run \`npx contextq init\` themselves.
24
+ ${t}
25
+ `;to(o,i)}r(lo,"ensureGitignored");function Ke(e,t,o,n=_){let s=S(e,".codex","hooks.json"),i=Ie(t,o,"codex",n),c="Codex requires this hook to be TRUSTED before it runs -- start `codex` in this repo and approve it at the hook-trust prompt (or review/trust via `/hooks` in a session)",a=co(e)?" -- WARNING: .codex/config.toml also declares an inline [hooks] table; Codex loads both hooks.json and the inline table and warns on every run. Prefer one representation per layer.":"",d=Oe({path:s,dir:S(e,".codex"),root:e,topLevelDefaults:{description:"ContextQ boot hook (managed by `npx contextq init` -- re-run the installer to update, do not hand-edit)"}},i,`SessionStart boot hook wired (agent=${t}, workspace=${o}). ${c}.${a}`);return d.action==="wired"&&lo(e,".codex/hooks.json"),d}r(Ke,"wireCodexSessionHook");var fo=/contextq.*hook boot|contextq-boot\.sh/;function go(e,t=_){return`${t} hook boot --format ${e} --require-config`}r(go,"hostHookCommand");function mo(e,t,o){let n=H(e,t,o);return e==="gemini"?Q(n,"settings.json"):e==="cursor"?Q(n,"hooks.json"):Q(n,"hooks","contextq.json")}r(mo,"hostHookPath");function ho(e){if(!oe(e))return{doc:{}};let t=po(e,"utf-8");try{let o=t.trim()===""?{}:JSON.parse(t);return!o||typeof o!="object"||Array.isArray(o)?{error:"top level is not a JSON object"}:{doc:o}}catch(o){return{error:`not valid JSON (${o.message})`}}}r(ho,"readJsonObject");function yo(e,t){let o=e.hooks??={};if(typeof o!="object"||Array.isArray(o))return;let n=o[t]??=[];return Array.isArray(n)?n:void 0}r(yo,"eventList");function ko(e){return JSON.stringify(e).match(fo)!==null}r(ko,"alreadyWired");function Ue(e,t){let o=mo(e,t.homeDir,t.env),n=oe(o),{doc:s,error:i}=ho(o);if(!s)return{surface:e,path:o,action:"refused",detail:`${i}; left untouched`};let c=go(e,t.runner),a=e==="gemini"?"SessionStart":"sessionStart";e!=="gemini"&&(s.version??=1);let d=yo(s,a);return d?ko(d)?{surface:e,path:o,action:"skipped",detail:"contextq boot hook already present"}:V(o)?{surface:e,path:o,action:"refused",detail:`sits in a git work tree and is not gitignored; add the SessionStart hook by hand: ${c}`}:(e==="gemini"?d.push({hooks:[{type:"command",name:"contextq-boot",command:c,timeout:3e4}]}):e==="cursor"?d.push({command:c,timeout:30}):d.push({type:"command",bash:c,timeoutSec:30}),Le(je(o),{recursive:!0}),De(o,`${JSON.stringify(s,null,2)}
26
+ `,{mode:384}),uo(o,384),{surface:e,path:o,action:n?"added":"created",detail:`SessionStart boot hook (runs only in repos with a .contextq/config.json); ${e==="cursor"?"Cursor runs sessionStart without waiting, so the first turn may not see it":"the boot pack is injected at session start"}`}):{surface:e,path:o,action:"refused",detail:`hooks.${a} is not an array; left untouched`}}r(Ue,"wireHostHook");function Fe(e,t,o){let n=Q(e,".contextq","config.json");return oe(n)?{path:n,action:"skipped",detail:"repo already bound -- left untouched"}:(Le(je(n),{recursive:!0}),De(n,`${JSON.stringify({config_schema_version:5,workspace:t,agent:o,read_scope:"own+global"},null,2)}
27
+ `),{path:n,action:"created",detail:`binds this repo to workspace ${t} for the host hooks; commit it so other machines share the binding`})}r(Fe,"ensureRepoConfig");import{spawnSync as ne}from"child_process";import{existsSync as xo,readFileSync as wo}from"fs";import{join as So}from"path";var re="contextq@contextq-plugins",qe=[2,1,281],Co="CONTEXTQ_CLAUDE_BIN";function Me(e){return e[Co]||"claude"}r(Me,"claudeBin");function bo(e){let t=/(\d+)\.(\d+)\.(\d+)/.exec(e);return t?[Number(t[1]),Number(t[2]),Number(t[3])]:null}r(bo,"parseClaudeVersion");function vo(e,t){for(let o=0;o<3;o++)if(e[o]!==t[o])return e[o]>t[o];return!0}r(vo,"versionAtLeast");function Qe(e){let t=So(e,".mcp.json");if(!xo(t))return!1;try{return!!JSON.parse(wo(t,"utf-8")).mcpServers?.contextq}catch{return!1}}r(Qe,"hasLegacyMcpEntry");function _o(e=process.env){let t=ne(Me(e),["--version"],{encoding:"utf-8",env:e});if(t.error||t.status!==0)return{reason:"the `claude` binary was not found on PATH"};let o=bo(t.stdout);return o?vo(o,qe)?{version:o.join(".")}:{reason:`Claude Code ${o.join(".")} is older than ${qe.join(".")}, the ContextQ plugin's floor (its function-hook module needs prompt.edit and ui.render AbovePrompt)`}:{reason:`could not read a version from \`claude --version\` (${t.stdout.trim()})`}}r(_o,"detectClaude");function Eo(e){let t=e.split(`
28
+ `).map(o=>o.trim()).filter(o=>o.startsWith("{"));for(let o=t.length-1;o>=0;o--)try{return JSON.parse(t[o])}catch{continue}return null}r(Eo,"lastJsonLine");function $o(e){return`${e.replace(/\/$/,"")}/plugin/marketplace.json`}r($o,"marketplaceUrl");function Ye(e,t,o,n,s=process.env){let i=_o(s);if("reason"in i)return{status:"unavailable",reason:i.reason};let c=Me(s),a=ne(c,["plugin","marketplace","add",$o(t),"--scope","project"],{cwd:e,encoding:"utf-8",env:s});if(a.error||a.status!==0)return{status:"failed",reason:`\`claude plugin marketplace add\` exited ${a.status??"with an error"}`};let d=["plugin","install",re,"--scope","project","--json","--config",`apiKey=${o}`];t.replace(/\/$/,"")!==n.replace(/\/$/,"")&&d.push("--config",`apiUrl=${t}`);let l=ne(c,d,{cwd:e,encoding:"utf-8",env:s}),p=Eo(l.stdout??"");if(!p)return{status:"failed",reason:`\`claude plugin install --json\` printed no result line (exit ${l.status??"error"})`};if(p.outcome!=="ok"||l.status!==0){let f=typeof p.failureCode=="string"?` [${p.failureCode}]`:"";return{status:"failed",reason:`${String(p.message??"install failed")}${f}`}}let m=String(p.message??"installed");return{status:"installed",message:m,alreadyInstalled:/already installed/i.test(m)}}r(Ye,"installPlugin");function Xe(e){return e.noPlugin?{delegate:!1,reason:"--no-plugin: wiring Claude Code through .mcp.json"}:e.legacyMcpEntry?{delegate:!1,reason:".mcp.json already wires contextq -- keeping that path, not installing the plugin on top of it (both would register the same MCP server)"}:e.verifyStatus===401||e.verifyStatus===403?{delegate:!1,reason:`the server rejected this key (HTTP ${e.verifyStatus}) -- not handing it to the plugin, whose apiKey is stored machine-wide and would replace a working one; wiring .mcp.json instead`}:{delegate:!0}}r(Xe,"decidePluginDelegation");import{execFileSync as Po}from"child_process";import{existsSync as Je,readFileSync as Ao}from"fs";import{basename as Be,join as Ve}from"path";var To=/^[a-z0-9][a-z0-9_-]{0,63}$/,Y='lowercase letters, digits, "-", "_" (must start with a letter/digit, max 64 chars)';function se(e,t){if(!To.test(e))throw new Error(`${t} value "${e}" is not a valid slug -- allowed: ${Y}`);return e}r(se,"validateSlug");function O(e){let t=e.toLowerCase().replace(/[^a-z0-9_-]+/g,"-").replace(/^[-_]+|[-_]+$/g,"");return t=t.slice(0,64),t=t.replace(/[-_]+$/g,""),t===""?"default":t}r(O,"sanitizeSlugFallback");var ae=["claude","codex","gemini","cursor","copilot"],Ro="https://app.contextq.dev";function N(e){let t=(e??"").trim();return!t||/^\$\{[^}]*\}$/.test(t)?"":t}r(N,"usable");function Ho(e){let t=w(),o=N(e.CONTEXT_API_KEY)||N(e.CTQ_API_KEY),n=o||t.apiKey,s=(o?N(e.CONTEXT_API_URL)||N(e.CTQ_API_URL):"")||t.apiUrl||N(e.CONTEXT_API_URL)||Ro;return{apiKey:n,apiUrl:s.replace(/\/+$/,"")}}r(Ho,"resolveHookCreds");function Io(e,t){if(!t||t.trim()==="")return e;try{let o=JSON.parse(t);if(typeof o.cwd=="string"&&o.cwd)return o.cwd;if(Array.isArray(o.workspace_roots)&&typeof o.workspace_roots[0]=="string")return o.workspace_roots[0]}catch{return e}return e}r(Io,"sessionCwd");function ie(e,t){try{return Po("git",["-C",e,...t],{stdio:["ignore","pipe","ignore"],encoding:"utf-8"}).trim()}catch{return""}}r(ie,"git");function Oo(e,t,o){let n=Ve(e,".contextq","config.json");if(Je(n))return Ao(n,"utf-8");let s=O(Be(e));return JSON.stringify({workspace:(o||s).toLowerCase(),agent:t||s,read_scope:"own+global"})}r(Oo,"hookConfig");function X(e,t){return JSON.stringify(e==="cursor"?{additional_context:t}:e==="copilot"?{additionalContext:t}:{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:t}})}r(X,"formatForHost");async function We(e){let t=Io(e.cwd,e.stdinText),o=ie(t,["rev-parse","--show-toplevel"]);if(!o||e.requireConfig&&!Je(Ve(o,".contextq","config.json")))return"";let{apiKey:n,apiUrl:s}=Ho(e.env);if(!n)return X(e.format,"ContextQ: no API key found (CONTEXT_API_KEY or the credentials file `npx @contextq/cli init` writes), so no memory was loaded this session. Fix: run npx @contextq/cli init in this repo.");let i=new URLSearchParams({config:Oo(o,e.agent,e.workspace),agent_slug:e.agent??"",project:Be(o),git_root:o,branch:ie(o,["branch","--show-current"]),head_sha:ie(o,["rev-parse","--short","HEAD"]),goal:"",board:"",token_budget:"3000"}),c=e.fetchImpl??fetch,a=0,d="";try{let l=await c(`${s}/api/agent/hooks/session-start`,{method:"POST",headers:{Authorization:`Bearer ${n}`,Accept:"application/json","Content-Type":"application/x-www-form-urlencoded"},body:i.toString(),signal:AbortSignal.timeout(e.timeoutMs??1e4)});a=l.status,d=await l.text()}catch{a=0}if(a===401||a===403)return X(e.format,`ContextQ: ${s} refused the stored API key (HTTP ${a}): it is expired, revoked or wrong, so no memory was loaded and nothing will be saved this session. Fix: npx @contextq/cli init --key <new sk_live_ key>, or run init from a terminal to be prompted.`);if(a===0||a>=500)return X(e.format,`ContextQ: ${s} is unreachable or failing (HTTP ${a}), so no memory was loaded this session. The key was not checked; memory resumes when the server answers again.`);if(a<200||a>=300||d.trim()==="")return"";try{let p=JSON.parse(d).hookSpecificOutput?.additionalContext;return typeof p!="string"||p===""?"":X(e.format,p)}catch{return""}}r(We,"runHookBoot");async function Ge(e=500){return process.stdin.isTTY?"":new Promise(t=>{let o="",n=r(()=>{process.stdin.removeAllListeners("data"),process.stdin.removeAllListeners("end"),process.stdin.pause(),t(o)},"done"),s=setTimeout(n,e);process.stdin.setEncoding("utf8"),process.stdin.on("data",i=>{o+=i}),process.stdin.on("end",()=>{clearTimeout(s),n()})})}r(Ge,"readStdinIfPiped");import{appendFileSync as No,existsSync as ze,readFileSync as Ko}from"fs";import{execFileSync as Lo}from"child_process";import{join as ce}from"path";var Do=[".contextq/tasks/TASKS.md",".contextq/tasks/tasks/T*.md",".contextq/tasks/.render*",".contextq/tasks/status.log"];function jo(e){try{return Lo("git",["-C",e,"ls-files","--error-unmatch","--",".contextq/tasks/TASKS.md"],{stdio:"ignore"}),!0}catch{return!1}}r(jo,"manifestTracked");function Ze(e){let t=ce(e,".gitignore");if(!ze(ce(e,".git")))return{path:t,action:"skipped",detail:"not a git repo -- no board render to keep out of commits"};if(jo(e))return{path:ce(e,".contextq","tasks","TASKS.md"),action:"skipped",detail:"this repo tracks a git-sourced board -- left untouched"};let o=ze(t)?Ko(t,"utf-8"):"",n=new Set(o.split(`
29
+ `).map(c=>c.trim())),s=Do.filter(c=>!n.has(c));if(s.length===0)return{path:t,action:"skipped",detail:"board render already gitignored"};let i=o===""||o.endsWith(`
30
+ `)?"":`
31
+ `;return No(t,`${i}${s.join(`
32
+ `)}
33
+ `),{path:t,action:"created",detail:"gitignored the task board render (.contextq/tasks/TASKS.md is a read-only copy of the cloud board, never committed)"}}r(Ze,"initTasksDir");function et(e){console.log(`
34
+ No git repo or existing Claude Code project found here, so there is nothing on
35
+ disk to scaffold. For Claude Desktop, Cowork, or any other MCP-capable client
36
+ that reads a manually-edited config file, connect ContextQ in 3 steps:
37
+
38
+ 1. Open the ContextQ dashboard -> Settings -> API keys -> create a key
39
+ ${e}/dashboard/api-keys
40
+
41
+ 2. Add this to your client's MCP config file (e.g. claude_desktop_config.json):
42
+
43
+ {
44
+ "mcpServers": {
45
+ "contextq": {
46
+ "command": "npx",
47
+ "args": ["-y", "@contextq/mcp"],
48
+ "env": {
49
+ "CONTEXT_API_URL": "${e}",
50
+ "CONTEXT_API_KEY": "<your key from step 1>"
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ 3. Restart the client so it picks up the new MCP server.
57
+
58
+ There is no auto-connect for Desktop/Cowork today -- these clients only read
59
+ their config file on start, so step 2 has to be a manual paste. Re-run
60
+ "npx @contextq/cli init" inside a git repo (or an existing Claude Code project) to
61
+ get the automatic .mcp.json + hook + task-manifest scaffolding instead.
62
+ `)}r(et,"printDesktopGuide");async function tt(e){return process.stdin.isTTY?new Promise(t=>{let o=process.stdin,n=process.stdout;n.write(e);let s="",i=r(c=>{let a=c.toString("utf8");switch(a){case`
63
+ `:case"\r":case"":o.setRawMode?.(!1),o.pause(),o.removeListener("data",i),n.write(`
64
+ `),t(s.trim());break;case"":n.write(`
65
+ `),process.exit(130);break;case"\x7F":case"\b":s.length>0&&(s=s.slice(0,-1),n.write("\b \b"));break;default:s+=a,n.write("*")}},"onData");o.setRawMode?.(!0),o.resume(),o.setEncoding("utf8"),o.on("data",i)}):""}r(tt,"promptHidden");import{existsSync as le,mkdirSync as ot,readdirSync as Uo,readFileSync as nt,statSync as Fo,writeFileSync as qo}from"fs";import{join as J}from"path";function Mo(e,t){let o=new RegExp(`^ {2}${t}: "((?:[^"\\\\]|\\\\.)*)"$`,"m"),n=e.match(o);return n?n[1].replace(/\\(.)/g,"$1"):null}r(Mo,"extractFrontmatterMetadataValue");function Qo(e){return Mo(e,"cluster_key")}r(Qo,"extractClusterKey");function Yo(e){if(!le(e))return[];let t=[];for(let o of Uo(e)){let n=J(e,o);if(!Fo(n).isDirectory())continue;let s=J(n,"SKILL.md");if(!le(s))continue;let i=nt(s,"utf-8"),c=Qo(i);c&&t.push({folder:o,clusterKey:c})}return t}r(Yo,"scanLocalSkills");function Xo(e,t){return e.replace(/^name: .+$/m,`name: ${t}`)}r(Xo,"withFrontmatterName");async function rt(e){ot(e.dir,{recursive:!0});let t=Yo(e.dir),o=new Map(t.map(a=>[a.clusterKey,a.folder])),n=new Set(t.map(a=>a.folder)),s=await e.fetchSkills(),i={written:[],updated:[],unchanged:[],orphans:[]},c=new Set;for(let a of s){c.add(a.clusterKey);let l=o.get(a.clusterKey);l||(l=n.has(a.slug)?`${a.slug}-c${a.contextId}`:a.slug,n.add(l),o.set(a.clusterKey,l));let p=l===a.slug?a.markdown:Xo(a.markdown,l),m=J(e.dir,l),f=J(m,"SKILL.md"),E=le(f)?nt(f,"utf-8"):null;if(E===p){i.unchanged.push(l);continue}ot(m,{recursive:!0}),qo(f,p,"utf-8"),i[E===null?"written":"updated"].push(l)}for(let a of t)c.has(a.clusterKey)||i.orphans.push(a.folder);return i}r(rt,"syncSkills");var A="https://app.contextq.dev";function Jo(e){let t=e.length>0&&!e[0].startsWith("-"),o={command:t?e[0]:"",embedKey:!1,noPlugin:!1,help:!1};for(let n=t?1:0;n<e.length;n++){let s=e[n];switch(s){case"--key":o.key=e[++n];break;case"--url":o.url=e[++n];break;case"--workspace":o.workspace=e[++n];break;case"--agent":o.agent=e[++n];break;case"--embed-key":o.embedKey=!0;break;case"--no-plugin":o.noPlugin=!0;break;case"--surface":{let i=e[++n];if(i!=="claude-code"&&i!=="codex"&&!T.includes(i))throw new Error(`--surface value "${i}" is invalid -- allowed: claude-code, codex, ${T.join(", ")}`);o.surface=i;break}case"-h":case"--help":o.help=!0;break;default:throw new Error(`unknown argument: ${s}`)}}return o}r(Jo,"parseArgs");function K(){console.log(`contextq \u2014 one-command installer for ContextQ
69
66
 
70
67
  Usage:
71
68
  npx @contextq/cli init [options]
72
69
 
70
+ API key: with none stored, init prompts for it (hidden) or reads CONTEXT_API_KEY /
71
+ CTQ_API_KEY from the environment. The key is checked against the server before it
72
+ is saved; a refused key is never written.
73
+
73
74
  Options:
74
- --key <sk_live_...> API key (else CONTEXT_API_KEY/CTQ_API_KEY env, else prompt)
75
- --url <url> ContextQ API URL (default: ${DEFAULT_URL})
75
+ --key <sk_live_...> Pass or rotate the key on the command line (visible in shell
76
+ history; prefer the prompt or the env var)
77
+ --url <url> ContextQ API URL (default: ${A})
76
78
  --workspace <slug> Workspace slug for the contextq boot hook (default: repo basename,
77
- sanitized). Allowed: ${SLUG_PATTERN_DESCRIPTION}. Case is folded to
79
+ sanitized). Allowed: ${Y}. Case is folded to
78
80
  lowercase automatically (workspace slugs are always lowercase).
79
81
  --agent <slug> Agent slug for the contextq boot hook (default: repo basename,
80
- sanitized). Allowed: ${SLUG_PATTERN_DESCRIPTION}.
82
+ sanitized). Allowed: ${Y}.
81
83
  --embed-key Write the literal API key into .mcp.json instead of the
82
84
  safer \${CONTEXT_API_KEY} placeholder (opt-in: convenience
83
85
  vs. a secret sitting on disk)
84
- --surface <s> Force surface detection to exactly one of: claude-code, codex.
85
- Skips autodetection entirely (default: autodetect both --
86
- a repo can be, and often is, both at once).
86
+ --no-plugin Wire Claude Code through .mcp.json even when a \`claude\` binary
87
+ (2.1.281+) is on PATH. By default init installs the ContextQ
88
+ plugin at project scope instead, which carries the MCP server and
89
+ the lifecycle hooks and keeps the key in Claude Code's own store.
90
+ --surface <s> Force surface detection to exactly one of: claude-code, codex,
91
+ gemini, cursor, copilot. Skips autodetection entirely (default:
92
+ autodetect all -- a machine can be, and often is, several at once).
93
+ gemini, cursor and copilot get the remote MCP server in their
94
+ user-level config (~/.gemini/settings.json, ~/.cursor/mcp.json,
95
+ ~/.copilot/mcp-config.json) with the key in the header, mode 600.
87
96
  -h, --help Show this help
88
97
 
89
98
  Usage:
90
99
  npx @contextq/cli skills sync <dir> [options]
91
100
 
92
- Pulls this tenant's distilled procedures (T376) as standard Agent Skills
101
+ Pulls this tenant's distilled procedures as standard Agent Skills
93
102
  folders (<dir>/<slug>/SKILL.md). Safe to re-run: an existing folder is
94
103
  matched by its own metadata.cluster_key and rewritten in place, never
95
104
  duplicated; a folder whose context no longer resolves is reported as an
@@ -101,321 +110,6 @@ Options:
101
110
  --key <sk_live_...> API key (else CONTEXT_API_KEY/CTQ_API_KEY env, else the
102
111
  credential file written by \`contextq init\`)
103
112
  --url <url> ContextQ API URL (else the credential file, else
104
- CONTEXT_API_URL/CTQ_API_URL env, default: ${DEFAULT_URL})
105
- `);
106
- }
107
- function relative(root, path) {
108
- return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
109
- }
110
- function label(action) {
111
- const known = {
112
- created: '[create]',
113
- added: '[add] ',
114
- wired: '[create]',
115
- rotated: '[rotate]',
116
- skipped: '[skip] ',
117
- verified: '[check]',
118
- failed: '[check]',
119
- };
120
- return known[action] ?? `[${action}]`;
121
- }
122
- /**
123
- * T474: the installer used to wire .mcp.json / the SessionStart hook / the
124
- * task manifest and declare success without ever confirming the key + URL it
125
- * just wrote actually authenticate against the server. Ping a cheap
126
- * read-scoped endpoint so a typo'd key or URL fails loudly here instead of
127
- * surfacing later as "the MCP tools aren't showing up".
128
- */
129
- async function verifyConnection(apiUrl, apiKey) {
130
- try {
131
- const res = await fetch(`${apiUrl.replace(/\/$/, '')}/api/workspaces`, {
132
- headers: { Authorization: `Bearer ${apiKey}` },
133
- });
134
- return { ok: res.status === 200, status: res.status };
135
- }
136
- catch (err) {
137
- return { ok: false, error: err.message };
138
- }
139
- }
140
- async function runInit(args) {
141
- const cwd = process.cwd();
142
- const { surfaces, root } = detectSurface(cwd, { forcedSurface: args.surface });
143
- console.log('ContextQ init');
144
- console.log('=============');
145
- if (surfaces.length === 1 && surfaces[0] === 'desktop') {
146
- console.log(`Surface detected: desktop/other (no git repo, .claude/, or .codex/ found at ${cwd})`);
147
- printDesktopGuide(args.url ?? DEFAULT_URL);
148
- return;
149
- }
150
- console.log(`Surface(s) detected: ${surfaces.join(', ')} (root: ${root})`);
151
- const existing = readExistingCreds();
152
- const apiUrl = args.url || existing.apiUrl || process.env.CONTEXT_API_URL || DEFAULT_URL;
153
- // T676: a demo run with only an AMBIENT env var set (direnv-exported
154
- // CONTEXT_API_KEY from some repo's .env.local, say) used to be treated
155
- // exactly like an explicit --key, silently overwriting a pre-existing
156
- // machine-wide credential file with no backup. decideCredentialWrite()
157
- // (platform-cred.ts) now requires --key for rotation, backs up before any
158
- // real overwrite, and no-ops when nothing would change -- see its doc
159
- // comment for the full branch table.
160
- const ambientVarName = process.env.CONTEXT_API_KEY
161
- ? 'CONTEXT_API_KEY'
162
- : process.env.CTQ_API_KEY
163
- ? 'CTQ_API_KEY'
164
- : null;
165
- const ambientKey = ambientVarName ? process.env[ambientVarName] : '';
166
- let decision = decideCredentialWrite({ explicitKey: args.key, ambientVarName, ambientKey, apiUrl });
167
- if (decision.action === null) {
168
- // No --key, no ambient env var, and no existing file -- the only case
169
- // decideCredentialWrite() can't resolve on its own.
170
- const typed = await promptHidden('ContextQ API key (sk_live_...): ');
171
- if (!KEY_RE.test(typed)) {
172
- throw new Error('no valid API key available (expected sk_live_<40 hex chars>). Pass --key, set ' +
173
- 'CONTEXT_API_KEY, or re-run from an interactive terminal.');
174
- }
175
- decision = decideCredentialWrite({ explicitKey: typed, ambientVarName: null, ambientKey: '', apiUrl });
176
- }
177
- const apiKey = decision.apiKey;
178
- console.log(decision.credLine);
179
- // T656: --agent/--workspace (and the basename(root) fallback) end up
180
- // interpolated into a shell command written to the SessionStart hook (see
181
- // wireSessionHook below) -- validate/sanitize BEFORE that, on top of the
182
- // shell-quoting session-hook.ts itself applies as defense in depth. An
183
- // explicit flag value that doesn't fit the slug pattern is a hard error
184
- // (no silent truncation); the basename(root) fallback is sanitized
185
- // instead, since the user never typed it.
186
- let agent;
187
- if (args.agent) {
188
- agent = validateSlug(args.agent, '--agent');
189
- }
190
- else {
191
- agent = sanitizeSlugFallback(basename(root));
192
- }
193
- let workspace;
194
- if (args.workspace) {
195
- // Workspace slugs are always lowercase in this product (CLAUDE.md) --
196
- // folding case is a documented convention, not a silent rewrite, so an
197
- // explicit --workspace that differs from the API only by case is
198
- // lowercased and then validated (never lowercased AWAY a genuine
199
- // charset violation). --agent gets no such courtesy: it has no
200
- // documented lowercase-only convention, so an explicit --agent value
201
- // is validated as typed.
202
- const lowered = args.workspace.toLowerCase();
203
- workspace = validateSlug(lowered, '--workspace');
204
- if (lowered !== args.workspace) {
205
- console.log(`[info] --workspace lowercased to match slug convention: "${args.workspace}" -> "${lowered}"`);
206
- }
207
- }
208
- else {
209
- workspace = sanitizeSlugFallback(basename(root));
210
- }
211
- console.log(`[info] agent=${agent} workspace=${workspace}`);
212
- let mcpResult;
213
- if (surfaces.includes('claude-code')) {
214
- mcpResult = wireMcpJson(root, apiUrl, args.embedKey, apiKey);
215
- console.log(`${label(mcpResult.action)} ${relative(root, mcpResult.path)} (${mcpResult.detail})`);
216
- const hookResult = wireSessionHook(root, agent, workspace);
217
- console.log(`${label(hookResult.action)} ${relative(root, hookResult.path)} (${hookResult.detail})`);
218
- const tasksResult = initTasksDir(root);
219
- console.log(`${label(tasksResult.action)} ${relative(root, tasksResult.path)} (${tasksResult.detail})`);
220
- }
221
- if (surfaces.includes('codex')) {
222
- console.log('');
223
- console.log('[info] Codex hooks -- assumptions this install makes:');
224
- console.log(' - Sandbox/approval: the boot hook makes an outbound HTTP call to the ContextQ');
225
- console.log(' API. Verified empirically (codex-cli 0.146.0, `codex exec --sandbox');
226
- console.log(' workspace-write`, no approval flag): a TRUSTED hook\'s outbound call is NOT');
227
- console.log(' gated by sandbox_mode/approval_policy the way model-issued shell commands');
228
- console.log(' are -- the real gate is hook TRUST (see the note below), not the sandbox.');
229
- console.log(' Once trusted, this should work under any sandbox_mode/approval_policy combo.');
230
- console.log(' If it still produces no injected context, `codex doctor` shows current sandbox');
231
- console.log(' + approval state as a starting point for diagnosis.');
232
- console.log(' - Native Memories: Codex ships its own Memories feature (off by default). If you');
233
- console.log(' turn it on, set memories.disable_on_external_context = true in config.toml (or');
234
- console.log(' its older alias no_memories_if_mcp_or_web_search) -- otherwise a session that');
235
- console.log(' used MCP tools (including ContextQ\'s own MCP server) still feeds Codex\'s native');
236
- console.log(' memory extraction too, duplicating what ContextQ already captures.');
237
- console.log('');
238
- const codexHookResult = wireCodexSessionHook(root, agent, workspace);
239
- console.log(`${label(codexHookResult.action)} ${relative(root, codexHookResult.path)} (${codexHookResult.detail})`);
240
- }
241
- const verifyResult = await verifyConnection(apiUrl, apiKey);
242
- if (verifyResult.ok) {
243
- console.log(`${label('verified')} connection to ${apiUrl} (GET /api/workspaces -> 200)`);
244
- }
245
- else {
246
- const detail = verifyResult.status ? `HTTP ${verifyResult.status}` : verifyResult.error ?? 'unknown error';
247
- console.log(`${label('failed')} connection to ${apiUrl} (${detail}) -- check your API key and --url`);
248
- if (verifyResult.status === 401 || verifyResult.status === 403) {
249
- process.exitCode = 1;
250
- }
251
- }
252
- if (mcpResult && !args.embedKey && mcpResult.action !== 'skipped') {
253
- console.log('');
254
- console.log('NOTE: .mcp.json references ${CONTEXT_API_KEY} -- Claude Code expands that from');
255
- console.log('its OWN process environment, not from the credential file just written.');
256
- console.log('Export it in your shell profile before starting Claude Code:');
257
- console.log(' export CONTEXT_API_KEY=<your key>');
258
- console.log('...or re-run with --embed-key to write the literal key into .mcp.json instead.');
259
- }
260
- console.log('');
261
- const doneSurfaces = surfaces.filter((s) => s !== 'desktop');
262
- const doneNote = doneSurfaces
263
- .map((s) => (s === 'claude-code' ? 'a new Claude Code session' : 'a new `codex` run'))
264
- .join(' / start ');
265
- console.log(`Done. Open ${doneNote} in this repo to pick up the wiring.`);
266
- // Claude Code 2.1.268+ can install the same MCP server as a plugin, which
267
- // collects the API key through the manifest's userConfig (secure storage,
268
- // never settings.json) instead of needing CONTEXT_API_KEY exported into the
269
- // shell. This installer still exists for Codex, Desktop, CI, and the
270
- // contextq SessionStart hook, none of which the plugin covers -- so point
271
- // at it rather than replacing this path.
272
- if (surfaces.includes('claude-code')) {
273
- console.log('');
274
- console.log('Claude Code 2.1.268+ can install the MCP half of this as a plugin instead,');
275
- console.log('with no CONTEXT_API_KEY to export:');
276
- console.log(' claude plugin marketplace add https://app.contextq.dev/plugin/marketplace.json');
277
- console.log(' claude plugin install contextq@contextq-plugins --config apiKey=<your key>');
278
- console.log('See docs/mcp-setup.md. This installer stays the path for Codex and CI.');
279
- }
280
- }
281
- /**
282
- * Separate from parseArgs() on purpose: `skills sync <dir>` has a
283
- * subcommand + a required positional argument, a shape parseArgs()'s
284
- * single-command-token model doesn't fit. Dispatched from main() before
285
- * parseArgs() ever sees the argv.
286
- */
287
- function parseSkillsSyncArgs(argv) {
288
- const positional = [];
289
- const args = {};
290
- for (let i = 0; i < argv.length; i++) {
291
- const a = argv[i];
292
- switch (a) {
293
- case '--workspace':
294
- args.workspace = argv[++i];
295
- break;
296
- case '--key':
297
- args.key = argv[++i];
298
- break;
299
- case '--url':
300
- args.url = argv[++i];
301
- break;
302
- default:
303
- if (a.startsWith('-')) {
304
- throw new Error(`unknown argument: ${a}`);
305
- }
306
- positional.push(a);
307
- }
308
- }
309
- if (positional.length === 0) {
310
- throw new Error('missing <dir> argument -- usage: contextq skills sync <dir>');
311
- }
312
- if (positional.length > 1) {
313
- throw new Error(`unexpected extra argument: ${positional[1]}`);
314
- }
315
- return { ...args, dir: positional[0] };
316
- }
317
- /**
318
- * Resolve credentials the SAME way `contextq init`'s verifyConnection() call
319
- * does (explicit flag > ambient env var > the credential file `init`
320
- * already wrote) but read-only -- `skills sync` never writes credentials,
321
- * it only needs something to authenticate with.
322
- */
323
- function resolveApiCreds(args) {
324
- const existing = readExistingCreds();
325
- const apiUrl = args.url || existing.apiUrl || process.env.CONTEXT_API_URL || process.env.CTQ_API_URL || DEFAULT_URL;
326
- const apiKey = args.key || process.env.CONTEXT_API_KEY || process.env.CTQ_API_KEY || existing.apiKey;
327
- if (!apiKey) {
328
- throw new Error('no API key available -- pass --key, set CONTEXT_API_KEY, or run `contextq init` first to write the credential file');
329
- }
330
- return { apiUrl, apiKey };
331
- }
332
- /**
333
- * `format=json` (not the default `zip`) so this never needs an unzip
334
- * dependency in the published `contextq` npm package -- the bulk export
335
- * route (src/routes/agent.ts) supports both from the same query.
336
- */
337
- async function fetchSkillsFromApi(apiUrl, apiKey, workspace) {
338
- const url = new URL('/api/agent/skills/export', apiUrl.replace(/\/$/, '') + '/');
339
- url.searchParams.set('format', 'json');
340
- if (workspace)
341
- url.searchParams.set('workspace', workspace);
342
- const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
343
- if (!res.ok) {
344
- throw new Error(`GET ${url.pathname}${url.search} -> HTTP ${res.status}`);
345
- }
346
- const body = (await res.json());
347
- return body.skills;
348
- }
349
- async function runSkillsSync(args) {
350
- console.log('contextq skills sync');
351
- console.log('=====================');
352
- const { apiUrl, apiKey } = resolveApiCreds(args);
353
- console.log(`[info] syncing to ${args.dir}${args.workspace ? ` (workspace: ${args.workspace})` : ''}`);
354
- const result = await syncSkills({
355
- dir: args.dir,
356
- fetchSkills: () => fetchSkillsFromApi(apiUrl, apiKey, args.workspace),
357
- });
358
- for (const f of result.written)
359
- console.log(`${label('created')} ${f}/SKILL.md`);
360
- for (const f of result.updated)
361
- console.log(`${label('added')} ${f}/SKILL.md (rewritten in place)`);
362
- console.log(`[info] ${result.written.length} written, ${result.updated.length} updated, ${result.unchanged.length} unchanged`);
363
- if (result.orphans.length > 0) {
364
- console.log(`[warn] ${result.orphans.length} orphaned folder(s) -- left on disk, not deleted:`);
365
- for (const f of result.orphans)
366
- console.log(` ${f}/ (its context no longer resolves)`);
367
- }
368
- }
369
- async function main() {
370
- const argv = process.argv.slice(2);
371
- if (argv[0] === 'skills') {
372
- if (argv[1] === '-h' || argv[1] === '--help') {
373
- printHelp();
374
- return;
375
- }
376
- if (argv[1] !== 'sync') {
377
- console.error(`contextq: unknown "skills" subcommand "${argv[1] ?? ''}" -- expected "sync"`);
378
- printHelp();
379
- process.exitCode = 1;
380
- return;
381
- }
382
- try {
383
- const skillsArgs = parseSkillsSyncArgs(argv.slice(2));
384
- await runSkillsSync(skillsArgs);
385
- }
386
- catch (err) {
387
- console.error(`contextq: ${err.message}`);
388
- process.exitCode = 1;
389
- }
390
- return;
391
- }
392
- let args;
393
- try {
394
- args = parseArgs(argv);
395
- }
396
- catch (err) {
397
- console.error(`contextq: ${err.message}`);
398
- printHelp();
399
- process.exitCode = 1;
400
- return;
401
- }
402
- if (args.help || !args.command) {
403
- printHelp();
404
- return;
405
- }
406
- if (args.command !== 'init') {
407
- console.error(`contextq: unknown command "${args.command}"`);
408
- printHelp();
409
- process.exitCode = 1;
410
- return;
411
- }
412
- try {
413
- await runInit(args);
414
- }
415
- catch (err) {
416
- console.error(`contextq: ${err.message}`);
417
- process.exitCode = 1;
418
- }
419
- }
420
- main();
421
- //# sourceMappingURL=index.js.map
113
+ CONTEXT_API_URL/CTQ_API_URL env, default: ${A})
114
+ `)}r(K,"printHelp");function P(e,t){return t.startsWith(`${e}/`)?t.slice(e.length+1):t}r(P,"relative");function k(e){return{created:"[create]",added:"[add] ",wired:"[create]",rotated:"[rotate]",skipped:"[skip] ",verified:"[check]",failed:"[check]",refused:"[refuse]"}[e]??`[${e}]`}r(k,"label");async function Bo(e,t){try{let o=await fetch(`${e.replace(/\/$/,"")}/api/workspaces`,{headers:{Authorization:`Bearer ${t}`}});return{ok:o.status===200,status:o.status}}catch(o){return{ok:!1,error:o.message}}}r(Bo,"verifyConnection");async function Vo(e){let t=process.cwd(),{surfaces:o,root:n}=ke(t,{forcedSurface:e.surface});if(console.log("ContextQ init"),console.log("============="),o.length===1&&o[0]==="desktop"){console.log(`Surface detected: desktop/other (no git repo, .claude/, or .codex/ found at ${t})`),et(e.url??A);return}console.log(`Surface(s) detected: ${o.join(", ")} (root: ${n})`);let s=w(),i=e.url||s.apiUrl||process.env.CONTEXT_API_URL||A,c=process.env.CONTEXT_API_KEY?"CONTEXT_API_KEY":process.env.CTQ_API_KEY?"CTQ_API_KEY":null,a=c?process.env[c]:"",{decision:d,verify:l}=await _e({explicitKey:e.key,ambientVarName:c,ambientKey:a,apiUrl:i,interactive:!!process.stdin.isTTY,verify:r(u=>Bo(i,u),"verify"),prompt:tt}),p=d.apiKey;if(console.log(d.credLine),!l.ok){let u=l.status?`HTTP ${l.status}`:l.error??"unknown error";console.log(`[warn] could not verify the key against ${i} (${u}); it was kept, re-run init once the server answers`)}let m;e.agent?m=se(e.agent,"--agent"):m=O(it(n));let f;if(e.workspace){let u=e.workspace.toLowerCase();f=se(u,"--workspace"),u!==e.workspace&&console.log(`[info] --workspace lowercased to match slug convention: "${e.workspace}" -> "${u}"`)}else f=O(it(n));console.log(`[info] agent=${m} workspace=${f}`);let E=process.env.CONTEXTQ_HOOK_RUNNER||_,$,B=!1,R=[],de=!1;if(o.includes("claude-code")){let u=Xe({noPlugin:e.noPlugin,legacyMcpEntry:Qe(n),verifyStatus:l.status});if(!u.delegate)console.log(`[info] ${u.reason}`);else{let g=Ye(n,i,p,A);g.status==="installed"?(B=!0,R.push("claude-code"),console.log(`${k(g.alreadyInstalled?"skipped":"added")} .claude/settings.json (${re} at project scope -- ${g.message})`)):console.log(`[info] plugin install ${g.status}: ${g.reason} -- falling back to .mcp.json wiring`)}if(!B){$=Ee(n,i,e.embedKey,p),console.log(`${k($.action)} ${P(n,$.path)} (${$.detail})`),R.push("claude-code");let g=Ne(n,m,f,E);console.log(`${k(g.action)} ${P(n,g.path)} (${g.detail})`),g.action==="refused"&&(process.exitCode=1)}let y=Ze(n);console.log(`${k(y.action)} ${P(n,y.path)} (${y.detail})`)}if(o.includes("codex")){console.log(""),console.log("[info] Codex hooks -- assumptions this install makes:"),console.log(" - Sandbox/approval: the boot hook makes an outbound HTTP call to the ContextQ"),console.log(" API. Verified empirically (codex-cli 0.146.0, `codex exec --sandbox"),console.log(" workspace-write`, no approval flag): a TRUSTED hook's outbound call is NOT"),console.log(" gated by sandbox_mode/approval_policy the way model-issued shell commands"),console.log(" are -- the real gate is hook TRUST (see the note below), not the sandbox."),console.log(" Once trusted, this should work under any sandbox_mode/approval_policy combo."),console.log(" If it still produces no injected context, `codex doctor` shows current sandbox"),console.log(" + approval state as a starting point for diagnosis."),console.log(" - Native Memories: Codex ships its own Memories feature (off by default). If you"),console.log(" turn it on, set memories.disable_on_external_context = true in config.toml (or"),console.log(" its older alias no_memories_if_mcp_or_web_search) -- otherwise a session that"),console.log(" used MCP tools (including ContextQ's own MCP server) still feeds Codex's native"),console.log(" memory extraction too, duplicating what ContextQ already captures."),console.log("");let u=Ke(n,m,f,E);if(console.log(`${k(u.action)} ${P(n,u.path)} (${u.detail})`),u.action==="refused"&&(process.exitCode=1),!l.ok)console.log("[info] Codex MCP: not wired -- the key did not verify, and it would be written into .codex/config.toml");else{let y=Re(n,i,p);console.log(`${k(y.action)} ${P(n,y.path)} (${y.detail})`),y.action==="refused"?process.exitCode=1:R.push("codex")}}for(let u of T){if(!o.includes(u))continue;if(!l.ok){console.log(`[info] ${b[u].label}: not wired -- the key did not verify, and it would be written into the host config`);continue}let y=he(u,{homeDir:st(),env:process.env,apiUrl:i,apiKey:p});console.log(`${k(y.action)} ${y.path} (${b[u].label}: ${y.detail})`),y.action==="refused"?process.exitCode=1:R.push(u);let g=Ue(u,{homeDir:st(),env:process.env,runner:E});console.log(`${k(g.action)} ${g.path} (${b[u].label}: ${g.detail})`),g.action==="refused"?process.exitCode=1:de=!0}if(l.ok)console.log(`${k("verified")} connection to ${i} (GET /api/workspaces -> 200)`);else{let u=l.status?`HTTP ${l.status}`:l.error??"unknown error";console.log(`${k("failed")} connection to ${i} (${u}) -- check your API key and --url`),(l.status===401||l.status===403)&&(process.exitCode=1)}if($&&!e.embedKey&&$.action!=="skipped"&&(console.log(""),console.log("NOTE: .mcp.json references ${CONTEXT_API_KEY}. When that variable is unset, the"),console.log(`current @contextq/mcp reads the key from ${h()} instead.`),console.log('If `npx @contextq/mcp` still logs "Auth: NONE", export it before starting Claude Code:'),console.log(" export CONTEXT_API_KEY=<your key>"),console.log("...or re-run with --embed-key to write the literal key into .mcp.json instead.")),de){let u=Fe(n,f,m);console.log(`${k(u.action)} ${P(n,u.path)} (${u.detail})`)}console.log("");let at={"claude-code":"a new Claude Code session",codex:"a new `codex` run",gemini:"a new `gemini` session",cursor:"a new Cursor or `cursor-agent` session",copilot:"a new `copilot` session"},ue=o.filter(u=>u!=="desktop"&&R.includes(u)).map(u=>at[u]).join(" / start ");ue?console.log(`Done. Open ${ue} in this repo to pick up the wiring.`):(console.log("Nothing was wired: no surface got a ContextQ MCP connection. Fix what is reported above and re-run."),process.exitCode=1),B?(console.log(""),console.log("Next: in that Claude Code session, run /contextq:init to bind this repo to a workspace"),console.log("and project. Until it writes .contextq/config.json, the plugin's MCP tools work but its"),console.log("SessionStart/PreCompact/SessionEnd hooks stay silent and say so.")):o.includes("claude-code")&&(console.log(""),console.log("Claude Code 2.1.281+ can carry all of this as a plugin instead -- MCP server and"),console.log("lifecycle hooks, key in Claude Code's own store. Re-run init without --no-plugin after"),console.log("removing the contextq entry from .mcp.json, rather than installing the plugin on top:"),console.log("both would register the same MCP server. See https://app.contextq.dev/install.md."))}r(Vo,"runInit");function Wo(e){let t=[],o={};for(let n=0;n<e.length;n++){let s=e[n];switch(s){case"--workspace":o.workspace=e[++n];break;case"--key":o.key=e[++n];break;case"--url":o.url=e[++n];break;default:if(s.startsWith("-"))throw new Error(`unknown argument: ${s}`);t.push(s)}}if(t.length===0)throw new Error("missing <dir> argument -- usage: contextq skills sync <dir>");if(t.length>1)throw new Error(`unexpected extra argument: ${t[1]}`);return{...o,dir:t[0]}}r(Wo,"parseSkillsSyncArgs");function Go(e){let t=w(),o=e.url||t.apiUrl||process.env.CONTEXT_API_URL||process.env.CTQ_API_URL||A,n=e.key||process.env.CONTEXT_API_KEY||process.env.CTQ_API_KEY||t.apiKey;if(!n)throw new Error("no API key available -- pass --key, set CONTEXT_API_KEY, or run `contextq init` first to write the credential file");return{apiUrl:o,apiKey:n}}r(Go,"resolveApiCreds");async function zo(e,t,o){let n=new URL("/api/agent/skills/export",e.replace(/\/$/,"")+"/");n.searchParams.set("format","json"),o&&n.searchParams.set("workspace",o);let s=await fetch(n,{headers:{Authorization:`Bearer ${t}`}});if(!s.ok)throw new Error(`GET ${n.pathname}${n.search} -> HTTP ${s.status}`);return(await s.json()).skills}r(zo,"fetchSkillsFromApi");async function Zo(e){console.log("contextq skills sync"),console.log("=====================");let{apiUrl:t,apiKey:o}=Go(e);console.log(`[info] syncing to ${e.dir}${e.workspace?` (workspace: ${e.workspace})`:""}`);let n=await rt({dir:e.dir,fetchSkills:r(()=>zo(t,o,e.workspace),"fetchSkills")});for(let s of n.written)console.log(`${k("created")} ${s}/SKILL.md`);for(let s of n.updated)console.log(`${k("added")} ${s}/SKILL.md (rewritten in place)`);if(console.log(`[info] ${n.written.length} written, ${n.updated.length} updated, ${n.unchanged.length} unchanged`),n.orphans.length>0){console.log(`[warn] ${n.orphans.length} orphaned folder(s) -- left on disk, not deleted:`);for(let s of n.orphans)console.log(` ${s}/ (its context no longer resolves)`)}}r(Zo,"runSkillsSync");function en(e){let t="claude",o=!1,n,s;for(let i=0;i<e.length;i++){let c=e[i],a=e[i+1];if(c==="--format"&&a){if(!ae.includes(a))throw new Error(`--format must be one of: ${ae.join(", ")}`);t=a,i++}else if(c==="--agent"&&a)n=a,i++;else if(c==="--workspace"&&a)s=a,i++;else if(c==="--require-config")o=!0;else throw new Error(`unknown "hook boot" argument "${c}"`)}return{format:t,agent:n,workspace:s,requireConfig:o}}r(en,"parseHookBootArgs");async function tn(e){try{let t=en(e),o=await We({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd(),env:process.env,stdinText:await Ge(),...t});o&&process.stdout.write(`${o}
115
+ `)}catch(t){console.error(`contextq hook boot: ${t.message}`)}}r(tn,"runHookBootCommand");async function on(){let e=process.argv.slice(2);if(e[0]==="hook"){if(e[1]!=="boot"){console.error(`contextq: unknown "hook" subcommand "${e[1]??""}" -- expected "boot"`),process.exitCode=1;return}await tn(e.slice(2));return}if(e[0]==="skills"){if(e[1]==="-h"||e[1]==="--help"){K();return}if(e[1]!=="sync"){console.error(`contextq: unknown "skills" subcommand "${e[1]??""}" -- expected "sync"`),K(),process.exitCode=1;return}try{let o=Wo(e.slice(2));await Zo(o)}catch(o){console.error(`contextq: ${o.message}`),process.exitCode=1}return}let t;try{t=Jo(e)}catch(o){console.error(`contextq: ${o.message}`),K(),process.exitCode=1;return}if(t.help||!t.command){K();return}if(t.command!=="init"){console.error(`contextq: unknown command "${t.command}"`),K(),process.exitCode=1;return}try{await Vo(t)}catch(o){console.error(`contextq: ${o.message}`),process.exitCode=1}}r(on,"main");on();