@captra-ai/cli 0.5.5 → 0.6.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/README.md
CHANGED
|
@@ -5,11 +5,14 @@ The Captra developer CLI for creating, inspecting, developing, testing, and buil
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install --save-dev @captra-ai/cli
|
|
7
7
|
npx captra inspect
|
|
8
|
+
npx captra secrets
|
|
8
9
|
npx captra dev
|
|
9
10
|
npx captra test
|
|
10
11
|
npx captra build
|
|
11
12
|
```
|
|
12
13
|
|
|
14
|
+
`captra secrets` follows the browser import graph and fails on server-only imports, server environment access, public secret variables, and recognizable credential literals without printing secret values. Use `captra secrets --dist` to inspect compiled browser assets.
|
|
15
|
+
|
|
13
16
|
Most new projects should start with `npx create-captra my-app`, which installs this CLI and generates the full-stack starter.
|
|
14
17
|
|
|
15
18
|
[CLI documentation](https://vaibhavbansal26.github.io/captra/getting-started) · [GitHub](https://github.com/VaibhavBansal26/captra/tree/main/packages/cli) · MIT
|
package/bin/captra.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { basename, dirname, resolve } from 'node:path'
|
|
3
|
+
import { basename, dirname, extname, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
|
|
7
7
|
const [, , command='help', ...arguments_] = process.argv
|
|
8
|
-
const argument=
|
|
8
|
+
const positional=arguments_.filter(value=>!value.startsWith('--')),argument=positional[0],root=resolve(argument??'.')
|
|
9
9
|
const templateRoot=resolve(dirname(fileURLToPath(import.meta.url)),'../templates/fullstack')
|
|
10
10
|
|
|
11
11
|
if(command==='create'||command==='init') createProject(root)
|
|
12
12
|
else if(command==='dev') run('npm',['run','dev'],root)
|
|
13
|
-
else if(command==='build')
|
|
13
|
+
else if(command==='build'){if(!secretCheck(root,{dist:false}))process.exit(1);run('npm',['run','build'],root)}
|
|
14
14
|
else if(command==='test') run('npm',['test'],root)
|
|
15
15
|
else if(command==='inspect') inspect(root)
|
|
16
16
|
else if(command==='doctor') doctor(root)
|
|
17
|
+
else if(command==='secrets'||command==='check-secrets'){if(!secretCheck(root,{dist:arguments_.includes('--dist')}))process.exitCode=1}
|
|
17
18
|
else if(command==='add') add(arguments_[0],arguments_[1],resolve(arguments_[2]??'.'))
|
|
18
19
|
else help()
|
|
19
20
|
|
|
@@ -44,11 +45,32 @@ function doctor(directory){
|
|
|
44
45
|
check('Application contract',existsSync(resolve(directory,'src/captra.app.ts')),'create src/captra.app.ts')
|
|
45
46
|
check('Capability directory',existsSync(resolve(directory,'src/capabilities')),'create src/capabilities')
|
|
46
47
|
const configs=['tsconfig.json','tsconfig.app.json','tsconfig.server.json'].some(file=>existsSync(resolve(directory,file)));check('TypeScript configuration',configs,'add a tsconfig.json')
|
|
47
|
-
|
|
48
|
-
check('No browser-exposed AI secrets',exposed.length===0,exposed.length?`rename ${exposed.join(', ')} and keep it server-only`:'keep provider keys server-only')
|
|
48
|
+
check('No browser-exposed secrets',secretCheck(directory,{dist:false,quiet:true}),'run captra secrets for findings')
|
|
49
49
|
console.log(`\n${findings.every(item=>item.passed)?'Ready — no blocking issues found.':`${findings.filter(item=>!item.passed).length} issue(s) need attention.`}\n`)
|
|
50
50
|
if(findings.some(item=>!item.passed))process.exitCode=1
|
|
51
51
|
}
|
|
52
|
+
function secretCheck(directory,{dist=false,quiet=false}={}){
|
|
53
|
+
const config=readSecurityConfig(directory),findings=[...scanPublicEnv(directory,config)]
|
|
54
|
+
const sources=dist?listFiles(resolve(directory,config.outputDirectory??'dist'),file=>/\.(?:js|mjs|cjs|html|css)$/i.test(file)):browserGraph(directory,config)
|
|
55
|
+
for(const file of sources)if(!ignored(file,directory,config.ignoreFiles)){const content=readFileSync(file,'utf8');findings.push(...scanContent(content,file,directory,config,{compiled:dist}))}
|
|
56
|
+
if(!quiet){console.log(`\nCaptra secret check: ${directory}${dist?' (compiled output)':''}`);if(findings.length)for(const finding of findings)console.log(`× ${finding.file}:${finding.line} — ${finding.message}`);else console.log(`✓ No browser-reachable secrets or server-only imports found`);console.log('')}
|
|
57
|
+
return findings.length===0
|
|
58
|
+
}
|
|
59
|
+
function readSecurityConfig(directory){const path=resolve(directory,'package.json');if(!existsSync(path))return {};try{return JSON.parse(readFileSync(path,'utf8')).captra?.security?.secretCheck??{}}catch{return {}}}
|
|
60
|
+
function scanPublicEnv(directory,config){const findings=[];for(const file of ['.env','.env.local','.env.development','.env.production']){const path=resolve(directory,file);if(!existsSync(path)||ignored(path,directory,config.ignoreFiles))continue;readFileSync(path,'utf8').split(/\r?\n/).forEach((line,index)=>{const name=line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1];if(name&&isSensitiveName(name)&&isPublicEnv(name)&&!(config.allowEnv??[]).includes(name))findings.push({file,line:index+1,message:`public environment variable ${name} looks secret`})})}return findings}
|
|
61
|
+
function scanContent(content,file,directory,config,{compiled}){const findings=[],display=relative(directory,file)||basename(file),lines=content.split(/\r?\n/),allowEnv=new Set(config.allowEnv??[]),serverImports=/^(?:node:|captra\/(?:server|openai|postgres)|@captra-ai\/(?:server|openai|postgres))(?:\/|$)/
|
|
62
|
+
lines.forEach((line,index)=>{
|
|
63
|
+
if(!compiled&&!/^\s*(?:import|export)\s+type\b/.test(line)){for(const match of line.matchAll(/(?:from\s*|import\s*(?:\(\s*)?|require\s*\(\s*)['"]([^'"]+)['"]/g))if(serverImports.test(match[1]))findings.push({file:display,line:index+1,message:`server-only import ${match[1]} is browser-reachable`})}
|
|
64
|
+
for(const match of line.matchAll(/(?:process\.env|import\.meta\.env)(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[['"]([^'"]+)['"]\])/g)){const name=match[1]??match[2];if(!allowEnv.has(name)&&(match[0].startsWith('process.env')||isSensitiveName(name)))findings.push({file:display,line:index+1,message:`browser-reachable environment access ${name}`})}
|
|
65
|
+
if(/(?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}|gh(?:p|o|u|s|r)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/.test(line))findings.push({file:display,line:index+1,message:'credential-like literal found; value hidden'})
|
|
66
|
+
});return findings
|
|
67
|
+
}
|
|
68
|
+
function browserGraph(directory,config){const configured=(config.browserEntries??[]).map(value=>resolve(directory,value)),defaults=['src/main.ts','src/main.tsx','src/main.js','src/main.jsx','src/client.ts','src/client.tsx','src/client.js','src/client.jsx'].map(value=>resolve(directory,value)),queue=(configured.length?configured:defaults).filter(existsSync),seen=new Set(),files=[];while(queue.length){const file=queue.shift();if(seen.has(file)||!existsSync(file))continue;seen.add(file);files.push(file);const content=readFileSync(file,'utf8');for(const line of content.split(/\r?\n/)){if(/^\s*import\s+type\b/.test(line)||/^\s*export\s+type\b/.test(line))continue;for(const match of line.matchAll(/(?:from\s*|import\s*\()\s*['"](\.[^'"]+)['"]/g)){const target=resolveModule(dirname(file),match[1]);if(target)queue.push(target)}}}return files}
|
|
69
|
+
function resolveModule(directory,specifier){const base=resolve(directory,specifier),withoutJs=/\.js$/i.test(base)?base.slice(0,-3):base;for(const candidate of [base,withoutJs,`${withoutJs}.ts`,`${withoutJs}.tsx`,`${withoutJs}.js`,`${withoutJs}.jsx`,resolve(withoutJs,'index.ts'),resolve(withoutJs,'index.tsx'),resolve(withoutJs,'index.js')])if(existsSync(candidate)&&statSync(candidate).isFile())return candidate}
|
|
70
|
+
function listFiles(directory,accept){if(!existsSync(directory))return [];const files=[];for(const entry of readdirSync(directory)){const path=resolve(directory,entry),stats=statSync(path);if(stats.isDirectory())files.push(...listFiles(path,accept));else if(accept(path))files.push(path)}return files}
|
|
71
|
+
function ignored(file,directory,patterns=[]){const name=relative(directory,file).replaceAll('\\','/');return patterns.some(pattern=>name===pattern||pattern.endsWith('/**')&&name.startsWith(pattern.slice(0,-3)))}
|
|
72
|
+
function isSensitiveName(name){return /(?:OPENAI|ANTHROPIC|GEMINI|GOOGLE.*AI|DATABASE|POSTGRES|API[_-]?KEY|SECRET|TOKEN|PRIVATE[_-]?KEY|PASSWORD)/i.test(name)}
|
|
73
|
+
function isPublicEnv(name){return /^(?:VITE_|NEXT_PUBLIC_|PUBLIC_|REACT_APP_)/i.test(name)}
|
|
52
74
|
function add(kind,name,directory){
|
|
53
75
|
if(!['capability','tool','plugin'].includes(kind??''))fail('Usage: captra add <capability|tool|plugin> <name> [directory]')
|
|
54
76
|
if(!name)fail(`A ${kind} name is required`)
|
|
@@ -68,4 +90,4 @@ function title(value){return value.split(/[-_.]+/).filter(Boolean).map(word=>wor
|
|
|
68
90
|
function javascriptName(value){const words=value.split(/[-_.]+/).filter(Boolean);const name=words[0]+words.slice(1).map(word=>word[0].toUpperCase()+word.slice(1)).join('');return /^\d/.test(name)?`captra${name}`:name}
|
|
69
91
|
function run(binary,args,cwd){const result=spawnSync(binary,args,{cwd,stdio:'inherit',shell:process.platform==='win32'});process.exit(result.status??1)}
|
|
70
92
|
function fail(message){console.error(message);process.exit(1)}
|
|
71
|
-
function help(){console.log(`\nCaptra — the capability-first AI application framework\n\n captra create <directory>\n captra add capability <name> [directory]\n captra add tool <name> [directory]\n captra add plugin <name> [directory]\n captra doctor [directory]\n captra dev [directory]\n captra build [directory]\n captra test [directory]\n captra inspect [directory]\n`)}
|
|
93
|
+
function help(){console.log(`\nCaptra — the capability-first AI application framework\n\n captra create <directory>\n captra add capability <name> [directory]\n captra add tool <name> [directory]\n captra add plugin <name> [directory]\n captra doctor [directory]\n captra secrets [directory] [--dist]\n captra dev [directory]\n captra build [directory]\n captra test [directory]\n captra inspect [directory]\n`)}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "concurrently -k \"vite\" \"tsx watch src/server.ts\"",
|
|
7
|
-
"build": "tsc -p tsconfig.app.json --noEmit && vite build && tsc -p tsconfig.server.json",
|
|
7
|
+
"build": "captra secrets && tsc -p tsconfig.app.json --noEmit && vite build && captra secrets --dist && tsc -p tsconfig.server.json",
|
|
8
8
|
"start": "node dist-server/server.js",
|
|
9
9
|
"test": "vitest run"
|
|
10
10
|
},
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"zod": "^4.0.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
+
"@captra-ai/cli": "^0.6.0",
|
|
18
19
|
"@types/node": "^24.0.0",
|
|
19
20
|
"@types/react": "^19.0.0",
|
|
20
21
|
"@types/react-dom": "^19.0.0",
|
|
@@ -2,7 +2,7 @@ import { useCallback,useState } from 'react'
|
|
|
2
2
|
import { CapabilityOutput } from 'captra/react'
|
|
3
3
|
import { CaptraDevtools,type DevtoolsReplay } from 'captra/devtools'
|
|
4
4
|
import type { CaptraEvent,RemoteCapabilityResult } from 'captra'
|
|
5
|
-
import { HelloCard } from './
|
|
5
|
+
import { HelloCard } from './components/HelloCard.js'
|
|
6
6
|
import { client } from './client.js'
|
|
7
7
|
import { BrandIntro } from './BrandIntro.js'
|
|
8
8
|
import './hello-ai.css'
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import { agent,defineTool,type
|
|
1
|
+
import { agent,defineTool,type ModelAdapter } from 'captra'
|
|
2
2
|
import { createOpenAI } from 'captra/openai'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
+
import { HelloCard } from '../components/HelloCard.js'
|
|
4
5
|
|
|
5
6
|
const HelloResult=z.object({reply:z.string(),remembered:z.boolean(),provider:z.enum(['demo','openai'])})
|
|
6
|
-
type HelloResult=z.infer<typeof HelloResult>
|
|
7
|
+
export type HelloResult=z.infer<typeof HelloResult>
|
|
7
8
|
|
|
8
9
|
const rememberContext=defineTool({name:'starter.remember',description:'Remember one useful fact about what the developer is building.',input:z.object({fact:z.string().min(1)}),output:z.object({saved:z.literal(true)}),async execute({fact},context){await context.memory.remember({tenantId:context.tenantId,scope:'user',ownerId:context.userId,type:'fact',content:fact,confidence:1});return {saved:true as const}}})
|
|
9
10
|
const demoModel:ModelAdapter={async generate(request){const user=[...request.messages].reverse().find(item=>item.role==='user'),input=JSON.parse(user?.content??'{}') as {message?:string},message=input.message??'Hello AI',system=request.messages.find(item=>item.role==='system')?.content??'',usedTool=request.messages.some(item=>item.role==='tool'),remembered=system.includes(message);if(!remembered&&!usedTool)return {text:'',model:'captra-demo',usage:{inputTokens:24,outputTokens:12},toolCalls:[{id:'remember-1',name:'starter.remember',input:{fact:message}}]};return {text:JSON.stringify({reply:`I’m running through Captra’s agent loop. A strong first feature for “${message}” is a typed assistant that remembers context and shows every tool call.`,remembered:remembered||usedTool,provider:'demo'}),model:'captra-demo',usage:{inputTokens:42,outputTokens:38}}}}
|
|
10
11
|
const environment=(globalThis as {process?:{env?:Record<string,string|undefined>}}).process?.env,provider=environment?.OPENAI_API_KEY&&environment.NODE_ENV!=='test'?'openai':'demo',model=provider==='openai'?createOpenAI({model:environment?.OPENAI_MODEL??'gpt-5-mini'}):demoModel
|
|
11
12
|
|
|
12
|
-
export function HelloCard({value}:CapabilityViewProps<HelloResult>){return <article className="result"><span>HELLO AI WORLD · {value.provider.toUpperCase()}</span><h2>{value.reply}</h2><p>{value.remembered?'Captra used or saved scoped memory for this run.':'No memory was needed.'}</p></article>}
|
|
13
|
-
HelloCard.displayName='HelloCard'
|
|
14
13
|
export const hello=agent({name:'hello-ai',description:'Run a real typed agent with scoped memory and an inspectable trace.',instructions:`You are the first Captra agent. Give one concise, useful suggestion based on the developer's message. If the exact message is not already in relevant memory, call starter.remember once. Return JSON with reply, remembered, and provider set to ${provider}.`,input:z.object({message:z.string().min(2)}),output:HelloResult,memory:['user'],tools:[rememberContext],budget:{maxSteps:3,maxToolCalls:1,maxTokens:1_200},approval:'never',model,view:HelloCard})
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CapabilityViewProps } from 'captra'
|
|
2
|
+
import type { HelloResult } from '../capabilities/hello.js'
|
|
3
|
+
|
|
4
|
+
export function HelloCard({value}:CapabilityViewProps<HelloResult>){return <article className="result"><span>HELLO AI WORLD · {value.provider.toUpperCase()}</span><h2>{value.reply}</h2><p>{value.remembered?'Captra used or saved scoped memory for this run.':'No memory was needed.'}</p></article>}
|
|
5
|
+
HelloCard.displayName='HelloCard'
|