@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/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
Every Caveman Cloud operation from the terminal, plus the Cloud MCP server.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
7
|
-
cvm setup --project PROJECT_ID # signs in, picks the project, installs the Caveman Cloud skill
|
|
6
|
+
npx -y @caveman-ai/cloud@rc setup --project PROJECT_ID # installs cvm, signs in, connects your coding agents
|
|
8
7
|
```
|
|
9
8
|
|
|
9
|
+
`@rc` is the newest release; `latest` is still the 0.0.1 bootstrap. `npm i -g @caveman-ai/cloud@rc` installs cvm by itself.
|
|
10
|
+
|
|
10
11
|
```sh
|
|
11
12
|
cvm login # browser sign-in; one sign-in for cvm and caveman
|
|
12
13
|
cvm projects list
|
|
@@ -24,13 +25,33 @@ cvm mcp # Cloud MCP server over stdio
|
|
|
24
25
|
|
|
25
26
|
## Setup
|
|
26
27
|
|
|
27
|
-
`cvm setup`
|
|
28
|
+
`cvm setup` connects your coding agents to Caveman Cloud. Each step is safe to run again, and running it again is how you repair a setup.
|
|
29
|
+
|
|
30
|
+
1. **Install cvm.** Run from a package runner's cache (npx, pnpm dlx, bunx) or a project's `node_modules`, it installs its own version with `npm i -g` (asked on a terminal) unless a cvm as new is already on `PATH`, so your agents start an installed `cvm`. If npm can't (EACCES), setup goes on and the agents run `npx -y @caveman-ai/cloud@VERSION mcp` instead.
|
|
31
|
+
2. **Sign in**, unless the saved sign-in or `CAVE_TOKEN` already answers.
|
|
32
|
+
3. **Pick the project**: `--project UUID`, else `CAVE_PROJECT_ID`, the repository's binding or the saved default, else your only project. With several, a terminal gets a picker; otherwise setup lists them and exits 2. The project is saved as the default, as `cvm context set` does.
|
|
33
|
+
4. **Find the agents**: Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and OpenCode, by their CLI on `PATH` or their config directory. A terminal gets a list to tick; otherwise every agent found is used. `--agent claude,codex,…` names them.
|
|
34
|
+
5. **Install the Caveman Cloud skill**: `~/.claude/skills/caveman-cloud` for Claude Code (`$CLAUDE_CONFIG_DIR/skills` when set), and `~/.agents/skills/caveman-cloud`, which the other six read.
|
|
35
|
+
6. **Register the MCP server** `caveman-cloud` (stdio) in each agent's user config:
|
|
36
|
+
|
|
37
|
+
| Agent | Config |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Claude Code | `claude mcp add --scope user`, else `~/.claude.json` `mcpServers` |
|
|
40
|
+
| Codex | `~/.codex/config.toml` `[mcp_servers.caveman-cloud]` (`$CODEX_HOME`) |
|
|
41
|
+
| Cursor | `~/.cursor/mcp.json` `mcpServers` |
|
|
42
|
+
| VS Code | `mcp.json` `servers` in your user profile (`~/Library/Application Support/Code/User`, `~/.config/Code/User`, `%APPDATA%\Code\User`) |
|
|
43
|
+
| Windsurf | `~/.config/devin/mcp_config.json` when present, else `~/.codeium/windsurf/mcp_config.json`, `mcpServers` |
|
|
44
|
+
| Gemini CLI | `~/.gemini/settings.json` `mcpServers` |
|
|
45
|
+
| OpenCode | `~/.config/opencode/opencode.json` `mcp` (`type: local`) |
|
|
46
|
+
|
|
47
|
+
Terminal agents run `cvm mcp`. Cursor, VS Code and Windsurf don't inherit your shell's `PATH`, so they run `node` and `cvm.mjs` by absolute path.
|
|
48
|
+
7. **Ready**: what changed for each agent. On a terminal, setup then offers to start Claude Code or Codex with the task below (`--no-launch` or `--yes` skip the offer).
|
|
49
|
+
|
|
50
|
+
Setup reports each step to the console's onboarding screen as it runs (best effort). It writes nothing in the repository. In an agent's config it inserts its one entry and leaves the rest of the file byte for byte. It leaves alone a file that isn't plain JSON (one with comments), and a `caveman-cloud` server it didn't add; both are reported with the entry to add yourself (exit 1). It replaces the `caveman cloud mcp-serve` entry an older `caveman` wrote. It replaces only a skill it installed itself (marked by a `.cvm-version` file).
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
2. **Picks the project**: `--project UUID`, else `CAVE_PROJECT_ID`, the repository's binding or the saved default, else your only project (with more than one it lists them and exits 2). It checks the project and saves it as the default, as `cvm context set` does.
|
|
31
|
-
3. **Installs the Caveman Cloud skill** for the coding agents it finds: Claude Code in `~/.claude/skills/caveman-cloud` (`$CLAUDE_CONFIG_DIR/skills` when set), Codex and Cursor in `~/.agents/skills/caveman-cloud`. With none found it installs to `~/.agents/skills`. `--agent claude|codex|cursor` installs for that agent only.
|
|
52
|
+
`cvm setup --remove` takes back the skills and the MCP entries it added, restoring each config to what it was when cvm's entry was the only change, and keeps you signed in. Every later `cvm` command refreshes an installed skill when cvm was upgraded. Then open your agent in the repository and ask it: "Set up Caveman Cloud for this repository."
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
Not on a terminal (an agent's shell, CI, `--format json`), setup asks nothing and prints one JSON object: `ok`, `api`, `project`, `skill`, `agents` (`[{id, skill, mcp, reason}]`, each `changed`, `unchanged` or `failed`), `onboarding_reported` and `next`. `NO_COLOR` turns colour off.
|
|
34
55
|
|
|
35
56
|
## An eval in one file
|
|
36
57
|
|
package/bin/agents.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// The coding agents `cvm setup` connects: where each keeps its skills and its
|
|
2
|
+
// MCP servers, and the edits that add or take back the Caveman Cloud ones.
|
|
3
|
+
// Every path here is the agent's documented user-level location (ADR 0118).
|
|
4
|
+
import {readFileSync,writeFileSync,mkdirSync,renameSync,existsSync,realpathSync,cpSync,rmSync,readdirSync,statSync,unlinkSync} from 'node:fs';
|
|
5
|
+
import {execFileSync} from 'node:child_process';
|
|
6
|
+
import {randomUUID} from 'node:crypto';
|
|
7
|
+
import {homedir,tmpdir} from 'node:os';
|
|
8
|
+
import {join,dirname,delimiter} from 'node:path';
|
|
9
|
+
import {fileURLToPath} from 'node:url';
|
|
10
|
+
|
|
11
|
+
export const version=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
12
|
+
export const NAME='caveman-cloud';
|
|
13
|
+
const home=homedir(),env=process.env,win=process.platform==='win32';
|
|
14
|
+
const xdg=env.XDG_CONFIG_HOME||join(home,'.config'),appData=env.APPDATA||join(home,'AppData','Roaming');
|
|
15
|
+
const claudeDir=env.CLAUDE_CONFIG_DIR||join(home,'.claude'),codexDir=env.CODEX_HOME||join(home,'.codex');
|
|
16
|
+
export const onPath=name=>(env.PATH??'').split(delimiter).find(dir=>dir&&['',...(win?['.cmd','.exe']:[])].some(ext=>existsSync(join(dir,name+ext))));
|
|
17
|
+
|
|
18
|
+
// The Caveman Cloud skill, built from public/skills into this package. Claude
|
|
19
|
+
// Code reads its own skills directory; every other agent here reads the open
|
|
20
|
+
// Agent Skills one.
|
|
21
|
+
export const skillDirs={claude:join(claudeDir,'skills',NAME),agents:join(home,'.agents','skills',NAME)};
|
|
22
|
+
export const marker='.cvm-version';
|
|
23
|
+
const bundle=fileURLToPath(new URL('../dist/skills/caveman-cloud',import.meta.url));
|
|
24
|
+
// One cvm at a time changes a skills directory, and first clears what an
|
|
25
|
+
// interrupted one left. A lock over a minute old is a crashed cvm's (two cvms
|
|
26
|
+
// taking over the same stale lock at one moment can both go ahead).
|
|
27
|
+
export function locked(dir,work){
|
|
28
|
+
const parent=dirname(dir),lock=join(parent,'.caveman-cloud.lock'),busy='another cvm is installing it';
|
|
29
|
+
try{mkdirSync(lock);}catch(error){
|
|
30
|
+
if(error.code!=='EEXIST')throw error;
|
|
31
|
+
if(Date.now()-statSync(lock).mtimeMs<60000)return busy;
|
|
32
|
+
rmSync(lock,{recursive:true,force:true});
|
|
33
|
+
try{mkdirSync(lock);}catch{return busy;}
|
|
34
|
+
}
|
|
35
|
+
try{
|
|
36
|
+
for(const name of readdirSync(parent))if(name.startsWith('.caveman-cloud-'))rmSync(join(parent,name),{recursive:true,force:true});
|
|
37
|
+
return work();
|
|
38
|
+
}finally{rmSync(lock,{recursive:true,force:true});}
|
|
39
|
+
}
|
|
40
|
+
// Only a directory cvm wrote (its marker says so) is replaced, by renames: an
|
|
41
|
+
// agent reading it finds the old skill or the new one, never half of either.
|
|
42
|
+
export function installSkill(dir){
|
|
43
|
+
mkdirSync(dirname(dir),{recursive:true});
|
|
44
|
+
return locked(dir,()=>{
|
|
45
|
+
if(existsSync(dir)&&!existsSync(join(dir,marker)))return 'it holds a caveman-cloud skill cvm did not install';
|
|
46
|
+
const next=join(dirname(dir),`.caveman-cloud-${randomUUID()}`),old=next+'-old';
|
|
47
|
+
try{
|
|
48
|
+
cpSync(bundle,next,{recursive:true});writeFileSync(join(next,marker),version);
|
|
49
|
+
if(existsSync(dir))renameSync(dir,old);
|
|
50
|
+
renameSync(next,dir);
|
|
51
|
+
}catch(error){
|
|
52
|
+
rmSync(next,{recursive:true,force:true});
|
|
53
|
+
if(!existsSync(dir)&&existsSync(old))renameSync(old,dir);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
rmSync(old,{recursive:true,force:true});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// The installed skill is this version's bundle, file for file.
|
|
60
|
+
export function skillCurrent(dir){
|
|
61
|
+
const same=(a,b)=>readdirSync(a,{withFileTypes:true}).every(e=>e.isDirectory()?same(join(a,e.name),join(b,e.name)):readFileSync(join(a,e.name)).equals(readFileSync(join(b,e.name))));
|
|
62
|
+
try{return readFileSync(join(dir,marker),'utf8')===version&&same(bundle,dir);}catch{return false;}
|
|
63
|
+
}
|
|
64
|
+
export const removeSkill=dir=>existsSync(dirname(dir))&&locked(dir,()=>existsSync(join(dir,marker))&&(rmSync(dir,{recursive:true,force:true}),true))===true;
|
|
65
|
+
|
|
66
|
+
// A JSON config: the servers live under one key; an edit inserts or cuts text
|
|
67
|
+
// and never re-serializes the file, so comments-free formatting, key order and
|
|
68
|
+
// every other server stay byte for byte.
|
|
69
|
+
function jsonFormat(key,entry){
|
|
70
|
+
return {
|
|
71
|
+
entry,
|
|
72
|
+
read(text){const parsed=text.trim()?JSON.parse(text):{};if(!parsed||typeof parsed!=='object'||Array.isArray(parsed)||parsed[key]!==undefined&&(typeof parsed[key]!=='object'||!parsed[key]||Array.isArray(parsed[key])))throw new Error('unexpected shape');return parsed[key]?.[NAME];},
|
|
73
|
+
insert(text,value){
|
|
74
|
+
if(!text.trim())return [0,JSON.stringify({[key]:{[NAME]:value}},null,2)+'\n'];
|
|
75
|
+
const top=members(text,text.indexOf('{')),unit=top.list.length&&top.multiline?top.indent:' ',box=top.list.find(m=>m.key===key);
|
|
76
|
+
return box?into(text,members(text,box.valueStart),NAME,value,unit):into(text,top,key,{[NAME]:value},unit);
|
|
77
|
+
},
|
|
78
|
+
cut(text){
|
|
79
|
+
const box=members(text,text.indexOf('{')).list.find(m=>m.key===key);if(!box)return text;
|
|
80
|
+
const inner=members(text,box.valueStart),i=inner.list.findIndex(m=>m.key===NAME);if(i<0)return text;
|
|
81
|
+
if(inner.list.length===1)return text.slice(0,box.valueStart+1)+text.slice(inner.close);
|
|
82
|
+
return i?text.slice(0,inner.list[i-1].end)+text.slice(inner.list[i].end):text.slice(0,inner.list[0].start)+text.slice(inner.list[1].start);
|
|
83
|
+
},
|
|
84
|
+
snippet:value=>JSON.stringify({[key]:{[NAME]:value}},null,2),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// An object's members in valid JSON text, from the index of its '{'.
|
|
88
|
+
function members(text,at){
|
|
89
|
+
let i=at+1;const list=[];
|
|
90
|
+
const ws=()=>{while(/\s/.test(text[i]))i++;};
|
|
91
|
+
const string=()=>{const start=i++;while(i<text.length&&text[i]!=='"')i+=text[i]==='\\'?2:1;return JSON.parse(text.slice(start,++i));};
|
|
92
|
+
const value=()=>{
|
|
93
|
+
if(text[i]==='"')return string();
|
|
94
|
+
if(text[i]!=='{'&&text[i]!=='['){while(i<text.length&&!/[\s,}\]]/.test(text[i]))i++;return;}
|
|
95
|
+
let depth=0;
|
|
96
|
+
do{if(text[i]==='"'){string();continue;}if('{['.includes(text[i]))depth++;else if('}]'.includes(text[i]))depth--;i++;}while(depth&&i<text.length);
|
|
97
|
+
};
|
|
98
|
+
for(;;){
|
|
99
|
+
ws();if(text[i]===',')i++,ws();
|
|
100
|
+
if(text[i]==='}'||i>=text.length)break;
|
|
101
|
+
const start=i,key=string();ws();i++;ws();const valueStart=i;value();list.push({key,start,valueStart,end:i});
|
|
102
|
+
}
|
|
103
|
+
const first=list[0]?.start??i,lead=text.slice(at+1,first);
|
|
104
|
+
return {list,open:at,close:i,multiline:lead.includes('\n'),indent:lead.slice(lead.lastIndexOf('\n')+1)};
|
|
105
|
+
}
|
|
106
|
+
// Where and what to insert so `name: value` becomes the object's last member,
|
|
107
|
+
// in the object's own layout.
|
|
108
|
+
function into(text,box,name,value,unit){
|
|
109
|
+
const last=box.list.at(-1);
|
|
110
|
+
if(last&&!box.multiline)return [last.end,`, ${JSON.stringify(name)}: ${JSON.stringify(value)}`];
|
|
111
|
+
const member=indent=>`${JSON.stringify(name)}: ${JSON.stringify(value,null,unit).replaceAll('\n','\n'+indent)}`;
|
|
112
|
+
if(last)return [last.end,`,\n${box.indent}${member(box.indent)}`];
|
|
113
|
+
const line=text.slice(text.lastIndexOf('\n',box.open)+1).match(/^[ \t]*/)[0];
|
|
114
|
+
return [box.open+1,`\n${line+unit}${member(line+unit)}\n${line}`];
|
|
115
|
+
}
|
|
116
|
+
// Codex's config.toml: the server is its table and any sub-tables under it
|
|
117
|
+
// ([mcp_servers.caveman-cloud.env]). cvm appends one table and cuts them all.
|
|
118
|
+
const own=`mcp_servers.${NAME}`;
|
|
119
|
+
function tomlTables(text){
|
|
120
|
+
const heads=[...text.matchAll(/^[ \t]*\[\[?([^\]\n]+)\]\]?[ \t]*(?:#.*)?$/gm)];
|
|
121
|
+
return heads.map((m,i)=>({name:m[1].replace(/["'\s]/g,''),start:m.index,body:m.index+m[0].length,end:heads[i+1]?.index??text.length}));
|
|
122
|
+
}
|
|
123
|
+
const toml={
|
|
124
|
+
entry:command=>({command:command[0],args:command.slice(1)}),
|
|
125
|
+
read(text){
|
|
126
|
+
const main=tomlTables(text).find(t=>t.name===own);
|
|
127
|
+
if(!main){if(/mcp_servers[ \t]*\.[ \t]*["']?caveman-cloud\b|^[ \t]*["']?caveman-cloud["']?[ \t]*=/m.test(text))throw new Error('unexpected shape');return;}
|
|
128
|
+
const body=text.slice(main.body,main.end);
|
|
129
|
+
const field=name=>{const raw=body.match(new RegExp(`^[ \\t]*${name}[ \\t]*=[ \\t]*(.+)$`,'m'))?.[1];if(raw===undefined)return;try{return JSON.parse(raw);}catch{return JSON.parse(raw.replace(/\s+#.*$/,''));}};
|
|
130
|
+
return {command:field('command'),args:field('args')??[]};
|
|
131
|
+
},
|
|
132
|
+
insert(text,value){
|
|
133
|
+
const table=`[${own}]\ncommand = ${JSON.stringify(value.command)}\nargs = ${JSON.stringify(value.args)}\n`;
|
|
134
|
+
return [text.length,(text&&!text.endsWith('\n')?'\n':'')+(text.trim()?'\n':'')+table];
|
|
135
|
+
},
|
|
136
|
+
cut(text){
|
|
137
|
+
for(const t of tomlTables(text).filter(t=>t.name===own||t.name.startsWith(own+'.')).reverse()){
|
|
138
|
+
// A table that ran to the end takes the blank line before it along.
|
|
139
|
+
const start=t.end>=text.length&&text[t.start-1]==='\n'&&text[t.start-2]==='\n'?t.start-1:t.start;
|
|
140
|
+
text=text.slice(0,start)+text.slice(t.end);
|
|
141
|
+
}
|
|
142
|
+
return text;
|
|
143
|
+
},
|
|
144
|
+
snippet:value=>toml.insert('',value)[1].trimEnd(),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const stdio=command=>({command:command[0],args:command.slice(1)});
|
|
148
|
+
const devin=win?join(appData,'devin'):join(xdg,'devin'),windsurf=join(home,'.codeium','windsurf');
|
|
149
|
+
const vscode=process.platform==='darwin'?join(home,'Library','Application Support','Code','User'):win?join(appData,'Code','User'):join(xdg,'Code','User');
|
|
150
|
+
const opencode=join(xdg,'opencode');
|
|
151
|
+
// gui: the agent is an app that does not inherit the shell's PATH, so its
|
|
152
|
+
// server runs by absolute paths.
|
|
153
|
+
export const agents=[
|
|
154
|
+
{id:'claude',name:'Claude Code',bin:'claude',dirs:[claudeDir],skill:skillDirs.claude,config:join(env.CLAUDE_CONFIG_DIR||home,'.claude.json'),format:jsonFormat('mcpServers',command=>({type:'stdio',...stdio(command),env:{}}))},
|
|
155
|
+
{id:'codex',name:'Codex',bin:'codex',dirs:[codexDir],skill:skillDirs.agents,config:join(codexDir,'config.toml'),format:toml},
|
|
156
|
+
{id:'cursor',name:'Cursor',gui:true,dirs:[join(home,'.cursor')],skill:skillDirs.agents,config:join(home,'.cursor','mcp.json'),format:jsonFormat('mcpServers',command=>({type:'stdio',...stdio(command)}))},
|
|
157
|
+
{id:'vscode',name:'VS Code',gui:true,dirs:[vscode],skill:skillDirs.agents,config:join(vscode,'mcp.json'),format:jsonFormat('servers',command=>({type:'stdio',...stdio(command)}))},
|
|
158
|
+
// Windsurf is now Devin Desktop: its config moved to ~/.config/devin, and an
|
|
159
|
+
// older install still reads ~/.codeium/windsurf.
|
|
160
|
+
{id:'windsurf',name:'Windsurf',gui:true,dirs:[devin,windsurf],skill:skillDirs.agents,config:join(existsSync(devin)?devin:windsurf,'mcp_config.json'),format:jsonFormat('mcpServers',stdio)},
|
|
161
|
+
{id:'gemini',name:'Gemini CLI',bin:'gemini',dirs:[join(home,'.gemini')],skill:skillDirs.agents,config:join(home,'.gemini','settings.json'),format:jsonFormat('mcpServers',stdio)},
|
|
162
|
+
{id:'opencode',name:'OpenCode',bin:'opencode',dirs:[opencode],skill:skillDirs.agents,config:join(opencode,existsSync(join(opencode,'opencode.json'))||!existsSync(join(opencode,'opencode.jsonc'))?'opencode.json':'opencode.jsonc'),format:jsonFormat('mcp',command=>({type:'local',command,enabled:true}))},
|
|
163
|
+
];
|
|
164
|
+
// The node an app should start: the PATH's own `node` (Homebrew's
|
|
165
|
+
// /opt/homebrew/bin/node) when it is this one, because the versioned path
|
|
166
|
+
// process.execPath resolves to is gone after the next upgrade. A per-shell
|
|
167
|
+
// link (fnm's multishells, anything under the temp dir) is gone sooner.
|
|
168
|
+
export function stableNode(){
|
|
169
|
+
const dir=onPath('node');if(!dir)return process.execPath;
|
|
170
|
+
const path=join(dir,win?'node.exe':'node');
|
|
171
|
+
try{
|
|
172
|
+
const temp=realpathSync(tmpdir());
|
|
173
|
+
if(realpathSync(path)!==realpathSync(process.execPath)||/multishell/i.test(path)||realpathSync(dir).startsWith(temp))return process.execPath;
|
|
174
|
+
return path;
|
|
175
|
+
}catch{return process.execPath;}
|
|
176
|
+
}
|
|
177
|
+
export const detected=()=>agents.filter(a=>a.bin&&onPath(a.bin)||a.dirs.some(dir=>existsSync(dir))).map(a=>a.id);
|
|
178
|
+
|
|
179
|
+
// An entry is cvm's when it runs `cvm mcp`; caveman v4's `caveman cloud
|
|
180
|
+
// mcp-serve` entry of the same name is replaced; anything else is someone else's.
|
|
181
|
+
const words=entry=>[entry?.command,entry?.args].flat(2).filter(word=>typeof word==='string');
|
|
182
|
+
const owner=entry=>{
|
|
183
|
+
const w=words(entry);
|
|
184
|
+
if(w.includes('mcp')&&w.some(word=>/(^|[\\/])cvm(\.mjs|\.cmd|\.exe)?$/.test(word)||/^@caveman-ai\/cloud(@|$)/.test(word)))return 'cvm';
|
|
185
|
+
if(w.includes('cloud')&&w.includes('mcp-serve'))return 'v4';
|
|
186
|
+
};
|
|
187
|
+
const same=(a,b)=>JSON.stringify(words(a))===JSON.stringify(words(b));
|
|
188
|
+
|
|
189
|
+
// What cvm inserted into each config, so --remove takes back exactly that.
|
|
190
|
+
const statePath=join(env.CAVEMAN_HOME??join(home,'.caveman'),'cvm-setup.json');
|
|
191
|
+
function state(next){
|
|
192
|
+
let all={};try{all=JSON.parse(readFileSync(statePath,'utf8'));}catch{}
|
|
193
|
+
if(!next)return all;
|
|
194
|
+
const merged={...all,...next};for(const key of Object.keys(merged))if(!merged[key])delete merged[key];
|
|
195
|
+
mkdirSync(dirname(statePath),{recursive:true,mode:0o700});
|
|
196
|
+
const tmp=`${statePath}.${randomUUID()}.tmp`;writeFileSync(tmp,JSON.stringify(merged,null,2)+'\n',{mode:0o600});renameSync(tmp,statePath);
|
|
197
|
+
}
|
|
198
|
+
const readText=path=>{try{return readFileSync(path,'utf8');}catch(error){if(error.code==='ENOENT')return null;throw error;}};
|
|
199
|
+
function writeText(path,text){
|
|
200
|
+
if(existsSync(path))path=realpathSync(path); // a symlinked config stays a symlink
|
|
201
|
+
let mode=0o600;try{mode=statSync(path).mode&0o777;}catch{}
|
|
202
|
+
mkdirSync(dirname(path),{recursive:true});
|
|
203
|
+
const tmp=`${path}.${randomUUID()}.tmp`;writeFileSync(tmp,text,{mode,flag:'wx'});renameSync(tmp,path);
|
|
204
|
+
}
|
|
205
|
+
// Windows runs the agents' .cmd shims through a shell, which splits on spaces.
|
|
206
|
+
export const quoted=args=>win?args.map(arg=>/[\s"]/.test(arg)?`"${arg.replaceAll('"','""')}"`:arg):args;
|
|
207
|
+
const run=(bin,args)=>execFileSync(bin,quoted(args),{stdio:['ignore','pipe','pipe'],timeout:20000,shell:win});
|
|
208
|
+
|
|
209
|
+
const why=error=>String(error.stderr||error.message).trim().split('\n')[0].slice(0,160);
|
|
210
|
+
// The config without cvm's entry. The exact text cvm inserted comes out only
|
|
211
|
+
// when what is left parses, holds no entry, and a fresh insert would turn it
|
|
212
|
+
// back into this file: then the result is byte for byte the original. Any
|
|
213
|
+
// other edit since (a server pasted beside it) gets the entry cut out alone.
|
|
214
|
+
function without(format,text,before,existing){
|
|
215
|
+
if(before&&text.split(before.inserted).length===2){
|
|
216
|
+
const base=text.replace(before.inserted,'');
|
|
217
|
+
try{if(format.read(base)===undefined){const [at,again]=format.insert(base,existing);if(base.slice(0,at)+again+base.slice(at)===text)return base;}}catch{}
|
|
218
|
+
}
|
|
219
|
+
return format.cut(text);
|
|
220
|
+
}
|
|
221
|
+
// Adds the server to one agent: changed, unchanged or failed (with a reason,
|
|
222
|
+
// and the snippet to add by hand when the file is not cvm's to edit). Nothing
|
|
223
|
+
// is written that does not parse back with the entry in it.
|
|
224
|
+
export function connect(agent,command){
|
|
225
|
+
const {format,config}=agent,wanted=format.entry(command),snippet=format.snippet(wanted);
|
|
226
|
+
let text,existing;
|
|
227
|
+
try{text=readText(config);existing=format.read(text??'');}
|
|
228
|
+
catch{return {status:'failed',reason:`${tilde(config)} is not plain ${format===toml?'TOML cvm can read':'JSON'}; add the server yourself`,snippet};}
|
|
229
|
+
const who=existing&&owner(existing);
|
|
230
|
+
if(existing&&!who)return {status:'failed',reason:`${tilde(config)} already has a caveman-cloud server cvm did not add; left it alone`,snippet};
|
|
231
|
+
if(existing&&who==='cvm'&&same(existing,wanted))return {status:'unchanged'};
|
|
232
|
+
const replaced=who==='v4'?'replaced the caveman v4 entry':undefined;
|
|
233
|
+
try{
|
|
234
|
+
// Claude Code rewrites ~/.claude.json while it runs: its own CLI makes the change.
|
|
235
|
+
if(agent.id==='claude'&&onPath('claude')){
|
|
236
|
+
if(existing)run('claude',['mcp','remove',NAME,'--scope','user']);
|
|
237
|
+
run('claude',['mcp','add','--scope','user',NAME,'--',...command]);
|
|
238
|
+
return {status:'changed',reason:replaced};
|
|
239
|
+
}
|
|
240
|
+
const before=state()[config];
|
|
241
|
+
// A re-run first takes back its own earlier insert, so --remove can still
|
|
242
|
+
// restore the original.
|
|
243
|
+
const base=existing?without(format,text,before,existing):text??'';
|
|
244
|
+
const [at,inserted]=format.insert(base,wanted),next=base.slice(0,at)+inserted+base.slice(at);
|
|
245
|
+
let fits=false;try{fits=same(format.read(next),wanted);}catch{}
|
|
246
|
+
if(!fits)throw new Error('cvm could not insert its entry cleanly; nothing was written');
|
|
247
|
+
writeText(config,next);
|
|
248
|
+
if(!same(format.read(readText(config)),wanted))throw new Error('the server did not read back');
|
|
249
|
+
state({[config]:{inserted,created:text===null||Boolean(before?.created)}});
|
|
250
|
+
return {status:'changed',reason:replaced};
|
|
251
|
+
}catch(error){return {status:'failed',reason:`${tilde(config)}: ${why(error)}`,snippet};}
|
|
252
|
+
}
|
|
253
|
+
// Takes the server back out when it is cvm's. Returns {path} when it was
|
|
254
|
+
// removed, {path,error} when it could not be, nothing when there was none.
|
|
255
|
+
export function disconnect(agent){
|
|
256
|
+
const {format,config}=agent;
|
|
257
|
+
let text,existing;try{text=readText(config);existing=text===null?undefined:format.read(text);}catch{return;}
|
|
258
|
+
if(owner(existing)!=='cvm')return;
|
|
259
|
+
try{
|
|
260
|
+
if(agent.id==='claude'&&onPath('claude')){run('claude',['mcp','remove',NAME,'--scope','user']);return {path:config};}
|
|
261
|
+
const before=state()[config],next=without(format,text,before,existing);
|
|
262
|
+
if(format.read(next)!==undefined)throw new Error('the entry did not come out cleanly; nothing was written');
|
|
263
|
+
if(before?.created&&!next.trim())unlinkSync(config);else writeText(config,next);
|
|
264
|
+
state({[config]:undefined});
|
|
265
|
+
return {path:config};
|
|
266
|
+
}catch(error){return {path:config,error:why(error)};}
|
|
267
|
+
}
|
|
268
|
+
export const tilde=path=>path===home||path.startsWith(home+(win?'\\':'/'))?'~'+path.slice(home.length):path;
|
package/bin/cvm.mjs
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
// points at the macOS Keychain item or the 0600 credentials file), so either
|
|
5
5
|
// CLI's sign-in serves both, and a refreshed pair goes back to the same place,
|
|
6
6
|
// so neither CLI ever signs the other out.
|
|
7
|
-
import {readFileSync,writeFileSync,mkdirSync,chmodSync,renameSync,existsSync,realpathSync,
|
|
7
|
+
import {readFileSync,writeFileSync,mkdirSync,chmodSync,renameSync,existsSync,realpathSync,rmSync} from 'node:fs';
|
|
8
8
|
import {execFileSync,spawn} from 'node:child_process';
|
|
9
9
|
import {randomUUID} from 'node:crypto';
|
|
10
10
|
import {homedir} from 'node:os';
|
|
11
|
-
import {join,dirname
|
|
11
|
+
import {join,dirname} from 'node:path';
|
|
12
12
|
import {fileURLToPath} from 'node:url';
|
|
13
13
|
import {parseArgs} from 'node:util';
|
|
14
14
|
import {setTimeout as pause} from 'node:timers/promises';
|
|
15
|
+
import {version,skillDirs,marker,installSkill} from './agents.mjs';
|
|
15
16
|
|
|
16
|
-
const version=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
17
17
|
const client=()=>import('../dist/cloud-client.mjs');
|
|
18
18
|
const cavemanHome=process.env.CAVEMAN_HOME??join(homedir(),'.caveman');
|
|
19
19
|
const credentialsPath=join(cavemanHome,'credentials');
|
|
@@ -93,8 +93,9 @@ function openBrowser(url){
|
|
|
93
93
|
try{spawn(command,args,{detached:true,stdio:'ignore'}).on('error',()=>{}).unref();}catch{}
|
|
94
94
|
}
|
|
95
95
|
// RFC 8628 device grant: the requests `caveman login` makes, and the
|
|
96
|
-
// credential stored where it stores one.
|
|
97
|
-
|
|
96
|
+
// credential stored where it stores one. onCode shows the code instead of
|
|
97
|
+
// stderr (the setup screen).
|
|
98
|
+
async function login(args=[],{onCode}={}){
|
|
98
99
|
const {CloudError}=await client();
|
|
99
100
|
let values;try{({values}=parseArgs({args,strict:true,options:{instance:{type:'string'},'no-browser':{type:'boolean'}}}));}catch{throw new CloudError('cave_usage','Use cvm login [--instance URL] [--no-browser].',2);}
|
|
100
101
|
const baseURL=origin(values.instance||process.env.CAVE_API_URL||readConfig().baseURL||'https://api.caveman.so');
|
|
@@ -105,7 +106,7 @@ async function login(args=[]){
|
|
|
105
106
|
if(started.status===403&&code.error?.code==='cave_device_login_disabled')throw new CloudError('cave_device_login_disabled','Device login is not open on this installation.',3);
|
|
106
107
|
if(!started.ok||typeof code.device_code!=='string')throw new CloudError(code.error?.code??'cave_device_login_failed',`Could not start sign-in at ${baseURL} (HTTP ${started.status}).`,1);
|
|
107
108
|
const link=code.verification_uri_complete??code.verification_uri;
|
|
108
|
-
process.stderr.write(`Confirm the code ${code.user_code} in your browser: ${link}\n`);
|
|
109
|
+
if(onCode)onCode(code.user_code,link);else process.stderr.write(`Confirm the code ${code.user_code} in your browser: ${link}\n`);
|
|
109
110
|
if(!values['no-browser']&&!process.env.CAVE_NO_BROWSER&&/^https?:\/\//.test(link))openBrowser(link);
|
|
110
111
|
let interval=Number(code.interval??5)*1000;const deadline=Date.now()+Number(code.expires_in??600)*1000;
|
|
111
112
|
const expired=new CloudError('expired_token','The code expired before it was confirmed. Run cvm login again.',3);
|
|
@@ -178,46 +179,6 @@ async function logout(){
|
|
|
178
179
|
return 0;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
// The Caveman Cloud skill, built from public/skills into this package. Claude
|
|
182
|
-
// Code reads its own skills directory; Codex and Cursor read the open Agent
|
|
183
|
-
// Skills one.
|
|
184
|
-
const skillDirs={claude:join(process.env.CLAUDE_CONFIG_DIR||join(homedir(),'.claude'),'skills','caveman-cloud'),agents:join(homedir(),'.agents','skills','caveman-cloud')};
|
|
185
|
-
const marker='.cvm-version',labels={claude:'Claude Code',codex:'Codex',cursor:'Cursor'};
|
|
186
|
-
// One cvm at a time changes a skills directory, and first clears what an
|
|
187
|
-
// interrupted one left. A lock over a minute old is a crashed cvm's (two cvms
|
|
188
|
-
// taking over the same stale lock at one moment can both go ahead).
|
|
189
|
-
function locked(dir,work){
|
|
190
|
-
const parent=dirname(dir),lock=join(parent,'.caveman-cloud.lock'),busy='another cvm is installing it';
|
|
191
|
-
try{mkdirSync(lock);}catch(error){
|
|
192
|
-
if(error.code!=='EEXIST')throw error;
|
|
193
|
-
if(Date.now()-statSync(lock).mtimeMs<60000)return busy;
|
|
194
|
-
rmSync(lock,{recursive:true,force:true});
|
|
195
|
-
try{mkdirSync(lock);}catch{return busy;}
|
|
196
|
-
}
|
|
197
|
-
try{
|
|
198
|
-
for(const name of readdirSync(parent))if(name.startsWith('.caveman-cloud-'))rmSync(join(parent,name),{recursive:true,force:true});
|
|
199
|
-
return work();
|
|
200
|
-
}finally{rmSync(lock,{recursive:true,force:true});}
|
|
201
|
-
}
|
|
202
|
-
// Only a directory cvm wrote (its marker says so) is replaced, by renames: an
|
|
203
|
-
// agent reading it finds the old skill or the new one, never half of either.
|
|
204
|
-
function installSkill(dir){
|
|
205
|
-
mkdirSync(dirname(dir),{recursive:true});
|
|
206
|
-
return locked(dir,()=>{
|
|
207
|
-
if(existsSync(dir)&&!existsSync(join(dir,marker)))return 'it holds a caveman-cloud skill cvm did not install';
|
|
208
|
-
const next=join(dirname(dir),`.caveman-cloud-${randomUUID()}`),old=next+'-old';
|
|
209
|
-
try{
|
|
210
|
-
cpSync(fileURLToPath(new URL('../dist/skills/caveman-cloud',import.meta.url)),next,{recursive:true});writeFileSync(join(next,marker),version);
|
|
211
|
-
if(existsSync(dir))renameSync(dir,old);
|
|
212
|
-
renameSync(next,dir);
|
|
213
|
-
}catch(error){
|
|
214
|
-
rmSync(next,{recursive:true,force:true});
|
|
215
|
-
if(!existsSync(dir)&&existsSync(old))renameSync(old,dir);
|
|
216
|
-
throw error;
|
|
217
|
-
}
|
|
218
|
-
rmSync(old,{recursive:true,force:true});
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
182
|
// Runs a client command for its JSON answer, the client's own printing held back.
|
|
222
183
|
async function call(...args){
|
|
223
184
|
const {runCloudCLI}=await client();
|
|
@@ -225,74 +186,10 @@ async function call(...args){
|
|
|
225
186
|
try{const code=await runCloudCLI([...args,'--format','json'],{context,login:loginCommand});return {...JSON.parse(out),code};}
|
|
226
187
|
finally{process.stdout.write=write;}
|
|
227
188
|
}
|
|
228
|
-
//
|
|
229
|
-
// answers it in a form the server accepts.
|
|
230
|
-
function repository(){
|
|
231
|
-
const git=(...args)=>{try{return execFileSync('git',args,{encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:5000}).trim();}catch{return '';}};
|
|
232
|
-
const [,owner,name]=git('remote','get-url','origin').match(/([^/:]+)\/([^/]+?)(?:\.git)?\/?$/)??[];
|
|
233
|
-
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')};
|
|
234
|
-
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}$/};
|
|
235
|
-
return Object.entries(facts).filter(([key,value])=>value&&valid[key].test(value));
|
|
236
|
-
}
|
|
189
|
+
// `cvm setup` lives in setup.mjs: the screen, the agents and the session.
|
|
237
190
|
async function setup(args){
|
|
238
|
-
const {
|
|
239
|
-
|
|
240
|
-
let values;try{({values}=parseArgs({args,strict:true,options:{project:{type:'string'},agent:{type:'string'},remove:{type:'boolean'},format:{type:'string'}}}));}catch{throw new CloudError('cave_usage',usage,2);}
|
|
241
|
-
const json=(values.format??(process.stdout.isTTY?'table':'json'))==='json';
|
|
242
|
-
if(values.format&&!['json','table'].includes(values.format)||values.agent&&!labels[values.agent])throw new CloudError('cave_usage',usage,2);
|
|
243
|
-
const plain=text=>String(text).replace(/[\x00-\x1f\x7f-\x9f\p{Cf}\u2028\u2029]/gu,' ');
|
|
244
|
-
const out=(value,lines)=>process.stdout.write((json?JSON.stringify(value):lines.join('\n'))+'\n');
|
|
245
|
-
const failed=({code,...result})=>(process.stdout.write(JSON.stringify(result)+'\n'),code);
|
|
246
|
-
if(values.remove){
|
|
247
|
-
const removed=Object.values(skillDirs).filter(dir=>existsSync(dirname(dir))&&locked(dir,()=>existsSync(join(dir,marker))&&(rmSync(dir,{recursive:true,force:true}),true))===true);
|
|
248
|
-
out({skill:{removed}},[...removed.map(dir=>`Removed ${dir}`),...removed.length?[]:['No Caveman Cloud skill installed by cvm.'],'cvm logout signs you out.']);
|
|
249
|
-
return 0;
|
|
250
|
-
}
|
|
251
|
-
// 1. A credential that answers, or a sign-in.
|
|
252
|
-
let me=await call('auth','status');
|
|
253
|
-
if(me.code===3&&!process.env.CAVE_TOKEN){await login();me=await call('auth','status');}
|
|
254
|
-
if(me.code)return failed(me);
|
|
255
|
-
// 2. The project, resolved as every cvm command resolves it, saved the way
|
|
256
|
-
// `cvm context set` saves it.
|
|
257
|
-
const shown=await call('context','show',...(values.project?['--project',values.project]:[]));
|
|
258
|
-
if(shown.code)return failed(shown);
|
|
259
|
-
let project;
|
|
260
|
-
if(shown.project_id){const got=await call('projects','get',shown.project_id);if(got.code)return failed(got);project=got.data;}
|
|
261
|
-
else{
|
|
262
|
-
const list=await call('projects','list');if(list.code)return failed(list);
|
|
263
|
-
const projects=(list.data.data??[]).map(({id,name})=>({id,name}));
|
|
264
|
-
if(projects.length!==1){
|
|
265
|
-
out({schema_version:1,ok:false,status:'failed',error:{code:'cave_project_required',message:`This sign-in reaches ${projects.length} projects: pass --project UUID.`},projects},[`This sign-in reaches ${projects.length} projects: pass --project UUID.`,...projects.map(p=>` ${p.id} ${plain(p.name)}`)]);
|
|
266
|
-
return 2;
|
|
267
|
-
}
|
|
268
|
-
project=projects[0];
|
|
269
|
-
}
|
|
270
|
-
const saved=await call('context','set',project.id);if(saved.code)return failed(saved);
|
|
271
|
-
// 3. The skill, for the agent named, or for every agent found here.
|
|
272
|
-
const has=name=>(process.env.PATH??'').split(delimiter).some(dir=>dir&&['','.cmd','.exe'].some(ext=>existsSync(join(dir,name+ext))));
|
|
273
|
-
const found=values.agent?[values.agent]:[['claude',dirname(dirname(skillDirs.claude))],['codex',join(homedir(),'.codex')],['cursor',join(homedir(),'.cursor')]].filter(([name,dir])=>has(name)||existsSync(dir)).map(([name])=>name);
|
|
274
|
-
const agents=found.length?found:['codex','cursor']; // none found: the open directory, which both read
|
|
275
|
-
const dirOf=agent=>agent==='claude'?skillDirs.claude:skillDirs.agents,outcome=new Map();
|
|
276
|
-
for(const dir of new Set(agents.map(dirOf))){try{outcome.set(dir,installSkill(dir));}catch(error){outcome.set(dir,error.message);}}
|
|
277
|
-
const installed=agents.filter(agent=>!outcome.get(dirOf(agent))).map(agent=>({agent,path:dirOf(agent)}));
|
|
278
|
-
const refused=[...outcome].filter(([,reason])=>reason).map(([path,reason])=>({path,reason}));
|
|
279
|
-
// 4. The onboarding screen follows along; it knows the two clients below.
|
|
280
|
-
// Best effort: no report is no failure.
|
|
281
|
-
const reporter=values.agent??found.find(agent=>agent!=='cursor');
|
|
282
|
-
let reported=false;
|
|
283
|
-
if(['claude','codex'].includes(reporter)&&installed.length&&!refused.length){
|
|
284
|
-
try{
|
|
285
|
-
const session=await call('wizard','start','--client',reporter,...repository().map(([key,value])=>`--${key.replaceAll('_','-')}=${value}`),'--project',project.id);
|
|
286
|
-
const tasks=[['sign_in','Sign in',me.data.user.email||new URL(shown.endpoint).host],['project','Choose the project',project.name],['skill','Install the Caveman Cloud skill',[...new Set(installed.map(i=>labels[i.agent]))].join(', ')]].map(([id,label,detail])=>({id,label,status:'done',detail:String(detail).slice(0,240)}));
|
|
287
|
-
reported=!session.code&&!(await call('wizard','update',session.data.id,'--phase','completed','--tasks',JSON.stringify(tasks),'--project',project.id)).code;
|
|
288
|
-
}catch{}
|
|
289
|
-
}
|
|
290
|
-
const next=`Open ${labels[reporter??found[0]]??'your coding agent'} in your repository and ask: "Set up Caveman Cloud for this repository."`;
|
|
291
|
-
out({ok:!refused.length,signed_in:true,api:shown.endpoint,project:{id:project.id,name:project.name},skill:{version,installed,refused},onboarding_reported:reported,next},[
|
|
292
|
-
`Signed in as ${plain(me.data.user.email)} at ${shown.endpoint}`,`Project ${plain(project.name)} (${project.id})`,
|
|
293
|
-
...[...new Set(installed.map(i=>i.path))].map(path=>`Installed the Caveman Cloud skill for ${installed.filter(i=>i.path===path).map(i=>labels[i.agent]).join(' and ')}: ${path}`),
|
|
294
|
-
...refused.map(r=>`Left ${r.path} alone: ${r.reason}`),'',next]);
|
|
295
|
-
return refused.length?1:0;
|
|
191
|
+
const {setup:run}=await import('./setup.mjs');
|
|
192
|
+
return run(args,{client,context,login,call,cvm:realpathSync(fileURLToPath(import.meta.url))});
|
|
296
193
|
}
|
|
297
194
|
|
|
298
195
|
const argv=process.argv.slice(2);
|