@aglit-ai/cli 0.1.9 → 0.1.11

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
@@ -8,8 +8,8 @@ After the CLI and a compatible signed desktop release have been published:
8
8
  ```sh
9
9
  pnpm add -g @aglit-ai/cli
10
10
  aglit
11
- aglit start --headed
12
- aglit start --headless
11
+ aglit ui
12
+ aglit start
13
13
  aglit status
14
14
  aglit disable
15
15
  aglit enable
@@ -18,10 +18,9 @@ aglit uninstall
18
18
 
19
19
  Run `aglit` in an interactive terminal to open the guided setup. `aglit init`
20
20
  also runs this flow and installs the signed runtime if it is missing. Setup asks
21
- for a computer name and whether to keep the app in the background or open its
22
- Desktop window. **Headless is the default**, including login startup. Headed
23
- mode is an explicit choice; it shows the same app and profile. `aglit start
24
- --headless` hides its window without disabling its connection.
21
+ for a computer name and AI provider, then enables Anywhere Access. It runs in
22
+ the background without a window-mode prompt. After setup, `aglit ui`
23
+ opens the Desktop window using the same app and profile. `aglit start` hides its window without disabling its connection.
25
24
 
26
25
  Setup opens Aglit's Google login in the browser,
27
26
  detects existing Codex/Claude logins, and uses a configured authenticated provider
@@ -32,21 +31,25 @@ accounts remain owned by the user; uninstall does not delete their original logi
32
31
  ```sh
33
32
  aglit init --name "Office Mac" --provider codex
34
33
  aglit providers
35
- aglit teams
34
+ aglit rename --name "Office Mac"
35
+ aglit console
36
+ aglit team --name "My team"
37
+ aglit teams --open
36
38
  aglit pipelines
37
39
  aglit pipelines --open
38
- aglit init --name "Office Mac" --provider auto --team TEAM_ID
39
40
  aglit invite USER_EMAIL --team TEAM_ID
40
41
  ```
41
42
 
42
- The terminal shows the current account's team memberships and authorized
43
- pipeline devices. `aglit pipelines --open` opens the existing encrypted pipeline
44
- viewer for pipeline contents and controls. A device listed in the catalog is not
45
- proof that its pipelines are connected. Team selection remains invitation
46
- context; it does not share the newly registered computer.
43
+ Setup registers a Personal computer with your signed-in account. It asks for its
44
+ name and AI provider, requests the required OS permissions, guides installation of
45
+ the Aglit AI Chrome extension, and enables Anywhere Access. Team selection and
46
+ pipeline discovery are separate from setup.
47
+ Use `aglit team --name "My team"` to associate this computer with a team on your account. The CLI derives its installed workspace; no server-side workspace record or private-key provisioning is required. Manage invitations at teams.aglit.ai.
48
+ Use `aglit teams` to inspect memberships and `aglit pipelines --open` to open the
49
+ encrypted pipeline viewer. A listed device does not prove its pipelines are connected.
47
50
 
48
51
  For unattended setup, use `aglit init --json --name "Office Mac" --provider codex
49
- --account YOUR_EMAIL --headless`. `--account` checks the actual signed-in
52
+ --account YOUR_EMAIL`. `--account` checks the actual signed-in
50
53
  account before configuration or remote access is enabled; it never selects or
51
54
  impersonates an account. JSON mode and redirected input skip interactive prompts.
52
55
  Provide an explicit `--provider` when detection
@@ -65,7 +68,7 @@ Successful registration prints the direct employee link at
65
68
  `https://console.aglit.ai/employees/<device-id>` and includes it as `employeeUrl`
66
69
  in JSON output.
67
70
 
68
- `--team` selects the existing backend team/invitation scope. It does not grant team
71
+ `aglit invite EMAIL --team TEAM_ID` uses the existing backend invitation scope. It does not grant team
69
72
  members access to a separately registered personal computer. The backend requires
70
73
  current accepted membership/admin authority for its operations; a pending
71
74
  invitation is not access. Shared computer enrollment needs an authoritative team
@@ -126,3 +129,11 @@ No port change has shipped; provider registration and native acceptance remain
126
129
  required before qualifying coexistence.
127
130
 
128
131
  Anywhere Access requires a two-factor security code by default; an explicit off preference is preserved. In CLI headless mode, incoming connection requests display the code in a native window without opening the full app. This requires a logged-in graphical desktop session; an SSH-only/server environment cannot display the prompt. Codes are not printed to the terminal.
132
+
133
+ After computer-use permissions, setup opens the published Aglit AI Chrome Web Store
134
+ listing. Choose Add to Chrome and approve Chrome’s prompt. Setup waits until the
135
+ Store extension is installed and enabled in Chrome’s selected profile; it never
136
+ changes a disabled extension setting or browser policy. Installation detection is
137
+ separate from browser-control connectivity.
138
+
139
+ This release supports Apple Silicon Macs. Windows publication is deferred until its signed native runtime is available.
package/bin/aglit.cjs CHANGED
@@ -4,34 +4,50 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const os = require('node:os');
6
6
  const { parseArgs } = require('node:util');
7
+ const { createInterface } = require('node:readline/promises');
7
8
  const installation = require('../src/installation.cjs');
8
9
  const updates = require('../src/updates.cjs');
