@invitedlabs/cli 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,55 @@
1
+ Apache License
2
+
3
+ Version 2.0, January 2004
4
+
5
+ http://www.apache.org/licenses/
6
+
7
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
+
9
+ 1. Definitions.
10
+
11
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
16
+
17
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
18
+
19
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
20
+
21
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
22
+
23
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
24
+
25
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
26
+
27
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
28
+
29
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
30
+
31
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
32
+
33
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
34
+
35
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
36
+
37
+ You must give any other recipients of the Work or Derivative Works a copy of this License; and
38
+
39
+ You must cause any modified files to carry prominent notices stating that You changed the files; and
40
+
41
+ You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
42
+
43
+ If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44
+
45
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46
+
47
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48
+
49
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50
+
51
+ 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52
+
53
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54
+
55
+ END OF TERMS AND CONDITIONS
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # invited
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `invited` command connects terminal agents and AI assistants to your Invited Identity. It is a client of Invited's MCP connector (`https://invited.you/mcp`), the same connector Claude and ChatGPT use: it sets up the connector in Claude Code, Cursor, Codex CLI and Gemini CLI, and it can log in itself to print, in a terminal, the fragments you share with it.
4
+
5
+ It reads only the fragments you choose on Invited's consent screen, for as long as you chose, like every connected assistant. It never reads your Invited Page, which is for the people and programs that visit it. It holds an OAuth log-in like any other connected assistant, never a vault key, and decrypts nothing.
6
+
7
+ ## Install
8
+
9
+ Node 22.13 or later. Nothing else is installed: the CLI has no dependencies and uses only Node's own modules (`fetch`, `node:crypto`, `node:http`, `node:fs`).
10
+
11
+ ```sh
12
+ npx @invitedlabs/cli connect claude-code # add Invited to Claude Code, which then logs in itself
13
+ npx @invitedlabs/cli login # or log this CLI in: you approve in your browser
14
+ npx @invitedlabs/cli whoami # then print the fragments you share with it
15
+ ```
16
+
17
+ or install it once with `npm install --global @invitedlabs/cli` and run `invited …`.
18
+
19
+ From a checkout of the platform repository, `npm run build:cli` builds `cli/dist` (`npm run build` does it too); then run `node cli/dist/index.js --help`.
20
+
21
+ ## Commands
22
+
23
+ Every command prints plain English, exits 0 when it did what was asked and 1 otherwise, and uses no colour (set `FORCE_COLOR=1` for bold headings; `NO_COLOR` always wins). `--origin <url>` (or `INVITED_ORIGIN`) points any command at another Invited, such as a local one on `http://127.0.0.1:4180`; the default is `https://invited.you`.
24
+
25
+ | Command | What it does |
26
+ | --- | --- |
27
+ | `invited connect <host>` | Adds Invited's MCP server to `claude-code`, `cursor`, `codex` or `gemini` as `invited`, keeping every other server in the file as it is. The host then logs in to Invited itself. `--dry-run` prints what it would write and writes nothing; `--bridge` writes the `mcp-remote` bridge instead (`npx -y mcp-remote@0.14.3 https://invited.you/mcp`, pinned to the reviewed version) for a host version without remote log-in; `--config <file>` edits another file. |
28
+ | `invited login` | Logs this CLI in to your Identity. Your browser opens Invited's consent screen ("Connect your Identity?", its box naming Invited CLI): you choose the fragments it may read and for how long (30 days, 7 days, 24 hours, or until you disconnect), approve it, and the browser returns to the CLI, which says how many fragments it can read. `--no-browser` prints the address to open instead (an SSH session, say). `--private`, which 0.1.0 needed to share fragments, is still taken: every log-in asks now. |
29
+ | `invited whoami` | Prints the fragments you share with this CLI as Markdown, by section, and when that sharing ends (`invited://identity`). `--json` prints `invited://identity.json`: the same as JSON. |
30
+ | `invited fragments --type <type>` | Prints the fragments you share with this CLI of one or more types (`get_fragments`); repeat `--type` for more. Types are keys such as `skill`, `language`, `experience`, `cuisines` or `dietaryPreferences`. `--json` prints the structured answer. |
31
+ | `invited fragments --search <words>` | Prints the fragments you share with this CLI that match, best first (`search_fragments`). |
32
+ | `invited private` | Prints the fragments you share with this CLI (`private_fragments`) and when that sharing ends. `--type <type>` (repeatable) prints only those types; `--json` prints the structured answer. |
33
+ | `invited status` | Shows the log-in this CLI holds: your Identity, its scopes (`identity:private` while you share fragments with it), when it was last used and when it expires. `--json` prints the same as JSON, with `assistantsUrl`, the address of Activity's Assistants. It reads the credentials file only. |
34
+ | `invited logout` | Revokes this CLI's own log-in at Invited and deletes its credentials. `invited revoke` does the same. |
35
+ | `invited --help`, `invited help <command>`, `invited --version` | Help and the version. |
36
+
37
+ When nothing is shared with the CLI (you shared none, or the time you chose has ended), `whoami`, `fragments` and `private` say so: run `invited login` to choose again.
38
+
39
+ Where `connect` writes, and how each host logs in:
40
+
41
+ | Host | File | Entry | Log in |
42
+ | --- | --- | --- | --- |
43
+ | Claude Code | `~/.claude.json` (user scope) | `"invited": { "type": "http", "url": "https://invited.you/mcp" }` | `claude mcp login invited`, or `/mcp` in Claude Code |
44
+ | Cursor | `~/.cursor/mcp.json` | `"invited": { "url": "https://invited.you/mcp" }` | Customize > MCP in Cursor |
45
+ | Codex CLI | `~/.codex/config.toml` (`$CODEX_HOME`) | `[mcp_servers.invited]` `url = "https://invited.you/mcp"` | `codex mcp login invited` |
46
+ | Gemini CLI | `~/.gemini/settings.json` | `"invited": { "httpUrl": "https://invited.you/mcp" }` | when Gemini CLI first connects; `/mcp auth invited` again |
47
+
48
+ A file the CLI cannot edit safely (JSON with comments, a TOML table written inline) is left as it is, and the CLI prints the entry to add by hand. Running `connect` again changes nothing.
49
+
50
+ ## Your log-in
51
+
52
+ `invited login` is OAuth 2.1 with PKCE as the pre-registered public client `invited-cli`. While you approve, the CLI listens on `http://127.0.0.1:<port>/callback`, on a port the system picks, bound to this computer only. The listener accepts one answer, the one carrying this log-in's `state` and Invited's `iss`, and closes as soon as it arrives, or after 5 minutes.
53
+
54
+ The log-in is kept in `~/.invited/credentials.json` (set `INVITED_CONFIG_DIR` to keep it elsewhere): the refresh token, your Identity's id, its scopes, and when it was made and last used (and, empty since 0.2.0, the Page address 0.1.0 kept). The file is written with mode 0600 in a 0700 folder (on Windows, your user profile's permissions protect it). Access tokens are never written: each command renews one in memory, and Invited issues a new refresh token every time, so the file changes after every command. Two commands at once wait for each other rather than renew the same token.
55
+
56
+ A log-in lasts 30 days from its last use, and 90 days at most; then run `invited login` again.
57
+
58
+ ## Reads and the budget
59
+
60
+ `whoami`, `fragments` and `private` are reads of what you share with the CLI, the same reads a connected assistant makes. Each one appears in your access log in Activity, Reads, as Invited CLI. Invited allows each connection 60 reads a minute; every Invited CLI logged in to your Identity shares one connection, so they share that budget. Past it, the CLI says how many seconds to wait.
61
+
62
+ ## What it reads
63
+
64
+ Fragments reach the CLI only when you share them on the consent screen (`invited login`), and only for as long as you chose. While they are shared, Invited's server can read those fragments to pass them to the CLI; with your vault on, everything else stays sealed. Activity, Assistants shows what is shared and until when, and Disconnect ends it at once.
65
+
66
+ ## Removing it
67
+
68
+ 1. `invited logout` revokes the log-in and deletes `~/.invited/credentials.json` (the folder `~/.invited` can then go too).
69
+ 2. To remove the connection itself, use Activity, Assistants: https://invited.you/app/activity?tab=assistants. Its reads stay in your access log for 3 months. The CLI can end only its own log-in; other assistants are disconnected there.
70
+ 3. Remove the `invited` entry from each host you connected (`claude mcp remove invited`, or delete it from the file listed above).
71
+ 4. `npm uninstall --global @invitedlabs/cli` if you installed it.
72
+
73
+ ## Changes in 0.2.0
74
+
75
+ - The CLI reads only the fragments you share with it, never your Page, as every connected assistant does since 8 November 2026. `invited login` always asks you to choose them (`--private` is still taken); `whoami` and `fragments` print them; `status` no longer shows a Page. 0.1.0 reads only after `invited login --private`, and its `invited fragments` answers "Invited answered in a form this CLI does not know.": update to 0.2.0.
76
+
77
+ - Connected assistants and their reads are in Activity now, not Settings: `invited status`, the messages and this README send you to Activity, Assistants (https://invited.you/app/activity?tab=assistants).
78
+ - `invited status --json` names that address `assistantsUrl`. In 0.1.0 it was `settingsUrl`, which is gone: a script that read it reads `assistantsUrl` instead.
79
+ - Your access log keeps each read for 3 months.
80
+ - "Log in" and "log out" in every message, as on invited.you. The commands are still `invited login` and `invited logout`.
package/dist/args.js ADDED
@@ -0,0 +1,95 @@
1
+ import { CliError } from './errors.js';
2
+ const VALUES = {
3
+ '--origin': 'origin',
4
+ '--config': 'config',
5
+ '--search': 'search',
6
+ '--type': 'type',
7
+ };
8
+ const SWITCHES = {
9
+ '--json': 'json',
10
+ '--dry-run': 'dryRun',
11
+ '--bridge': 'bridge',
12
+ '--no-browser': 'noBrowser',
13
+ '--private': 'private',
14
+ '--help': 'help',
15
+ '-h': 'help',
16
+ '--version': 'version',
17
+ '-v': 'version',
18
+ };
19
+ /** Which options each command takes (help and version go with any). */
20
+ export const COMMAND_OPTIONS = {
21
+ connect: ['--origin', '--dry-run', '--bridge', '--config'],
22
+ login: ['--origin', '--no-browser', '--private'],
23
+ whoami: ['--origin', '--json'],
24
+ fragments: ['--origin', '--json', '--type', '--search'],
25
+ private: ['--origin', '--json', '--type'],
26
+ status: ['--origin', '--json'],
27
+ logout: ['--origin'],
28
+ revoke: ['--origin'],
29
+ help: [],
30
+ };
31
+ export function parseArgs(argv) {
32
+ const flags = {
33
+ types: [],
34
+ json: false,
35
+ dryRun: false,
36
+ bridge: false,
37
+ noBrowser: false,
38
+ private: false,
39
+ help: false,
40
+ version: false,
41
+ };
42
+ const positionals = [];
43
+ const given = new Set();
44
+ let command = null;
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const arg = argv[i];
47
+ if (arg === '--') {
48
+ positionals.push(...argv.slice(i + 1));
49
+ break;
50
+ }
51
+ if (!arg.startsWith('-') || arg === '-') {
52
+ if (command === null)
53
+ command = arg;
54
+ else
55
+ positionals.push(arg);
56
+ continue;
57
+ }
58
+ const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
59
+ const name = equals > 0 ? arg.slice(0, equals) : arg;
60
+ const inline = equals > 0 ? arg.slice(equals + 1) : undefined;
61
+ if (name in VALUES) {
62
+ const value = inline ?? argv[++i];
63
+ if (value === undefined || value === '' || (inline === undefined && value.startsWith('--')))
64
+ throw new CliError(`${name} needs a value.`);
65
+ const key = VALUES[name];
66
+ if (key === 'type')
67
+ flags.types.push(...value
68
+ .split(',')
69
+ .map(type => type.trim())
70
+ .filter(Boolean));
71
+ else if (flags[key] !== undefined)
72
+ throw new CliError(`Give ${name} once.`);
73
+ else
74
+ flags[key] = value;
75
+ }
76
+ else if (name in SWITCHES) {
77
+ if (inline !== undefined)
78
+ throw new CliError(`${name} takes no value.`);
79
+ flags[SWITCHES[name]] = true;
80
+ }
81
+ else
82
+ throw new CliError(`Unknown option ${name}. Run \`invited --help\` to see the options.`);
83
+ given.add(name.startsWith('--') ? name : name === '-h' ? '--help' : '--version');
84
+ }
85
+ return { command, positionals, flags, given };
86
+ }
87
+ /** Refuse an option the command does not take, so nothing is silently ignored. */
88
+ export function checkOptions(command, given) {
89
+ const allowed = COMMAND_OPTIONS[command] ?? [];
90
+ for (const name of given)
91
+ if (name !== '--help' && name !== '--version' && !allowed.includes(name))
92
+ throw new CliError(allowed.length
93
+ ? `\`invited ${command}\` does not take ${name}. It takes: ${allowed.join(', ')}.`
94
+ : `\`invited ${command}\` takes no options.`);
95
+ }
@@ -0,0 +1,29 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Opens an http(s) address in the person's browser without a shell: the
4
+ * address is one argument to the system's opener, never parsed by cmd or sh.
5
+ * Resolves false when the opener cannot start (no desktop, an SSH session);
6
+ * the address is printed either way.
7
+ */
8
+ export function openBrowser(url, platform) {
9
+ if (!/^https?:\/\//.test(url))
10
+ return Promise.resolve(false);
11
+ const [command, args] = platform === 'win32'
12
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
13
+ : platform === 'darwin'
14
+ ? ['open', [url]]
15
+ : ['xdg-open', [url]];
16
+ return new Promise(resolve => {
17
+ try {
18
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
19
+ child.once('error', () => resolve(false));
20
+ child.once('spawn', () => {
21
+ child.unref();
22
+ resolve(true);
23
+ });
24
+ }
25
+ catch {
26
+ resolve(false);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,344 @@
1
+ import { CliError } from './errors.js';
2
+ import { refreshExpiry } from './credentials.js';
3
+ import { duration, formatTime, plural } from './format.js';
4
+ import { listenForCallback } from './loopback.js';
5
+ import { McpError, mcpRequest, ScopeRefused } from './mcp.js';
6
+ import { authorizationUrl, CLIENT_ID, discover, exchangeCode, GrantRefused, newState, pkcePair, PRIVATE_SCOPE, PUBLIC_SCOPE, refreshTokens, revokeRefreshToken, } from './oauth.js';
7
+ /** Activity's Assistants, where every connected assistant is listed and disconnected (owner, 3 November 2026: what
8
+ * sent members to Settings for assistants and the access log says Activity; Settings keeps one row linking there). */
9
+ export const assistantsUrl = (origin) => `${origin}/app/activity?tab=assistants`;
10
+ export const assistantsLine = (origin) => `Manage every connected assistant in Activity at ${assistantsUrl(origin)}.`;
11
+ const iso = (time) => new Date(time).toISOString();
12
+ const notConnected = (origin) => new CliError(`The Invited CLI is not connected to ${origin}. Run \`invited login\` to connect it.`);
13
+ /** Access tokens for this command: one renewal (refresh token rotation)
14
+ * under the credentials lock, kept in memory only and renewed again when it
15
+ * nears its hour or the server refuses it. */
16
+ export function accessTokens(ctx, primed) {
17
+ let current = primed ?? null;
18
+ return async (force) => {
19
+ if (current && !force && current.expiresAt - 60_000 > ctx.now())
20
+ return current.token;
21
+ current = await ctx.store.locked(async () => {
22
+ const file = await ctx.store.read();
23
+ const connection = file.connections[ctx.origin];
24
+ if (!connection)
25
+ throw notConnected(ctx.origin);
26
+ let tokens;
27
+ try {
28
+ tokens = await refreshTokens(ctx.fetch, connection, ctx.now());
29
+ }
30
+ catch (error) {
31
+ if (error instanceof GrantRefused)
32
+ throw new CliError(`Invited did not renew this CLI's log-in: ${error.description} Run \`invited login\` to connect again.`);
33
+ throw error;
34
+ }
35
+ file.connections[ctx.origin] = {
36
+ ...connection,
37
+ refreshToken: tokens.refreshToken,
38
+ scopes: tokens.scopes,
39
+ identityId: tokens.identityId,
40
+ lastUsedAt: iso(ctx.now()),
41
+ };
42
+ await ctx.store.write(file);
43
+ return { token: tokens.accessToken, expiresAt: tokens.accessExpiresAt };
44
+ });
45
+ return current.token;
46
+ };
47
+ }
48
+ /** Nothing is shared with the CLI now (it was given none, or the sharing ended): the member chooses again. */
49
+ export const NOTHING_SHARED = 'Nothing is shared with the CLI right now. Run `invited login` to choose the fragments it may read.';
50
+ /** An answer read as every read writes it, or the CLI says it does not know the form. */
51
+ function inScopeOf(value) {
52
+ const data = value;
53
+ if (typeof data !== 'object' ||
54
+ data === null ||
55
+ !(data.expiresAt === null || typeof data.expiresAt === 'string') ||
56
+ !Array.isArray(data.fragments))
57
+ throw new CliError('Invited answered in a form this CLI does not know.');
58
+ return data;
59
+ }
60
+ /** A read's refusal in the CLI's words: nothing shared now is the way to share again. */
61
+ function refused(error) {
62
+ if (error instanceof ScopeRefused && error.scope === PRIVATE_SCOPE)
63
+ throw new CliError(NOTHING_SHARED);
64
+ if (error instanceof McpError)
65
+ throw new CliError(error.message);
66
+ throw error;
67
+ }
68
+ /** One tool call: its answer, from its structured content or the JSON its text carries. A tool error is the server's
69
+ * explanation (an unknown type). The Profile photo's image, after the JSON, is not printed: a terminal shows its
70
+ * answer as any other, the path the JSON holds on its first line and its caption, if any, under it. */
71
+ async function callTool(ctx, name, args) {
72
+ let result;
73
+ try {
74
+ result = await mcpRequest(ctx.fetch, ctx.origin, accessTokens(ctx), 'tools/call', { name, arguments: args });
75
+ }
76
+ catch (error) {
77
+ refused(error);
78
+ }
79
+ const blocks = Array.isArray(result.content) ? result.content : [];
80
+ const texts = blocks.flatMap(item => (item && typeof item === 'object' && 'text' in item ? [String(item.text)] : []));
81
+ if (result.isError === true)
82
+ throw new CliError(texts.join('\n') || 'Invited could not read those fragments.');
83
+ if (result.structuredContent !== undefined)
84
+ return inScopeOf(result.structuredContent);
85
+ try {
86
+ return inScopeOf(JSON.parse(texts.findLast(text => text.trimStart().startsWith('{')) ?? ''));
87
+ }
88
+ catch (error) {
89
+ if (error instanceof CliError)
90
+ throw error;
91
+ throw new CliError('Invited answered in a form this CLI does not know.');
92
+ }
93
+ }
94
+ async function readResource(ctx, token, uri) {
95
+ const result = await mcpRequest(ctx.fetch, ctx.origin, token, 'resources/read', { uri });
96
+ const content = (Array.isArray(result.contents) ? result.contents[0] : null);
97
+ if (typeof content?.text !== 'string')
98
+ throw new CliError('Invited answered without the document.');
99
+ return content.text;
100
+ }
101
+ /** How long the sharing lasts, in words. An edit never ends it (owner, 8 November 2026: under Current and Specify an
102
+ * edit updates the shared copy, as under Automatic). */
103
+ const lasting = (ctx, expiresAt) => expiresAt ? `until ${formatTime(expiresAt, ctx.timeZone)}` : 'until you disconnect it in Activity';
104
+ /** A fragment as the CLI prints it: its name, then its answer, a line at a time. */
105
+ const shownFragment = (ctx, fragment) => `${ctx.style.bold(fragment.label)}: ${fragment.answer.replace(/\r?\n/g, '\n ')}`;
106
+ // login ----------------------------------------------------------------------
107
+ /** Every log-in asks for identity:private (owner, 8 November 2026): the connector serves only the fragments the
108
+ * member chooses on the consent screen, so a log-in with identity:public alone reads nothing. `--private`, 0.1.0's way
109
+ * to ask for it, is still taken. */
110
+ export async function login(ctx, options) {
111
+ const { noBrowser } = options;
112
+ const endpoints = await discover(ctx.fetch, ctx.origin);
113
+ const { verifier, challenge } = pkcePair();
114
+ const state = newState();
115
+ const callback = await listenForCallback({
116
+ state,
117
+ issuer: endpoints.issuer,
118
+ requireIss: endpoints.issInResponse,
119
+ timeoutMs: ctx.loginTimeoutMs,
120
+ });
121
+ let tokens;
122
+ try {
123
+ const url = authorizationUrl(endpoints, callback.redirectUri, challenge, state, [PUBLIC_SCOPE, PRIVATE_SCOPE]);
124
+ ctx.out('To connect the Invited CLI to your Identity, approve it in your browser and choose the fragments it may read.\n');
125
+ const opened = !noBrowser && (await ctx.openBrowser(url));
126
+ ctx.out((opened
127
+ ? 'Your browser should open. If it does not, open this address:\n'
128
+ : 'Open this address in your browser:\n') +
129
+ `${url}\nWaiting for your approval (up to ${duration(ctx.loginTimeoutMs)})...\n`);
130
+ const outcome = await callback.outcome;
131
+ if (!outcome.ok)
132
+ throw new CliError(outcome.message);
133
+ try {
134
+ tokens = await exchangeCode(ctx.fetch, endpoints, { code: outcome.code, verifier, redirectUri: callback.redirectUri }, ctx.now());
135
+ }
136
+ catch (error) {
137
+ if (error instanceof GrantRefused)
138
+ throw new CliError(`Invited did not finish logging you in: ${error.description} Run \`invited login\` again.`);
139
+ throw error;
140
+ }
141
+ }
142
+ finally {
143
+ callback.close();
144
+ }
145
+ const now = iso(ctx.now());
146
+ const connection = {
147
+ origin: ctx.origin,
148
+ clientId: CLIENT_ID,
149
+ resource: endpoints.resource,
150
+ tokenEndpoint: endpoints.token,
151
+ revocationEndpoint: endpoints.revocation,
152
+ identityId: tokens.identityId,
153
+ scopes: tokens.scopes,
154
+ refreshToken: tokens.refreshToken,
155
+ connectedAt: now,
156
+ lastUsedAt: now,
157
+ // No read names a Page since 8 November 2026; kept, null, so an older CLI still reads the file.
158
+ pageUrl: null,
159
+ pageReadAt: null,
160
+ };
161
+ const previous = await ctx.store.locked(async () => {
162
+ const file = await ctx.store.read();
163
+ const before = file.connections[ctx.origin];
164
+ file.connections[ctx.origin] = connection;
165
+ await ctx.store.write(file);
166
+ return before;
167
+ });
168
+ // A log-in this one replaces ends now rather than in 30 days.
169
+ if (previous)
170
+ await revokeRefreshToken(ctx.fetch, previous).catch(() => undefined);
171
+ // One read says what it may read; the connection stands either way. Shared nothing, it reads nothing: since migration
172
+ // 190 that read is an empty list, before it a refusal, and the CLI says the same of both.
173
+ const none = 'Connected, but you shared no fragments with it, so it can read nothing. Run `invited login` again to choose some.';
174
+ let read = none, reads = false;
175
+ if (tokens.scopes.includes(PRIVATE_SCOPE))
176
+ try {
177
+ const shared = inScopeOf(JSON.parse(await readResource(ctx, accessTokens(ctx, { token: tokens.accessToken, expiresAt: tokens.accessExpiresAt }), 'invited://identity.json')));
178
+ reads = shared.fragments.length > 0;
179
+ read = reads
180
+ ? `Connected. The Invited CLI can read ${plural(shared.fragments.length, 'fragment')} you chose to share with it, ${lasting(ctx, shared.expiresAt)}.`
181
+ : none;
182
+ }
183
+ catch (error) {
184
+ if (error instanceof ScopeRefused)
185
+ read = none;
186
+ else if (error instanceof CliError)
187
+ read = `Connected, but your fragments could not be read just now: ${error.message}`;
188
+ else if (error instanceof SyntaxError)
189
+ read = 'Connected, but Invited answered in a form this CLI does not know.';
190
+ else
191
+ throw error;
192
+ }
193
+ ctx.out(`${read}\n` +
194
+ `Its log-in is saved in ${ctx.store.path} and lasts 30 days from its last use (90 days at most).\n` +
195
+ (reads ? 'Try: invited whoami\n' : ''));
196
+ }
197
+ // whoami ---------------------------------------------------------------------
198
+ /** The fragments the member lets the CLI read, as Markdown (invited://identity), or as JSON (invited://identity.json). */
199
+ export async function whoami(ctx, json) {
200
+ let text;
201
+ try {
202
+ text = await readResource(ctx, accessTokens(ctx), json ? 'invited://identity.json' : 'invited://identity');
203
+ }
204
+ catch (error) {
205
+ refused(error);
206
+ }
207
+ ctx.out(text.endsWith('\n') ? text : text + '\n');
208
+ }
209
+ // fragments ------------------------------------------------------------------
210
+ /** The fragments the member lets the CLI read, of some types (get_fragments) or that match some words, best first
211
+ * (search_fragments). */
212
+ export async function fragments(ctx, options) {
213
+ if (options.types.length && options.search !== undefined)
214
+ throw new CliError('Use --type or --search, not both.');
215
+ if (!options.types.length && options.search === undefined)
216
+ throw new CliError('Name a fragment type with --type <type> (repeat it for more), or search with --search <words>. For example: invited fragments --type skill');
217
+ const types = [...new Set(options.types)];
218
+ const data = types.length
219
+ ? await callTool(ctx, 'get_fragments', { types })
220
+ : await callTool(ctx, 'search_fragments', { query: options.search });
221
+ if (options.json) {
222
+ ctx.out(JSON.stringify(data, null, 2) + '\n');
223
+ return;
224
+ }
225
+ const list = data.fragments;
226
+ const what = types.length
227
+ ? `of type ${types.join(', ')}`
228
+ : `matching "${options.search}"${list.length > 1 ? ', best first' : ''}`;
229
+ if (!list.length) {
230
+ ctx.out(`No shared fragments ${what}.\n`);
231
+ return;
232
+ }
233
+ const lines = [`${plural(list.length, 'shared fragment')} ${what}`, ''];
234
+ for (const fragment of list)
235
+ lines.push(shownFragment(ctx, fragment));
236
+ ctx.out(lines.join('\n') + '\n');
237
+ }
238
+ // private --------------------------------------------------------------------
239
+ /** private_fragments: the fragments the member shares with the CLI (step 4b), and when that sharing ends. Nothing
240
+ * shared (none was, it ended, or the log-in has only the public scope) is the message to choose again. */
241
+ export async function privateFragments(ctx, options) {
242
+ const types = [...new Set(options.types)];
243
+ const data = await callTool(ctx, 'private_fragments', types.length ? { types } : {});
244
+ if (options.json) {
245
+ ctx.out(JSON.stringify(data, null, 2) + '\n');
246
+ return;
247
+ }
248
+ const list = data.fragments;
249
+ const until = lasting(ctx, data.expiresAt);
250
+ const what = types.length ? ` of type ${types.join(', ')}` : '';
251
+ if (!list.length) {
252
+ ctx.out(`No fragments${what} are shared with the Invited CLI. Its sharing lasts ${until}.\n`);
253
+ return;
254
+ }
255
+ const lines = [`${plural(list.length, 'fragment')}${what} shared with the Invited CLI ${until}.`, ''];
256
+ for (const fragment of list)
257
+ lines.push(shownFragment(ctx, fragment));
258
+ lines.push('', assistantsLine(ctx.origin));
259
+ ctx.out(lines.join('\n') + '\n');
260
+ }
261
+ // status ---------------------------------------------------------------------
262
+ /** What this CLI holds, from its credentials file alone: no network. Listing
263
+ * other assistants needs the member's browser session (Activity). */
264
+ export async function status(ctx, json) {
265
+ const file = await ctx.store.read();
266
+ const connection = file.connections[ctx.origin];
267
+ if (!connection) {
268
+ if (json)
269
+ ctx.out(JSON.stringify({ connected: false, origin: ctx.origin }, null, 2) + '\n');
270
+ else
271
+ ctx.out(`The Invited CLI is not connected to ${ctx.origin}. Run \`invited login\` to connect it.\n`);
272
+ return 1;
273
+ }
274
+ const { expiresAt, latest } = refreshExpiry(connection);
275
+ const expired = expiresAt <= ctx.now();
276
+ if (json) {
277
+ ctx.out(JSON.stringify({
278
+ connected: !expired,
279
+ origin: connection.origin,
280
+ clientId: connection.clientId,
281
+ identityId: connection.identityId,
282
+ // No read names a Page since 8 November 2026: null unless an older CLI's log-in named one.
283
+ pageUrl: connection.pageUrl,
284
+ pageReadAt: connection.pageReadAt,
285
+ scopes: connection.scopes,
286
+ connectedAt: connection.connectedAt,
287
+ lastUsedAt: connection.lastUsedAt,
288
+ expiresAt: iso(expiresAt),
289
+ expiresAtLatest: iso(latest),
290
+ credentialsFile: ctx.store.path,
291
+ assistantsUrl: assistantsUrl(ctx.origin),
292
+ }, null, 2) + '\n');
293
+ return expired ? 1 : 0;
294
+ }
295
+ const time = (value) => formatTime(value, ctx.timeZone);
296
+ const reads = connection.scopes.includes(PRIVATE_SCOPE);
297
+ // The public scope first, as every connection holds it.
298
+ const scopes = [...connection.scopes]
299
+ .sort((a, b) => Number(b === PUBLIC_SCOPE) - Number(a === PUBLIC_SCOPE))
300
+ .map(scope => (scope === PRIVATE_SCOPE ? 'identity:private (the fragments you chose to share with it)' : scope))
301
+ .join(', ');
302
+ const rows = [
303
+ ['Identity', connection.identityId],
304
+ ['Scopes', scopes],
305
+ ['Connected', time(connection.connectedAt)],
306
+ ['Last used', time(connection.lastUsedAt)],
307
+ [
308
+ expired ? 'Expired' : 'Expires',
309
+ expired
310
+ ? `${time(expiresAt)}: run \`invited login\``
311
+ : `${time(expiresAt)} unless used before then (${time(latest)} at the latest)`,
312
+ ],
313
+ ['Credentials', ctx.store.path],
314
+ ];
315
+ ctx.out([
316
+ ctx.style.bold(`The Invited CLI is logged in to ${connection.origin} (client ${connection.clientId}).`),
317
+ ...rows.map(([label, value]) => ` ${label.padEnd(13)}${value}`),
318
+ reads
319
+ ? 'Run `invited whoami` to see the fragments it reads and when that sharing ends.'
320
+ : 'It reads nothing: you shared no fragments with it. Run `invited login` to choose some.',
321
+ 'The CLI can end only its own log-in (`invited logout`).',
322
+ assistantsLine(ctx.origin),
323
+ ].join('\n') + '\n');
324
+ return expired ? 1 : 0;
325
+ }
326
+ // logout ---------------------------------------------------------------------
327
+ export async function logout(ctx, target) {
328
+ if (target !== undefined && !['invited-cli', 'invited', 'cli'].includes(target))
329
+ throw new CliError(`The Invited CLI can end only its own log-in, not ${target}'s. Disconnect ${target} in Activity at ${assistantsUrl(ctx.origin)}.`);
330
+ const ended = await ctx.store.locked(async () => {
331
+ const file = await ctx.store.read();
332
+ const connection = file.connections[ctx.origin];
333
+ if (!connection)
334
+ return false;
335
+ await revokeRefreshToken(ctx.fetch, connection);
336
+ delete file.connections[ctx.origin];
337
+ await ctx.store.write(file);
338
+ return true;
339
+ });
340
+ ctx.out((ended
341
+ ? `Revoked the Invited CLI's log-in to ${ctx.origin} and deleted its credentials from ${ctx.store.path}.\n`
342
+ : `The Invited CLI was not logged in to ${ctx.origin}; nothing to revoke.\n`) +
343
+ `Other assistants stay connected. ${assistantsLine(ctx.origin)}\n`);
344
+ }