@invitedlabs/cli 0.1.0 → 0.2.1
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 +39 -24
- package/dist/commands.js +129 -162
- package/dist/connect.js +4 -4
- package/dist/loopback.js +37 -64
- package/dist/main.js +41 -39
- package/dist/mcp.js +3 -3
- package/dist/oauth.js +7 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# invited
|
|
2
2
|
|
|
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
|
|
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
4
|
|
|
5
|
-
It reads
|
|
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
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
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
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npx @invitedlabs/cli connect claude-code # add Invited to Claude Code, which then
|
|
13
|
-
npx @invitedlabs/cli login # or
|
|
14
|
-
npx @invitedlabs/cli whoami # then print
|
|
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
15
|
```
|
|
16
16
|
|
|
17
17
|
or install it once with `npm install --global @invitedlabs/cli` and run `invited …`.
|
|
@@ -24,19 +24,21 @@ Every command prints plain English, exits 0 when it did what was asked and 1 oth
|
|
|
24
24
|
|
|
25
25
|
| Command | What it does |
|
|
26
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
|
|
28
|
-
| `invited login` |
|
|
29
|
-
| `invited whoami` | Prints
|
|
30
|
-
| `invited fragments --type <type>` | Prints
|
|
31
|
-
| `invited fragments --search <words>` | Prints the
|
|
32
|
-
| `invited private` | Prints the
|
|
33
|
-
| `invited status` | Shows the
|
|
34
|
-
| `invited logout` | Revokes this CLI's own
|
|
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
35
|
| `invited --help`, `invited help <command>`, `invited --version` | Help and the version. |
|
|
36
36
|
|
|
37
|
-
|
|
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
38
|
|
|
39
|
-
|
|
39
|
+
Where `connect` writes, and how each host logs in:
|
|
40
|
+
|
|
41
|
+
| Host | File | Entry | Log in |
|
|
40
42
|
| --- | --- | --- | --- |
|
|
41
43
|
| Claude Code | `~/.claude.json` (user scope) | `"invited": { "type": "http", "url": "https://invited.you/mcp" }` | `claude mcp login invited`, or `/mcp` in Claude Code |
|
|
42
44
|
| Cursor | `~/.cursor/mcp.json` | `"invited": { "url": "https://invited.you/mcp" }` | Customize > MCP in Cursor |
|
|
@@ -45,25 +47,38 @@ Where `connect` writes, and how each host signs in:
|
|
|
45
47
|
|
|
46
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.
|
|
47
49
|
|
|
48
|
-
## Your
|
|
50
|
+
## Your log-in
|
|
49
51
|
|
|
50
|
-
`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
|
|
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.
|
|
51
53
|
|
|
52
|
-
The
|
|
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.
|
|
53
55
|
|
|
54
|
-
A
|
|
56
|
+
A log-in lasts 30 days from its last use, and 90 days at most; then run `invited login` again.
|
|
55
57
|
|
|
56
58
|
## Reads and the budget
|
|
57
59
|
|
|
58
|
-
`whoami` and `
|
|
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.
|
|
59
61
|
|
|
60
|
-
##
|
|
62
|
+
## What it reads
|
|
61
63
|
|
|
62
|
-
|
|
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.
|
|
63
65
|
|
|
64
66
|
## Removing it
|
|
65
67
|
|
|
66
|
-
1. `invited logout` revokes the
|
|
67
|
-
2. To remove the connection itself, use
|
|
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.
|
|
68
70
|
3. Remove the `invited` entry from each host you connected (`claude mcp remove invited`, or delete it from the file listed above).
|
|
69
71
|
4. `npm uninstall --global @invitedlabs/cli` if you installed it.
|
|
72
|
+
|
|
73
|
+
## Changes in 0.2.1
|
|
74
|
+
|
|
75
|
+
- After you approve or cancel the log-in in your browser, the CLI sends the browser to Invited's own page for it, in the same look as invited.you's log-in pages. The page says only whether it connected; the code stays with the CLI.
|
|
76
|
+
|
|
77
|
+
## Changes in 0.2.0
|
|
78
|
+
|
|
79
|
+
- 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.
|
|
80
|
+
|
|
81
|
+
- 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).
|
|
82
|
+
- `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.
|
|
83
|
+
- Your access log keeps each read for 3 months.
|
|
84
|
+
- "Log in" and "log out" in every message, as on invited.you. The commands are still `invited login` and `invited logout`.
|
package/dist/commands.js
CHANGED
|
@@ -4,8 +4,10 @@ import { duration, formatTime, plural } from './format.js';
|
|
|
4
4
|
import { listenForCallback } from './loopback.js';
|
|
5
5
|
import { McpError, mcpRequest, ScopeRefused } from './mcp.js';
|
|
6
6
|
import { authorizationUrl, CLIENT_ID, discover, exchangeCode, GrantRefused, newState, pkcePair, PRIVATE_SCOPE, PUBLIC_SCOPE, refreshTokens, revokeRefreshToken, } from './oauth.js';
|
|
7
|
-
|
|
8
|
-
|
|
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)}.`;
|
|
9
11
|
const iso = (time) => new Date(time).toISOString();
|
|
10
12
|
const notConnected = (origin) => new CliError(`The Invited CLI is not connected to ${origin}. Run \`invited login\` to connect it.`);
|
|
11
13
|
/** Access tokens for this command: one renewal (refresh token rotation)
|
|
@@ -27,7 +29,7 @@ export function accessTokens(ctx, primed) {
|
|
|
27
29
|
}
|
|
28
30
|
catch (error) {
|
|
29
31
|
if (error instanceof GrantRefused)
|
|
30
|
-
throw new CliError(`Invited did not renew this CLI's
|
|
32
|
+
throw new CliError(`Invited did not renew this CLI's log-in: ${error.description} Run \`invited login\` to connect again.`);
|
|
31
33
|
throw error;
|
|
32
34
|
}
|
|
33
35
|
file.connections[ctx.origin] = {
|
|
@@ -43,29 +45,68 @@ export function accessTokens(ctx, primed) {
|
|
|
43
45
|
return current.token;
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
93
|
}
|
|
59
94
|
async function readResource(ctx, token, uri) {
|
|
60
95
|
const result = await mcpRequest(ctx.fetch, ctx.origin, token, 'resources/read', { uri });
|
|
61
96
|
const content = (Array.isArray(result.contents) ? result.contents[0] : null);
|
|
62
97
|
if (typeof content?.text !== 'string')
|
|
63
98
|
throw new CliError('Invited answered without the document.');
|
|
64
|
-
return
|
|
99
|
+
return content.text;
|
|
65
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 ')}`;
|
|
66
106
|
// login ----------------------------------------------------------------------
|
|
67
|
-
/**
|
|
68
|
-
|
|
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. */
|
|
69
110
|
export async function login(ctx, options) {
|
|
70
111
|
const { noBrowser } = options;
|
|
71
112
|
const endpoints = await discover(ctx.fetch, ctx.origin);
|
|
@@ -79,11 +120,8 @@ export async function login(ctx, options) {
|
|
|
79
120
|
});
|
|
80
121
|
let tokens;
|
|
81
122
|
try {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
ctx.out(options.private
|
|
85
|
-
? 'To connect the Invited CLI to your Identity, approve it in your browser and choose the private fragments to share with it.\n'
|
|
86
|
-
: 'To connect the Invited CLI to your Identity, approve it in your browser.\n');
|
|
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');
|
|
87
125
|
const opened = !noBrowser && (await ctx.openBrowser(url));
|
|
88
126
|
ctx.out((opened
|
|
89
127
|
? 'Your browser should open. If it does not, open this address:\n'
|
|
@@ -97,7 +135,7 @@ export async function login(ctx, options) {
|
|
|
97
135
|
}
|
|
98
136
|
catch (error) {
|
|
99
137
|
if (error instanceof GrantRefused)
|
|
100
|
-
throw new CliError(`Invited did not
|
|
138
|
+
throw new CliError(`Invited did not finish logging you in: ${error.description} Run \`invited login\` again.`);
|
|
101
139
|
throw error;
|
|
102
140
|
}
|
|
103
141
|
}
|
|
@@ -116,6 +154,7 @@ export async function login(ctx, options) {
|
|
|
116
154
|
refreshToken: tokens.refreshToken,
|
|
117
155
|
connectedAt: now,
|
|
118
156
|
lastUsedAt: now,
|
|
157
|
+
// No read names a Page since 8 November 2026; kept, null, so an older CLI still reads the file.
|
|
119
158
|
pageUrl: null,
|
|
120
159
|
pageReadAt: null,
|
|
121
160
|
};
|
|
@@ -126,166 +165,102 @@ export async function login(ctx, options) {
|
|
|
126
165
|
await ctx.store.write(file);
|
|
127
166
|
return before;
|
|
128
167
|
});
|
|
129
|
-
// A
|
|
168
|
+
// A log-in this one replaces ends now rather than in 30 days.
|
|
130
169
|
if (previous)
|
|
131
170
|
await revokeRefreshToken(ctx.fetch, previous).catch(() => undefined);
|
|
132
|
-
// One read
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
await
|
|
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;
|
|
139
192
|
}
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
if (!(error instanceof CliError))
|
|
143
|
-
throw error;
|
|
144
|
-
read = `Connected, but your Page could not be read just now: ${error.message}`;
|
|
145
|
-
}
|
|
146
|
-
const shares = tokens.scopes.includes(PRIVATE_SCOPE);
|
|
147
193
|
ctx.out(`${read}\n` +
|
|
148
|
-
(
|
|
149
|
-
|
|
150
|
-
: options.private
|
|
151
|
-
? 'You shared no private fragments with it. Run `invited login --private` again to share some.\n'
|
|
152
|
-
: '') +
|
|
153
|
-
`Its sign-in is saved in ${ctx.store.path} and lasts 30 days from its last use (90 days at most).\n` +
|
|
154
|
-
(shares ? 'Try: invited private\n' : 'Try: invited whoami\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' : ''));
|
|
155
196
|
}
|
|
156
197
|
// whoami ---------------------------------------------------------------------
|
|
198
|
+
/** The fragments the member lets the CLI read, as Markdown (invited://identity), or as JSON (invited://identity.json). */
|
|
157
199
|
export async function whoami(ctx, json) {
|
|
158
|
-
|
|
159
|
-
let read;
|
|
200
|
+
let text;
|
|
160
201
|
try {
|
|
161
|
-
|
|
202
|
+
text = await readResource(ctx, accessTokens(ctx), json ? 'invited://identity.json' : 'invited://identity');
|
|
162
203
|
}
|
|
163
204
|
catch (error) {
|
|
164
|
-
|
|
165
|
-
throw new CliError(error.message);
|
|
166
|
-
throw error;
|
|
205
|
+
refused(error);
|
|
167
206
|
}
|
|
168
|
-
ctx.out(
|
|
169
|
-
await rememberPage(ctx, read.pageUrl);
|
|
207
|
+
ctx.out(text.endsWith('\n') ? text : text + '\n');
|
|
170
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). */
|
|
171
212
|
export async function fragments(ctx, options) {
|
|
172
213
|
if (options.types.length && options.search !== undefined)
|
|
173
214
|
throw new CliError('Use --type or --search, not both.');
|
|
174
215
|
if (!options.types.length && options.search === undefined)
|
|
175
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');
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
result = await mcpRequest(ctx.fetch, ctx.origin, accessTokens(ctx), 'tools/call', call);
|
|
182
|
-
}
|
|
183
|
-
catch (error) {
|
|
184
|
-
if (error instanceof McpError)
|
|
185
|
-
throw new CliError(error.message);
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
const text = Array.isArray(result.content)
|
|
189
|
-
? result.content
|
|
190
|
-
.map(item => (item && typeof item === 'object' && 'text' in item ? String(item.text) : ''))
|
|
191
|
-
.join('\n')
|
|
192
|
-
: '';
|
|
193
|
-
// A tool error is the server's explanation (an unknown type, nothing published).
|
|
194
|
-
if (result.isError === true)
|
|
195
|
-
throw new CliError(text || 'Invited could not read those fragments.');
|
|
196
|
-
let data = result.structuredContent;
|
|
197
|
-
if (!data) {
|
|
198
|
-
try {
|
|
199
|
-
data = JSON.parse(text);
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
data = undefined;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (typeof data?.pageUrl !== 'string' || !Array.isArray(data.fragments))
|
|
206
|
-
throw new CliError('Invited answered in a form this CLI does not know.');
|
|
207
|
-
await rememberPage(ctx, data.pageUrl);
|
|
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 });
|
|
208
221
|
if (options.json) {
|
|
209
222
|
ctx.out(JSON.stringify(data, null, 2) + '\n');
|
|
210
223
|
return;
|
|
211
224
|
}
|
|
212
225
|
const list = data.fragments;
|
|
213
|
-
const what =
|
|
214
|
-
? `of type ${
|
|
226
|
+
const what = types.length
|
|
227
|
+
? `of type ${types.join(', ')}`
|
|
215
228
|
: `matching "${options.search}"${list.length > 1 ? ', best first' : ''}`;
|
|
216
229
|
if (!list.length) {
|
|
217
|
-
ctx.out(`No
|
|
230
|
+
ctx.out(`No shared fragments ${what}.\n`);
|
|
218
231
|
return;
|
|
219
232
|
}
|
|
220
|
-
const lines = [`${plural(list.length, '
|
|
221
|
-
for (const fragment of list)
|
|
222
|
-
lines.push(
|
|
223
|
-
if (fragment.question)
|
|
224
|
-
lines.push(` Answers: ${fragment.question}`);
|
|
225
|
-
lines.push(` ${fragment.url}`);
|
|
226
|
-
}
|
|
233
|
+
const lines = [`${plural(list.length, 'shared fragment')} ${what}`, ''];
|
|
234
|
+
for (const fragment of list)
|
|
235
|
+
lines.push(shownFragment(ctx, fragment));
|
|
227
236
|
ctx.out(lines.join('\n') + '\n');
|
|
228
237
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
* or the
|
|
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. */
|
|
232
241
|
export async function privateFragments(ctx, options) {
|
|
233
242
|
const types = [...new Set(options.types)];
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
result = await mcpRequest(ctx.fetch, ctx.origin, accessTokens(ctx), 'tools/call', {
|
|
237
|
-
name: 'private_fragments',
|
|
238
|
-
arguments: types.length ? { types } : {},
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
catch (error) {
|
|
242
|
-
if (error instanceof ScopeRefused && error.scope === PRIVATE_SCOPE)
|
|
243
|
-
throw new CliError(NOTHING_SHARED);
|
|
244
|
-
if (error instanceof McpError)
|
|
245
|
-
throw new CliError(error.message);
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
const text = Array.isArray(result.content)
|
|
249
|
-
? result.content
|
|
250
|
-
.map(item => (item && typeof item === 'object' && 'text' in item ? String(item.text) : ''))
|
|
251
|
-
.join('\n')
|
|
252
|
-
: '';
|
|
253
|
-
// A tool error is the server's explanation (an unknown type).
|
|
254
|
-
if (result.isError === true)
|
|
255
|
-
throw new CliError(text || 'Invited could not read those fragments.');
|
|
256
|
-
let data = result.structuredContent;
|
|
257
|
-
if (!data) {
|
|
258
|
-
try {
|
|
259
|
-
data = JSON.parse(text);
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
data = undefined;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (!data || !(data.expiresAt === null || typeof data.expiresAt === 'string') || !Array.isArray(data.fragments))
|
|
266
|
-
throw new CliError('Invited answered in a form this CLI does not know.');
|
|
243
|
+
const data = await callTool(ctx, 'private_fragments', types.length ? { types } : {});
|
|
267
244
|
if (options.json) {
|
|
268
245
|
ctx.out(JSON.stringify(data, null, 2) + '\n');
|
|
269
246
|
return;
|
|
270
247
|
}
|
|
271
248
|
const list = data.fragments;
|
|
272
|
-
const until =
|
|
273
|
-
? `until ${formatTime(data.expiresAt, ctx.timeZone)}`
|
|
274
|
-
: 'until you disconnect it in Settings or edit the fragment';
|
|
249
|
+
const until = lasting(ctx, data.expiresAt);
|
|
275
250
|
const what = types.length ? ` of type ${types.join(', ')}` : '';
|
|
276
251
|
if (!list.length) {
|
|
277
|
-
ctx.out(`No
|
|
252
|
+
ctx.out(`No fragments${what} are shared with the Invited CLI. Its sharing lasts ${until}.\n`);
|
|
278
253
|
return;
|
|
279
254
|
}
|
|
280
|
-
const lines = [`${plural(list.length, '
|
|
255
|
+
const lines = [`${plural(list.length, 'fragment')}${what} shared with the Invited CLI ${until}.`, ''];
|
|
281
256
|
for (const fragment of list)
|
|
282
|
-
lines.push(
|
|
283
|
-
lines.push('',
|
|
257
|
+
lines.push(shownFragment(ctx, fragment));
|
|
258
|
+
lines.push('', assistantsLine(ctx.origin));
|
|
284
259
|
ctx.out(lines.join('\n') + '\n');
|
|
285
260
|
}
|
|
286
261
|
// status ---------------------------------------------------------------------
|
|
287
262
|
/** What this CLI holds, from its credentials file alone: no network. Listing
|
|
288
|
-
* other assistants needs the member's browser session (
|
|
263
|
+
* other assistants needs the member's browser session (Activity). */
|
|
289
264
|
export async function status(ctx, json) {
|
|
290
265
|
const file = await ctx.store.read();
|
|
291
266
|
const connection = file.connections[ctx.origin];
|
|
@@ -304,6 +279,7 @@ export async function status(ctx, json) {
|
|
|
304
279
|
origin: connection.origin,
|
|
305
280
|
clientId: connection.clientId,
|
|
306
281
|
identityId: connection.identityId,
|
|
282
|
+
// No read names a Page since 8 November 2026: null unless an older CLI's log-in named one.
|
|
307
283
|
pageUrl: connection.pageUrl,
|
|
308
284
|
pageReadAt: connection.pageReadAt,
|
|
309
285
|
scopes: connection.scopes,
|
|
@@ -312,28 +288,19 @@ export async function status(ctx, json) {
|
|
|
312
288
|
expiresAt: iso(expiresAt),
|
|
313
289
|
expiresAtLatest: iso(latest),
|
|
314
290
|
credentialsFile: ctx.store.path,
|
|
315
|
-
|
|
291
|
+
assistantsUrl: assistantsUrl(ctx.origin),
|
|
316
292
|
}, null, 2) + '\n');
|
|
317
293
|
return expired ? 1 : 0;
|
|
318
294
|
}
|
|
319
295
|
const time = (value) => formatTime(value, ctx.timeZone);
|
|
296
|
+
const reads = connection.scopes.includes(PRIVATE_SCOPE);
|
|
320
297
|
// The public scope first, as every connection holds it.
|
|
321
298
|
const scopes = [...connection.scopes]
|
|
322
299
|
.sort((a, b) => Number(b === PUBLIC_SCOPE) - Number(a === PUBLIC_SCOPE))
|
|
323
|
-
.map(scope => scope ===
|
|
324
|
-
? 'identity:public (your public Identity)'
|
|
325
|
-
: scope === PRIVATE_SCOPE
|
|
326
|
-
? 'identity:private (the private fragments you share with it)'
|
|
327
|
-
: scope)
|
|
300
|
+
.map(scope => (scope === PRIVATE_SCOPE ? 'identity:private (the fragments you chose to share with it)' : scope))
|
|
328
301
|
.join(', ');
|
|
329
302
|
const rows = [
|
|
330
303
|
['Identity', connection.identityId],
|
|
331
|
-
[
|
|
332
|
-
'Page',
|
|
333
|
-
connection.pageUrl
|
|
334
|
-
? `${connection.pageUrl} (as of ${time(connection.pageReadAt ?? connection.lastUsedAt)})`
|
|
335
|
-
: 'not read yet: run `invited whoami`',
|
|
336
|
-
],
|
|
337
304
|
['Scopes', scopes],
|
|
338
305
|
['Connected', time(connection.connectedAt)],
|
|
339
306
|
['Last used', time(connection.lastUsedAt)],
|
|
@@ -346,20 +313,20 @@ export async function status(ctx, json) {
|
|
|
346
313
|
['Credentials', ctx.store.path],
|
|
347
314
|
];
|
|
348
315
|
ctx.out([
|
|
349
|
-
ctx.style.bold(`The Invited CLI is
|
|
316
|
+
ctx.style.bold(`The Invited CLI is logged in to ${connection.origin} (client ${connection.clientId}).`),
|
|
350
317
|
...rows.map(([label, value]) => ` ${label.padEnd(13)}${value}`),
|
|
351
|
-
|
|
352
|
-
? 'Run `invited
|
|
353
|
-
: 'It reads no
|
|
354
|
-
'The CLI can end only its own
|
|
355
|
-
|
|
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),
|
|
356
323
|
].join('\n') + '\n');
|
|
357
324
|
return expired ? 1 : 0;
|
|
358
325
|
}
|
|
359
326
|
// logout ---------------------------------------------------------------------
|
|
360
327
|
export async function logout(ctx, target) {
|
|
361
328
|
if (target !== undefined && !['invited-cli', 'invited', 'cli'].includes(target))
|
|
362
|
-
throw new CliError(`The Invited CLI can end only its own
|
|
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)}.`);
|
|
363
330
|
const ended = await ctx.store.locked(async () => {
|
|
364
331
|
const file = await ctx.store.read();
|
|
365
332
|
const connection = file.connections[ctx.origin];
|
|
@@ -371,7 +338,7 @@ export async function logout(ctx, target) {
|
|
|
371
338
|
return true;
|
|
372
339
|
});
|
|
373
340
|
ctx.out((ended
|
|
374
|
-
? `Revoked the Invited CLI's
|
|
375
|
-
: `The Invited CLI was not
|
|
376
|
-
`Other assistants stay connected. ${
|
|
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`);
|
|
377
344
|
}
|
package/dist/connect.js
CHANGED
|
@@ -30,7 +30,7 @@ const HOST = {
|
|
|
30
30
|
scope: 'user scope: every project',
|
|
31
31
|
native: url => ({ type: 'http', url }),
|
|
32
32
|
bridged: bridge => ({ type: 'stdio', ...bridge }),
|
|
33
|
-
signIn: 'Claude Code
|
|
33
|
+
signIn: 'Claude Code logs in to Invited itself: run `claude mcp login invited` (or /mcp inside Claude Code) and approve in your browser.',
|
|
34
34
|
source: 'https://code.claude.com/docs/en/mcp',
|
|
35
35
|
},
|
|
36
36
|
cursor: {
|
|
@@ -40,7 +40,7 @@ const HOST = {
|
|
|
40
40
|
scope: 'global: every project',
|
|
41
41
|
native: url => ({ url }),
|
|
42
42
|
bridged: bridge => ({ type: 'stdio', ...bridge }),
|
|
43
|
-
signIn: 'Cursor
|
|
43
|
+
signIn: 'Cursor logs in to Invited itself: in Cursor, open Customize > MCP, log in to invited and approve in your browser.',
|
|
44
44
|
source: 'https://cursor.com/docs/mcp',
|
|
45
45
|
},
|
|
46
46
|
codex: {
|
|
@@ -50,7 +50,7 @@ const HOST = {
|
|
|
50
50
|
scope: 'user configuration, shared by the Codex CLI, IDE extension and app',
|
|
51
51
|
native: url => ({ url }),
|
|
52
52
|
bridged: bridge => ({ ...bridge }),
|
|
53
|
-
signIn: 'Codex
|
|
53
|
+
signIn: 'Codex logs in to Invited itself: run `codex mcp login invited` and approve in your browser.',
|
|
54
54
|
source: 'https://developers.openai.com/codex/mcp',
|
|
55
55
|
},
|
|
56
56
|
gemini: {
|
|
@@ -60,7 +60,7 @@ const HOST = {
|
|
|
60
60
|
scope: 'user settings: every project',
|
|
61
61
|
native: url => ({ httpUrl: url }),
|
|
62
62
|
bridged: bridge => ({ ...bridge }),
|
|
63
|
-
signIn: 'Gemini CLI
|
|
63
|
+
signIn: 'Gemini CLI logs in to Invited itself: start gemini and approve in your browser when it connects (/mcp auth invited logs in again).',
|
|
64
64
|
source: 'https://geminicli.com/docs/tools/mcp-server/',
|
|
65
65
|
},
|
|
66
66
|
};
|
package/dist/loopback.js
CHANGED
|
@@ -1,51 +1,27 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
1
|
import { createServer } from 'node:http';
|
|
3
2
|
import { duration } from './format.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const style = `:root{color-scheme:dark}*{box-sizing:border-box}` +
|
|
20
|
-
`body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:32px 24px;background:#121212;color:#f5f5f5;` +
|
|
21
|
-
`font:400 14px/20px 'Creato Display','Helvetica Neue',Arial,sans-serif;-webkit-font-smoothing:antialiased}` +
|
|
22
|
-
// The design system's cut-corner frame (styles/invited.css, .iv-dialog): a
|
|
23
|
-
// 1px frame in the line colour, and the surface inside it cut 2 − √2 of a
|
|
24
|
-
// pixel less, so the diagonal frame is as thick as the straight one.
|
|
25
|
-
`main{width:100%;max-width:440px;padding:1px;background:#4a4a4a;` +
|
|
26
|
-
`clip-path:polygon(0 0,calc(100% - 16px) 0,100% 16px,100% 100%,16px 100%,0 calc(100% - 16px))}` +
|
|
27
|
-
`main>div{padding:40px 36px 36px;background:#1c1c1c;` +
|
|
28
|
-
`clip-path:polygon(0 0,calc(100% - 15.414px) 0,100% 15.414px,100% 100%,15.414px 100%,0 calc(100% - 15.414px))}` +
|
|
29
|
-
`.lockup{display:block;width:clamp(91px,30vw,138px);height:auto;margin:0 0 28px -10px;color:#f5f5f5}` +
|
|
30
|
-
`.eyebrow{margin:0 0 12px;font:400 11px/16px 'B612 Mono',ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;color:#bdbdbd}` +
|
|
31
|
-
`.eyebrow b{font-weight:400;color:${status === 200 ? '#8fd3a4' : '#f0b36c'}}` +
|
|
32
|
-
`h1{margin:0 0 10px;font:500 24px/30px 'Creato Display','Helvetica Neue',Arial,sans-serif;letter-spacing:-.3px;text-wrap:balance}` +
|
|
33
|
-
`p{margin:0;color:#bdbdbd}` +
|
|
34
|
-
`@media (max-width:480px){main>div{padding:28px 24px 24px}}`;
|
|
35
|
-
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
36
|
-
`<title>${escape(title)}</title><style nonce="${nonce}">${style}</style></head>` +
|
|
37
|
-
`<body><main><div>${LOCKUP.replace('<svg ', '<svg class="lockup" ')}` +
|
|
38
|
-
`<p class="eyebrow">Invited CLI · <b>${tone}</b></p>` +
|
|
39
|
-
`<h1>${escape(title)}</h1><p>${escape(message)}</p></div></main></body></html>`;
|
|
3
|
+
/** Where the browser goes once it has come back to the CLI: Invited's own page for it (since 0.2.1; owner, 8 November
|
|
4
|
+
* 2026: "align that styling with the new pages"), the log-in screens' frame with the clouds, which says in a line
|
|
5
|
+
* what the terminal says in full. The address carries only that word, and no referrer goes with it, so Invited never
|
|
6
|
+
* sees the code. */
|
|
7
|
+
function sendOn(response, issuer, result, after) {
|
|
8
|
+
response.writeHead(303, {
|
|
9
|
+
Location: `${issuer}/auth/cli?result=${result}`,
|
|
10
|
+
'Cache-Control': 'no-store',
|
|
11
|
+
'Referrer-Policy': 'no-referrer',
|
|
12
|
+
Connection: 'close',
|
|
13
|
+
});
|
|
14
|
+
response.end(after);
|
|
15
|
+
}
|
|
16
|
+
/** A request that is no log-in's answer (another host, method or path): a line of text, and nowhere to go. */
|
|
17
|
+
function refuse(response, status, message) {
|
|
40
18
|
response.writeHead(status, {
|
|
41
|
-
'Content-Type': 'text/
|
|
19
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
42
20
|
'Cache-Control': 'no-store',
|
|
43
|
-
'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${nonce}'`,
|
|
44
21
|
'X-Content-Type-Options': 'nosniff',
|
|
45
|
-
'Referrer-Policy': 'no-referrer',
|
|
46
22
|
Connection: 'close',
|
|
47
23
|
});
|
|
48
|
-
response.end(
|
|
24
|
+
response.end(message);
|
|
49
25
|
}
|
|
50
26
|
export async function listenForCallback(options) {
|
|
51
27
|
let settle;
|
|
@@ -56,39 +32,39 @@ export async function listenForCallback(options) {
|
|
|
56
32
|
const server = createServer((request, response) => {
|
|
57
33
|
const host = `127.0.0.1:${port}`;
|
|
58
34
|
if (finished) {
|
|
59
|
-
// The same answer again (a browser retrying its navigation)
|
|
60
|
-
//
|
|
35
|
+
// The same answer again (a browser retrying its navigation) goes where
|
|
36
|
+
// it went; anything else learns the log-in has ended.
|
|
61
37
|
const again = new URL(request.url ?? '/', `http://${host}`);
|
|
62
38
|
if (answered && again.pathname === '/callback' && again.searchParams.get('state') === options.state)
|
|
63
|
-
return
|
|
64
|
-
return
|
|
39
|
+
return sendOn(response, options.issuer, answered);
|
|
40
|
+
return sendOn(response, options.issuer, 'ended');
|
|
65
41
|
}
|
|
66
42
|
if (request.headers.host !== host)
|
|
67
|
-
return
|
|
43
|
+
return refuse(response, 400, 'This address is only for the Invited CLI on this computer.');
|
|
68
44
|
if (request.method !== 'GET')
|
|
69
|
-
return
|
|
45
|
+
return refuse(response, 405, 'Not allowed. Return to the terminal.');
|
|
70
46
|
const url = new URL(request.url ?? '/', `http://${host}`);
|
|
71
47
|
if (url.pathname !== '/callback')
|
|
72
|
-
return
|
|
48
|
+
return refuse(response, 404, 'Not found. Return to the terminal.');
|
|
73
49
|
const query = url.searchParams;
|
|
74
|
-
// Another
|
|
50
|
+
// Another log-in's answer (a stray tab) changes nothing.
|
|
75
51
|
if (query.getAll('state').length !== 1 || query.get('state') !== options.state)
|
|
76
|
-
return
|
|
77
|
-
const finish = (
|
|
52
|
+
return sendOn(response, options.issuer, 'ended');
|
|
53
|
+
const finish = (result, outcome) => {
|
|
78
54
|
finished = true;
|
|
79
|
-
answered =
|
|
80
|
-
|
|
55
|
+
answered = result;
|
|
56
|
+
sendOn(response, options.issuer, result, () => settle(outcome));
|
|
81
57
|
};
|
|
82
58
|
const iss = query.get('iss');
|
|
83
59
|
if ((options.requireIss && iss === null) || (iss !== null && iss !== options.issuer))
|
|
84
|
-
return finish(
|
|
60
|
+
return finish('failed', {
|
|
85
61
|
ok: false,
|
|
86
62
|
message: `The browser's answer came from ${iss ?? 'an unnamed server'}, not ${options.issuer}, so it was refused. Run \`invited login\` again.`,
|
|
87
63
|
});
|
|
88
64
|
const error = query.get('error');
|
|
89
65
|
if (error !== null) {
|
|
90
66
|
const description = query.get('error_description');
|
|
91
|
-
return finish(
|
|
67
|
+
return finish(error === 'access_denied' ? 'cancelled' : 'failed', {
|
|
92
68
|
ok: false,
|
|
93
69
|
message: error === 'access_denied'
|
|
94
70
|
? 'You cancelled the connection in your browser, so nothing was connected.'
|
|
@@ -97,14 +73,11 @@ export async function listenForCallback(options) {
|
|
|
97
73
|
}
|
|
98
74
|
const code = query.get('code');
|
|
99
75
|
if (!code)
|
|
100
|
-
return finish(
|
|
76
|
+
return finish('failed', {
|
|
101
77
|
ok: false,
|
|
102
|
-
message: 'The browser came back without a
|
|
78
|
+
message: 'The browser came back without a log-in code. Run `invited login` again.',
|
|
103
79
|
});
|
|
104
|
-
return finish(
|
|
105
|
-
ok: true,
|
|
106
|
-
code,
|
|
107
|
-
});
|
|
80
|
+
return finish('connected', { ok: true, code });
|
|
108
81
|
});
|
|
109
82
|
await new Promise((resolve, reject) => {
|
|
110
83
|
server.once('error', reject);
|
|
@@ -122,9 +95,9 @@ export async function listenForCallback(options) {
|
|
|
122
95
|
});
|
|
123
96
|
close();
|
|
124
97
|
}, options.timeoutMs);
|
|
125
|
-
/** Once the browser has been answered, closing lingers a moment: the
|
|
126
|
-
* finishes reaching the browser (
|
|
127
|
-
* browser that retries its navigation
|
|
98
|
+
/** Once the browser has been answered, closing lingers a moment: the answer
|
|
99
|
+
* finishes reaching the browser (a page was once cut off mid-response), and
|
|
100
|
+
* a browser that retries its navigation is sent on again instead of seeing
|
|
128
101
|
* "This site can't be reached". The time limit closes at once. */
|
|
129
102
|
function close(force = false) {
|
|
130
103
|
clearTimeout(timer);
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { checkOptions, COMMAND_OPTIONS, parseArgs } from './args.js';
|
|
3
3
|
import { openBrowser } from './browser.js';
|
|
4
|
-
import { fragments, login, logout, privateFragments,
|
|
4
|
+
import { assistantsLine, fragments, login, logout, privateFragments, status, whoami, } from './commands.js';
|
|
5
5
|
import { connect, HOSTS } from './connect.js';
|
|
6
6
|
import { configDir, credentialStore } from './credentials.js';
|
|
7
7
|
import { CliError } from './errors.js';
|
|
@@ -10,9 +10,11 @@ import { VERSION } from './version.js';
|
|
|
10
10
|
/**
|
|
11
11
|
* `invited`: the command-line client of Invited's MCP connector
|
|
12
12
|
* (docs/mcp-connector-2026-10-11.md, "3e: CLI"). It configures assistants'
|
|
13
|
-
* hosts to use the connector, and
|
|
14
|
-
* client `invited-cli` to print
|
|
15
|
-
*
|
|
13
|
+
* hosts to use the connector, and logs in itself as the pre-registered
|
|
14
|
+
* client `invited-cli` to print, in a terminal, the fragments the member
|
|
15
|
+
* chooses to share with it on the consent screen (owner, 8 November 2026: the
|
|
16
|
+
* connector reads those alone, never the Page). It holds an OAuth refresh
|
|
17
|
+
* token like any other client, never a vault key.
|
|
16
18
|
*/
|
|
17
19
|
export const DEFAULT_ORIGIN = 'https://invited.you';
|
|
18
20
|
const defaults = () => ({
|
|
@@ -38,7 +40,7 @@ export function normalOrigin(value) {
|
|
|
38
40
|
}
|
|
39
41
|
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
40
42
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
|
|
41
|
-
throw new CliError(`${value} is not safe
|
|
43
|
+
throw new CliError(`${value} is not safe to log in to: use https, or http only on this computer.`);
|
|
42
44
|
if (url.username || url.password || url.search || url.hash || !['', '/', '/mcp', '/mcp/'].includes(url.pathname))
|
|
43
45
|
throw new CliError(`Give Invited's address without a path, such as ${DEFAULT_ORIGIN}.`);
|
|
44
46
|
return url.origin;
|
|
@@ -52,54 +54,55 @@ name "invited", keeping every other server in the file as it is. Hosts:
|
|
|
52
54
|
cursor ~/.cursor/mcp.json
|
|
53
55
|
codex ~/.codex/config.toml ($CODEX_HOME)
|
|
54
56
|
gemini ~/.gemini/settings.json
|
|
55
|
-
The host then
|
|
57
|
+
The host then logs in to Invited itself: you approve it in your browser.
|
|
56
58
|
|
|
57
59
|
--dry-run Show what would be written; write nothing
|
|
58
60
|
--bridge Use the mcp-remote bridge (npx -y mcp-remote@0.14.3) instead of the
|
|
59
|
-
host's own
|
|
61
|
+
host's own log-in, for a host version without remote log-in
|
|
60
62
|
--config <file> The configuration file to edit, instead of the host's usual one
|
|
61
63
|
--origin <url> Invited's address (default ${DEFAULT_ORIGIN})
|
|
62
64
|
`,
|
|
63
|
-
login: `Usage: invited login [--
|
|
65
|
+
login: `Usage: invited login [--no-browser] [--origin <url>]
|
|
64
66
|
|
|
65
67
|
Connects this CLI to your Identity: your browser opens Invited, you approve the
|
|
66
|
-
Invited CLI
|
|
67
|
-
(http://127.0.0.1, a port the
|
|
68
|
-
or after 5 minutes. The
|
|
68
|
+
Invited CLI and choose the fragments it may read and for how long, and the
|
|
69
|
+
browser returns to a listener on this computer (http://127.0.0.1, a port the
|
|
70
|
+
system picks), which closes as soon as it answers or after 5 minutes. The log-in
|
|
71
|
+
is saved in ~/.invited/credentials.json. (--private, which 0.1.0 needed for this,
|
|
72
|
+
is still taken.)
|
|
69
73
|
|
|
70
|
-
--private Also ask for your private fragments: in the browser you choose
|
|
71
|
-
which to share with this CLI, and for how long
|
|
72
74
|
--no-browser Print the address to open instead of opening the browser
|
|
73
75
|
--origin <url> Invited's address (default ${DEFAULT_ORIGIN})
|
|
74
76
|
`,
|
|
75
77
|
whoami: `Usage: invited whoami [--json] [--origin <url>]
|
|
76
78
|
|
|
77
|
-
Prints
|
|
78
|
-
as Markdown (invited://identity), or as JSON with --json
|
|
79
|
+
Prints the fragments you share with this CLI, by section, and when that sharing
|
|
80
|
+
ends: as Markdown (invited://identity), or as JSON with --json
|
|
81
|
+
(invited://identity.json).
|
|
79
82
|
`,
|
|
80
83
|
fragments: `Usage: invited fragments --type <type> [--type <type> ...] [--json]
|
|
81
84
|
invited fragments --search <words> [--json]
|
|
82
85
|
|
|
83
|
-
Prints
|
|
84
|
-
match some words, best first (search_fragments). Types are keys
|
|
85
|
-
language, experience, cuisines or dietaryPreferences.
|
|
86
|
+
Prints the fragments you share with this CLI of the given types (get_fragments),
|
|
87
|
+
or the ones that match some words, best first (search_fragments). Types are keys
|
|
88
|
+
such as skill, language, experience, cuisines or dietaryPreferences.
|
|
86
89
|
`,
|
|
87
90
|
private: `Usage: invited private [--type <type> ...] [--json] [--origin <url>]
|
|
88
91
|
|
|
89
|
-
Prints the
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
Prints the fragments you share with this CLI (private_fragments), and when that
|
|
93
|
+
sharing ends. You choose them in your browser with \`invited login\`. While they
|
|
94
|
+
are shared, Invited's server can read them to pass them on.
|
|
92
95
|
`,
|
|
93
96
|
status: `Usage: invited status [--json] [--origin <url>]
|
|
94
97
|
|
|
95
|
-
Shows the
|
|
96
|
-
|
|
97
|
-
assistant you have connected needs your browser:
|
|
98
|
+
Shows the log-in this CLI holds: your Identity, its scopes, when it was last
|
|
99
|
+
used and when it expires. It reads the credentials file only. Listing every
|
|
100
|
+
assistant you have connected needs your browser: Activity, Assistants.
|
|
98
101
|
`,
|
|
99
102
|
logout: `Usage: invited logout [--origin <url>] (also: invited revoke)
|
|
100
103
|
|
|
101
|
-
Revokes this CLI's own
|
|
102
|
-
assistants are disconnected in
|
|
104
|
+
Revokes this CLI's own log-in at Invited and deletes its credentials. Other
|
|
105
|
+
assistants are disconnected in Activity, not here.
|
|
103
106
|
`,
|
|
104
107
|
};
|
|
105
108
|
HELP.revoke = HELP.logout;
|
|
@@ -113,28 +116,27 @@ ${style.bold('Usage')}
|
|
|
113
116
|
|
|
114
117
|
${style.bold('Commands')}
|
|
115
118
|
connect <host> Add Invited's MCP server to ${HOSTS.slice(0, -1).join(', ')} or ${HOSTS.at(-1)}
|
|
116
|
-
login Connect this CLI to your Identity (you approve in your browser)
|
|
117
|
-
whoami Print
|
|
118
|
-
fragments Print
|
|
119
|
-
private Print the
|
|
120
|
-
status Show the
|
|
121
|
-
logout Revoke this CLI's
|
|
119
|
+
login Connect this CLI to your Identity (you approve and choose in your browser)
|
|
120
|
+
whoami Print the fragments you share with this CLI, as Markdown
|
|
121
|
+
fragments Print shared fragments: --type <type> (repeatable) or --search <words>
|
|
122
|
+
private Print the fragments you share with this CLI and when that ends
|
|
123
|
+
status Show the log-in this CLI holds
|
|
124
|
+
logout Revoke this CLI's log-in and delete its credentials (also: revoke)
|
|
122
125
|
|
|
123
126
|
${style.bold('Options')}
|
|
124
127
|
--origin <url> Invited's address (default ${DEFAULT_ORIGIN}, or INVITED_ORIGIN)
|
|
125
128
|
--json Print JSON (whoami, fragments, private, status)
|
|
126
129
|
--dry-run connect: show what would be written, write nothing
|
|
127
|
-
--bridge connect: use the mcp-remote bridge instead of the host's own
|
|
130
|
+
--bridge connect: use the mcp-remote bridge instead of the host's own log-in
|
|
128
131
|
--config <file> connect: the configuration file to edit
|
|
129
132
|
--no-browser login: print the address instead of opening a browser
|
|
130
|
-
--private login: also choose private fragments to share with this CLI
|
|
131
133
|
-h, --help Show help (invited help <command> for one command)
|
|
132
134
|
-v, --version Show the version
|
|
133
135
|
|
|
134
|
-
The CLI keeps its
|
|
135
|
-
and reads
|
|
136
|
-
|
|
137
|
-
${
|
|
136
|
+
The CLI keeps its log-in in ~/.invited/credentials.json (INVITED_CONFIG_DIR moves it)
|
|
137
|
+
and reads only the fragments you choose to share with it, for as long as you chose,
|
|
138
|
+
within 60 reads a minute.
|
|
139
|
+
${assistantsLine(DEFAULT_ORIGIN)}
|
|
138
140
|
`;
|
|
139
141
|
}
|
|
140
142
|
/** Runs one command; resolves to the exit code (0 or 1). */
|
|
@@ -182,7 +184,7 @@ export async function main(argv, partial = {}) {
|
|
|
182
184
|
const ctx = { ...environment, origin, store, style };
|
|
183
185
|
switch (command) {
|
|
184
186
|
case 'login':
|
|
185
|
-
await login(ctx, { noBrowser: flags.noBrowser
|
|
187
|
+
await login(ctx, { noBrowser: flags.noBrowser });
|
|
186
188
|
return 0;
|
|
187
189
|
case 'whoami':
|
|
188
190
|
await whoami(ctx, flags.json);
|
package/dist/mcp.js
CHANGED
|
@@ -19,7 +19,7 @@ export const headerValue = (value) => /^[\x20-\x7e]*$/.test(value) && !/^=\?base
|
|
|
19
19
|
? value
|
|
20
20
|
: `=?base64?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
|
21
21
|
/** A JSON-RPC error in an answer the server sent with HTTP 200 (an unknown
|
|
22
|
-
* resource
|
|
22
|
+
* resource or tool). */
|
|
23
23
|
export class McpError extends CliError {
|
|
24
24
|
code;
|
|
25
25
|
data;
|
|
@@ -90,7 +90,7 @@ async function answer(response, id) {
|
|
|
90
90
|
return outcome(body);
|
|
91
91
|
case 401: {
|
|
92
92
|
const reason = typeof body.error_description === 'string' ? ` (${body.error_description.replace(/\.$/, '')})` : '';
|
|
93
|
-
throw new CliError(`Invited did not accept this CLI's
|
|
93
|
+
throw new CliError(`Invited did not accept this CLI's log-in${reason}. Run \`invited login\` to connect again.`);
|
|
94
94
|
}
|
|
95
95
|
case 403: {
|
|
96
96
|
const challenge = response.headers.get('www-authenticate') ?? '';
|
|
@@ -103,7 +103,7 @@ async function answer(response, id) {
|
|
|
103
103
|
case 429: {
|
|
104
104
|
const data = (error.data ?? {});
|
|
105
105
|
const wait = retryAfter(response) ?? (typeof data.retryAfter === 'number' ? data.retryAfter : 60);
|
|
106
|
-
throw new CliError(`Too many reads: this connection has used its 60 reads a minute, which every Invited CLI
|
|
106
|
+
throw new CliError(`Too many reads: this connection has used its 60 reads a minute, which every Invited CLI logged in to your Identity shares. Try again in ${wait} seconds.`);
|
|
107
107
|
}
|
|
108
108
|
case 503:
|
|
109
109
|
throw new CliError(`Invited could not complete this request. Try again in ${retryAfter(response) ?? 30} seconds.`);
|
package/dist/oauth.js
CHANGED
|
@@ -11,9 +11,10 @@ import { readJson, retryAfter, send } from './http.js';
|
|
|
11
11
|
* given: discovery must name that origin as the issuer and every endpoint.
|
|
12
12
|
*/
|
|
13
13
|
export const CLIENT_ID = 'invited-cli';
|
|
14
|
-
/** What the CLI asks for:
|
|
15
|
-
* private
|
|
16
|
-
*
|
|
14
|
+
/** What the CLI asks for, every log-in: both scopes, as the consent screen
|
|
15
|
+
* grants identity:private only beside identity:public. Since 8 November 2026
|
|
16
|
+
* every read needs identity:private: the fragments the member chooses to share
|
|
17
|
+
* on the consent screen (Phase 4, step 4b), never the Page. */
|
|
17
18
|
export const PUBLIC_SCOPE = 'identity:public';
|
|
18
19
|
export const PRIVATE_SCOPE = 'identity:private';
|
|
19
20
|
export const mcpUrl = (origin) => origin + '/mcp';
|
|
@@ -23,7 +24,7 @@ async function getJson(fetch, url) {
|
|
|
23
24
|
throw new CliError(`${new URL(url).origin} answered ${response.status} for ${new URL(url).pathname}.`);
|
|
24
25
|
return readJson(response);
|
|
25
26
|
}
|
|
26
|
-
const notInvited = (origin) => new CliError(`${origin} does not describe itself as Invited's connector, so this CLI will not
|
|
27
|
+
const notInvited = (origin) => new CliError(`${origin} does not describe itself as Invited's connector, so this CLI will not log in there.`);
|
|
27
28
|
/** The protected resource metadata (RFC 9728), then the authorization server
|
|
28
29
|
* metadata (RFC 8414), each checked against the origin. */
|
|
29
30
|
export async function discover(fetch, origin) {
|
|
@@ -105,7 +106,7 @@ async function tokenRequest(fetch, endpoint, form, now) {
|
|
|
105
106
|
});
|
|
106
107
|
const body = await readJson(response);
|
|
107
108
|
if (response.status === 429)
|
|
108
|
-
throw new CliError(`Invited is limiting requests for this
|
|
109
|
+
throw new CliError(`Invited is limiting requests for this log-in. Try again in ${retryAfter(response) ?? 60} seconds.`);
|
|
109
110
|
if (response.status >= 500)
|
|
110
111
|
throw new CliError(`Invited could not complete this request. Try again in ${retryAfter(response) ?? 30} seconds.`);
|
|
111
112
|
if (response.status !== 200)
|
|
@@ -161,6 +162,5 @@ export async function revokeRefreshToken(fetch, connection) {
|
|
|
161
162
|
const body = await readJson(response);
|
|
162
163
|
const wait = retryAfter(response);
|
|
163
164
|
const detail = typeof body.error_description === 'string' ? `: ${body.error_description}` : '';
|
|
164
|
-
throw new CliError(`Invited did not revoke the
|
|
165
|
-
(wait ? ` Try again in ${wait} seconds.` : ''));
|
|
165
|
+
throw new CliError(`Invited did not revoke the log-in (${response.status}${detail}).` + (wait ? ` Try again in ${wait} seconds.` : ''));
|
|
166
166
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invitedlabs/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The invited command: connect terminal agents and AI assistants to your Invited Identity through Invited's MCP connector.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://invited.you",
|