agent-policy-kit 0.1.0 → 0.3.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/src/detect.mjs CHANGED
@@ -4,17 +4,65 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
 
7
- const CLIENTS = ['codex','claude','kimi','agy'];
7
+ const CLIENTS = ['chatgpt','claude','kimi','gemini','grok'];
8
+ // Each tool's own executable keeps its native name: ChatGPT runs Codex, and Gemini runs Antigravity (agy).
9
+ const EXECUTABLES = {chatgpt:'codex',claude:'claude',kimi:'kimi',gemini:'agy',grok:'grok'};
8
10
  const OUTPUT_LIMIT = 65536;
9
11
  const VERSION = String.raw`(\d{1,4}\.\d{1,4}\.\d{1,4}(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,63})?(?:\+[A-Za-z0-9][A-Za-z0-9.-]{0,63})?)`;
10
12
  function failure(code) { return Object.assign(new Error(code),{code}); }
11
- function options({env=process.env,hostHome=os.homedir(),timeoutMs=5000} = {}) {
12
- if (!env || typeof env !== 'object' || typeof hostHome !== 'string' || !path.isAbsolute(hostHome) || hostHome.includes('\0') || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) throw failure('INVALID_DETECTION_OPTIONS');
13
- return {env,hostHome:path.resolve(hostHome),timeoutMs};
13
+ const absolute = value => typeof value === 'string' && path.isAbsolute(value) && !value.includes('\0');
14
+ function options({env=process.env,hostHome=os.homedir(),timeoutMs=5000,platform=process.platform,systemApplications} = {}) {
15
+ // AGENT_POLICY_APPLICATIONS_DIR replaces /Applications, so hermetic runs never see the host's installed apps.
16
+ systemApplications ??= (typeof env?.AGENT_POLICY_APPLICATIONS_DIR === 'string' && env.AGENT_POLICY_APPLICATIONS_DIR) || '/Applications';
17
+ if (!env || typeof env !== 'object' || !absolute(hostHome) || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 || typeof platform !== 'string' || !absolute(systemApplications)) throw failure('INVALID_DETECTION_OPTIONS');
18
+ return {env,hostHome:path.resolve(hostHome),timeoutMs,platform,systemApplications:path.resolve(systemApplications)};
14
19
  }
15
- function candidates(client,{env,hostHome}) {
20
+ // Desktop apps ship their own copy of a CLI and keep it off PATH. ChatGPT Desktop runs Codex from a
21
+ // fixed bundle path; Claude Desktop keeps one Claude Code per <version>/<build> directory.
22
+ const CHATGPT_CODEX = 'ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex';
23
+ const CLAUDE_DESKTOP = 'Library/Application Support/Claude/claude-code';
24
+ const DESKTOP_VERSION = /^(\d{1,4})\.(\d{1,4})\.(\d{1,4})$/;
25
+ const DESKTOP_BUILD = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
26
+ const DESKTOP_LIMIT = 8;
27
+ async function directories(dir) {
28
+ try { return (await fs.readdir(dir,{withFileTypes:true})).filter(entry=>entry.isDirectory()).map(entry=>entry.name); }
29
+ catch { return []; }
30
+ }
31
+ function newerFirst(a,b) {
32
+ const [x,y] = [a,b].map(name=>name.match(DESKTOP_VERSION).slice(1).map(Number));
33
+ return y[0]-x[0] || y[1]-x[1] || y[2]-x[2];
34
+ }
35
+ async function desktopCandidates(client,{hostHome,platform,systemApplications}) {
36
+ if (platform !== 'darwin') return [];
37
+ if (client === 'chatgpt') return [systemApplications,path.join(hostHome,'Applications')].map(dir=>path.join(dir,CHATGPT_CODEX));
38
+ if (client !== 'claude') return [];
39
+ const base = path.join(hostHome,CLAUDE_DESKTOP), files = [];
40
+ for (const version of (await directories(base)).filter(name=>DESKTOP_VERSION.test(name)).sort(newerFirst)) {
41
+ // Only builds that contain the CLI count toward the limit, so empty or partial folders cannot hide a working one.
42
+ // Within one version the most recently written CLI comes first; the name breaks ties.
43
+ const builds = [];
44
+ for (const build of (await directories(path.join(base,version))).filter(name=>DESKTOP_BUILD.test(name))) {
45
+ const file = path.join(base,version,build,'claude.app/Contents/MacOS/claude');
46
+ try { const stat = await fs.stat(file); if (stat.isFile()) builds.push({file,build,mtime:stat.mtimeMs}); } catch {}
47
+ }
48
+ builds.sort((a,b)=>b.mtime-a.mtime || (a.build < b.build ? -1 : a.build > b.build ? 1 : 0));
49
+ for (const {file} of builds) {
50
+ files.push(file);
51
+ // The limit bounds detection time: each candidate that exists is started once with --version.
52
+ if (files.length === DESKTOP_LIMIT) return files;
53
+ }
54
+ }
55
+ return files;
56
+ }
57
+ async function candidates(client,settings) {
58
+ const result = cliCandidates(client,settings);
59
+ for (const file of await desktopCandidates(client,settings)) result.push({file,origin:'desktop_app'});
60
+ return result.filter((candidate,index) => result.findIndex(c=>c.file === candidate.file) === index);
61
+ }
62
+ function cliCandidates(client,{env,hostHome}) {
16
63
  const dirs = typeof env.PATH === 'string' ? env.PATH.split(path.delimiter) : [];
17
- const names = process.platform === 'win32' ? [client + '.exe',client] : [client];
64
+ const executable = EXECUTABLES[client];
65
+ const names = process.platform === 'win32' ? [executable + '.exe',executable] : [executable];
18
66
  const result = [];
19
67
  for (const dir of dirs) {
20
68
  // Never search the working directory through empty/relative PATH segments.
@@ -23,9 +71,9 @@ function candidates(client,{env,hostHome}) {
23
71
  }
24
72
  const conventional = client === 'kimi' ? ['.kimi-code/bin/kimi']
25
73
  : client === 'claude' ? ['.local/bin/claude','.claude/local/claude']
26
- : [`.local/bin/${client}`];
74
+ : [`.local/bin/${executable}`];
27
75
  for (const relative of conventional) result.push({file:path.join(hostHome,relative),origin:'known_location'});
28
- return result.filter((candidate,index) => result.findIndex(c=>c.file === candidate.file) === index);
76
+ return result;
29
77
  }
30
78
  async function inspect(file) {
31
79
  try {
@@ -78,10 +126,11 @@ function probe(executable,arg,{env,timeoutMs}) {
78
126
  }
79
127
  function identity(client,output) {
80
128
  const patterns = {
81
- codex:[new RegExp(String.raw`^\s*codex(?:-cli)?\s+v?${VERSION}(?:\s|$)`,'im')],
129
+ chatgpt:[new RegExp(String.raw`^\s*codex(?:-cli)?\s+v?${VERSION}(?:\s|$)`,'im')],
82
130
  claude:[new RegExp(String.raw`^\s*v?${VERSION}\s+\(Claude Code\)(?:\s|$)`,'im'),new RegExp(String.raw`^\s*Claude Code\s+v?${VERSION}(?:\s|$)`,'im')],
83
131
  kimi:[new RegExp(String.raw`^\s*kimi(?:\s+code(?:\s+cli)?)?(?:,?\s+version)?\s+v?${VERSION}(?:\s|$)`,'im')],
84
- agy:[new RegExp(String.raw`^\s*(?:Antigravity|agy)\s+v?${VERSION}(?:\s|$)`,'im')],
132
+ gemini:[new RegExp(String.raw`^\s*(?:Antigravity|agy)\s+v?${VERSION}(?:\s|$)`,'im')],
133
+ grok:[new RegExp(String.raw`^\s*grok\s+v?${VERSION}(?:\s|$)`,'im')],
85
134
  };
86
135
  for (const pattern of patterns[client]) { const match = output.match(pattern); if (match && match[1].length <= 128) return match[1]; }
87
136
  return null;
@@ -91,7 +140,7 @@ async function verify(client,executable,opts) {
91
140
  if (version.status !== 'ok') return {status:version.status};
92
141
  const branded = identity(client,version.output);
93
142
  if (branded) return {status:'detected',version:branded};
94
- if (client !== 'kimi' && client !== 'agy') return {status:'invalid'};
143
+ if (client !== 'kimi' && client !== 'gemini') return {status:'invalid'};
95
144
  const number = version.output.match(new RegExp(String.raw`^\s*v?${VERSION}(?:\s|$)`));
96
145
  if (!number || number[1].length > 128) return {status:'invalid'};
97
146
  const help = await probe(executable,'--help',opts);
@@ -109,7 +158,7 @@ export async function resolveClient(client,opts = {}) {
109
158
  const settings = options(opts);
110
159
  let best = {client,status:'not_found',version:null,origin:null,executable:null};
111
160
  const checked = new Set();
112
- for (const candidate of candidates(client,settings)) {
161
+ for (const candidate of await candidates(client,settings)) {
113
162
  const inspected = await inspect(candidate.file);
114
163
  if (inspected.status === 'not_found') continue;
115
164
  if (inspected.status === 'invalid') {
@@ -131,6 +180,6 @@ export function summarizeDetection(records) {
131
180
  return records.filter(r=>CLIENTS.includes(r?.client)).map(({client,status,version,origin})=>{
132
181
  status = ['detected','not_found','invalid','timeout'].includes(status) ? status : 'invalid';
133
182
  if (status === 'detected' && (typeof version !== 'string' || version.length > 128 || !new RegExp(`^${VERSION}$`).test(version))) status = 'invalid';
134
- return {client,status,version:status === 'detected' ? version : null,origin:['path','known_location'].includes(origin) ? origin : null};
183
+ return {client,status,version:status === 'detected' ? version : null,origin:['path','known_location','desktop_app'].includes(origin) ? origin : null};
135
184
  });
136
185
  }
package/src/explicit.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Pure command codec, also embedded in the offline configuration page.
2
- export const TOOL_FLAGS=Object.freeze({codex:'codex',claude:'claude',kimi:'kimi',antigravity:'agy'});
3
- export const EXPLICIT_FIELDS=Object.freeze(['roles','max-concurrent',...Object.keys(TOOL_FLAGS),...Object.keys(TOOL_FLAGS).map(flag=>'avoid-'+flag)]);
2
+ export const TOOL_FLAGS=Object.freeze({chatgpt:'chatgpt',claude:'claude',kimi:'kimi',gemini:'gemini',grok:'grok'});
3
+ export const EXPLICIT_FIELDS=Object.freeze(['roles','max-concurrent',...Object.keys(TOOL_FLAGS),...Object.keys(TOOL_FLAGS).map(flag=>'avoid-'+flag),...Object.keys(TOOL_FLAGS).map(flag=>flag+'-max-concurrent')]);
4
4
  const EXPLICIT_MODEL=/^[A-Za-z0-9][A-Za-z0-9._:@/\[\]-]{0,127}$/;
5
5
  const EXPLICIT_EFFORTS=['none','minimal','low','medium','high','xhigh','max'];
6
6
  function invalidExplicit(){throw Object.assign(new Error('Invalid explicit configuration.'),{code:'INVALID_EXPLICIT_CONFIG'});}
@@ -11,6 +11,8 @@ function explicitList(value,limit){
11
11
  return list;
12
12
  }
13
13
 
14
+ // Grok has no per-role effort, so its value is the whole model id; a trailing ':<effort>' would be ambiguous and is refused.
15
+ const grokEffortSuffix=model=>{const colon=model.lastIndexOf(':');return colon>=0&&['native',...EXPLICIT_EFFORTS].includes(model.slice(colon+1));};
14
16
  export function parseExplicitOptions(options,roleIds){
15
17
  if(!options||typeof options!=='object'||Array.isArray(options))invalidExplicit();
16
18
  const fields=Object.getOwnPropertyDescriptors(options);
@@ -21,7 +23,7 @@ export function parseExplicitOptions(options,roleIds){
21
23
  if(requested.some(role=>!roleIds.includes(role)))invalidExplicit();
22
24
  const roles=roleIds.filter(role=>requested.includes(role)),maxConcurrent=Number(options['max-concurrent']);
23
25
  if(!/^[1-9]\d*$/.test(options['max-concurrent'])||!Number.isInteger(maxConcurrent)||maxConcurrent>32)invalidExplicit();
24
- for(const flag of Object.keys(TOOL_FLAGS))if(options['avoid-'+flag]!==undefined&&options[flag]===undefined)invalidExplicit();
26
+ for(const flag of Object.keys(TOOL_FLAGS))if((options['avoid-'+flag]!==undefined||options[flag+'-max-concurrent']!==undefined)&&options[flag]===undefined)invalidExplicit();
25
27
  const clients={};
26
28
  for(const flag of Object.keys(options).filter(flag=>Object.hasOwn(TOOL_FLAGS,flag))){
27
29
  const client=TOOL_FLAGS[flag],choices={};
@@ -29,7 +31,8 @@ export function parseExplicitOptions(options,roleIds){
29
31
  const at=entry.indexOf('='),role=entry.slice(0,at).trim(),value=entry.slice(at+1).trim();
30
32
  if(at<1||!roles.includes(role)||Object.hasOwn(choices,role))invalidExplicit();
31
33
  let model=value,effort=null;
32
- if(client==='agy'){if(!['pro','flash','flash_lite','inherit'].includes(model))invalidExplicit();}
34
+ if(client==='gemini'){if(!['pro','flash','flash_lite','inherit'].includes(model))invalidExplicit();}
35
+ else if(client==='grok'){if(grokEffortSuffix(value))invalidExplicit();}
33
36
  else{
34
37
  const colon=value.lastIndexOf(':');if(colon<1)invalidExplicit();
35
38
  model=value.slice(0,colon);const level=value.slice(colon+1);
@@ -41,7 +44,9 @@ export function parseExplicitOptions(options,roleIds){
41
44
  const avoidModels=options['avoid-'+flag]===undefined?[]:explicitList(options['avoid-'+flag],32);
42
45
  if(avoidModels.some(model=>!EXPLICIT_MODEL.test(model)))invalidExplicit();
43
46
  const first=choices[roles[0]];
44
- clients[client]={code:{...first},light:{...first},roles:Object.fromEntries(roles.map(role=>[role,choices[role]])),avoidModels};
47
+ const limit=options[flag+'-max-concurrent'];
48
+ if(limit!==undefined&&(!/^[1-9]\d*$/.test(limit)||Number(limit)>32))invalidExplicit();
49
+ clients[client]={...(limit===undefined?{}:{maxConcurrent:Number(limit)}),code:{...first},light:{...first},roles:Object.fromEntries(roles.map(role=>[role,choices[role]])),avoidModels};
45
50
  }
46
51
  if(!Object.keys(clients).length)invalidExplicit();
47
52
  return {schemaVersion:1,maxConcurrent,...(roles.length===roleIds.length?{}:{selectedRoles:roles}),clients};
@@ -63,9 +68,11 @@ export function explicitOptions(config,roleCatalog){
63
68
  options[flag]=roles.map(role=>{
64
69
  const selected=preferences.roles?.[role]??preferences[kinds.get(role)];
65
70
  if(!selected||typeof selected.model!=='string'||!EXPLICIT_MODEL.test(selected.model)||(selected.effort!==null&&!EXPLICIT_EFFORTS.includes(selected.effort)))invalidExplicit();
66
- if(client==='agy'&&selected.effort!==null)invalidExplicit();
67
- return `${role}=${selected.model}${client==='agy'?'':':'+(selected.effort??'native')}`;
71
+ if(['gemini','grok'].includes(client)&&selected.effort!==null)invalidExplicit();
72
+ if(client==='grok'&&grokEffortSuffix(selected.model))invalidExplicit();
73
+ return `${role}=${selected.model}${['gemini','grok'].includes(client)?'':':'+(selected.effort??'native')}`;
68
74
  }).join(',');
75
+ if(preferences.maxConcurrent!==undefined)options[flag+'-max-concurrent']=String(preferences.maxConcurrent);
69
76
  if(!Array.isArray(preferences.avoidModels)||preferences.avoidModels.some(model=>typeof model!=='string'||!EXPLICIT_MODEL.test(model)))invalidExplicit();
70
77
  if(preferences.avoidModels.length)options['avoid-'+flag]=preferences.avoidModels.join(',');
71
78
  }
@@ -77,6 +84,6 @@ export function formatExplicitCommand(config,version,roleCatalog){
77
84
  if(typeof version!=='string'||!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))throw new Error('Invalid release version.');
78
85
  const options=explicitOptions(config,roleCatalog),quote=value=>"'"+value.replaceAll("'","'\"'\"'")+"'";
79
86
  const lines=[`npx --yes --ignore-scripts --package agent-policy-kit@${version} agent-policy setup`];
80
- for(const [name,value]of Object.entries(options))lines.push(`--${name} ${name==='max-concurrent'?value:quote(value)}`);
87
+ for(const [name,value]of Object.entries(options))lines.push(`--${name} ${name==='max-concurrent'||name.endsWith('-max-concurrent')?value:quote(value)}`);
81
88
  lines.push('--apply');return lines.join(' \\\n ');
82
89
  }
@@ -0,0 +1,203 @@
1
+ import fs from 'node:fs/promises';
2
+ import {constants} from 'node:fs';
3
+ import path from 'node:path';
4
+ import {parseToml} from './toml.mjs';
5
+ import {CLIENTS} from './policy.mjs';
6
+ import {inspectConfiguration} from './install.mjs';
7
+
8
+ const ROOTS={chatgpt:'.codex',claude:'.claude',kimi:'.kimi-code',gemini:'.gemini',grok:'.grok'};
9
+ const ENV_ROOTS={chatgpt:'CODEX_HOME',claude:'CLAUDE_CONFIG_DIR',kimi:'KIMI_CODE_HOME',grok:'GROK_HOME'};
10
+ const MAX_FILE=1024*1024,MAX_TOTAL=4*MAX_FILE,MAX_ENTRIES=256;
11
+ const identifier=/^[A-Za-z0-9][A-Za-z0-9._:@/\[\]-]{0,127}$/;
12
+ const namePattern=/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,79}$/;
13
+ const record=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
14
+ const failure=code=>Object.assign(new Error(code),{code});
15
+ function safeString(value,pattern=identifier){
16
+ if(typeof value!=='string'||!pattern.test(value)||/^(?:sk-|ghp_|github_pat_)/.test(value)||/\/(?:Users|home)\//.test(value))throw failure('UNSUPPORTED_NATIVE_SETTING');
17
+ return value;
18
+ }
19
+ async function safePath(target){
20
+ let current=path.parse(target).root;
21
+ for(const part of path.relative(current,target).split(path.sep).filter(Boolean)){
22
+ current=path.join(current,part);
23
+ try{if((await fs.lstat(current)).isSymbolicLink())throw failure('UNSAFE_FILE');}
24
+ catch(error){if(error.code==='ENOENT')return;throw error;}
25
+ }
26
+ }
27
+ async function readFile(file,budget){
28
+ await safePath(file);
29
+ let handle;
30
+ try{
31
+ handle=await fs.open(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
32
+ const before=await handle.stat();
33
+ if(!before.isFile())throw failure('UNSAFE_FILE');
34
+ if(before.size>MAX_FILE||budget.bytes+before.size>MAX_TOTAL)throw failure('INSPECTION_LIMIT');
35
+ const buffer=Buffer.alloc(MAX_FILE+1);let size=0;
36
+ while(size<buffer.length){const chunk=await handle.read(buffer,size,buffer.length-size,null);if(!chunk.bytesRead)break;size+=chunk.bytesRead;}
37
+ const after=await handle.stat();
38
+ budget.bytes+=size;
39
+ if(size>MAX_FILE||budget.bytes>MAX_TOTAL)throw failure('INSPECTION_LIMIT');
40
+ if(before.size!==after.size||before.mtimeMs!==after.mtimeMs||size!==after.size)throw failure('CONFIG_CHANGED_DURING_READ');
41
+ return buffer.subarray(0,size).toString('utf8');
42
+ }catch(error){if(error.code==='ENOENT')return null;throw error;}
43
+ finally{await handle?.close();}
44
+ }
45
+ function warning(client,error){
46
+ const allowed=['UNSAFE_FILE','INSPECTION_LIMIT','CONFIG_CHANGED_DURING_READ','UNSUPPORTED_NATIVE_SETTING','UNSUPPORTED_ROLE_FORMAT','UNSUPPORTED_ROLE_EFFORT','INVALID_TOML','INVALID_JSON','EACCES','EPERM','NATIVE_CAP_CONFLICT','EXTERNAL_ROLE_REFERENCES_NOT_READ'];
47
+ const code=allowed.includes(error?.code)?error.code:'NATIVE_READ_FAILED';
48
+ if(!client.warnings.includes(code))client.warnings.push(code);
49
+ client.status='attention';
50
+ }
51
+ function scalar(value){
52
+ const text=value.trim();
53
+ if(text.startsWith('"')){const match=text.match(/^("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/);if(!match)throw failure('UNSUPPORTED_ROLE_FORMAT');try{return JSON.parse(match[1]);}catch{throw failure('UNSUPPORTED_ROLE_FORMAT');}}
54
+ if(text.startsWith("'")){const match=text.match(/^'((?:[^']|'')*)'\s*(?:#.*)?$/);if(!match)throw failure('UNSUPPORTED_ROLE_FORMAT');return match[1].replaceAll("''", "'");}
55
+ const match=text.match(/^([A-Za-z0-9][A-Za-z0-9._:@/\[\]-]*)(?:\s+#.*)?$/);
56
+ if(!match)throw failure('UNSUPPORTED_ROLE_FORMAT');
57
+ return match[1];
58
+ }
59
+ function frontmatter(text){
60
+ const match=text.replace(/^\uFEFF/,'').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
61
+ if(!match)throw failure('UNSUPPORTED_ROLE_FORMAT');
62
+ const fields={};
63
+ for(const line of match[1].split(/\r?\n/)){
64
+ // YAML merges, quoted keys and flow mappings can supply model fields that
65
+ // this deliberately small scalar reader cannot resolve safely.
66
+ if(/^(?:<<\s*:|["'{\[])/.test(line))throw failure('UNSUPPORTED_ROLE_FORMAT');
67
+ const field=line.match(/^(name|model|effort|subagent)[ \t]*:\s*(.*)$/);
68
+ if(!field)continue;
69
+ if(Object.hasOwn(fields,field[1]))throw failure('UNSUPPORTED_ROLE_FORMAT');
70
+ if(field[1]==='subagent'){
71
+ const boolean=field[2].trim().match(/^(true|false)(?:\s+#.*)?$/);
72
+ if(!boolean)throw failure('UNSUPPORTED_ROLE_FORMAT');
73
+ fields.subagent=boolean[1]==='true';
74
+ }else fields[field[1]]=scalar(field[2]);
75
+ }
76
+ if(!fields.name)throw failure('UNSUPPORTED_ROLE_FORMAT');
77
+ return fields;
78
+ }
79
+ function cap(value){if(!Number.isSafeInteger(value)||value<1)throw failure('UNSUPPORTED_NATIVE_SETTING');return value;}
80
+ function optionalString(value,pattern){return value===undefined?null:safeString(value,pattern);}
81
+ function extractSettings(client,id,native,env,models){
82
+ if(!record(native))throw failure('UNSUPPORTED_NATIVE_SETTING');
83
+ if(id==='grok'){
84
+ const subagents=native.subagents??{};if(!record(subagents))throw failure('UNSUPPORTED_NATIVE_SETTING');
85
+ const configured=subagents.models??{};if(!record(configured)||Object.keys(configured).length>MAX_ENTRIES)throw failure(record(configured)?'INSPECTION_LIMIT':'UNSUPPORTED_NATIVE_SETTING');
86
+ for(const[name,model]of Object.entries(configured))models.set(safeString(name,namePattern),safeString(model));
87
+ client.defaults.model=models.get('general-purpose')??null;
88
+ if(subagents.max_concurrent!==undefined)client.concurrency.value=cap(subagents.max_concurrent);
89
+ return;
90
+ }
91
+ if(id==='chatgpt'){
92
+ const agents=native.agents??{};if(!record(agents))throw failure('UNSUPPORTED_NATIVE_SETTING');
93
+ client.defaults.model=optionalString(agents.default_subagent_model);
94
+ client.defaults.effort=optionalString(agents.default_subagent_reasoning_effort);
95
+ if(agents.enabled!==undefined){if(typeof agents.enabled!=='boolean')throw failure('UNSUPPORTED_NATIVE_SETTING');client.defaults.enabled=agents.enabled;}
96
+ const current=agents.max_concurrent_threads_per_session,legacy=agents.max_threads;
97
+ if(current!==undefined&&legacy!==undefined&&current!==legacy)throw failure('NATIVE_CAP_CONFLICT');
98
+ if(current!==undefined||legacy!==undefined)client.concurrency.value=cap(current??legacy);
99
+ if(Object.values(agents).some(value=>record(value)&&value.config_file!==undefined))warning(client,failure('EXTERNAL_ROLE_REFERENCES_NOT_READ'));
100
+ }else if(id==='claude'){
101
+ const stored=native.env??{};if(!record(stored))throw failure('UNSUPPORTED_NATIVE_SETTING');
102
+ client.defaults.model=optionalString(stored.CLAUDE_CODE_SUBAGENT_MODEL);
103
+ const count=stored.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS;
104
+ if(count!==undefined){if(typeof count!=='string'||!/^\d{1,9}$/.test(count))throw failure('UNSUPPORTED_NATIVE_SETTING');client.concurrency.value=cap(Number(count));}
105
+ client.overrides={};
106
+ for(const[key,field]of [['CLAUDE_CODE_SUBAGENT_MODEL','model'],['CLAUDE_CODE_EFFORT_LEVEL','effort']]){
107
+ const value=env[key]??(field==='effort'?stored[key]:undefined);
108
+ if(value!==undefined)client.overrides[field]=safeString(value);
109
+ }
110
+ if((env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE??stored.CLAUDE_CODE_SUBAGENT_MODEL_FORCE)==='1')client.overrides.forceModel=true;
111
+ if(env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS!==undefined){if(!/^\d{1,9}$/.test(env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS))throw failure('UNSUPPORTED_NATIVE_SETTING');client.overrides.maxConcurrent=cap(Number(env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS));}
112
+ }else if(id==='kimi'){
113
+ const secondary=native.secondary_model??{};if(!record(secondary))throw failure('UNSUPPORTED_NATIVE_SETTING');
114
+ client.defaults.model=optionalString(secondary.default_model);
115
+ client.defaults.effort=optionalString(secondary.default_effort);
116
+ const pool=secondary.models??{};if(!record(pool)||Object.keys(pool).length>MAX_ENTRIES)throw failure('INSPECTION_LIMIT');
117
+ const aliases=new Set([...Object.keys(pool),...(client.defaults.model?[client.defaults.model]:[])]);
118
+ for(const alias of aliases){
119
+ const entry=native.models?.[alias];
120
+ client.pool.push({alias:safeString(alias),effort:optionalString(secondary.default_effort??entry?.overrides?.default_effort??entry?.default_effort),configured:record(entry)});
121
+ }
122
+ }
123
+ }
124
+ async function rolesIn(directory,client,id,budget,models,depth=0){
125
+ await safePath(directory);
126
+ let directoryHandle;
127
+ try{directoryHandle=await fs.opendir(directory);}catch(error){if(error.code==='ENOENT')return;throw error;}
128
+ const entries=[];
129
+ for await(const entry of directoryHandle){if(++budget.entries>MAX_ENTRIES)throw failure('INSPECTION_LIMIT');entries.push(entry);}
130
+ for(const entry of entries.sort((a,b)=>a.name.localeCompare(b.name,'en'))){
131
+ const file=path.join(directory,entry.name);
132
+ try{
133
+ if(entry.isSymbolicLink())throw failure('UNSAFE_FILE');
134
+ if(entry.isDirectory()){
135
+ if(id==='chatgpt')continue;
136
+ if(depth>=4)throw failure('INSPECTION_LIMIT');
137
+ await rolesIn(file,client,id,budget,models,depth+1);continue;
138
+ }
139
+ if(!entry.name.endsWith(id==='chatgpt'?'.toml':'.md'))continue;
140
+ const body=await readFile(file,budget);if(body===null)throw failure('CONFIG_CHANGED_DURING_READ');
141
+ const fields=id==='chatgpt'?parseToml(body):frontmatter(body);
142
+ const role={name:safeString(fields.name,namePattern),model:optionalString(fields.model),effort:optionalString(id==='chatgpt'?fields.model_reasoning_effort:fields.effort)};
143
+ if(id==='kimi'){
144
+ // APK's Markdown role files contain explicit routing guidance, not a
145
+ // native model binding. Other prose is never interpreted as configuration.
146
+ role.model=null;role.effort=null;role.modelSelection='parent';
147
+ if(role.name.startsWith('apk-')){
148
+ const routing=body.match(/^The parent must select configured pool alias ([A-Za-z0-9][A-Za-z0-9._:@/\[\]-]{0,127}) for this role; Markdown frontmatter does not select a model\. Requested effort: (native default|[A-Za-z0-9_-]+)\.$/m);
149
+ if(routing){role.model=safeString(routing[1]);role.effort=routing[2]==='native default'?null:safeString(routing[2]);role.modelSelection='guidance';}
150
+ }
151
+ }
152
+ if(id==='grok'){
153
+ // The native per-subagent model override lives in config.toml, not in the agent file.
154
+ role.model=models.get(role.name)??role.model;
155
+ if(fields.effort!==undefined){role.effort=null;warning(client,failure('UNSUPPORTED_ROLE_EFFORT'));}
156
+ }
157
+ if(id==='gemini'){
158
+ if(role.model!==null&&!['pro','flash','flash_lite','inherit'].includes(role.model))throw failure('UNSUPPORTED_NATIVE_SETTING');
159
+ role.enabled=fields.subagent??null;
160
+ if(fields.effort!==undefined){role.effort=null;warning(client,failure('UNSUPPORTED_ROLE_EFFORT'));}
161
+ }
162
+ if(client.roles.some(r=>r.name===role.name))throw failure('UNSUPPORTED_ROLE_FORMAT');
163
+ client.roles.push(role);
164
+ }catch(error){warning(client,error);}
165
+ }
166
+ }
167
+ async function readGuidance(root,client,budget){
168
+ const text=await readFile(path.join(root,'AGENTS.md'),budget);
169
+ if(text===null)return;
170
+ const blocks=[...text.matchAll(/<!-- BEGIN agent-policy-kit -->\r?\n([\s\S]*?)<!-- END agent-policy-kit -->/g)];
171
+ if(!blocks.length)return;
172
+ if(blocks.length!==1)throw failure('UNSUPPORTED_NATIVE_SETTING');
173
+ const counts=[...blocks[0][1].matchAll(/^When delegating, run at most (\d+) subagents concurrently in this session\./gm)];
174
+ if(counts.length!==1)throw failure('UNSUPPORTED_NATIVE_SETTING');
175
+ client.concurrency.value=cap(Number(counts[0][1]));
176
+ }
177
+ export async function readConfiguration({client=null,home,env=process.env,explicitHome=false,stateDir=path.join(home,'.agent-policy-kit')}={}){
178
+ if(!path.isAbsolute(home)||client!==null&&!CLIENTS.includes(client))throw failure('INVALID_CLIENT');
179
+ const report={schemaVersion:1,scope:'user',modelExecution:'not_checked',clients:{}};
180
+ for(const id of client?[client]:CLIENTS){
181
+ const root=path.resolve(!explicitHome&&env[ENV_ROOTS[id]]?env[ENV_ROOTS[id]]:path.join(home,ROOTS[id]));
182
+ const c={status:'not_configured',defaults:{model:null,effort:null,enabled:null},concurrency:{value:null,kind:['chatgpt','claude','grok'].includes(id)?'native_setting':'guidance'},roles:[],pool:[],warnings:[],apk:{status:'not_checked'}};
183
+ report.clients[id]=c;const budget={bytes:0,entries:0},models=new Map();
184
+ try{
185
+ await safePath(root);
186
+ if(id!=='gemini'){
187
+ try{
188
+ const text=await readFile(path.join(root,id==='claude'?'settings.json':'config.toml'),budget);
189
+ if(text!==null){let native;if(id==='claude'){try{native=JSON.parse(text);}catch{throw failure('INVALID_JSON');}}else native=parseToml(text);extractSettings(c,id,native,env,models);}
190
+ else if(id==='claude')extractSettings(c,id,{},env,models);
191
+ }catch(error){warning(c,error);}
192
+ }
193
+ await rolesIn(path.join(root,id==='gemini'?'config/agents':'agents'),c,id,budget,models);
194
+ if(id==='kimi'||id==='gemini'){try{await readGuidance(root,c,budget);}catch(error){warning(c,error);}}
195
+ if(c.status!=='attention'&&(c.roles.length||c.pool.length||c.defaults.model!==null||c.defaults.effort!==null||c.defaults.enabled!==null||c.concurrency.value!==null||Object.keys(c.overrides??{}).length))c.status='configured';
196
+ if(root===path.resolve(home,ROOTS[id])){
197
+ try{const installed=await inspectConfiguration({client:id,home,stateDir});c.apk.status=installed.status;if(installed.status==='verified'){c.apk.releaseVersion=installed.releaseVersion;c.apk.preset=installed.preset;}}
198
+ catch(error){c.apk.status=error.code==='OWNED_DRIFT'?'modified':'attention';warning(c,error);}
199
+ }
200
+ }catch(error){warning(c,error);}
201
+ }
202
+ return report;
203
+ }
package/src/install.mjs CHANGED
@@ -10,7 +10,7 @@ import catalog from '../models/catalog.json' with {type:'json'};
10
10
  import { parseToml, setTomlValue, restoreTomlValue, removeTomlValue } from './toml.mjs';
11
11
  import { nativeLocations, nativeRoles, instructionBlock, nativeWarnings, BEGIN, END } from './native.mjs';
12
12
 
13
- const CLIENTS = ['codex', 'claude', 'kimi', 'agy'];
13
+ const CLIENTS = ['chatgpt', 'claude', 'kimi', 'gemini', 'grok'];
14
14
  const LIMIT = 1024 * 1024;
15
15
  const digest = value => value === null ? null : createHash('sha256').update(value).digest('hex');
16
16
  const json = value => JSON.stringify(value, null, 2) + '\n';
@@ -155,7 +155,8 @@ function selectedAliases(preferences,selectedRoles=ROLE_IDS) {
155
155
  }
156
156
  function tomlSettings(client,preferences,maxConcurrent,selectedRoles=ROLE_IDS) {
157
157
  const worker=rolePreference(preferences,selectedRoles[0]);
158
- if(client==='codex')return [[['agents','max_concurrent_threads_per_session'],maxConcurrent],[['agents','default_subagent_model'],worker.model],...(worker.effort===null?[]:[[['agents','default_subagent_reasoning_effort'],worker.effort]])];
158
+ if(client==='chatgpt')return [[['agents','max_concurrent_threads_per_session'],maxConcurrent],[['agents','default_subagent_model'],worker.model],...(worker.effort===null?[]:[[['agents','default_subagent_reasoning_effort'],worker.effort]])];
159
+ if(client==='grok')return [[['subagents','max_concurrent'],maxConcurrent],...selectedRoles.map(role=>[['subagents','models','apk-'+role],rolePreference(preferences,role).model]),[['subagents','models','general-purpose'],worker.model]];
159
160
  if(client!=='kimi')return [];
160
161
  const selected=selectedAliases(preferences,selectedRoles),roles=effectiveRoles(preferences,selectedRoles);
161
162
  return [[['secondary_model','default_model'],worker.model],...selected.map(value=>[['secondary_model','models',value.model],'Use for '+roles.filter(role=>role.model===value.model).map(role=>'apk-'+role.role).join(', ')+'.']),...selected.filter(value=>value.effort!==null).map(value=>[['models',value.model,'overrides','default_effort'],value.effort])];
@@ -182,15 +183,23 @@ function scalar(op,target,type) {
182
183
  literalValue(op.before,type,true);
183
184
  return literalValue(op.after,type);
184
185
  }
185
- function validateTomlSnapshot(file,client,schemaVersion) {
186
+ function validateTomlSnapshot(file,client,schemaVersion,selectedRoles=ROLE_IDS) {
186
187
  if(!Array.isArray(file.ops)||!file.ops.every(op=>object(op)&&Array.isArray(op.path)&&op.path.every(key=>typeof key==='string')&&op.path.length<=4))fail('INVALID_RECEIPT');
187
- if(client==='codex') {
188
+ if(client==='chatgpt') {
188
189
  if(!(schemaVersion===2?[3]:[2,3]).includes(file.ops.length))fail('INVALID_RECEIPT');
189
190
  const cap=scalar(file.ops[0],['agents','max_concurrent_threads_per_session'],'number');
190
191
  if(cap<1||cap>32)fail('INVALID_RECEIPT');
191
192
  scalar(file.ops[1],['agents','default_subagent_model'],'string');if(file.ops.length===3)scalar(file.ops[2],['agents','default_subagent_reasoning_effort'],'string');
192
193
  return;
193
194
  }
195
+ if(client==='grok') {
196
+ if(schemaVersion!==4||file.ops.length!==selectedRoles.length+2)fail('INVALID_RECEIPT');
197
+ const cap=scalar(file.ops[0],['subagents','max_concurrent'],'number');
198
+ if(cap<1||cap>32)fail('INVALID_RECEIPT');
199
+ for(const[i,role]of selectedRoles.entries())scalar(file.ops[1+i],['subagents','models','apk-'+role],'string');
200
+ scalar(file.ops.at(-1),['subagents','models','general-purpose'],'string');
201
+ return;
202
+ }
194
203
  if(client!=='kimi')fail('INVALID_RECEIPT');
195
204
  if(schemaVersion===2) {
196
205
  if(![4,6].includes(file.ops.length))fail('INVALID_RECEIPT');
@@ -253,7 +262,7 @@ function validateReceipt(r,plan) {
253
262
  } else if(plan.client==='claude') {
254
263
  fields(file,['path','type','existed','ops','created']);if(file.type!=='json')fail('INVALID_RECEIPT');validateJsonSnapshot(file);
255
264
  } else {
256
- fields(file,['path','type','existed','ops']);if(file.type!=='toml')fail('INVALID_RECEIPT');validateTomlSnapshot(file,plan.client,r.schemaVersion);
265
+ fields(file,['path','type','existed','ops']);if(file.type!=='toml')fail('INVALID_RECEIPT');validateTomlSnapshot(file,plan.client,r.schemaVersion,r.schemaVersion===4?r.selectedRoles:LEGACY_ROLE_IDS);
257
266
  }
258
267
  }
259
268
  return r;
@@ -348,7 +357,7 @@ export async function planInstall(options) {
348
357
  if(!own(policy.clients,plan.client))fail('INVALID_POLICY');
349
358
  let selected=policy.clients[plan.client],resolvedAliases=false;
350
359
  const roleIds=selectedRoleIds(policy);
351
- const maxConcurrent=policy.maxConcurrent,policyHash=digest(json(canonical(policy)));
360
+ const maxConcurrent=selected.maxConcurrent??policy.maxConcurrent,policyHash=digest(json(canonical(policy)));
352
361
  const policyBefore=await readDependency(plan,l.policy,plan.stateDir);
353
362
  const baselines=record?await undoFiles(plan,record,false):new Map();
354
363
  if(record&&!replace) {
@@ -373,8 +382,15 @@ export async function planInstall(options) {
373
382
  const required=kimiPrerequisite(native,selected,roleIds);if(required)return prerequisite(plan,required);
374
383
  if(selectedAliases(selected,roleIds).some(value=>value.effort!==null)&&native.secondary_model?.default_effort!==undefined&&typeof native.secondary_model.default_effort!=='string')fail('UNSUPPORTED_CONFIG');
375
384
  }
376
- if(plan.client==='codex') {
377
- if(native.agents&&own(native.agents,'max_threads'))fail('CODEX_CAP_ALIAS_CONFLICT');
385
+ if(plan.client==='grok') {
386
+ const subagents=native.subagents;
387
+ if(subagents!==undefined&&!object(subagents))fail('UNSUPPORTED_CONFIG');
388
+ if(subagents?.models!==undefined&&!object(subagents.models))fail('UNSUPPORTED_CONFIG');
389
+ const cap=subagents?.max_concurrent;
390
+ if(cap!==undefined&&(!Number.isSafeInteger(cap)||cap<1))fail('UNSUPPORTED_CONFIG');
391
+ }
392
+ if(plan.client==='chatgpt') {
393
+ if(native.agents&&own(native.agents,'max_threads'))fail('CHATGPT_CAP_ALIAS_CONFLICT');
378
394
  const cap=native.agents?.max_concurrent_threads_per_session;
379
395
  if(cap!==undefined&&(!Number.isSafeInteger(cap)||cap<1))fail('UNSUPPORTED_CONFIG');
380
396
  }
package/src/native.mjs CHANGED
@@ -4,27 +4,30 @@ import {ROLE_DEFINITIONS,ROLE_IDS} from './roles.mjs';
4
4
 
5
5
  export const BEGIN = '<!-- BEGIN agent-policy-kit -->';
6
6
  export const END = '<!-- END agent-policy-kit -->';
7
- const directory = {codex:'.codex',claude:'.claude',kimi:'.kimi-code',agy:'.gemini'};
7
+ const directory = {chatgpt:'.codex',claude:'.claude',kimi:'.kimi-code',gemini:'.gemini',grok:'.grok'};
8
8
  export function nativeHomeConflict(client,home,env) {
9
- const variable={codex:'CODEX_HOME',claude:'CLAUDE_CONFIG_DIR',kimi:'KIMI_CODE_HOME'}[client];
9
+ const variable={chatgpt:'CODEX_HOME',claude:'CLAUDE_CONFIG_DIR',kimi:'KIMI_CODE_HOME',grok:'GROK_HOME'}[client];
10
10
  const active=variable&&env[variable];
11
11
  return typeof active==='string'&&active.length>0&&path.resolve(active)!==path.resolve(home,directory[client]);
12
12
  }
13
13
  export function nativeLocations(client,home) {
14
14
  const root=path.join(home,directory[client]);
15
15
  return {
16
- config:client==='agy'?null:path.join(root,client==='claude'?'settings.json':'config.toml'),
16
+ config:client==='gemini'?null:path.join(root,client==='claude'?'settings.json':'config.toml'),
17
17
  instructions:path.join(root,client==='claude'?'CLAUDE.md':'AGENTS.md'),
18
- roles:Object.keys(ROLES).map(role=>path.join(root,client==='agy'?'config/agents':'agents',`apk-${role}.${client==='codex'?'toml':'md'}`))
18
+ roles:Object.keys(ROLES).map(role=>path.join(root,client==='gemini'?'config/agents':'agents',`apk-${role}.${client==='chatgpt'?'toml':'md'}`))
19
19
  };
20
20
  }
21
21
  export function nativeRoles(client,config,selectedRoles=ROLE_IDS) {
22
22
  return selectedRoles.map(role=>{
23
23
  const selected=rolePreference(config,role),name='apk-'+role;
24
24
  const {description,prompt}=ROLE_DEFINITIONS.find(value=>value.id===role);
25
- if(client==='codex') return Object.entries({name,description,model:selected.model,...(selected.effort===null?{}:{model_reasoning_effort:selected.effort}),developer_instructions:prompt}).map(([k,v])=>`${k} = ${JSON.stringify(v)}\n`).join('');
25
+ if(client==='chatgpt') return Object.entries({name,description,model:selected.model,...(selected.effort===null?{}:{model_reasoning_effort:selected.effort}),developer_instructions:prompt}).map(([k,v])=>`${k} = ${JSON.stringify(v)}\n`).join('');
26
26
  const fields=[`name: ${name}`,`description: ${JSON.stringify(description)}`];
27
- if(client==='kimi') {
27
+ // Grok selects the model from config.toml [subagents.models]; read-only roles use the bundled plan permission mode.
28
+ if(client==='grok') {
29
+ if(['reviewer','explorer'].includes(role))fields.push('permission_mode: plan');
30
+ } else if(client==='kimi') {
28
31
  if(['reviewer','explorer'].includes(role))fields.push('tools: [Read, Grep, Glob]');
29
32
  } else {
30
33
  fields.push(`model: ${JSON.stringify(selected.model)}`);
@@ -44,17 +47,18 @@ export function instructionBlock(client,config,maxConcurrent,selectedRoles=ROLE_
44
47
  for(const role of selectedRoles) {const selected=rolePreference(config,role);text.push(`For apk-${role}, select pool alias ${selected.model} (effort ${selected.effort??'native default'}).`);}
45
48
  text.push('Roles sharing an alias share its native effort default; an explicit effort pin for that alias also affects its native-default roles.');
46
49
  text.push("Use the Agent tool's model selection when spawning; the role file alone does not select a model. The concurrency limit is guidance; no native Agent cap is configured.");
47
- } else if(client==='agy') {
50
+ } else if(client==='gemini') {
48
51
  for(const role of selectedRoles)text.push(`When delegating to apk-${role}, use model tier ${rolePreference(config,role).model}.`);
49
52
  text.push('Per-role effort and a hard concurrency cap are not supported by the documented native agent settings.');
50
53
  }
54
+ else if(client==='grok')text.push('Use the named role definitions for model selection. Per-role effort is not supported by the documented native agent settings; subagents use the main session effort. Explicit spawn settings and higher-priority instructions can supersede user defaults.');
51
55
  else if(client==='claude')text.push('Use the named role definitions for model and effort selection. Session, project, managed settings, environment overrides and some spawn paths can supersede these user defaults; the native cap applies to ordinary Agent-tool spawns with documented exceptions.');
52
56
  else text.push('Use the named role definitions for model and effort selection. Explicit spawn settings and higher-priority instructions can supersede user defaults.');
53
- if(client==='codex'||client==='claude')text.push('An omitted role effort uses the native client default; it does not pin a level.');
57
+ if(client==='chatgpt'||client==='claude')text.push('An omitted role effort uses the native client default; it does not pin a level.');
54
58
  if(config.avoidModels.length)text.push(`Avoid these models when delegating: ${config.avoidModels.join(', ')}. This is routing guidance.`);
55
59
  text.push(END);
56
60
  return text.join('\n')+'\n';
57
61
  }
58
62
  export function nativeWarnings(client) {
59
- return client==='kimi'?['kimi-concurrency-guidance-only','kimi-model-pool-routing-guidance']:client==='agy'?['agy-concurrency-guidance-only','agy-per-role-effort-unsupported']:client==='claude'?['claude-native-precedence-limits']:['codex-native-precedence-limits'];
63
+ return client==='kimi'?['kimi-concurrency-guidance-only','kimi-model-pool-routing-guidance']:client==='gemini'?['gemini-concurrency-guidance-only','gemini-per-role-effort-unsupported']:client==='grok'?['grok-per-role-effort-unsupported','grok-native-precedence-limits']:client==='claude'?['claude-native-precedence-limits']:['chatgpt-native-precedence-limits'];
60
64
  }