9
- const { createUi, teamsFromRoles } = require('../src/ui.cjs');
10
+ const { createUi } = require('../src/ui.cjs');
10
11
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
12
+ function assertSetupRuntime(state) {
13
+ if (!Number.isSafeInteger(state.setupVersion) || state.setupVersion < 2)
14
+ throw Error('This CLI requires a newer Aglit Computer Use runtime. Run aglit update, then rerun aglit init.');
15
+ }
11
16
  const help = `Aglit Computer Use
12
17
 
13
18
  aglit Interactive setup
14
19
  aglit init Install and register this computer
15
20
  [--name "Computer name"] [--provider codex|claude|auto]
16
- [--account EMAIL] [--headed | --headless] [--channel latest|canary|embedded]
17
- [--team TEAM_ID] [--invite EMAIL]
21
+ [--account EMAIL] [--channel latest|canary|embedded]
18
22
  aglit install Install the signed Desktop runtime
19
- aglit start [--headed | --headless]
23
+ aglit start
24
+ aglit ui
20
25
  aglit status [--json]
26
+ aglit rename --name "Computer name"
27
+ aglit console
21
28
  aglit update [--check] [--channel latest|canary|embedded] [--json]
22
29
  aglit updates on|off|status [--json]
23
30
  aglit enable | disable
24
- aglit teams
31
+ aglit teams [--open]
32
+ aglit team --name "Team name" [--team TEAM_ID] [--workspace WORKSPACE_ID]
25
33
  aglit pipelines [--open]
26
34
  aglit providers
35
+ aglit models list | choose
36
+ aglit models publish | unpublish --connection CONNECTION_ID --model MODEL_ID
37
+ aglit models register --device DEVICE_ID --fingerprint SHA256 [--name NAME]
38
+ aglit models prefer CONNECTION_ID|auto
39
+ aglit models remove CONNECTION_ID
40
+ aglit models pool POOL_ID --name NAME --connections local:ACCOUNT_A,local:ACCOUNT_B
41
+ aglit models test --model MODEL_ID [--connection CONNECTION_ID] [--harness]
42
+ aglit models unserve
27
43
  aglit invite EMAIL --team TEAM_ID
28
44
  aglit uninstall
29
45
 
30
- Aglit runs in the background by default. --headed opens the Desktop window.
46
+ Aglit runs in the background. Use aglit ui to open the Desktop window.
31
47
  Use --json with install, init, start or status for non-interactive JSON output.
32
48
  Uninstall revokes access and purges only this installation. Mini Computer,
33
49
  VMs, shared browser profiles and original provider logins remain separate.
34
- Team selection does not grant access to this personal computer.
50
+ Setup registers a Personal computer. Team invitations are separate commands.
35
51
  `;
36
52
 
