@caveman-ai/cloud 0.1.0-rc.28 → 0.1.0-rc.30
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/README.md +28 -7
- package/bin/agents.mjs +268 -0
- package/bin/cvm.mjs +10 -113
- package/bin/setup.mjs +315 -0
- package/bin/tui.mjs +168 -0
- package/dist/cloud-client.mjs +1 -1
- package/dist/cloud-mcp.mjs +5 -5
- package/dist/skills/caveman-cloud/SKILL.md +61 -113
- package/dist/skills/caveman-cloud/references/caveman-cloud-compare.md +65 -5
- package/dist/skills/caveman-cloud/references/caveman-cloud-connect.md +83 -47
- package/dist/skills/caveman-cloud/references/caveman-cloud-console.md +81 -0
- package/dist/skills/caveman-cloud/references/caveman-cloud-criteria.md +86 -0
- package/dist/skills/caveman-cloud/references/caveman-cloud-dashboards.md +38 -32
- package/dist/skills/caveman-cloud/references/caveman-cloud-evals.md +84 -162
- package/dist/skills/caveman-cloud/references/caveman-cloud-project.md +12 -2
- package/dist/skills/caveman-cloud/references/caveman-cloud-scenarios.md +3 -11
- package/dist/skills/caveman-cloud/references/caveman-cloud-setup-inventory.md +51 -0
- package/dist/skills/caveman-cloud/references/caveman-cloud-setup-wiring.md +45 -0
- package/dist/skills/caveman-cloud/references/caveman-cloud-setup.md +17 -74
- package/dist/skills/caveman-cloud/references/caveman-cloud-traces.md +73 -6
- package/dist/skills/caveman-cloud/references/caveman-discover.md +76 -114
- package/dist/skills/caveman-cloud/references/caveman-evidence-review.md +9 -4
- package/dist/skills/caveman-cloud/references/caveman-onboard.md +8 -7
- package/dist/skills/caveman-cloud/references/caveman-setup-recipes.md +102 -0
- package/dist/skills/caveman-cloud/references/caveman-setup.md +92 -257
- package/dist/skills/caveman-cloud/references/caveman-sql.md +3 -0
- package/package.json +5 -2
package/bin/setup.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// `cvm setup` (ADR 0106, 0118): installs cvm when it runs from a package
|
|
2
|
+
// runner's cache, signs in, picks the project, finds the coding agents,
|
|
3
|
+
// installs the Caveman Cloud skill and registers the `caveman-cloud` MCP server
|
|
4
|
+
// in each, then offers to start the agent. Each step shows on the terminal and
|
|
5
|
+
// on the console's onboarding screen (a wizard session). Not on a terminal it
|
|
6
|
+
// asks nothing and prints one JSON object.
|
|
7
|
+
import {existsSync,realpathSync} from 'node:fs';
|
|
8
|
+
import {execFileSync,spawn} from 'node:child_process';
|
|
9
|
+
import {join,dirname,delimiter,sep} from 'node:path';
|
|
10
|
+
import {parseArgs} from 'node:util';
|
|
11
|
+
import {setTimeout as pause} from 'node:timers/promises';
|
|
12
|
+
import {agents,detected,onPath,skillDirs,installSkill,skillCurrent,removeSkill,connect,disconnect,version,tilde,quoted,stableNode} from './agents.mjs';
|
|
13
|
+
import {capabilities,screen,confirm,clip,colors} from './tui.mjs';
|
|
14
|
+
|
|
15
|
+
// The wizard session's tasks, in order: the console renders these ids.
|
|
16
|
+
const TASKS=[['install','Install cvm'],['sign_in','Sign in'],['project','Project'],['agents','Agents'],['skills','Skills'],['mcp','MCP'],['ready','Ready']];
|
|
17
|
+
const PROMPT='Set up Caveman Cloud for this repository.';
|
|
18
|
+
const byId=Object.fromEntries(agents.map(a=>[a.id,a]));
|
|
19
|
+
const names=ids=>ids.map(id=>byId[id].name);
|
|
20
|
+
const listed=items=>items.length<2?items.join(''):`${items.slice(0,-1).join(', ')} and ${items.at(-1)}`;
|
|
21
|
+
const plain=text=>String(text??'').replace(/[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/gu,' ');
|
|
22
|
+
const win=process.platform==='win32';
|
|
23
|
+
const quiet={c:colors({depth:0}),start(){},update(){},waiting(){},note(){},async end(){}};
|
|
24
|
+
const real=path=>{try{return realpathSync(path);}catch{}};
|
|
25
|
+
|
|
26
|
+
// What the onboarding screen shows of the repository, each only when git
|
|
27
|
+
// answers it in a form the server accepts.
|
|
28
|
+
function repository(){
|
|
29
|
+
const git=(...args)=>{try{return execFileSync('git',args,{encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:5000}).trim();}catch{return '';}};
|
|
30
|
+
const [,owner,name]=git('remote','get-url','origin').match(/([^/:]+)\/([^/]+?)(?:\.git)?\/?$/)??[];
|
|
31
|
+
const facts={repo_owner:owner,repo_name:name,default_branch:git('symbolic-ref','--short','refs/remotes/origin/HEAD').replace(/^origin\//,'')||git('branch','--show-current'),head_sha:git('rev-parse','HEAD')};
|
|
32
|
+
const valid={repo_owner:/^[A-Za-z0-9._-]{1,100}$/,repo_name:/^[A-Za-z0-9._-]{1,100}$/,default_branch:/^(?!-)(?!.*\.\.)[^\s~^:?*[\\\x00-\x1f\x7f]{1,255}$/,head_sha:/^[0-9a-f]{7,64}$/};
|
|
33
|
+
return Object.fromEntries(Object.entries(facts).filter(([key,value])=>value&&valid[key].test(value)));
|
|
34
|
+
}
|
|
35
|
+
const exec=(command,args)=>new Promise(resolve=>{
|
|
36
|
+
let err='';const child=spawn(command,args,{stdio:['ignore','ignore','pipe'],shell:win});
|
|
37
|
+
child.stderr.on('data',d=>err+=d);child.on('error',e=>resolve({code:1,err:e.message}));child.on('close',code=>resolve({code,err}));
|
|
38
|
+
});
|
|
39
|
+
// A cvm on PATH that is not a package runner's or a project's copy.
|
|
40
|
+
const globalCvm=()=>(process.env.PATH??'').split(delimiter).filter(dir=>dir&&!/[\\/](_npx|node_modules)([\\/]|$)/.test(dir)).map(dir=>join(dir,win?'cvm.cmd':'cvm')).find(existsSync);
|
|
41
|
+
const npmRoot=()=>{try{return execFileSync('npm',['root','-g'],{encoding:'utf8',timeout:20000,stdio:['ignore','pipe','ignore'],shell:win}).trim()||undefined;}catch{}};
|
|
42
|
+
// Semver order, prereleases included: is version a older than b? A version
|
|
43
|
+
// that does not parse is the oldest.
|
|
44
|
+
export function older(a,b){
|
|
45
|
+
const parse=v=>/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v??'');
|
|
46
|
+
const x=parse(a),y=parse(b);
|
|
47
|
+
if(!x)return Boolean(y);if(!y)return false;
|
|
48
|
+
for(let i=1;i<4;i++)if(Number(x[i])!==Number(y[i]))return Number(x[i])<Number(y[i]);
|
|
49
|
+
if(!x[4]||!y[4])return Boolean(x[4])&&!y[4]; // a prerelease comes before its release
|
|
50
|
+
const p=x[4].split('.'),q=y[4].split('.');
|
|
51
|
+
for(let i=0;i<Math.max(p.length,q.length);i++){
|
|
52
|
+
if(p[i]===q[i])continue;
|
|
53
|
+
if(p[i]===undefined)return true;if(q[i]===undefined)return false;
|
|
54
|
+
const n=/^\d+$/.test(p[i]),m=/^\d+$/.test(q[i]);
|
|
55
|
+
return n&&m?Number(p[i])<Number(q[i]):n!==m?n:p[i]<q[i];
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function setup(args,{client,context,login,call,cvm}){
|
|
61
|
+
const {CloudError,CloudClient,inertJSON}=await client();
|
|
62
|
+
const usage='Use cvm setup [--project UUID] [--agent claude,codex,cursor,vscode,windsurf,gemini,opencode] [--yes] [--no-launch] [--remove] [--format json|table].';
|
|
63
|
+
let values;try{({values}=parseArgs({args,strict:true,options:{project:{type:'string'},agent:{type:'string'},remove:{type:'boolean'},format:{type:'string'},yes:{type:'boolean',short:'y'},'no-launch':{type:'boolean'}}}));}catch{throw new CloudError('cave_usage',usage,2);}
|
|
64
|
+
const named=values.agent?.split(',').map(id=>id.trim()).filter(Boolean);
|
|
65
|
+
if(values.format&&!['json','table'].includes(values.format)||named&&(!named.length||named.some(id=>!byId[id])))throw new CloudError('cave_usage',usage,2);
|
|
66
|
+
const json=(values.format??(process.stdout.isTTY?'table':'json'))==='json';
|
|
67
|
+
const say=lines=>process.stdout.write(lines.join('\n')+'\n');
|
|
68
|
+
if(values.remove){
|
|
69
|
+
const removed=Object.values(skillDirs).filter(dir=>{try{return removeSkill(dir);}catch{return false;}});
|
|
70
|
+
const results=agents.map(a=>({agent:a.id,...disconnect(a)})).filter(r=>r.path);
|
|
71
|
+
const gone=results.filter(r=>!r.error).map(({agent,path})=>({agent,path})),stuck=results.filter(r=>r.error).map(({agent,path,error})=>({agent,path,reason:error}));
|
|
72
|
+
if(json)say([inertJSON({skill:{removed},mcp:{removed:gone,...stuck.length?{failed:stuck}:{}}})]);
|
|
73
|
+
else say([...removed.map(dir=>`Removed ${tilde(dir)}`),...gone.map(r=>`Removed the caveman-cloud MCP server from ${byId[r.agent].name} (${tilde(r.path)})`),
|
|
74
|
+
...stuck.map(r=>`✗ Could not remove the caveman-cloud MCP server from ${byId[r.agent].name}: ${plain(r.reason)}`),...results.length||removed.length?[]:['Nothing cvm installed was found.'],'cvm logout signs you out.']);
|
|
75
|
+
return stuck.length?1:0;
|
|
76
|
+
}
|
|
77
|
+
const k=capabilities();
|
|
78
|
+
// A prompt needs a terminal at both ends; --yes and JSON take the defaults.
|
|
79
|
+
const ask=!json&&k.animate&&Boolean(process.stdin.isTTY)&&!values.yes;
|
|
80
|
+
const rows=TASKS.map(([id,label])=>({id,label,status:'pending',detail:''}));
|
|
81
|
+
const ui=json?quiet:screen({rows,version,k}),c=ui.c;
|
|
82
|
+
const changed=[];
|
|
83
|
+
|
|
84
|
+
// The onboarding screen follows the session: the full task list on every
|
|
85
|
+
// change and every 10 s while open. Best effort: a failed report fails nothing.
|
|
86
|
+
let api,session,sessionClient,heartbeat,phase='planning',pending='',chain=Promise.resolve(),reported=false;
|
|
87
|
+
const report=(extra={})=>{
|
|
88
|
+
if(!session)return;
|
|
89
|
+
const body={session_id:session,phase,tasks:rows.map(({id,label,status,detail})=>({id,label,status,detail})),pending_input:pending,...extra};
|
|
90
|
+
chain=chain.then(()=>api.execute('wizard.update',body)).then(()=>{reported=true;},()=>{reported=false;});
|
|
91
|
+
};
|
|
92
|
+
const close=async(end,extra)=>{
|
|
93
|
+
clearInterval(heartbeat);
|
|
94
|
+
if(session){phase=end;pending='';report(extra);await Promise.race([chain,pause(5000,undefined,{ref:false})]);session=undefined;}
|
|
95
|
+
};
|
|
96
|
+
const step=(id,status,detail)=>{
|
|
97
|
+
const row=rows.find(r=>r.id===id);row.status=status;if(detail!==undefined)row.detail=clip(plain(detail),240);
|
|
98
|
+
ui.update();report();
|
|
99
|
+
};
|
|
100
|
+
// Ctrl-C: the cursor back, the session cancelled, and what changed said.
|
|
101
|
+
let cancelling=false;
|
|
102
|
+
const cancel=async()=>{
|
|
103
|
+
if(cancelling)return;cancelling=true;
|
|
104
|
+
process.stdin.setRawMode?.(false);
|
|
105
|
+
await ui.end('ash');await close('cancelled');
|
|
106
|
+
const rerun=`Run cvm setup again to finish${changed.length?', or cvm setup --remove to take it back':''}.`;
|
|
107
|
+
say(json?[inertJSON({schema_version:1,ok:false,status:'failed',error:{code:'cave_cancelled',message:`Setup was cancelled. ${rerun}`},changed})]
|
|
108
|
+
:['Cancelled.',...changed.length?changed.map(line=>` changed: ${line}`):[' Nothing was changed.'],rerun]);
|
|
109
|
+
process.exit(130);
|
|
110
|
+
};
|
|
111
|
+
process.on('SIGINT',cancel);
|
|
112
|
+
const fail=async result=>{
|
|
113
|
+
const {code,...body}=result,message=body.error?.message??'Setup failed.';
|
|
114
|
+
for(const row of rows)if(row.status==='running')row.status='failed';
|
|
115
|
+
await ui.end('ash');await close('failed',{error:clip(plain(message),500)});
|
|
116
|
+
process.off('SIGINT',cancel);
|
|
117
|
+
say([json?inertJSON(body):`${c.red('✗')} ${plain(message)}`]);
|
|
118
|
+
return code||1;
|
|
119
|
+
};
|
|
120
|
+
ui.start();
|
|
121
|
+
|
|
122
|
+
// 1. cvm itself. A copy in a package runner's cache (npx, pnpm dlx, bunx) or
|
|
123
|
+
// in a project's node_modules installs globally once, so agents start an
|
|
124
|
+
// installed cvm and no config points at a copy that goes away. A global cvm
|
|
125
|
+
// as new or newer is used as it is.
|
|
126
|
+
const pkg=`@caveman-ai/cloud@${version}`,modules=/[\\/]node_modules[\\/]/.test(cvm);
|
|
127
|
+
const root=modules?npmRoot():undefined,globalRoot=root&&real(root);
|
|
128
|
+
const temporary=modules&&!(globalRoot&&cvm.startsWith(globalRoot+sep));
|
|
129
|
+
const installedAt=()=>{const path=root&&join(root,'@caveman-ai','cloud','bin','cvm.mjs');return path&&existsSync(path)?path:undefined;};
|
|
130
|
+
let installed=temporary?undefined:cvm;
|
|
131
|
+
step('install','running','');
|
|
132
|
+
if(!temporary)step('install','done',globalCvm()?'cvm is on your PATH':`running ${tilde(cvm)}`);
|
|
133
|
+
else{
|
|
134
|
+
const found=globalCvm(),current=found&&(()=>{try{return execFileSync(quoted([found])[0],['--version'],{encoding:'utf8',timeout:10000,stdio:['ignore','pipe','ignore'],shell:win}).trim();}catch{}})();
|
|
135
|
+
if(current&&!older(current,version)){
|
|
136
|
+
installed=installedAt()??(real(found)?.endsWith('cvm.mjs')?real(found):undefined);
|
|
137
|
+
step('install','done',`cvm ${current} is on your PATH`);
|
|
138
|
+
}else{
|
|
139
|
+
const yes=!ask||await ui.confirm(`${current?`Update cvm ${current} to`:'Install cvm'} ${version} with npm i -g?`);
|
|
140
|
+
if(yes===null)return cancel();
|
|
141
|
+
if(!yes)step('install','done','skipped · the MCP server runs through npx');
|
|
142
|
+
else{
|
|
143
|
+
step('install','running',`npm i -g ${pkg}`);
|
|
144
|
+
const result=await exec('npm',['i','-g',pkg]);
|
|
145
|
+
installed=result.code===0?installedAt():undefined;
|
|
146
|
+
if(installed)changed.push(`cvm ${version} installed (npm uninstall -g @caveman-ai/cloud removes it)`);
|
|
147
|
+
step('install',installed?'done':'failed',installed?`cvm ${version} installed`:`npm i -g failed (${plain(result.err).match(/E[A-Z]{3,}/)?.[0]??`exit ${result.code}`}) · the MCP server runs through npx`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Apps do not inherit the shell's PATH: they get absolute paths. Windows
|
|
152
|
+
// starts the .cmd shims (cvm, npx) only through a shell, which no agent uses,
|
|
153
|
+
// so there every agent gets node and a script.
|
|
154
|
+
const node=stableNode(),npxCli=join(dirname(process.execPath),'node_modules','npm','bin','npx-cli.js');
|
|
155
|
+
const app=installed?[node,installed,'mcp']:win&&existsSync(npxCli)?[node,npxCli,'-y',pkg,'mcp']:['npx','-y',pkg,'mcp'];
|
|
156
|
+
const shell=!win&&installed&&globalCvm()?['cvm','mcp']:app;
|
|
157
|
+
|
|
158
|
+
// 2. A credential that answers, or a sign-in in the browser.
|
|
159
|
+
step('sign_in','running','');
|
|
160
|
+
let me=await call('auth','status');
|
|
161
|
+
if(me.code===3&&!process.env.CAVE_TOKEN){
|
|
162
|
+
step('sign_in','running','confirm the code in your browser');
|
|
163
|
+
ui.waiting(true);
|
|
164
|
+
try{
|
|
165
|
+
await login([],{onCode:json?undefined:(code,link)=>{ui.bell();ui.note(()=>[`${c.dim('Code')} ${c.accent(c.bold([...plain(code)].join(' ')))}`,`${c.dim('Open')} ${plain(link)}`]);}});
|
|
166
|
+
}catch(error){ui.note();return fail({code:error.exitCode??1,schema_version:1,ok:false,status:'failed',error:{code:error.code??'cave_failed',message:error.message}});}
|
|
167
|
+
ui.note();ui.waiting(false);
|
|
168
|
+
me=await call('auth','status');
|
|
169
|
+
}
|
|
170
|
+
if(me.code)return fail(me);
|
|
171
|
+
// 3. The project, resolved as every cvm command resolves it, saved the way
|
|
172
|
+
// `cvm context set` saves it.
|
|
173
|
+
step('project','running','');
|
|
174
|
+
const shown=await call('context','show',...(values.project?['--project',values.project]:[]));
|
|
175
|
+
if(shown.code)return fail(shown);
|
|
176
|
+
step('sign_in','done',`${me.data.user.email} · ${new URL(shown.endpoint).host}`);
|
|
177
|
+
let project;
|
|
178
|
+
if(shown.project_id){const got=await call('projects','get',shown.project_id);if(got.code)return fail(got);project=got.data;}
|
|
179
|
+
else{
|
|
180
|
+
const list=await call('projects','list');if(list.code)return fail(list);
|
|
181
|
+
const projects=(list.data.data??[]).map(({id,name})=>({id,name}));
|
|
182
|
+
if(projects.length===1)project=projects[0];
|
|
183
|
+
else if(ask&&projects.length){
|
|
184
|
+
let at=0;
|
|
185
|
+
project=await ui.ask(width=>[c.bold('Which project?'),...projects.slice(Math.max(0,Math.min(at-3,projects.length-7)),Math.max(0,Math.min(at-3,projects.length-7))+7).map(p=>`${p===projects[at]?c.accent(ui.g.pointer):' '} ${clip(plain(p.name),Math.max(8,width-42))} ${c.dim(p.id)}`)],(key,done)=>{
|
|
186
|
+
if(['return','enter'].includes(key.name))done(projects[at]);
|
|
187
|
+
else if(['up','k'].includes(key.name))at=(at-1+projects.length)%projects.length;
|
|
188
|
+
else if(['down','j','tab'].includes(key.name))at=(at+1)%projects.length;
|
|
189
|
+
},'↑↓ enter');
|
|
190
|
+
if(!project)return cancel();
|
|
191
|
+
}else{
|
|
192
|
+
const message=`This sign-in reaches ${projects.length} projects: pass --project UUID.`;
|
|
193
|
+
await ui.end('ash');process.off('SIGINT',cancel);
|
|
194
|
+
say(json?[inertJSON({schema_version:1,ok:false,status:'failed',error:{code:'cave_project_required',message},projects})]:[message,...projects.map(p=>` ${p.id} ${plain(p.name)}`)]);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const saved=await call('context','set',project.id);if(saved.code)return fail(saved);
|
|
199
|
+
step('project','done',project.name);
|
|
200
|
+
|
|
201
|
+
// The session starts once there is a project. Its client is the agent the
|
|
202
|
+
// hand-off starts, else the first one connected; a pick that changes that
|
|
203
|
+
// starts the session again (the server cancels the first).
|
|
204
|
+
const found=detected(),launchable=id=>['claude','codex'].includes(id)&&onPath(id),handOff=list=>list.find(launchable)??list[0];
|
|
205
|
+
const begin=async id=>{
|
|
206
|
+
try{
|
|
207
|
+
api??=new CloudClient({...await context(),projectId:project.id});
|
|
208
|
+
session=(await api.execute('wizard.start',{client:id,...repository()})).data.id;sessionClient=id;
|
|
209
|
+
clearInterval(heartbeat);heartbeat=setInterval(()=>report(),10000);heartbeat.unref();
|
|
210
|
+
}catch{}
|
|
211
|
+
report();
|
|
212
|
+
};
|
|
213
|
+
const preset=named??found;
|
|
214
|
+
await begin(handOff(preset)??'codex');
|
|
215
|
+
|
|
216
|
+
// 4. The agents: every one found, or the ones named.
|
|
217
|
+
step('agents','running',names(preset).join(' · ')||'none found');
|
|
218
|
+
let chosen=preset;
|
|
219
|
+
if(ask&&!named){
|
|
220
|
+
const items=agents.map(a=>({id:a.id,on:found.includes(a.id)}));let at=0;
|
|
221
|
+
const pick=()=>items.filter(item=>item.on).map(item=>item.id);
|
|
222
|
+
phase='awaiting_confirmation';pending='Which coding agents should Caveman Cloud connect to?';report();
|
|
223
|
+
chosen=await ui.ask(width=>{
|
|
224
|
+
const lines=[c.dim('space toggles · enter connects · esc cancels')];let line='',length=0;
|
|
225
|
+
for(const [i,item] of items.entries()){
|
|
226
|
+
const text=`${item.on?ui.g.on:ui.g.off} ${byId[item.id].name}${found.includes(item.id)?'':' (not found)'}`;
|
|
227
|
+
if(length&&length+3+text.length>width){lines.push(line);line='';length=0;}
|
|
228
|
+
line+=(length?' ':' ')+(i===at?c.accent(text):found.includes(item.id)?text:c.dim(text));length+=(length?3:1)+text.length;
|
|
229
|
+
}
|
|
230
|
+
return [...lines,line];
|
|
231
|
+
},(key,done)=>{
|
|
232
|
+
if(['return','enter'].includes(key.name))return done(pick());
|
|
233
|
+
if(key.name==='space')items[at].on=!items[at].on;
|
|
234
|
+
else if(['up','left','k','h'].includes(key.name)||key.name==='tab'&&key.shift)at=(at-1+items.length)%items.length;
|
|
235
|
+
else if(['down','right','j','l','tab'].includes(key.name))at=(at+1)%items.length;
|
|
236
|
+
rows[3].detail=names(pick()).join(' · ')||'none';
|
|
237
|
+
},'↑↓ space enter');
|
|
238
|
+
if(!chosen)return cancel();
|
|
239
|
+
phase='planning';pending='';
|
|
240
|
+
if(session&&chosen.length&&handOff(chosen)!==sessionClient)await begin(handOff(chosen));
|
|
241
|
+
}
|
|
242
|
+
const none=chosen.length?undefined:found.length&&ask&&!named?'No agent chosen.':'No coding agent found on this machine.';
|
|
243
|
+
step('agents',none?'blocked':'done',names(chosen).join(' · ')||none);
|
|
244
|
+
|
|
245
|
+
// 5. The skill, into each agent's skill directory (a directory several read
|
|
246
|
+
// is written once).
|
|
247
|
+
phase='editing';
|
|
248
|
+
const skill=new Map();
|
|
249
|
+
if(!none){
|
|
250
|
+
step('skills','running','');
|
|
251
|
+
for(const dir of new Set(chosen.map(id=>byId[id].skill))){
|
|
252
|
+
try{
|
|
253
|
+
if(skillCurrent(dir)){skill.set(dir,{status:'unchanged'});continue;}
|
|
254
|
+
const refused=installSkill(dir);
|
|
255
|
+
skill.set(dir,refused?{status:'failed',reason:`left ${tilde(dir)} alone: ${refused}`}:{status:'changed'});
|
|
256
|
+
if(!refused)changed.push(`skill in ${tilde(dir)}`);
|
|
257
|
+
}catch(error){skill.set(dir,{status:'failed',reason:`${tilde(dir)}: ${error.message}`});}
|
|
258
|
+
}
|
|
259
|
+
step('skills',[...skill.values()].some(s=>s.status==='failed')?'failed':'done',[...skill.keys()].map(tilde).join(' · '));
|
|
260
|
+
}else step('skills','blocked','no coding agent to install it for');
|
|
261
|
+
|
|
262
|
+
// 6. The MCP server, in every chosen agent.
|
|
263
|
+
const mcp=new Map();
|
|
264
|
+
if(!none){
|
|
265
|
+
step('mcp','running','');
|
|
266
|
+
for(const id of chosen){
|
|
267
|
+
const result=connect(byId[id],byId[id].gui?app:shell);mcp.set(id,result);
|
|
268
|
+
if(result.status==='changed')changed.push(`MCP server in ${byId[id].name} (${tilde(byId[id].config)})`);
|
|
269
|
+
}
|
|
270
|
+
const counts=['changed','unchanged','failed'].map(s=>[s,[...mcp.values()].filter(r=>r.status===s).length]).filter(([,n])=>n);
|
|
271
|
+
step('mcp',counts.some(([s])=>s==='failed')?'failed':'done',counts.map(([s,n])=>`${n} ${s}`).join(' · '));
|
|
272
|
+
}else step('mcp','blocked','no coding agent to connect');
|
|
273
|
+
|
|
274
|
+
// 7. Ready: every agent's outcome, the session closed, the hand-off.
|
|
275
|
+
const results=chosen.map(id=>{
|
|
276
|
+
const s=skill.get(byId[id].skill),m=mcp.get(id),reason=[s.reason,m.reason].filter(Boolean).join('; ');
|
|
277
|
+
return {id,skill:s.status,mcp:m.status,...reason?{reason}:{}};
|
|
278
|
+
});
|
|
279
|
+
const problems=results.filter(r=>r.skill==='failed'||r.mcp==='failed');
|
|
280
|
+
const ok=!none&&!problems.length,connected=results.filter(r=>r.mcp!=='failed').map(r=>byId[r.id].name);
|
|
281
|
+
step('ready',ok?'done':'failed',ok?`connected to ${listed(connected)}`:none??`${problems.length} of ${chosen.length} agents need attention`);
|
|
282
|
+
await close(ok?'completed':'failed',ok?{}:{error:clip(plain(none??`${byId[problems[0].id].name}: ${problems[0].reason}`),500)});
|
|
283
|
+
await ui.end(ok?'flare':'ash');
|
|
284
|
+
process.off('SIGINT',cancel);
|
|
285
|
+
const target=handOff(chosen);
|
|
286
|
+
const next=`Open ${target?byId[target].name:'your coding agent'} in your repository and ask: "${PROMPT}"`;
|
|
287
|
+
if(json){
|
|
288
|
+
const installedSkills=chosen.filter(id=>skill.get(byId[id].skill).status!=='failed').map(id=>({agent:id,path:byId[id].skill}));
|
|
289
|
+
const refused=[...skill].filter(([,s])=>s.status==='failed').map(([path,s])=>({path,reason:s.reason.replace(/^left \S+ alone: /,'')}));
|
|
290
|
+
say([inertJSON({ok,signed_in:true,api:shown.endpoint,project:{id:project.id,name:project.name},skill:{version,installed:installedSkills,refused},agents:results,onboarding_reported:reported,next})]);
|
|
291
|
+
return ok?0:1;
|
|
292
|
+
}
|
|
293
|
+
const lines=[''];
|
|
294
|
+
if(connected.length)lines.push(`${c.green('✓')} Caveman Cloud is connected to ${listed(connected)}.`);
|
|
295
|
+
for(const r of results){
|
|
296
|
+
if(r.reason&&!problems.includes(r))lines.push(` ${byId[r.id].name}: ${r.reason}.`);
|
|
297
|
+
if(!problems.includes(r))continue;
|
|
298
|
+
lines.push(`${c.red('✗')} ${byId[r.id].name}: ${r.reason}${mcp.get(r.id).snippet?':':'.'}`);
|
|
299
|
+
if(r.mcp==='failed'&&mcp.get(r.id).snippet)lines.push(...mcp.get(r.id).snippet.split('\n').map(line=>` ${line}`));
|
|
300
|
+
}
|
|
301
|
+
if(none)lines.push(`${c.yellow('!')} ${none} ${found.length?'Run cvm setup again to choose one.':'Install one (Claude Code, Codex, Cursor, …), then run cvm setup again.'}`);
|
|
302
|
+
if(connected.length)lines.push('','Ask your agent:',c.bold(PROMPT));
|
|
303
|
+
say(lines);
|
|
304
|
+
// The hand-off: one key starts the agent with the task.
|
|
305
|
+
if(ask&&!values['no-launch']&&target&&launchable(target)&&connected.includes(byId[target].name)){
|
|
306
|
+
say(['']);
|
|
307
|
+
if(await confirm(`Start ${byId[target].name} here now?`,{k})){
|
|
308
|
+
const ignore=()=>{};process.on('SIGINT',ignore); // Ctrl-C belongs to the agent now
|
|
309
|
+
const code=await new Promise(resolve=>spawn(target,quoted([PROMPT]),{stdio:'inherit',shell:win}).on('error',()=>resolve(1)).on('close',resolve));
|
|
310
|
+
process.off('SIGINT',ignore);
|
|
311
|
+
return code??0;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return ok?0:1;
|
|
315
|
+
}
|
package/bin/tui.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The `cvm setup` screen: the mark as a sprite beside a row per step, redrawn
|
|
2
|
+
// in place as one live region that stays in the scrollback when it ends. Raw
|
|
3
|
+
// ANSI and node:readline, nothing else.
|
|
4
|
+
import {emitKeypressEvents} from 'node:readline';
|
|
5
|
+
import {setTimeout as pause} from 'node:timers/promises';
|
|
6
|
+
|
|
7
|
+
// docs/brand/assets/caveman-mark.svg, a pixel per cell; rows 0-2 are the tips.
|
|
8
|
+
const MARK=[' ## ',' #### ',' #### ',' ## ## ',' ## ## ','## ##',' ## ## ',' #### '];
|
|
9
|
+
// [truecolor, 256-colour]
|
|
10
|
+
const ORANGE=['#f36716',202],TIPS=[['#ff8a2a',208],['#ffb347',214],['#ffd27a',222]],FLARE=['#fff0c8',230],EMBER=['#4a1f08',52],ASH=[['#5a5a5a',240],['#7c7c7c',243],['#a0a0a0',247]];
|
|
11
|
+
const LABEL=13,WIDE=60;
|
|
12
|
+
const visible=line=>line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g,'');
|
|
13
|
+
export const clip=(text,width)=>text.length>width?`${text.slice(0,Math.max(0,width-1))}…`:text;
|
|
14
|
+
|
|
15
|
+
// What this terminal can show. CI, TERM=dumb or a pipe get no animation and
|
|
16
|
+
// no live region; NO_COLOR gets no colour (and the sprite in #).
|
|
17
|
+
export function capabilities(out=process.stdout,env=process.env){
|
|
18
|
+
const ci=Boolean(env.CI)&&env.CI!=='0'&&env.CI.toLowerCase()!=='false',dumb=env.TERM==='dumb',color=Boolean(out.isTTY)&&!dumb&&!env.NO_COLOR;
|
|
19
|
+
return {
|
|
20
|
+
animate:Boolean(out.isTTY)&&!ci&&!dumb,
|
|
21
|
+
depth:!color?0:/^(truecolor|24bit)$/i.test(env.COLORTERM??'')?24:8,
|
|
22
|
+
// A legacy Windows console (conhost on a raster font) has none of these glyphs.
|
|
23
|
+
ascii:dumb||process.platform==='win32'&&!env.WT_SESSION&&env.TERM_PROGRAM!=='vscode',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const ink=(depth,[hex,n])=>text=>depth===24?`\x1b[38;2;${[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)).join(';')}m${text}\x1b[0m`:depth?`\x1b[38;5;${n}m${text}\x1b[0m`:text;
|
|
27
|
+
export function colors({depth}){
|
|
28
|
+
const sgr=code=>text=>depth?`\x1b[${code}m${text}\x1b[0m`:text;
|
|
29
|
+
return {bold:sgr('1'),dim:sgr('2'),green:sgr('32'),red:sgr('31'),yellow:sgr('33'),accent:ink(depth,ORANGE)};
|
|
30
|
+
}
|
|
31
|
+
export const glyphs=({ascii})=>ascii
|
|
32
|
+
?{pending:'[ ]',running:'>',done:'+',failed:'x',blocked:'!',on:'[x]',off:'[ ]',pointer:'>',pixel:'#',width:3}
|
|
33
|
+
:{pending:'◻',running:'▸',done:'✓',failed:'✗',blocked:'!',on:'◼',off:'◻',pointer:'›',pixel:'■',width:1};
|
|
34
|
+
|
|
35
|
+
// 8 rows of 16 columns. ignite lights the rows bottom to top over ticks 0-8,
|
|
36
|
+
// idle flickers the tips, flare is one bright frame, ash is a failure.
|
|
37
|
+
export function sprite(state,tick,k){
|
|
38
|
+
const pixel=!k.depth||k.ascii?'#':'■';
|
|
39
|
+
return MARK.map((row,r)=>[...row].map((cell,c)=>{
|
|
40
|
+
if(cell===' ')return ' ';
|
|
41
|
+
if(state==='ignite'&&r<8-tick)return k.depth?ink(k.depth,EMBER)(pixel)+' ':' ';
|
|
42
|
+
const tone=state==='ash'?ASH[(r+c)%3]:state==='flare'?FLARE:r>2?ORANGE:state==='idle'?TIPS[(r*5+c*3+tick+(tick>>1)*c)%3]:TIPS[0];
|
|
43
|
+
return ink(k.depth,tone)(pixel)+' ';
|
|
44
|
+
}).join(''));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The screen at a width: the sprite and the rows beside it, or from 60 columns
|
|
48
|
+
// down a one-row wordmark over the rows. Every row is clipped, never wrapped.
|
|
49
|
+
export function frame({rows,version,art,hint},width,k){
|
|
50
|
+
const c=colors(k),g=glyphs(k),tone={pending:c.dim,running:c.accent,done:c.green,failed:c.red,blocked:c.yellow};
|
|
51
|
+
const count=`${rows.filter(r=>r.status==='done').length}/${rows.length}`;
|
|
52
|
+
const line=(row,room)=>{
|
|
53
|
+
const right=row.status==='running'&&hint?hint:row===rows.at(-1)?count:'';
|
|
54
|
+
const head=`${g[row.status].padEnd(g.width)} ${row.label.padEnd(LABEL)}`;
|
|
55
|
+
if(head.length>=room)return tone[row.status](clip(head,room));
|
|
56
|
+
const detail=clip(row.detail,room-head.length-(right?right.length+2:0)),gap=room-head.length-detail.length-right.length;
|
|
57
|
+
return `${tone[row.status](g[row.status].padEnd(g.width))} ${row.status==='pending'?c.dim(row.label.padEnd(LABEL)):row.label.padEnd(LABEL)}${c.dim(detail)}${right&&gap>=2?' '.repeat(gap)+c.dim(right):''}`;
|
|
58
|
+
};
|
|
59
|
+
const tag=`cvm ${version}`;
|
|
60
|
+
if(width<WIDE){
|
|
61
|
+
const title=`${g.pixel} Caveman Cloud`;
|
|
62
|
+
return [`${c.accent(g.pixel)} ${c.bold('Caveman Cloud')}${title.length+2+tag.length<=width?` ${c.dim(tag)}`:''}`,...rows.map(row=>` ${line(row,width-2)}`)];
|
|
63
|
+
}
|
|
64
|
+
const room=width-18,pad=room-'Caveman Cloud'.length-tag.length;
|
|
65
|
+
return [`${art[0]} ${c.bold('Caveman Cloud')}${pad>=2?' '.repeat(pad)+c.dim(tag):''}`,art[1],...rows.map((row,i)=>`${art[i+2]??' '.repeat(16)} ${line(row,room)}`)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A frame drawn in place: back to its first row, clear down, draw. A terminal
|
|
69
|
+
// made narrower since the last draw has wrapped those rows; the rewind counts them.
|
|
70
|
+
export function region(out,write){
|
|
71
|
+
let shown=[];
|
|
72
|
+
const height=()=>shown.reduce((n,line)=>n+Math.max(1,Math.ceil(visible(line).length/(out.columns||80))),0);
|
|
73
|
+
return {
|
|
74
|
+
draw(lines){const up=shown.length?height()-1:0;write(`${up?`\x1b[${up}A`:''}\r\x1b[J${lines.join('\n')}`);shown=lines;},
|
|
75
|
+
close(){if(shown.length)write('\n');shown=[];},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Keys typed or pasted before a prompt drew are not an answer to it: what is
|
|
80
|
+
// still buffered arrives in the first moments and is dropped, and an Enter in
|
|
81
|
+
// the first 400 ms is the tail of a double tap. Ctrl-C always counts.
|
|
82
|
+
export function keys(input,onKey){
|
|
83
|
+
emitKeypressEvents(input);
|
|
84
|
+
while(input.read()!==null);
|
|
85
|
+
input.setRawMode?.(true);input.resume();
|
|
86
|
+
const shown=Date.now();
|
|
87
|
+
const handler=(_,key={})=>{
|
|
88
|
+
const age=Date.now()-shown;
|
|
89
|
+
if(key.ctrl&&key.name==='c'||age>=150&&!(age<400&&['return','enter'].includes(key.name)))onKey(key);
|
|
90
|
+
};
|
|
91
|
+
input.on('keypress',handler);
|
|
92
|
+
return ()=>{input.off('keypress',handler);input.setRawMode?.(false);input.pause();};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The setup screen over `rows` ({label,status,detail}, changed by the caller,
|
|
96
|
+
// then update()). Without animation it prints notes as they come and the
|
|
97
|
+
// final frame once.
|
|
98
|
+
export function screen({rows,version,out=process.stdout,input=process.stdin,k=capabilities(out)}){
|
|
99
|
+
// Bound now: cvm's call() swaps process.stdout.write while a command runs.
|
|
100
|
+
const write=out.write.bind(out),c=colors(k),g=glyphs(k),area=region(out,write);
|
|
101
|
+
let state='ignite',tick=0,timer,extra=()=>[],hint='',open=false;
|
|
102
|
+
const width=()=>Math.max(20,(out.columns||80)-1);
|
|
103
|
+
const paint=()=>{if(open)area.draw([...frame({rows,version,art:sprite(state,tick,k),hint},width(),k),...extra(width())]);};
|
|
104
|
+
const every=(fps,step)=>{clearInterval(timer);timer=setInterval(()=>{step();paint();},1000/fps);timer.unref();};
|
|
105
|
+
const restore=()=>write('\x1b[?25h');
|
|
106
|
+
const ui={
|
|
107
|
+
c,g,
|
|
108
|
+
start(){
|
|
109
|
+
if(!k.animate)return;
|
|
110
|
+
open=true;write('\x1b[?25l');process.once('exit',restore);out.on('resize',paint);
|
|
111
|
+
every(25,()=>{if(++tick>8){state='steady';clearInterval(timer);}});paint();
|
|
112
|
+
},
|
|
113
|
+
update:paint,
|
|
114
|
+
// The tips flicker while the screen waits on the browser or a key.
|
|
115
|
+
waiting(on){if(!open)return;if(on){state='idle';every(8,()=>tick++);}else{clearInterval(timer);state='steady';paint();}},
|
|
116
|
+
// Lines under the frame (the sign-in code); a function of the width.
|
|
117
|
+
note(make){
|
|
118
|
+
if(!open){for(const line of make?.(width())??[])write(line+'\n');return;}
|
|
119
|
+
extra=make?w=>['',...make(w)]:()=>[];paint();
|
|
120
|
+
},
|
|
121
|
+
bell:()=>write('\x07'),
|
|
122
|
+
// A question under the frame: render(width) draws it, onKey(key,done)
|
|
123
|
+
// answers it. Esc or Ctrl-C resolves null.
|
|
124
|
+
ask(render,onKey,keyHint=''){
|
|
125
|
+
return new Promise(resolve=>{
|
|
126
|
+
hint=keyHint;ui.note(render);ui.bell();ui.waiting(true);
|
|
127
|
+
const stop=keys(input,key=>{
|
|
128
|
+
if(key.name==='escape'||key.ctrl&&key.name==='c')return done(null);
|
|
129
|
+
onKey(key,done);paint();
|
|
130
|
+
});
|
|
131
|
+
function done(value){stop();hint='';ui.note();ui.waiting(false);resolve(value);}
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
confirm(question){
|
|
135
|
+
let yes=true;
|
|
136
|
+
return ui.ask(()=>[`${question} ${c.dim(g.pointer)} ${yes?c.accent(c.bold('Yes')):c.dim('Yes')} ${c.dim('/')} ${yes?c.dim('No'):c.accent(c.bold('No'))}`],(key,done)=>{
|
|
137
|
+
if(key.name==='y')done(true);else if(key.name==='n')done(false);
|
|
138
|
+
else if(['return','enter'].includes(key.name))done(yes);
|
|
139
|
+
else if(['left','right','tab','h','l','up','down'].includes(key.name))yes=!yes;
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
// The last frame: a flare on Ready, ash on a failure; it stays in the scrollback.
|
|
143
|
+
async end(final){
|
|
144
|
+
clearInterval(timer);
|
|
145
|
+
if(!open){if(!k.animate)write(frame({rows,version,art:sprite(final==='ash'?'ash':'steady',0,k)},width(),k).join('\n')+'\n');return;}
|
|
146
|
+
extra=()=>[];hint='';state=final;paint();
|
|
147
|
+
if(final==='flare'){await pause(160);state='steady';paint();}
|
|
148
|
+
area.close();open=false;out.off('resize',paint);restore();process.off('exit',restore);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
return ui;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A yes/no question on its own line in the scrollback.
|
|
155
|
+
export function confirm(question,{out=process.stdout,input=process.stdin,k=capabilities(out)}={}){
|
|
156
|
+
const write=out.write.bind(out),c=colors(k),g=glyphs(k),area=region(out,write);
|
|
157
|
+
let yes=true;
|
|
158
|
+
const draw=active=>area.draw([active?`${question} ${c.dim(g.pointer)} ${yes?c.accent(c.bold('Yes')):c.dim('Yes')} ${c.dim('/')} ${yes?c.dim('No'):c.accent(c.bold('No'))}`:`${question} ${c.dim(g.pointer)} ${yes?'Yes':'No'}`]);
|
|
159
|
+
return new Promise(resolve=>{
|
|
160
|
+
draw(true);write('\x07');
|
|
161
|
+
const stop=keys(input,key=>{
|
|
162
|
+
if(key.name==='escape'||key.ctrl&&key.name==='c'||key.name==='n')yes=false;
|
|
163
|
+
else if(key.name==='y')yes=true;
|
|
164
|
+
else if(!['return','enter'].includes(key.name)){if(['left','right','tab','h','l','up','down'].includes(key.name))yes=!yes;return draw(true);}
|
|
165
|
+
stop();draw(false);area.close();resolve(yes);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|