@invitedlabs/cli 0.0.0-stage → 0.1.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 +55 -0
- package/README.md +68 -2
- package/dist/args.js +95 -0
- package/dist/browser.js +29 -0
- package/dist/commands.js +377 -0
- package/dist/connect.js +192 -0
- package/dist/credentials.js +150 -0
- package/dist/errors.js +9 -0
- package/dist/format.js +25 -0
- package/dist/http.js +39 -0
- package/dist/index.js +3 -0
- package/dist/loopback.js +140 -0
- package/dist/main.js +210 -0
- package/dist/mcp.js +144 -0
- package/dist/oauth.js +166 -0
- package/dist/toml.js +192 -0
- package/dist/version.js +4 -0
- package/package.json +24 -4
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,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# invited
|
|
2
2
|
|
|
3
|
-
|
|
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 sign in itself to print your public Identity in a terminal.
|
|
4
|
+
|
|
5
|
+
It reads what your Invited Page already publishes for AI readers and, if you choose to share them, private fragments you pick on Invited's consent screen. It holds an OAuth sign-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 signs in itself
|
|
13
|
+
npx @invitedlabs/cli login # or sign this CLI in: you approve in your browser
|
|
14
|
+
npx @invitedlabs/cli whoami # then print your public Identity
|
|
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 signs 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 sign-in; `--config <file>` edits another file. |
|
|
28
|
+
| `invited login` | Signs this CLI in to your Identity. Your browser opens Invited's consent screen ("Invited CLI wants to understand you"); approve it and the browser returns to the CLI. `--private` also asks for your private fragments: on the same screen you choose the sections and fragments to share and for how long (30 days, 7 days, 24 hours, or until you disconnect). `--no-browser` prints the address to open instead (an SSH session, say). |
|
|
29
|
+
| `invited whoami` | Prints your public Identity as Markdown (`invited://identity`, the same text as your Page's `/identity.md`). `--json` prints `invited://identity.json`. |
|
|
30
|
+
| `invited fragments --type <type>` | Prints your public fragments 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 public fragments that match, best first (`search_fragments`). |
|
|
32
|
+
| `invited private` | Prints the private 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. When nothing is shared (you shared none, or the sharing ended) it says so: run `invited login --private` to share again. |
|
|
33
|
+
| `invited status` | Shows the sign-in this CLI holds: your Identity and Page, its scopes (`identity:private` when you shared private fragments with it), when it was last used and when it expires. It reads the credentials file only. |
|
|
34
|
+
| `invited logout` | Revokes this CLI's own sign-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
|
+
Where `connect` writes, and how each host signs in:
|
|
38
|
+
|
|
39
|
+
| Host | File | Entry | Sign-in |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| Claude Code | `~/.claude.json` (user scope) | `"invited": { "type": "http", "url": "https://invited.you/mcp" }` | `claude mcp login invited`, or `/mcp` in Claude Code |
|
|
42
|
+
| Cursor | `~/.cursor/mcp.json` | `"invited": { "url": "https://invited.you/mcp" }` | Customize > MCP in Cursor |
|
|
43
|
+
| Codex CLI | `~/.codex/config.toml` (`$CODEX_HOME`) | `[mcp_servers.invited]` `url = "https://invited.you/mcp"` | `codex mcp login invited` |
|
|
44
|
+
| Gemini CLI | `~/.gemini/settings.json` | `"invited": { "httpUrl": "https://invited.you/mcp" }` | when Gemini CLI first connects; `/mcp auth invited` again |
|
|
45
|
+
|
|
46
|
+
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
|
+
|
|
48
|
+
## Your sign-in
|
|
49
|
+
|
|
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 sign-in's `state` and Invited's `iss`, and closes as soon as it arrives, or after 5 minutes.
|
|
51
|
+
|
|
52
|
+
The sign-in is kept in `~/.invited/credentials.json` (set `INVITED_CONFIG_DIR` to keep it elsewhere): the refresh token, your Identity's id, its scopes, when it was made and last used, and your Page's address. 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
|
+
|
|
54
|
+
A sign-in lasts 30 days from its last use, and 90 days at most; then run `invited login` again.
|
|
55
|
+
|
|
56
|
+
## Reads and the budget
|
|
57
|
+
|
|
58
|
+
`whoami` and `fragments` are reads of your public Identity, and `private` of what you share privately, the same reads a connected assistant makes. Each one appears in your access log in Settings, under Invited CLI. Invited allows each connection 60 reads a minute; every Invited CLI signed in to your Identity shares one connection, so they share that budget. Past it, the CLI says how many seconds to wait.
|
|
59
|
+
|
|
60
|
+
## Private fragments
|
|
61
|
+
|
|
62
|
+
Private fragments reach the CLI only when you share them on the consent screen (`invited login --private`). 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. Settings, Connected assistants shows what is shared and until when, and Disconnect ends it at once.
|
|
63
|
+
|
|
64
|
+
## Removing it
|
|
65
|
+
|
|
66
|
+
1. `invited logout` revokes the sign-in and deletes `~/.invited/credentials.json` (the folder `~/.invited` can then go too).
|
|
67
|
+
2. To remove the connection itself, use Settings, Connected assistants: https://invited.you/app/settings. Its reads stay in your access log for 12 months. The CLI can end only its own sign-in; other assistants are disconnected there.
|
|
68
|
+
3. Remove the `invited` entry from each host you connected (`claude mcp remove invited`, or delete it from the file listed above).
|
|
69
|
+
4. `npm uninstall --global @invitedlabs/cli` if you installed it.
|
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
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -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
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
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
|
+
export const settingsUrl = (origin) => `${origin}/app/settings`;
|
|
8
|
+
export const settingsLine = (origin) => `Manage every connected assistant in Settings at ${settingsUrl(origin)}.`;
|
|
9
|
+
const iso = (time) => new Date(time).toISOString();
|
|
10
|
+
const notConnected = (origin) => new CliError(`The Invited CLI is not connected to ${origin}. Run \`invited login\` to connect it.`);
|
|
11
|
+
/** Access tokens for this command: one renewal (refresh token rotation)
|
|
12
|
+
* under the credentials lock, kept in memory only and renewed again when it
|
|
13
|
+
* nears its hour or the server refuses it. */
|
|
14
|
+
export function accessTokens(ctx, primed) {
|
|
15
|
+
let current = primed ?? null;
|
|
16
|
+
return async (force) => {
|
|
17
|
+
if (current && !force && current.expiresAt - 60_000 > ctx.now())
|
|
18
|
+
return current.token;
|
|
19
|
+
current = await ctx.store.locked(async () => {
|
|
20
|
+
const file = await ctx.store.read();
|
|
21
|
+
const connection = file.connections[ctx.origin];
|
|
22
|
+
if (!connection)
|
|
23
|
+
throw notConnected(ctx.origin);
|
|
24
|
+
let tokens;
|
|
25
|
+
try {
|
|
26
|
+
tokens = await refreshTokens(ctx.fetch, connection, ctx.now());
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof GrantRefused)
|
|
30
|
+
throw new CliError(`Invited did not renew this CLI's sign-in: ${error.description} Run \`invited login\` to connect again.`);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
file.connections[ctx.origin] = {
|
|
34
|
+
...connection,
|
|
35
|
+
refreshToken: tokens.refreshToken,
|
|
36
|
+
scopes: tokens.scopes,
|
|
37
|
+
identityId: tokens.identityId,
|
|
38
|
+
lastUsedAt: iso(ctx.now()),
|
|
39
|
+
};
|
|
40
|
+
await ctx.store.write(file);
|
|
41
|
+
return { token: tokens.accessToken, expiresAt: tokens.accessExpiresAt };
|
|
42
|
+
});
|
|
43
|
+
return current.token;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Remember the Page a read named, for `invited status`. */
|
|
47
|
+
async function rememberPage(ctx, pageUrl) {
|
|
48
|
+
if (typeof pageUrl !== 'string' || !/^https?:\/\//.test(pageUrl))
|
|
49
|
+
return;
|
|
50
|
+
await ctx.store.locked(async () => {
|
|
51
|
+
const file = await ctx.store.read();
|
|
52
|
+
const connection = file.connections[ctx.origin];
|
|
53
|
+
if (!connection)
|
|
54
|
+
return;
|
|
55
|
+
file.connections[ctx.origin] = { ...connection, pageUrl, pageReadAt: iso(ctx.now()) };
|
|
56
|
+
await ctx.store.write(file);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function readResource(ctx, token, uri) {
|
|
60
|
+
const result = await mcpRequest(ctx.fetch, ctx.origin, token, 'resources/read', { uri });
|
|
61
|
+
const content = (Array.isArray(result.contents) ? result.contents[0] : null);
|
|
62
|
+
if (typeof content?.text !== 'string')
|
|
63
|
+
throw new CliError('Invited answered without the document.');
|
|
64
|
+
return { text: content.text, pageUrl: content._meta?.['you.invited/pageUrl'] };
|
|
65
|
+
}
|
|
66
|
+
// login ----------------------------------------------------------------------
|
|
67
|
+
/** The step-up: nothing private is shared with the CLI now. */
|
|
68
|
+
export const NOTHING_SHARED = 'Nothing is shared with the CLI right now. Run `invited login --private` to share fragments.';
|
|
69
|
+
export async function login(ctx, options) {
|
|
70
|
+
const { noBrowser } = options;
|
|
71
|
+
const endpoints = await discover(ctx.fetch, ctx.origin);
|
|
72
|
+
const { verifier, challenge } = pkcePair();
|
|
73
|
+
const state = newState();
|
|
74
|
+
const callback = await listenForCallback({
|
|
75
|
+
state,
|
|
76
|
+
issuer: endpoints.issuer,
|
|
77
|
+
requireIss: endpoints.issInResponse,
|
|
78
|
+
timeoutMs: ctx.loginTimeoutMs,
|
|
79
|
+
});
|
|
80
|
+
let tokens;
|
|
81
|
+
try {
|
|
82
|
+
const scopes = options.private ? [PUBLIC_SCOPE, PRIVATE_SCOPE] : [PUBLIC_SCOPE];
|
|
83
|
+
const url = authorizationUrl(endpoints, callback.redirectUri, challenge, state, scopes);
|
|
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');
|
|
87
|
+
const opened = !noBrowser && (await ctx.openBrowser(url));
|
|
88
|
+
ctx.out((opened
|
|
89
|
+
? 'Your browser should open. If it does not, open this address:\n'
|
|
90
|
+
: 'Open this address in your browser:\n') +
|
|
91
|
+
`${url}\nWaiting for your approval (up to ${duration(ctx.loginTimeoutMs)})...\n`);
|
|
92
|
+
const outcome = await callback.outcome;
|
|
93
|
+
if (!outcome.ok)
|
|
94
|
+
throw new CliError(outcome.message);
|
|
95
|
+
try {
|
|
96
|
+
tokens = await exchangeCode(ctx.fetch, endpoints, { code: outcome.code, verifier, redirectUri: callback.redirectUri }, ctx.now());
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof GrantRefused)
|
|
100
|
+
throw new CliError(`Invited did not complete the sign-in: ${error.description} Run \`invited login\` again.`);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
callback.close();
|
|
106
|
+
}
|
|
107
|
+
const now = iso(ctx.now());
|
|
108
|
+
const connection = {
|
|
109
|
+
origin: ctx.origin,
|
|
110
|
+
clientId: CLIENT_ID,
|
|
111
|
+
resource: endpoints.resource,
|
|
112
|
+
tokenEndpoint: endpoints.token,
|
|
113
|
+
revocationEndpoint: endpoints.revocation,
|
|
114
|
+
identityId: tokens.identityId,
|
|
115
|
+
scopes: tokens.scopes,
|
|
116
|
+
refreshToken: tokens.refreshToken,
|
|
117
|
+
connectedAt: now,
|
|
118
|
+
lastUsedAt: now,
|
|
119
|
+
pageUrl: null,
|
|
120
|
+
pageReadAt: null,
|
|
121
|
+
};
|
|
122
|
+
const previous = await ctx.store.locked(async () => {
|
|
123
|
+
const file = await ctx.store.read();
|
|
124
|
+
const before = file.connections[ctx.origin];
|
|
125
|
+
file.connections[ctx.origin] = connection;
|
|
126
|
+
await ctx.store.write(file);
|
|
127
|
+
return before;
|
|
128
|
+
});
|
|
129
|
+
// A sign-in this one replaces ends now rather than in 30 days.
|
|
130
|
+
if (previous)
|
|
131
|
+
await revokeRefreshToken(ctx.fetch, previous).catch(() => undefined);
|
|
132
|
+
// One read names the member's Page; the connection stands either way.
|
|
133
|
+
let read = 'Connected. The Invited CLI can read your public Identity.';
|
|
134
|
+
try {
|
|
135
|
+
const { pageUrl } = await readResource(ctx, accessTokens(ctx, { token: tokens.accessToken, expiresAt: tokens.accessExpiresAt }), 'invited://identity.json');
|
|
136
|
+
if (typeof pageUrl === 'string') {
|
|
137
|
+
read = `Connected. The Invited CLI can read the public Identity on ${pageUrl}.`;
|
|
138
|
+
await rememberPage(ctx, pageUrl);
|
|
139
|
+
}
|
|
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
|
+
ctx.out(`${read}\n` +
|
|
148
|
+
(shares
|
|
149
|
+
? 'It can also read the private fragments you chose to share with it, until that sharing ends.\n'
|
|
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'));
|
|
155
|
+
}
|
|
156
|
+
// whoami ---------------------------------------------------------------------
|
|
157
|
+
export async function whoami(ctx, json) {
|
|
158
|
+
const uri = json ? 'invited://identity.json' : 'invited://identity';
|
|
159
|
+
let read;
|
|
160
|
+
try {
|
|
161
|
+
read = await readResource(ctx, accessTokens(ctx), uri);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof McpError)
|
|
165
|
+
throw new CliError(error.message);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
ctx.out(read.text.endsWith('\n') ? read.text : read.text + '\n');
|
|
169
|
+
await rememberPage(ctx, read.pageUrl);
|
|
170
|
+
}
|
|
171
|
+
export async function fragments(ctx, options) {
|
|
172
|
+
if (options.types.length && options.search !== undefined)
|
|
173
|
+
throw new CliError('Use --type or --search, not both.');
|
|
174
|
+
if (!options.types.length && options.search === undefined)
|
|
175
|
+
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 call = options.types.length
|
|
177
|
+
? { name: 'get_fragments', arguments: { types: [...new Set(options.types)] } }
|
|
178
|
+
: { name: 'search_fragments', arguments: { query: options.search } };
|
|
179
|
+
let result;
|
|
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);
|
|
208
|
+
if (options.json) {
|
|
209
|
+
ctx.out(JSON.stringify(data, null, 2) + '\n');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const list = data.fragments;
|
|
213
|
+
const what = options.types.length
|
|
214
|
+
? `of type ${options.types.join(', ')}`
|
|
215
|
+
: `matching "${options.search}"${list.length > 1 ? ', best first' : ''}`;
|
|
216
|
+
if (!list.length) {
|
|
217
|
+
ctx.out(`No public fragments ${what} on ${data.pageUrl}\n`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const lines = [`${plural(list.length, 'public fragment')} ${what}, from ${data.pageUrl}`, ''];
|
|
221
|
+
for (const fragment of list) {
|
|
222
|
+
lines.push(`${ctx.style.bold(fragment.label)}: ${fragment.answer}`);
|
|
223
|
+
if (fragment.question)
|
|
224
|
+
lines.push(` Answers: ${fragment.question}`);
|
|
225
|
+
lines.push(` ${fragment.url}`);
|
|
226
|
+
}
|
|
227
|
+
ctx.out(lines.join('\n') + '\n');
|
|
228
|
+
}
|
|
229
|
+
/** private_fragments: the private fragments the member shares with the CLI
|
|
230
|
+
* (step 4b), and when that sharing ends. Nothing shared (none was, it ended,
|
|
231
|
+
* or the sign-in has only the public scope) is the step-up message. */
|
|
232
|
+
export async function privateFragments(ctx, options) {
|
|
233
|
+
const types = [...new Set(options.types)];
|
|
234
|
+
let result;
|
|
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.');
|
|
267
|
+
if (options.json) {
|
|
268
|
+
ctx.out(JSON.stringify(data, null, 2) + '\n');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const list = data.fragments;
|
|
272
|
+
const until = typeof data.expiresAt === 'string'
|
|
273
|
+
? `until ${formatTime(data.expiresAt, ctx.timeZone)}`
|
|
274
|
+
: 'until you disconnect it in Settings or edit the fragment';
|
|
275
|
+
const what = types.length ? ` of type ${types.join(', ')}` : '';
|
|
276
|
+
if (!list.length) {
|
|
277
|
+
ctx.out(`No private fragments${what} are shared with the Invited CLI. Its sharing lasts ${until}.\n`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const lines = [`${plural(list.length, 'private fragment')}${what} shared with the Invited CLI ${until}.`, ''];
|
|
281
|
+
for (const fragment of list)
|
|
282
|
+
lines.push(`${ctx.style.bold(fragment.label)}: ${fragment.answer.replace(/\r?\n/g, '\n ')}`);
|
|
283
|
+
lines.push('', settingsLine(ctx.origin));
|
|
284
|
+
ctx.out(lines.join('\n') + '\n');
|
|
285
|
+
}
|
|
286
|
+
// status ---------------------------------------------------------------------
|
|
287
|
+
/** What this CLI holds, from its credentials file alone: no network. Listing
|
|
288
|
+
* other assistants needs the member's browser session (Settings). */
|
|
289
|
+
export async function status(ctx, json) {
|
|
290
|
+
const file = await ctx.store.read();
|
|
291
|
+
const connection = file.connections[ctx.origin];
|
|
292
|
+
if (!connection) {
|
|
293
|
+
if (json)
|
|
294
|
+
ctx.out(JSON.stringify({ connected: false, origin: ctx.origin }, null, 2) + '\n');
|
|
295
|
+
else
|
|
296
|
+
ctx.out(`The Invited CLI is not connected to ${ctx.origin}. Run \`invited login\` to connect it.\n`);
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
const { expiresAt, latest } = refreshExpiry(connection);
|
|
300
|
+
const expired = expiresAt <= ctx.now();
|
|
301
|
+
if (json) {
|
|
302
|
+
ctx.out(JSON.stringify({
|
|
303
|
+
connected: !expired,
|
|
304
|
+
origin: connection.origin,
|
|
305
|
+
clientId: connection.clientId,
|
|
306
|
+
identityId: connection.identityId,
|
|
307
|
+
pageUrl: connection.pageUrl,
|
|
308
|
+
pageReadAt: connection.pageReadAt,
|
|
309
|
+
scopes: connection.scopes,
|
|
310
|
+
connectedAt: connection.connectedAt,
|
|
311
|
+
lastUsedAt: connection.lastUsedAt,
|
|
312
|
+
expiresAt: iso(expiresAt),
|
|
313
|
+
expiresAtLatest: iso(latest),
|
|
314
|
+
credentialsFile: ctx.store.path,
|
|
315
|
+
settingsUrl: settingsUrl(ctx.origin),
|
|
316
|
+
}, null, 2) + '\n');
|
|
317
|
+
return expired ? 1 : 0;
|
|
318
|
+
}
|
|
319
|
+
const time = (value) => formatTime(value, ctx.timeZone);
|
|
320
|
+
// The public scope first, as every connection holds it.
|
|
321
|
+
const scopes = [...connection.scopes]
|
|
322
|
+
.sort((a, b) => Number(b === PUBLIC_SCOPE) - Number(a === PUBLIC_SCOPE))
|
|
323
|
+
.map(scope => scope === PUBLIC_SCOPE
|
|
324
|
+
? 'identity:public (your public Identity)'
|
|
325
|
+
: scope === PRIVATE_SCOPE
|
|
326
|
+
? 'identity:private (the private fragments you share with it)'
|
|
327
|
+
: scope)
|
|
328
|
+
.join(', ');
|
|
329
|
+
const rows = [
|
|
330
|
+
['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
|
+
['Scopes', scopes],
|
|
338
|
+
['Connected', time(connection.connectedAt)],
|
|
339
|
+
['Last used', time(connection.lastUsedAt)],
|
|
340
|
+
[
|
|
341
|
+
expired ? 'Expired' : 'Expires',
|
|
342
|
+
expired
|
|
343
|
+
? `${time(expiresAt)}: run \`invited login\``
|
|
344
|
+
: `${time(expiresAt)} unless used before then (${time(latest)} at the latest)`,
|
|
345
|
+
],
|
|
346
|
+
['Credentials', ctx.store.path],
|
|
347
|
+
];
|
|
348
|
+
ctx.out([
|
|
349
|
+
ctx.style.bold(`The Invited CLI is signed in to ${connection.origin} (client ${connection.clientId}).`),
|
|
350
|
+
...rows.map(([label, value]) => ` ${label.padEnd(13)}${value}`),
|
|
351
|
+
connection.scopes.includes(PRIVATE_SCOPE)
|
|
352
|
+
? 'Run `invited private` to see the private fragments shared with it and when that sharing ends.'
|
|
353
|
+
: 'It reads no private fragments. Run `invited login --private` to share some with it.',
|
|
354
|
+
'The CLI can end only its own sign-in (`invited logout`).',
|
|
355
|
+
settingsLine(ctx.origin),
|
|
356
|
+
].join('\n') + '\n');
|
|
357
|
+
return expired ? 1 : 0;
|
|
358
|
+
}
|
|
359
|
+
// logout ---------------------------------------------------------------------
|
|
360
|
+
export async function logout(ctx, target) {
|
|
361
|
+
if (target !== undefined && !['invited-cli', 'invited', 'cli'].includes(target))
|
|
362
|
+
throw new CliError(`The Invited CLI can end only its own sign-in, not ${target}'s. Disconnect ${target} in Settings at ${settingsUrl(ctx.origin)}.`);
|
|
363
|
+
const ended = await ctx.store.locked(async () => {
|
|
364
|
+
const file = await ctx.store.read();
|
|
365
|
+
const connection = file.connections[ctx.origin];
|
|
366
|
+
if (!connection)
|
|
367
|
+
return false;
|
|
368
|
+
await revokeRefreshToken(ctx.fetch, connection);
|
|
369
|
+
delete file.connections[ctx.origin];
|
|
370
|
+
await ctx.store.write(file);
|
|
371
|
+
return true;
|
|
372
|
+
});
|
|
373
|
+
ctx.out((ended
|
|
374
|
+
? `Revoked the Invited CLI's sign-in to ${ctx.origin} and deleted its credentials from ${ctx.store.path}.\n`
|
|
375
|
+
: `The Invited CLI was not signed in to ${ctx.origin}; nothing to revoke.\n`) +
|
|
376
|
+
`Other assistants stay connected. ${settingsLine(ctx.origin)}\n`);
|
|
377
|
+
}
|