37
53
  async function finishUninstall(receipt) {
@@ -48,30 +64,33 @@ async function finishUninstall(receipt) {
48
64
  }
49
65
 
50
66
  async function main() {
51
- const delegated = updates.delegate(__filename); if (delegated !== null) { process.exitCode = delegated; return; }
67
+ const delegated = installation.development ? null : updates.delegate(__filename); if (delegated !== null) { process.exitCode = delegated; return; }
52
68
  const { values, positionals } = parseArgs({ options: {
53
- name: { type: 'string' }, provider: { type: 'string' }, account: { type: 'string' },
54
- team: { type: 'string' }, invite: { type: 'string' }, headed: { type: 'boolean' },
55
- headless: { type: 'boolean' }, check: { type: 'boolean' }, channel: { type: 'string' }, open: { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
69
+ connections: { type: 'string' },
70
+ harness: { type: 'boolean' },
71
+ device: { type: 'string' }, fingerprint: { type: 'string' }, challenge: { type: 'string' }, code: { type: 'string' }, connection: { type: 'string' }, model: { type: 'string' }, name: { type: 'string' }, provider: { type: 'string' }, account: { type: 'string' },
72
+ team: { type: 'string' }, workspace: { type: 'string' }, check: { type: 'boolean' }, channel: { type: 'string' }, open: { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
56
73
  }, allowPositionals: true, strict: true });
57
74
  const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !values.json && !process.env.CI);
58
75
  if (values.help || !positionals.length && !interactive) { process.stdout.write(help); return; }
59
- const [command = 'init', email] = positionals;
76
+ const [command = 'init', email, connectionId] = positionals;
60
77
  const options = {
61
- install: ['json', 'channel'], init: ['name', 'provider', 'account', 'team', 'invite', 'headed', 'headless', 'json', 'channel'],
62
- start: ['headed', 'headless', 'json', 'channel'], status: ['json'], update: ['check', 'channel', 'json'], updates: ['json'], enable: [], disable: [], teams: ['json'], pipelines: ['open', 'json'], providers: ['json'], invite: ['team'], uninstall: [],
78
+ models: ['device','fingerprint','name','json','challenge','code','connection','connections','model','harness'],
79
+ install: ['json', 'channel'], init: ['name', 'provider', 'account', 'json', 'channel'],
80
+ start: ['json', 'channel'], ui: ['json'], status: ['json'], update: ['check', 'channel', 'json'], updates: ['json'], enable: [], disable: [], rename: ['name', 'json'], console: ['json'], teams: ['json', 'open'], team: ['name', 'team', 'workspace', 'json'], pipelines: ['open', 'json'], providers: ['json'], invite: ['team'], uninstall: [],
63
81
  };
64
- if (!Object.hasOwn(options, command) || Object.keys(values).some(key => !options[command].includes(key)) || positionals.length > (['invite', 'updates'].includes(command) ? 2 : 1)) throw Error('Invalid command or options. Run aglit --help.');
82
+ if (!Object.hasOwn(options, command) || Object.keys(values).some(key => !options[command].includes(key)) || positionals.length > (command === 'models' ? 3 : ['invite', 'updates'].includes(command) ? 2 : 1)) throw Error('Invalid command or options. Run aglit --help.');
83
+ if (installation.development && !['models','status','init','providers','enable','disable','ui','start'].includes(command)) throw Error('Source profile supports model/setup, access and window commands; installed runtime management is separate');
65
84
  if (command === 'updates' && !['on', 'off', 'status'].includes(email)) throw Error('Use aglit updates on|off|status');
66
85
  if (values.channel !== undefined && !['latest', 'canary', 'embedded'].includes(values.channel)) throw Error('Update channel must be latest, canary or embedded');
67
- if (values.headed && values.headless) throw Error('Choose either --headed or --headless');
68
86
  if (values.team && !/^[A-Za-z0-9._:-]{1,128}$/.test(values.team)) throw Error('Invalid team ID');
69
- if (values.provider && !['auto', 'codex', 'claude'].includes(values.provider)) throw Error('Provider must be auto, codex or claude');
87
+ if (values.provider && !/^[a-z0-9._-]{1,64}$/.test(values.provider)) throw Error('Invalid provider');
88
+ if (command === 'models') require('../src/model-connections.cjs').validateModelCommand(email,connectionId,values);
70
89
  const validEmail = value => value.length <= 254 && /^[^\s@*]+@[^\s@*]+\.[^\s@*]+$/.test(value);
71
- const recipient = command === 'invite' ? email : values.invite;
90
+ const recipient = command === 'invite' ? email : undefined;
72
91
  if (recipient && !validEmail(recipient)) throw Error('An individual invitation email address is required');
73
92
  if (values.account !== undefined && !validEmail(values.account)) throw Error('An individual account email address is required');
74
- if ((command === 'invite' || values.invite) && (!recipient || !values.team)) throw Error('Invitations require an email address and --team TEAM_ID');
93
+ if (command === 'invite' && (!recipient || !values.team)) throw Error('Invitations require an email address and --team TEAM_ID');
75
94
  if (values.name !== undefined && (!values.name.trim() || values.name.length > 100 || /[\x00-\x1f\x7f-\x9f]/.test(values.name))) throw Error('Computer name must contain 1–100 printable characters');
76
95
  const ui = await createUi(interactive);
77
96
  const installed = () => fs.existsSync(path.join(installation.root, 'installation.json'));
@@ -108,10 +127,8 @@ async function main() {
108
127
  }
109
128
  }
110
129
  let name = values.name;
111
- let mode = values.headed ? 'headed' : 'headless';
130
+ const mode = command === 'ui' ? 'headed' : 'headless';
112
131
  if (command === 'init') {
113
- if (!name && interactive && !installed()) name = await ui.name(os.hostname());
114
- if (interactive && values.headed === undefined && values.headless === undefined) mode = await ui.mode();
115
132
  if (!installed()) {
116
133
  if (!interactive && !name) throw Error('Provide --name for unattended setup');
117
134
  await ui.step('Download and verify Desktop', () => installation.install(values.channel));
@@ -119,11 +136,11 @@ async function main() {
119
136
  }
120
137
  if (!installed()) throw Error('Run aglit init to install and register this computer first.');
121
138
  installation.readInstallation();
122
- if (['start', 'init'].includes(command)) {
139
+ if (!installation.development && ['start', 'ui', 'init'].includes(command)) {
123
140
  const recovered = await updates.recoverActivation();
124
141
  if (recovered.updating) { ui.result(recovered); ui.finish('Update in progress'); return; }
125
142
  }
126
- if (['start', 'init'].includes(command)) updates.initialize(values.channel);
143
+ if (!installation.development && ['start', 'ui', 'init'].includes(command)) updates.initialize(values.channel);
127
144
  const configPath = path.join(installation.profile, 'ipc/sdk-ipc.json');
128
145
  const control = (action, fields = {}) => require('../src/control.cjs').control(configPath, action, fields);
129
146
  let state;
@@ -150,21 +167,44 @@ async function main() {
150
167
  throw Error('Computer Use did not become ready. See runtime.log in its installation directory.');
151
168
  });
152
169
  }
153
- if (state.running && ['status', 'start', 'init'].includes(command)) updates.rememberStatus(state);
154
- if (['start', 'init'].includes(command) && state.running) updates.ensureWorker(state.pid);
170
+ if (!installation.development && state.running && ['status', 'start', 'ui', 'init'].includes(command)) updates.rememberStatus(state);
171
+ if (!installation.development && ['start', 'ui', 'init'].includes(command) && state.running) updates.ensureWorker(state.pid);
172
+ const modelQuestion = interactive ? async text => {
173
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
174
+ try { return await prompt.question(text); } finally { prompt.close(); }
175
+ } : undefined;
176
+ if (command === 'models') {
177
+ ui.result(await require('../src/model-connections.cjs').modelCommand(control,email,connectionId,values,modelQuestion));
178
+ ui.finish('Model connections'); return;
179
+ }
155
180
  if (command === 'status') { ui.result(state); ui.finish('Computer status'); return; }
181
+ if (command === 'console') { ui.result({ url: state.consoleUrl }); ui.finish('Console'); return; }
182
+ if (command === 'rename') {
183
+ if (!values.name) throw Error('Provide --name to rename this computer');
184
+ ui.result(await control('configure', { name: values.name })); ui.finish('Computer renamed'); return;
185
+ }
186
+ if (command === 'team') {
187
+ if (!values.name) throw Error('Provide --name for the team');
188
+ ui.result(await ui.step('Set up your team', () => control('setup-team', {
189
+ name: values.name, ...(values.team ? { teamId: values.team } : {}),
190
+ ...(values.workspace ? { workspaceId: values.workspace } : {}),
191
+ })));
192
+ ui.finish('Manage your team at https://teams.aglit.ai/'); return;
193
+ }
156
194
  if (command === 'teams' || command === 'pipelines') {
157
195
  const result = await ui.step(command === 'teams' ? 'Load your teams' : 'Load authorized pipeline devices', () => control(command));
158
196
  if (interactive) ui[command](result); else ui.result(result);
197
+ if (command === 'teams' && values.open) await control('open-teams');
159
198
  if (command === 'pipelines' && values.open) await control('open-pipelines');
160
199
  ui.finish(command === 'teams' ? 'Your teams' : 'Your pipelines'); return;
161
200
  }
162
- if (command === 'start' || command === 'init') {
201
+ if (command === 'start' || command === 'ui') {
163
202
  const window = await control('window', { mode });
164
203
  state = { ...state, ...window };
165
- if (command === 'start') { ui.result(state); ui.finish(mode === 'headed' ? 'Desktop window opened' : 'Running in the background'); return; }
204
+ ui.result(state); ui.finish(mode === 'headed' ? 'Desktop window opened' : 'Running in the background'); return;
166
205
  }
167
206
  if (command === 'init') {
207
+ assertSetupRuntime(state);
168
208
  if (!state.email) {
169
209
  state = await ui.step('Complete sign-in in your browser', async () => {
170
210
  await control('login');
@@ -180,33 +220,32 @@ async function main() {
180
220
  if (interactive && !values.account && !await ui.confirmAccount(state.email))
181
221
  throw Object.assign(Error('Setup cancelled before enabling remote access.'), { cancelled: true });
182
222
  ui.info(`Signed in as ${state.email}`);
183
- let teamId = values.team;
184
- if (interactive) {
185
- const discovery = await ui.step('Load your teams and pipelines', () => Promise.allSettled([control('teams'), control('pipelines')]));
186
- let teams = [];
187
- if (discovery[0].status === 'fulfilled') { ui.teams(discovery[0].value); teams = teamsFromRoles(discovery[0].value); }
188
- else ui.info('Teams are unavailable. You can retry with aglit teams.');
189
- if (discovery[1].status === 'fulfilled') ui.pipelines(discovery[1].value);
190
- else ui.info('Pipeline devices are unavailable. You can retry with aglit pipelines.');
191
- if (!teamId && teams.length) teamId = await ui.team(teams);
192
- }
223
+ if (!values.name && interactive) name = await ui.name(state.name || os.hostname());
193
224
  name ||= state.name;
194
- if (!name && interactive) name = await ui.name(os.hostname());
195
225
  if (!name) throw Error('Provide --name for unattended setup');
196
- const detected = await ui.step('Find signed-in AI providers', () => control('providers'));
197
- const available = detected.providers.filter(provider => provider.authenticated).map(provider => provider.provider);
198
- let provider = values.provider && values.provider !== 'auto' ? values.provider :
199
- available.includes(detected.preferred) ? detected.preferred : values.provider === 'auto' ?
200
- ['codex', 'claude'].find(candidate => available.includes(candidate)) : available.length === 1 ? available[0] : null;
201
- if (!provider && available.length && interactive) provider = await ui.provider(available, detected.preferred);
202
- if (!provider || !available.includes(provider)) throw Error('Sign in to Codex or Claude on this computer, then rerun aglit init --provider codex|claude.');
203
- ui.info(`AI provider: ${provider}`);
204
- await ui.step('Save computer settings', () => control('configure', { name, provider, ...(teamId ? { teamId } : {}) }));
205
- if (process.platform === 'darwin') ui.info('Approve the macOS screen recording and input-control requests. Setup waits for each permission before continuing. Previously denied permissions must be enabled in System Settings.');
226
+ const selected = await require('../src/model-connections.cjs').chooseModelConnection(control, { provider: values.provider, question: modelQuestion });
227
+ for (const warning of selected.view.warnings || []) ui.info(warning);
228
+ await ui.step('Save computer settings', () => control('configure', { name,
229
+ ...(selected.connectionId ? { provider: 'aglit', model_connection_id: selected.connectionId } : values.provider && values.provider !== 'auto' ? { provider: values.provider } : {}) }));
230
+ if (process.platform === 'darwin') ui.permissions();
206
231
  const permissions = await ui.step('Waiting for screen recording and input permissions', () => control('permissions'));
207
232
  if (!interactive) ui.result(permissions);
208
233
  if (permissions.permissions?.supported && (permissions.permissions.screenRecording !== 'granted' || permissions.permissions.accessibility !== 'granted'))
209
234
  throw Error('Grant Screen Recording and Accessibility in System Settings, then rerun aglit init. Anywhere Access has not been enabled.');
235
+ let browser = await ui.step('Check Aglit AI browser extension', () => control('browser-status'));
236
+ if (!browser.enabled) {
237
+ const prepared = await ui.step('Set up Aglit AI browser extension', () => control('browser-setup'));
238
+ ui.browserExtension(prepared);
239
+ browser = await ui.step('Waiting for Chrome extension installation', async () => {
240
+ const deadline = Date.now() + 180000;
241
+ do {
242
+ const current = await control('browser-status');
243
+ if (current.installed && current.enabled) return current;
244
+ await delay(1500);
245
+ } while (Date.now() < deadline);
246
+ throw Error('Add or enable the Aglit AI Chrome extension, then rerun aglit init.');
247
+ });
248
+ }
210
249
  state = await ui.step('Connect this computer to your account', async () => {
211
250
  await control('enable');
212
251
  const deadline = Date.now() + 30000;
@@ -217,11 +256,10 @@ async function main() {
217
256
  } while (Date.now() < deadline);
218
257
  throw Error('Anywhere Access is enabled but its connection is not ready. Check aglit status before retrying.');
219
258
  });
220
- if (values.invite) ui.result(await control('invite', { teamId: values.team, email: values.invite }));
221
- updates.rememberStatus(state);
259
+ if (!installation.development) updates.rememberStatus(state);
222
260
  if (typeof state.deviceId !== 'string' || !state.deviceId) throw Error('Registration returned no device ID. Check aglit status before retrying.');
223
261
  state = { ...state, employeeUrl: `https://console.aglit.ai/employees/${encodeURIComponent(state.deviceId)}` };
224
- ui.result(state); ui.finish(`Computer ready · ${state.employeeUrl}`); return;
262
+ ui.result(state); ui.finish('Anywhere Access enabled'); return;
225
263
  }
226
264
  if (command === 'invite') { ui.result(await control('invite', { teamId: values.team, email })); ui.finish('Invitation sent'); return; }
227
265
  if (command === 'uninstall') {
@@ -236,7 +274,8 @@ async function main() {
236
274
  ui.result(await control(command)); ui.finish('Done');
237
275
  }
238
276
 
239
- main().catch(error => {
277
+ module.exports = {assertSetupRuntime};
278
+ if (require.main === module) main().catch(error => {
240
279
  if (!error.cancelled) process.stderr.write(`aglit: ${error.message}\n`);
241
280
  process.exitCode = error.cancelled ? 130 : 1;
242
281
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglit-ai/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Install and manage Aglit Computer Use",
5
5
  "license": "UNLICENSED",
6
6
  "type": "commonjs",
@@ -12,6 +12,12 @@
12
12
  "src",
13
13
  "README.md"
14
14
  ],
15
+ "os": [
16
+ "darwin"
17
+ ],
18
+ "cpu": [
19
+ "arm64"
20
+ ],
15
21
  "engines": {
16
22
  "node": ">=22"
17
23
  },
package/src/control.cjs CHANGED
@@ -22,7 +22,7 @@ function control(configPath, action, fields = {}) {
22
22
  const id = crypto.randomUUID();
23
23
  const socket = net.createConnection(config.endpoint);
24
24
  let buffer = '', authenticated = false, settled = false;
25
- const timeout = setTimeout(() => finish(Error('Native command timed out; its outcome may be uncertain. Check aglit status before retrying.')), action === 'permissions' ? 5 * 60000 : 30000);
25
+ const timeout = setTimeout(() => finish(Error('Native command timed out; its outcome may be uncertain. Check aglit status before retrying.')), action === 'permissions' ? 5 * 60000 : ['enable','models-test'].includes(action) ? 120000 : 30000);
26
26
  const finish = (error, result) => {
27
27
  if (settled) return;
28
28
  settled = true; clearTimeout(timeout); socket.destroy();
@@ -8,7 +8,8 @@ const { pipeline } = require('node:stream/promises');
8
8
  const { once } = require('node:events');
9
9
  const { createRequire } = require('node:module');
10
10
 
11
- const root = path.join(process.platform === 'darwin' ? path.join(os.homedir(), 'Library/Application Support') :
11
+ const development = process.env.AGLIT_DEV_COMPUTER_USE_ROOT ? require('../../desktop-app/shared/computer-use-development.cjs').readComputerUseDevelopment() : null;
12
+ const root = development?.root || path.join(process.platform === 'darwin' ? path.join(os.homedir(), 'Library/Application Support') :
12
13
  process.platform === 'win32' ? process.env.APPDATA || path.join(os.homedir(), 'AppData/Roaming') : path.join(os.homedir(), '.config'), 'Aglit Computer Use');
13
14
  const profile = path.join(root, 'profile');
14
15
  const receiptPath = path.join(root, 'installation.json');
@@ -51,9 +52,9 @@ function readInstallation(allowPartialCleanup = false) {
51
52
  canonical(receiptPath);
52
53
  if (fs.statSync(receiptPath).size > 65536) throw Error('Installation receipt too large');
53
54
  const value = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
54
- if (value.kind !== 'aglit-computer-use-installation-v1' || value.root !== root ||
55
+ if (value.kind !== (development ? 'aglit-computer-use-development-v1' : 'aglit-computer-use-installation-v1') || value.root !== root ||
55
56
  value.profile !== profile || typeof value.executable !== 'string' ||
56
- !value.executable.startsWith(root + path.sep) || !/^[a-f0-9]{64}$/.test(value.sha256)) throw Error('Installation receipt differs');
57
+ (!development && !value.executable.startsWith(root + path.sep)) || !/^[a-f0-9]{64}$/.test(value.sha256)) throw Error('Installation receipt differs');
57
58
  if (!allowPartialCleanup) { canonical(value.executable); canonical(value.profile); }
58
59
  return value;
59
60
  }
@@ -195,6 +196,7 @@ async function install(channel) {
195
196
  return { installed: true, ...receipt };
196
197
  }
197
198
  function start() {
199
+ if (development) throw Error('Start the source runtime with scripts/dev/run-computer-use.cjs before invoking this CLI');
198
200
  if (fs.existsSync(path.join(root, 'uninstalling.json'))) throw Error('Computer Use is uninstalling. Run aglit uninstall to finish cleanup.');
199
201
  if (process.platform === 'win32') run(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command',
200
202
  '$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()); if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw "Run aglit init from a non-elevated interactive user terminal"}']);
@@ -262,4 +264,4 @@ function readUninstall() {
262
264
  !Number.isSafeInteger(receipt.pid) || receipt.pid <= 0) throw Error('Invalid uninstall receipt');
263
265
  return receipt;
264
266
  }
265
- module.exports = { root, profile, readInstallation, install, start, purge, purgeIncomplete, readUninstall, privateRoot, canonical, verifyPublisher, embeddedRelease, copyArchive, stageRuntime, verifyRuntime };
267
+ module.exports = { development, root, profile, readInstallation, install, start, purge, purgeIncomplete, readUninstall, privateRoot, canonical, verifyPublisher, embeddedRelease, copyArchive, stageRuntime, verifyRuntime };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ /** UI-free selection policy lives in runtime-api; the CLI prompts only on its
3
+ * explicit choice-required result, then persists the choice through native control. */
4
+ async function listModelConnections(control) {
5
+ try {
6
+ const view = await control('models-list');
7
+ if (!view || !Array.isArray(view.connections) || !view.selection || typeof view.selection.status !== 'string') throw Error('Invalid discovery response');
8
+ return view;
9
+ } catch {
10
+ return { connections: [], selection: { status: 'discovery-unavailable', connection_id: null },
11
+ warnings: ['Model endpoint discovery is unavailable. Existing provider settings are unchanged; retry aglit models list later.'] };
12
+ }
13
+ }
14
+ async function chooseModelConnection(control, { provider, question } = {}) {
15
+ let view = await listModelConnections(control);
16
+ if (view.selection.status === 'discovery-unavailable') return { view, connectionId: null, unavailable: true };
17
+ // Init/choose is an explicit connection action. Passive models-list never
18
+ // prompts; reconnect only the saved/chosen endpoint, never every known host.
19
+ if (['preferred-unavailable','setup-required'].includes(view.selection.status)) {
20
+ const remote = view.connections.filter(value => value.kind === 'remote');
21
+ let target = view.preferred_connection_id ? remote.find(value => value.id === view.preferred_connection_id) : remote.length === 1 ? remote[0] : undefined;
22
+ let chosen = false;
23
+ if (!target && !view.preferred_connection_id && remote.length > 1) {
24
+ if (!question) throw Error('More than one model connection is available. Run aglit models choose.');
25
+ const answer = (await question(remote.map((value,index)=>`${index+1}. ${value.name}`).join('\n')+'\nModel connection: ')).trim();
26
+ if (!/^[1-9]\d*$/.test(answer) || !remote[Number(answer)-1]) throw Error('Choose a listed model connection');
27
+ target = remote[Number(answer)-1]; chosen = true;
28
+ }
29
+ if (target) {
30
+ view = await modelCommand(control,'register',undefined,{device:target.device_id,fingerprint:target.identity_fingerprint,name:target.name},question);
31
+ if (chosen) view = await control('models-prefer',{connection_id:target.id});
32
+ }
33
+ }
34
+ let selection = view.selection;
35
+ if (provider && provider !== 'auto' && provider !== 'aglit') {
36
+ const candidates = view.connections.filter(value => value.available && value.models.some(model => model.provider === provider));
37
+ if (candidates.length === 1) {
38
+ view = await control('models-prefer', { connection_id: candidates[0].id }); selection = view.selection;
39
+ } else if (!candidates.length) throw Error('No connected account supplies that provider');
40
+ else { view = { ...view, connections: candidates }; selection = { status: 'choice-required', connection_id: null }; }
41
+ }
42
+ if (selection.status === 'choice-required') {
43
+ if (!question) throw Error('More than one model connection is available. Run aglit models choose or aglit models prefer CONNECTION_ID.');
44
+ const choices = view.connections.filter(value => value.available && value.models.length);
45
+ const answer = await question(choices.map((value,index)=>`${index+1}. ${value.name}`).join('\n')+'\nModel connection: ');
46
+ if (!/^[1-9]\d*$/.test(answer.trim()) || !choices[Number(answer.trim())-1]) throw Error('Choose a listed model connection');
47
+ view = await control('models-prefer', { connection_id: choices[Number(answer.trim())-1].id }); selection = view.selection;
48
+ }
49
+ if (selection.status === 'preferred-unavailable') throw Error('Your saved model connection is unavailable. Reconnect it or explicitly choose another with aglit models prefer CONNECTION_ID.');
50
+ if (selection.status !== 'selected') throw Error('No model connection is ready. Connect a local provider or register an Aglit model endpoint.');
51
+ return { view, connectionId: selection.connection_id };
52
+ }
53
+ function validateModelCommand(command, id, values) {
54
+ const allowed = {list:['json'],test:['connection','model','harness'],choose:[],pool:['name','connections'],publish:['connection','model'],unpublish:['connection','model'],unserve:[],register:['device','fingerprint','name','challenge','code'],prefer:[],remove:[]};
55
+ if (!Object.hasOwn(allowed,command) || Object.keys(values).some(key=>!allowed[command].includes(key)) ||
56
+ (!['prefer','remove','pool'].includes(command) && id) || (['prefer','remove','pool'].includes(command) && !id)) throw Error('Invalid model command or options');
57
+ if (command === 'pool' && (!/^[A-Za-z0-9._-]{1,80}$/.test(id) || !values.name?.trim() || values.name.length > 100 ||
58
+ typeof values.connections !== 'string' || values.connections.length > 6400 || !values.connections.split(',').every(value => /^local:[A-Za-z0-9._:-]{1,180}$/.test(value) && !value.startsWith('local:pool:'))))
59
+ throw Error('Use aglit models pool POOL_ID --name NAME --connections local:ACCOUNT_A,local:ACCOUNT_B');
60
+ if (command === 'register' && (!/^[A-Za-z0-9._:-]{1,180}$/.test(values.device || '') || !/^[a-f0-9]{64}$/.test(values.fingerprint || '')))
61
+ throw Error('Use --device DEVICE_ID and --fingerprint with the endpoint SHA-256 fingerprint');
62
+ if (Boolean(values.challenge) !== Boolean(values.code) || values.code && !/^[0-9]{4,12}$/.test(values.code) ||
63
+ values.challenge && (!/^[A-Za-z0-9._:-]{1,256}$/.test(values.challenge))) throw Error('Supply both --challenge and a numeric --code');
64
+ if (id && !/^[A-Za-z0-9._:-]{1,200}$/.test(id)) throw Error('Invalid model connection ID');
65
+ if (['publish','unpublish'].includes(command) && (!/^local:[A-Za-z0-9._:-]{1,180}$/.test(values.connection||'') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(values.model||''))) throw Error('Choose a local --connection and --model');
66
+ if (command === 'remove' && !id.startsWith('remote:')) throw Error('A registered remote connection ID is required');
67
+ }
68
+ async function modelCommand(control, command, id, values, question) {
69
+ validateModelCommand(command,id,values);
70
+ if (command === 'list') return listModelConnections(control);
71
+ if (command === 'test') {
72
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(values.model || '') || values.connection && !/^[A-Za-z0-9._:-]{1,200}$/.test(values.connection)) throw Error('Use aglit models test --model MODEL_ID [--connection CONNECTION_ID]');
73
+ return control('models-test', { model_id: values.model, ...(values.connection ? { connection_id: values.connection } : {}), ...(values.harness ? { harness: 'true' } : {}) });
74
+ }
75
+ if (command === 'pool') return control('models-pool-save', { pool_id: 'local:pool:' + id, name: values.name,
76
+ connection_ids: JSON.stringify(values.connections.split(',')) });
77
+ if (command === 'register') {
78
+ if (!values.device || !values.fingerprint || id) throw Error('Use aglit models register --device DEVICE_ID --fingerprint SHA256 [--name NAME]');
79
+ if (Boolean(values.challenge) !== Boolean(values.code) || values.code && !/^[0-9]{4,12}$/.test(values.code)) throw Error('Supply both --challenge and a numeric --code');
80
+ const fields = {device_id:values.device,identity_fingerprint:values.fingerprint,name:values.name||values.device, ...(values.challenge ? {challenge_id:values.challenge,code:values.code} : {})};
81
+ let result = await control('models-register',fields);
82
+ let consoleUrl;
83
+ if (typeof result.console_url === 'string') {
84
+ try { const url = new URL(result.console_url);
85
+ if ((url.protocol === 'https:' || process.env.NODE_ENV === 'development' && url.protocol === 'http:' && url.hostname === '127.0.0.1') &&
86
+ !url.username && !url.password && !url.search && !url.hash && url.pathname === '/employees/' + encodeURIComponent(values.device)) consoleUrl = url.href;
87
+ } catch { /* Never present an unvalidated authentication destination. */ }
88
+ }
89
+ const instructions = `A security-code dialog appears on ${fields.name}. Check that device.` +
90
+ (consoleUrl ? ` Open its console on that device if needed: ${consoleUrl}` : '');
91
+ if (result.authentication_required && result.challenge_id && question) {
92
+ const code = (await question(instructions + '\nRemote Access authentication code: ')).trim();
93
+ if (!/^[0-9]{4,12}$/.test(code)) throw Error('A numeric Remote Access code is required');
94
+ result = await control('models-register',{...fields,challenge_id:result.challenge_id,code});
95
+ }
96
+ if (result.authentication_required) throw Error(instructions + ' Complete registration with --challenge CHALLENGE_ID --code CODE' +
97
+ (result.challenge_id ? ' (challenge '+result.challenge_id+')' : ''));
98
+ return result;
99
+ }
100
+ if (command === 'prefer' || command === 'remove') {
101
+ if (!id || !/^[A-Za-z0-9._:-]{1,200}$/.test(id)) throw Error('A model connection ID is required');
102
+ return control(command==='prefer'?'models-prefer':'models-remove',{connection_id:command==='prefer'&&id==='auto'?'':id});
103
+ }
104
+ if (command === 'publish' || command === 'unpublish') return control('models-publish',{connection_id:values.connection,model_id:values.model,published:String(command==='publish')});
105
+ if (command === 'unserve') return control('models-unpublish-all');
106
+ if (command === 'choose') return (await chooseModelConnection(control,{question})).view;
107
+ throw Error('Use aglit models list|register|prefer|remove|publish|unpublish|unserve|choose');
108
+ }
109
+ module.exports={chooseModelConnection,listModelConnections,modelCommand,validateModelCommand};
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const crypto = require('node:crypto');
6
+ const { execFile } = require('node:child_process');
7
+ const { promisify } = require('node:util');
8
+ const run = promisify(execFile);
9
+ const need = (value, message) => { if (!value) throw Error(message); };
10
+
11
+ // Read a single payload from a verified npm tarball. Native tar writes only to
12
+ // stdout: package paths, links and lifecycle scripts never write into the guest.
13
+ async function packageFile(manifest, file, maximum, signal = AbortSignal.timeout(120000)) {
14
+ const url = new URL(manifest.dist.tarball);
15
+ need(url.protocol === 'https:' && url.hostname === 'registry.npmjs.org' && !url.port && !url.username && !url.password &&
16
+ /^sha512-[A-Za-z0-9+/]{86}==$/.test(manifest.dist.integrity), 'Untrusted runtime npm package');
17
+ need(['runtime/desktop-runtime.json', 'runtime/part.bin'].includes(file), 'Unknown runtime payload');
18
+ const directory = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'aglit-runtime-'));
19
+ const archive = path.join(directory, 'package.tgz');
20
+ try {
21
+ const response = await fetch(url, {redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(120000)])});
22
+ need(response.ok && response.body, 'Runtime npm download unavailable');
23
+ const fd = fs.openSync(archive, 'wx', 0o600), digest = crypto.createHash('sha512');
24
+ let size = 0;
25
+ try {
26
+ for await (const chunk of response.body) {
27
+ size += chunk.length; need(size <= maximum + 2 * 1024 ** 2, 'Runtime npm package exceeds limit');
28
+ digest.update(chunk);
29
+ let offset = 0;
30
+ while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset, chunk.length - offset);
31
+ }
32
+ need('sha512-' + digest.digest('base64') === manifest.dist.integrity, 'Runtime npm integrity differs');
33
+ } finally { fs.closeSync(fd); }
34
+ const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/tar.exe') : '/usr/bin/tar';
35
+ const {stdout} = await run(tar, ['-xOf', archive, 'package/' + file], {encoding: 'buffer', maxBuffer: maximum + 1, timeout: 30000, windowsHide: true});
36
+ need(stdout.length > 0 && stdout.length <= maximum, 'Runtime payload exceeds limit');
37
+ return stdout;
38
+ } finally { fs.rmSync(directory, {recursive: true, force: true}); }
39
+ }
40
+
41
+ // The same verified part stream feeds normal downloads and native baseline
42
+ // admission, whose existing descriptor custody stays owned by its native worker.
43
+ async function* archiveParts({descriptor, parts, signal}) {
44
+ need(parts.length === descriptor.chunks.length && parts.length > 0 && parts.length <= 32, 'Runtime part graph differs');
45
+ const sha256 = crypto.createHash('sha256'), sha512 = crypto.createHash('sha512');
46
+ let received = 0;
47
+ for (const [index, part] of descriptor.chunks.entries()) {
48
+ need(parts[index].name === part.package && parts[index].version === part.version, 'Runtime part identity differs');
49
+ const bytes = await packageFile(parts[index], part.file, part.size, signal);
50
+ need(bytes.length === part.size && crypto.createHash('sha256').update(bytes).digest('hex') === part.sha256 &&
51
+ crypto.createHash('sha512').update(bytes).digest('base64') === part.sha512, 'Runtime part checksum differs');
52
+ received += bytes.length; sha256.update(bytes); sha512.update(bytes);
53
+ yield bytes;
54
+ }
55
+ need(received === descriptor.archive.size && sha256.digest('hex') === descriptor.archive.sha256 &&
56
+ sha512.digest('base64') === descriptor.archive.sha512, 'Runtime archive checksum differs');
57
+ }
58
+
59
+ async function downloadArchive({descriptor, parts, destination, onProgress = () => {}}) {
60
+ const fd = fs.openSync(destination, 'wx', 0o600), owned = fs.fstatSync(fd);
61
+ let received = 0, verified = false;
62
+ try {
63
+ for await (const bytes of archiveParts({descriptor, parts})) {
64
+ let offset = 0;
65
+ while (offset < bytes.length) offset += fs.writeSync(fd, bytes, offset, bytes.length - offset);
66
+ received += bytes.length; onProgress(received);
67
+ }
68
+ fs.fsyncSync(fd); verified = true;
69
+ } finally {
70
+ fs.closeSync(fd);
71
+ if (!verified) {
72
+ const current = fs.lstatSync(destination);
73
+ if (current.dev === owned.dev && current.ino === owned.ino && current.isFile() && !current.isSymbolicLink()) fs.unlinkSync(destination);
74
+ }
75
+ }
76
+ }
77
+ module.exports = {packageFile, archiveParts, downloadArchive};
package/src/ui.cjs CHANGED
@@ -2,17 +2,7 @@
2
2
 
3
3
  const clean = value => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
4
4
  function teamsFromRoles(value) {
5
- const teams = new Map();
6
- for (const company of value?.companies || []) {
7
- for (const team of [...(company.teams || []), ...(company.organizations || []).flatMap(organization => organization.teams || [])]) {
8
- if (typeof team.teamId === 'string') teams.set(team.teamId, { id: team.teamId, roles: team.roles || [] });
9
- }
10
- }
11
- for (const role of value?.roles || []) {
12
- const id = role.teamId || (role.scopeType === 'team' ? role.scopeId : null);
13
- if (typeof id === 'string' && !teams.has(id)) teams.set(id, { id, roles: [role.roleLabel || role.role].filter(Boolean) });
14
- }
15
- return [...teams.values()];
5
+ return (value?.teams || []).map(team => ({ id: team.teamId, roles: [team.canManage ? 'Administrator' : 'Member'] }));
16
6
  }
17
7
  async function createUi(interactive) {
18
8
  if (!interactive) return {
@@ -20,6 +10,9 @@ async function createUi(interactive) {
20
10
  step: (_label, action) => action(),
21
11
  result: value => process.stdout.write(JSON.stringify(value, null, 2) + '\n'),
22
12
  info: message => process.stderr.write(message + '\n'),
13
+ selection: (label, value) => process.stderr.write(label + ': ' + clean(value) + '\n'),
14
+ permissions: () => process.stderr.write('In System Settings → Privacy & Security, enable Aglit Computer Use in Screen Recording and Accessibility. Keep each pane open until its permission is granted. Previously denied permissions must be enabled there manually.\n'),
15
+ browserExtension: value => process.stderr.write('In Chrome, open '+value.storeUrl+' and choose Add to Chrome. If installed but disabled, enable Aglit AI in chrome://extensions.\n'),
23
16
  finish() {},
24
17
  };
25
18
  const p = await import('@clack/prompts');
@@ -36,16 +29,11 @@ async function createUi(interactive) {
36
29
  interactive: true,
37
30
  name: initialValue => answer(p.text({ message: 'Name this computer', placeholder: initialValue, defaultValue: initialValue,
38
31
  validate: value => value && (!value.trim() || value.length > 100 || /[\x00-\x1f\x7f-\x9f]/.test(value)) ? 'Use 1–100 printable characters.' : undefined })),
39
- mode: () => answer(p.select({ message: 'How should Aglit run?', initialValue: 'headless', options: [
40
- { value: 'headless', label: 'Headless', hint: 'default · background' },
41
- { value: 'headed', label: 'Open Desktop window', hint: 'headed' },
42
- ] })),
43
32
  provider: (providers, preferred) => answer(p.select({ message: 'Choose your AI provider', initialValue: preferred,
44
33
  options: providers.map(value => ({ value, label: value === 'codex' ? 'Codex' : 'Claude' })) })),
45
- team: teams => answer(p.select({ message: 'Team for invitations', initialValue: '', options: [
46
- { value: '', label: 'Personal computer', hint: 'access stays with your account' },
47
- ...teams.map(team => ({ value: team.id, label: clean(team.id), hint: team.roles.map(clean).join(', ') })),
48
- ] })),
34
+ selection: (label, value) => p.log.step(clean(label) + '\n' + clean(value)),
35
+ permissions: () => p.note('Enable Aglit Computer Use in System Settings → Privacy & Security:\n1. Screen Recording (Screen & System Audio Recording on newer macOS)\n2. Accessibility\n\nSetup waits for each grant before opening the next pane.\nPreviously denied permissions must be enabled there manually.\nIf macOS asks you to quit and reopen Aglit Computer Use, do so and rerun aglit init.', 'Allow screen sharing and input'),
36
+ browserExtension: value => p.note('Open this listing in Chrome:\n' + clean(value.storeUrl) + '\n\nChoose Add to Chrome and approve the browser prompt.\nIf already installed but disabled, enable Aglit AI in chrome://extensions.\nSetup continues when the extension is installed and enabled.\nInstallation alone does not verify browser-control connectivity.', 'Add Aglit AI to Chrome'),
49
37
  teams(value) {
50
38
  const teams = teamsFromRoles(value);
51
39
  p.note(teams.length ? teams.map(team => `${clean(team.id)} ${team.roles.map(clean).join(', ')}`).join('\n') : 'No team memberships.', 'Your teams');