expandai 0.0.2 → 0.0.3
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 +42 -5
- package/build/dts/ApiClient.d.ts +128 -194
- package/build/dts/ApiClient.d.ts.map +1 -1
- package/build/dts/Auth/Domain.d.ts +95 -0
- package/build/dts/Auth/Domain.d.ts.map +1 -0
- package/build/dts/Auth/ManualAuthorization.d.ts +107 -0
- package/build/dts/Auth/ManualAuthorization.d.ts.map +1 -0
- package/build/dts/Auth/PendingInit.d.ts +35 -0
- package/build/dts/Auth/PendingInit.d.ts.map +1 -0
- package/build/dts/Auth/Pkce.d.ts +10 -0
- package/build/dts/Auth/Pkce.d.ts.map +1 -0
- package/build/dts/Auth/SetupGrantAuthorization.d.ts +34 -0
- package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +1 -0
- package/build/dts/Authentication.d.ts +10 -9
- package/build/dts/Authentication.d.ts.map +1 -1
- package/build/dts/BrowserOAuth.d.ts +7 -7
- package/build/dts/BrowserOAuth.d.ts.map +1 -1
- package/build/dts/CommandPath.d.ts +2 -0
- package/build/dts/CommandPath.d.ts.map +1 -0
- package/build/dts/Commands/Fetch.d.ts.map +1 -1
- package/build/dts/Commands/Hook.d.ts +5 -5
- package/build/dts/Commands/Init.d.ts +55 -0
- package/build/dts/Commands/Init.d.ts.map +1 -0
- package/build/dts/Commands/Mcp.d.ts +6 -6
- package/build/dts/Commands/Search.d.ts.map +1 -1
- package/build/dts/Commands/Skill.d.ts +4 -4
- package/build/dts/Commands/Status.d.ts +27 -0
- package/build/dts/Commands/Status.d.ts.map +1 -0
- package/build/dts/Commands/Telemetry.d.ts +7 -0
- package/build/dts/Commands/Telemetry.d.ts.map +1 -0
- package/build/dts/Commands/Uninstall.d.ts +4 -4
- package/build/dts/Config.d.ts +108 -17
- package/build/dts/Config.d.ts.map +1 -1
- package/build/dts/Hook/SnapshotNote.d.ts +8 -4
- package/build/dts/Hook/SnapshotNote.d.ts.map +1 -1
- package/build/dts/Init/ClaudeAdapter.d.ts +61 -0
- package/build/dts/Init/ClaudeAdapter.d.ts.map +1 -0
- package/build/dts/Init/ClaudeProfile.d.ts +78 -0
- package/build/dts/Init/ClaudeProfile.d.ts.map +1 -0
- package/build/dts/Init/Domain.d.ts +139 -0
- package/build/dts/Init/Domain.d.ts.map +1 -0
- package/build/dts/Init/HarnessAdapter.d.ts +33 -0
- package/build/dts/Init/HarnessAdapter.d.ts.map +1 -0
- package/build/dts/Init/HarnessDetector.d.ts +19 -0
- package/build/dts/Init/HarnessDetector.d.ts.map +1 -0
- package/build/dts/Init/HarnessRegistry.d.ts +50 -0
- package/build/dts/Init/HarnessRegistry.d.ts.map +1 -0
- package/build/dts/Init/MutationTransaction.d.ts +96 -0
- package/build/dts/Init/MutationTransaction.d.ts.map +1 -0
- package/build/dts/Init/Service.d.ts +72 -0
- package/build/dts/Init/Service.d.ts.map +1 -0
- package/build/dts/Init/StandardAdapters.d.ts +42 -0
- package/build/dts/Init/StandardAdapters.d.ts.map +1 -0
- package/build/dts/Init/Status.d.ts +149 -0
- package/build/dts/Init/Status.d.ts.map +1 -0
- package/build/dts/Install/Claude/Hook.d.ts +85 -25
- package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
- package/build/dts/Install/Claude/Mcp.d.ts +21 -18
- package/build/dts/Install/Claude/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Claude/Skill.d.ts +14 -15
- package/build/dts/Install/Claude/Skill.d.ts.map +1 -1
- package/build/dts/Install/Codex/Mcp.d.ts +14 -8
- package/build/dts/Install/Codex/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Codex/Skill.d.ts +4 -2
- package/build/dts/Install/Codex/Skill.d.ts.map +1 -1
- package/build/dts/Install/Cursor/Mcp.d.ts +13 -13
- package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Cursor/Skill.d.ts +4 -2
- package/build/dts/Install/Cursor/Skill.d.ts.map +1 -1
- package/build/dts/Install/Domain.d.ts +35 -5
- package/build/dts/Install/Domain.d.ts.map +1 -1
- package/build/dts/Install/Hook.d.ts +4 -4
- package/build/dts/Install/Hook.d.ts.map +1 -1
- package/build/dts/Install/Mcp.d.ts +5 -5
- package/build/dts/Install/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Hook.d.ts +4 -3
- package/build/dts/Install/Opencode/Hook.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Mcp.d.ts +11 -11
- package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Skill.d.ts +4 -2
- package/build/dts/Install/Opencode/Skill.d.ts.map +1 -1
- package/build/dts/Install/Skill.d.ts +3 -3
- package/build/dts/Install/Uninstall.d.ts +6 -6
- package/build/dts/Install/Uninstall.d.ts.map +1 -1
- package/build/dts/MarkdownAnsi.d.ts +29 -0
- package/build/dts/MarkdownAnsi.d.ts.map +1 -0
- package/build/dts/Mcp/ExpandDocs.d.ts +17 -5
- package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -1
- package/build/dts/Mcp/Server.d.ts +1 -1
- package/build/dts/Mcp/Server.d.ts.map +1 -1
- package/build/dts/Mcp/tools/Citations.d.ts +100 -71
- package/build/dts/Mcp/tools/Citations.d.ts.map +1 -1
- package/build/dts/Mcp/tools/Fetch.d.ts +16 -15
- package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -1
- package/build/dts/SecureFile.d.ts +23 -0
- package/build/dts/SecureFile.d.ts.map +1 -0
- package/build/dts/Snippets.d.ts +11 -12
- package/build/dts/Snippets.d.ts.map +1 -1
- package/build/dts/Telemetry.d.ts +30 -0
- package/build/dts/Telemetry.d.ts.map +1 -0
- package/build/dts/Version.d.ts +2 -0
- package/build/dts/Version.d.ts.map +1 -0
- package/build/dts/Xdg.d.ts +11 -0
- package/build/dts/Xdg.d.ts.map +1 -0
- package/build/esm/ApiClient.js +133 -10
- package/build/esm/ApiClient.js.map +1 -1
- package/build/esm/Auth/Domain.js +56 -0
- package/build/esm/Auth/Domain.js.map +1 -0
- package/build/esm/Auth/ManualAuthorization.js +253 -0
- package/build/esm/Auth/ManualAuthorization.js.map +1 -0
- package/build/esm/Auth/PendingInit.js +267 -0
- package/build/esm/Auth/PendingInit.js.map +1 -0
- package/build/esm/Auth/Pkce.js +23 -0
- package/build/esm/Auth/Pkce.js.map +1 -0
- package/build/esm/Auth/SetupGrantAuthorization.js +91 -0
- package/build/esm/Auth/SetupGrantAuthorization.js.map +1 -0
- package/build/esm/Authentication.js +38 -7
- package/build/esm/Authentication.js.map +1 -1
- package/build/esm/BrowserOAuth.js +7 -10
- package/build/esm/BrowserOAuth.js.map +1 -1
- package/build/esm/CommandPath.js +31 -0
- package/build/esm/CommandPath.js.map +1 -0
- package/build/esm/Commands/Fetch.js +30 -6
- package/build/esm/Commands/Fetch.js.map +1 -1
- package/build/esm/Commands/Init.js +371 -0
- package/build/esm/Commands/Init.js.map +1 -0
- package/build/esm/Commands/Search.js +6 -3
- package/build/esm/Commands/Search.js.map +1 -1
- package/build/esm/Commands/Status.js +186 -0
- package/build/esm/Commands/Status.js.map +1 -0
- package/build/esm/Commands/Telemetry.js +23 -0
- package/build/esm/Commands/Telemetry.js.map +1 -0
- package/build/esm/Config.js +502 -28
- package/build/esm/Config.js.map +1 -1
- package/build/esm/Hook/Claude.js +1 -1
- package/build/esm/Hook/Claude.js.map +1 -1
- package/build/esm/Hook/SnapshotNote.js +54 -7
- package/build/esm/Hook/SnapshotNote.js.map +1 -1
- package/build/esm/Init/ClaudeAdapter.js +137 -0
- package/build/esm/Init/ClaudeAdapter.js.map +1 -0
- package/build/esm/Init/ClaudeProfile.js +380 -0
- package/build/esm/Init/ClaudeProfile.js.map +1 -0
- package/build/esm/Init/Domain.js +118 -0
- package/build/esm/Init/Domain.js.map +1 -0
- package/build/esm/Init/HarnessAdapter.js +21 -0
- package/build/esm/Init/HarnessAdapter.js.map +1 -0
- package/build/esm/Init/HarnessDetector.js +65 -0
- package/build/esm/Init/HarnessDetector.js.map +1 -0
- package/build/esm/Init/HarnessRegistry.js +58 -0
- package/build/esm/Init/HarnessRegistry.js.map +1 -0
- package/build/esm/Init/MutationTransaction.js +494 -0
- package/build/esm/Init/MutationTransaction.js.map +1 -0
- package/build/esm/Init/Service.js +319 -0
- package/build/esm/Init/Service.js.map +1 -0
- package/build/esm/Init/StandardAdapters.js +116 -0
- package/build/esm/Init/StandardAdapters.js.map +1 -0
- package/build/esm/Init/Status.js +215 -0
- package/build/esm/Init/Status.js.map +1 -0
- package/build/esm/Install/Claude/Hook.js +182 -50
- package/build/esm/Install/Claude/Hook.js.map +1 -1
- package/build/esm/Install/Claude/Mcp.js +56 -26
- package/build/esm/Install/Claude/Mcp.js.map +1 -1
- package/build/esm/Install/Claude/Skill.js +49 -27
- package/build/esm/Install/Claude/Skill.js.map +1 -1
- package/build/esm/Install/Codex/Mcp.js +9 -4
- package/build/esm/Install/Codex/Mcp.js.map +1 -1
- package/build/esm/Install/Codex/Skill.js +4 -1
- package/build/esm/Install/Codex/Skill.js.map +1 -1
- package/build/esm/Install/Cursor/Mcp.js +7 -4
- package/build/esm/Install/Cursor/Mcp.js.map +1 -1
- package/build/esm/Install/Cursor/Skill.js +4 -1
- package/build/esm/Install/Cursor/Skill.js.map +1 -1
- package/build/esm/Install/Domain.js +60 -3
- package/build/esm/Install/Domain.js.map +1 -1
- package/build/esm/Install/Hook.js.map +1 -1
- package/build/esm/Install/Mcp.js +1 -1
- package/build/esm/Install/Mcp.js.map +1 -1
- package/build/esm/Install/Opencode/Hook.js +1 -1
- package/build/esm/Install/Opencode/Hook.js.map +1 -1
- package/build/esm/Install/Opencode/Mcp.js +6 -3
- package/build/esm/Install/Opencode/Mcp.js.map +1 -1
- package/build/esm/Install/Opencode/Skill.js +4 -1
- package/build/esm/Install/Opencode/Skill.js.map +1 -1
- package/build/esm/Install/Opencode/expandai-webfetch.js +27 -9
- package/build/esm/Install/Skill.js.map +1 -1
- package/build/esm/Install/Uninstall.js +2 -2
- package/build/esm/Install/Uninstall.js.map +1 -1
- package/build/esm/MarkdownAnsi.js +142 -0
- package/build/esm/MarkdownAnsi.js.map +1 -0
- package/build/esm/Mcp/ExpandDocs.js +44 -52
- package/build/esm/Mcp/ExpandDocs.js.map +1 -1
- package/build/esm/Mcp/RequestHeaders.js.map +1 -1
- package/build/esm/Mcp/Server.js +2 -1
- package/build/esm/Mcp/Server.js.map +1 -1
- package/build/esm/Mcp/tools/Citations.js +109 -8
- package/build/esm/Mcp/tools/Citations.js.map +1 -1
- package/build/esm/Mcp/tools/Fetch.js +36 -12
- package/build/esm/Mcp/tools/Fetch.js.map +1 -1
- package/build/esm/SecureFile.js +36 -0
- package/build/esm/SecureFile.js.map +1 -0
- package/build/esm/Snippets.js +12 -1
- package/build/esm/Snippets.js.map +1 -1
- package/build/esm/Telemetry.js +157 -0
- package/build/esm/Telemetry.js.map +1 -0
- package/build/esm/Version.js +4 -0
- package/build/esm/Version.js.map +1 -0
- package/build/esm/Xdg.js +15 -0
- package/build/esm/Xdg.js.map +1 -0
- package/build/esm/main.js +122 -14
- package/build/esm/main.js.map +1 -1
- package/build/skills/expandai/SKILL.md +26 -3
- package/package.json +4 -4
- package/src/ApiClient.ts +206 -13
- package/src/Auth/Domain.ts +63 -0
- package/src/Auth/ManualAuthorization.ts +296 -0
- package/src/Auth/PendingInit.ts +338 -0
- package/src/Auth/Pkce.ts +26 -0
- package/src/Auth/SetupGrantAuthorization.ts +116 -0
- package/src/Authentication.ts +49 -11
- package/src/BrowserOAuth.ts +10 -12
- package/src/CommandPath.ts +30 -0
- package/src/Commands/Fetch.ts +42 -11
- package/src/Commands/Init.ts +489 -0
- package/src/Commands/Search.ts +8 -3
- package/src/Commands/Status.ts +228 -0
- package/src/Commands/Telemetry.ts +42 -0
- package/src/Config.ts +695 -50
- package/src/Hook/Claude.ts +1 -1
- package/src/Hook/SnapshotNote.ts +55 -7
- package/src/Init/ClaudeAdapter.ts +191 -0
- package/src/Init/ClaudeProfile.ts +496 -0
- package/src/Init/Domain.ts +153 -0
- package/src/Init/HarnessAdapter.ts +35 -0
- package/src/Init/HarnessDetector.ts +77 -0
- package/src/Init/HarnessRegistry.ts +87 -0
- package/src/Init/MutationTransaction.ts +747 -0
- package/src/Init/Service.ts +385 -0
- package/src/Init/StandardAdapters.ts +141 -0
- package/src/Init/Status.ts +253 -0
- package/src/Install/Claude/Hook.ts +234 -81
- package/src/Install/Claude/Mcp.ts +80 -33
- package/src/Install/Claude/Skill.ts +70 -37
- package/src/Install/Codex/Mcp.ts +14 -9
- package/src/Install/Codex/Skill.ts +9 -5
- package/src/Install/Cursor/Mcp.ts +12 -9
- package/src/Install/Cursor/Skill.ts +9 -5
- package/src/Install/Domain.ts +77 -4
- package/src/Install/Hook.ts +3 -3
- package/src/Install/Mcp.ts +6 -6
- package/src/Install/Opencode/Hook.ts +5 -5
- package/src/Install/Opencode/Mcp.ts +11 -8
- package/src/Install/Opencode/Skill.ts +9 -5
- package/src/Install/Opencode/expandai-webfetch.js +27 -9
- package/src/Install/Skill.ts +3 -3
- package/src/Install/Uninstall.ts +3 -3
- package/src/MarkdownAnsi.ts +162 -0
- package/src/Mcp/ExpandDocs.ts +46 -55
- package/src/Mcp/Server.ts +2 -1
- package/src/Mcp/tools/Citations.ts +117 -8
- package/src/Mcp/tools/Fetch.ts +40 -14
- package/src/SecureFile.ts +70 -0
- package/src/Snippets.ts +19 -12
- package/src/Telemetry.ts +186 -0
- package/src/Version.ts +3 -0
- package/src/Xdg.ts +18 -0
- package/src/main.ts +171 -12
- package/build/dts/DeviceAuth.d.ts +0 -87
- package/build/dts/DeviceAuth.d.ts.map +0 -1
- package/build/esm/DeviceAuth.js +0 -127
- package/build/esm/DeviceAuth.js.map +0 -1
- package/src/DeviceAuth.ts +0 -193
package/src/Auth/Pkce.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { Redacted } from 'effect'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RFC 7636 requires 43–128 characters of unreserved alphabet; 32 random bytes
|
|
6
|
+
* encode to 43 base64url characters, which is also what the API's
|
|
7
|
+
* `^[A-Za-z0-9_-]{43}$` challenge check expects.
|
|
8
|
+
*/
|
|
9
|
+
const VERIFIER_BYTES = 32
|
|
10
|
+
|
|
11
|
+
/** PKCE code verifier. Redacted so it cannot reach a log or a JSON result. */
|
|
12
|
+
export function createCodeVerifier(): Redacted.Redacted<string> {
|
|
13
|
+
return Redacted.make(randomBytes(VERIFIER_BYTES).toString('base64url'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function codeChallengeFor(verifier: Redacted.Redacted<string>): string {
|
|
17
|
+
return createHash('sha256').update(Redacted.value(verifier)).digest('base64url')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Opaque high-entropy value for non-PKCE uses such as the OAuth `state`
|
|
22
|
+
* parameter. Same entropy and encoding, different purpose.
|
|
23
|
+
*/
|
|
24
|
+
export function createHighEntropySecret(): string {
|
|
25
|
+
return randomBytes(VERIFIER_BYTES).toString('base64url')
|
|
26
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Effect, Redacted, Schema } from 'effect'
|
|
2
|
+
import { ConfigManager } from '../Config.js'
|
|
3
|
+
import {
|
|
4
|
+
ManualAuthorization,
|
|
5
|
+
ManualAuthorizationCredentialSaveFailed,
|
|
6
|
+
ManualAuthorizationDenied,
|
|
7
|
+
ManualAuthorizationRefused,
|
|
8
|
+
} from './ManualAuthorization.js'
|
|
9
|
+
|
|
10
|
+
interface SetupGrantAuthorizationInput {
|
|
11
|
+
readonly setupToken: Redacted.Redacted<string>
|
|
12
|
+
readonly harness: 'agent-harnesses'
|
|
13
|
+
readonly scope: 'project' | 'global'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SetupGrantAuthorizationError extends Schema.TaggedError<SetupGrantAuthorizationError>()(
|
|
17
|
+
'SetupGrantAuthorizationError',
|
|
18
|
+
{
|
|
19
|
+
code: Schema.Literal(
|
|
20
|
+
'invalid_or_expired',
|
|
21
|
+
'stored_credential_conflict',
|
|
22
|
+
'request_failed',
|
|
23
|
+
'denied',
|
|
24
|
+
'credential_save_failed',
|
|
25
|
+
'refused',
|
|
26
|
+
'config_unreadable',
|
|
27
|
+
),
|
|
28
|
+
recoverable: Schema.Boolean,
|
|
29
|
+
},
|
|
30
|
+
) {
|
|
31
|
+
get message() {
|
|
32
|
+
switch (this.code) {
|
|
33
|
+
// Checked before redemption, so the token is untouched and the user can
|
|
34
|
+
// retry with the same one after clearing the stored credential.
|
|
35
|
+
case 'stored_credential_conflict':
|
|
36
|
+
return 'An Expand API key is already stored on this machine, and completing this setup would replace it with no way to recover it. Nothing was changed and your setup prompt is still valid. Run `expandai logout` to clear the stored key, then run this setup command again.'
|
|
37
|
+
case 'request_failed':
|
|
38
|
+
return 'Could not reach Expand to redeem the setup token. Nothing was sent, so the same token is still good — check the network and try again.'
|
|
39
|
+
case 'denied':
|
|
40
|
+
return 'This setup authorization was denied. Copy a fresh setup prompt from the Expand dashboard.'
|
|
41
|
+
// Checked before redemption, so like stored_credential_conflict the token is
|
|
42
|
+
// untouched and the same prompt works once the file is dealt with.
|
|
43
|
+
case 'config_unreadable':
|
|
44
|
+
return 'The Expand config file on this machine could not be read, so setup cannot tell whether completing it would overwrite an existing API key. Nothing was redeemed and your setup prompt is still valid. Run `expandai whoami` to see which file it is, fix or delete it, then run this setup command again.'
|
|
45
|
+
// Server-side misconfiguration: a fresh prompt is refused identically.
|
|
46
|
+
case 'refused':
|
|
47
|
+
return 'Expand refused to redeem this setup token for a reason a fresh prompt cannot fix — most likely an OAuth client, resource, or scope misconfiguration on the server. Do not request another prompt; report this.'
|
|
48
|
+
// The token redeemed — it is spent — but the credentials could not be
|
|
49
|
+
// written locally. A fresh prompt hits the same wall until the local
|
|
50
|
+
// storage problem is fixed, so say that instead of blaming the token.
|
|
51
|
+
case 'credential_save_failed':
|
|
52
|
+
return 'The setup token was redeemed, but the credentials could not be saved on this machine. Fix credential storage — run `expandai whoami` to see the path it uses, and check its permissions — then copy a fresh setup prompt from the Expand dashboard.'
|
|
53
|
+
default:
|
|
54
|
+
return 'The setup token is invalid, expired, or already used. Copy a fresh setup prompt from the Expand dashboard.'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setupGrantError(code: typeof SetupGrantAuthorizationError.fields.code.Type): SetupGrantAuthorizationError {
|
|
60
|
+
return new SetupGrantAuthorizationError({
|
|
61
|
+
code,
|
|
62
|
+
recoverable: code !== 'denied' && code !== 'credential_save_failed' && code !== 'refused',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Dashboard-minted setup: the "token" in the prompt is a pre-approved RFC 8628
|
|
68
|
+
* device code, already bound to the minting user and pinned to their
|
|
69
|
+
* organization server-side. Redeeming is a single poll of the OAuth token
|
|
70
|
+
* endpoint — better-auth owns generation, expiry and the single-use burn, so
|
|
71
|
+
* there is no redemption protocol of ours left to get wrong.
|
|
72
|
+
*/
|
|
73
|
+
export class SetupGrantAuthorization extends Effect.Service<SetupGrantAuthorization>()(
|
|
74
|
+
'@expandai/cli/Auth/SetupGrantAuthorization',
|
|
75
|
+
{
|
|
76
|
+
dependencies: [ConfigManager.Default, ManualAuthorization.Default],
|
|
77
|
+
effect: Effect.gen(function* () {
|
|
78
|
+
const configManager = yield* ConfigManager
|
|
79
|
+
const manualAuthorization = yield* ManualAuthorization
|
|
80
|
+
const authorize = Effect.fn('SetupGrantAuthorization.authorize')(function* (input: SetupGrantAuthorizationInput) {
|
|
81
|
+
// The *file*, specifically. `loadConfig` collapses env and file into one
|
|
82
|
+
// value, so it cannot tell an ambient `EXPAND_API_KEY` — which must
|
|
83
|
+
// never block setup — from a key written by `expandai login`, which
|
|
84
|
+
// saving new credentials would overwrite with no way to recover it.
|
|
85
|
+
//
|
|
86
|
+
// Refused before redemption: nothing has been spent, so the same prompt
|
|
87
|
+
// still works once the user clears the stored key.
|
|
88
|
+
// `readConfigFile` never fails — it reports an unreadable file through
|
|
89
|
+
// `parseFailed` and hands back an empty config — so `Effect.either` here was
|
|
90
|
+
// always Right and the ApiKey check silently passed on a corrupt file. That
|
|
91
|
+
// matters: redemption is single-use, so proceeding would spend the token and
|
|
92
|
+
// *then* fail to save. Refuse first, while the prompt is still good.
|
|
93
|
+
const storedFile = yield* configManager.readConfigFile()
|
|
94
|
+
if (storedFile.parseFailed) return yield* setupGrantError('config_unreadable')
|
|
95
|
+
if (storedFile.config.authMethod?._tag === 'ApiKey') {
|
|
96
|
+
return yield* setupGrantError('stored_credential_conflict')
|
|
97
|
+
}
|
|
98
|
+
yield* Effect.annotateCurrentSpan({ harness: input.harness, scope: input.scope })
|
|
99
|
+
yield* manualAuthorization.redeemPreApproved(input.setupToken).pipe(
|
|
100
|
+
Effect.mapError((error) => {
|
|
101
|
+
if (error instanceof ManualAuthorizationDenied) return setupGrantError('denied')
|
|
102
|
+
if (error instanceof ManualAuthorizationCredentialSaveFailed) {
|
|
103
|
+
return setupGrantError('credential_save_failed')
|
|
104
|
+
}
|
|
105
|
+
if (error instanceof ManualAuthorizationRefused) return setupGrantError('refused')
|
|
106
|
+
if (error._tag === 'ManualAuthorizationApiError' && error.kind === 'request_failed') {
|
|
107
|
+
return setupGrantError('request_failed')
|
|
108
|
+
}
|
|
109
|
+
return setupGrantError('invalid_or_expired')
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
return { authorize } as const
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
) {}
|
package/src/Authentication.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { Command, Prompt } from '@effect/cli'
|
|
|
2
2
|
import { Console, DateTime, Duration, Effect, Either, Match, Schema, String } from 'effect'
|
|
3
3
|
import open from 'open'
|
|
4
4
|
import { BrowserOAuthService } from './BrowserOAuth.js'
|
|
5
|
-
import { ApiKey, ApiKeyConfig, ConfigManager,
|
|
6
|
-
|
|
5
|
+
import { ApiKey, ApiKeyConfig, type AuthMethod, ConfigManager, isAuthMethodLocallyUsable } from './Config.js'
|
|
6
|
+
|
|
7
|
+
// Re-exported: these moved to Config.ts, where the credential types live, so
|
|
8
|
+
// that credential precedence can consult usability without a cycle.
|
|
9
|
+
export { type AuthMethodState, authMethodState, isAuthMethodLocallyUsable } from './Config.js'
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
import { Uninstall } from './Install/Uninstall.js'
|
|
9
12
|
|
|
10
13
|
/** Render a user-facing description of the current auth state. Pure given DateTime.now. */
|
|
11
14
|
export const formatAuthStatus = Effect.fn('Authentication.formatAuthStatus')(function* (authMethod: AuthMethod) {
|
|
@@ -32,6 +35,17 @@ export class Authentication extends Effect.Service<Authentication>()('@expandai/
|
|
|
32
35
|
const browserOAuth = yield* BrowserOAuthService
|
|
33
36
|
const uninstall = yield* Uninstall
|
|
34
37
|
|
|
38
|
+
const hasLocallyUsableCredentials = Effect.fn('Authentication.hasLocallyUsableCredentials')(function* () {
|
|
39
|
+
yield* configManager.ensureSecureStorage()
|
|
40
|
+
const loaded = yield* Effect.either(configManager.loadConfig())
|
|
41
|
+
if (Either.isLeft(loaded)) {
|
|
42
|
+
if (loaded.left._tag === 'NotAuthenticated') return false
|
|
43
|
+
return yield* Effect.fail(loaded.left)
|
|
44
|
+
}
|
|
45
|
+
const now = yield* DateTime.now
|
|
46
|
+
return isAuthMethodLocallyUsable(loaded.right.authMethod, now)
|
|
47
|
+
})
|
|
48
|
+
|
|
35
49
|
const apiKeyLogin = Effect.fn('Authentication.apiKeyLogin')(function* () {
|
|
36
50
|
const apiKey = yield* Prompt.password({ message: 'Enter your API Key' })
|
|
37
51
|
yield* configManager.updateConfig({
|
|
@@ -40,13 +54,41 @@ export class Authentication extends Effect.Service<Authentication>()('@expandai/
|
|
|
40
54
|
yield* Console.log('API key saved successfully!')
|
|
41
55
|
})
|
|
42
56
|
|
|
43
|
-
const
|
|
57
|
+
const browserLogin = Effect.fnUntraced(function* <R>(
|
|
58
|
+
showAuthorizeUrl: (authorizeUrl: URL) => Effect.Effect<void, unknown, R>,
|
|
59
|
+
) {
|
|
44
60
|
yield* Console.log(
|
|
45
61
|
String.stripMargin(`
|
|
46
62
|
|
|
|
47
63
|
|Starting browser authorization...`),
|
|
48
64
|
)
|
|
49
|
-
yield* browserOAuth.login(
|
|
65
|
+
yield* browserOAuth.login(showAuthorizeUrl)
|
|
66
|
+
yield* Console.log(
|
|
67
|
+
String.stripMargin(`
|
|
68
|
+
|
|
|
69
|
+
|Successfully authenticated!`),
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const printAuthorizeUrl = Effect.fnUntraced(function* (authorizeUrl: URL) {
|
|
74
|
+
yield* Console.log(
|
|
75
|
+
String.stripMargin(`
|
|
76
|
+
|
|
|
77
|
+
|Open this URL in a browser:
|
|
78
|
+
| ${authorizeUrl.toString()}
|
|
79
|
+
|
|
|
80
|
+
|Waiting for browser authorization...`),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// `init` already asks for harness and scope. Keep browser login on the
|
|
85
|
+
// existing loopback OAuth path without introducing a third Prompt choice.
|
|
86
|
+
const loginWithBrowser = Effect.fn('Authentication.loginWithBrowser')(function* () {
|
|
87
|
+
yield* browserLogin(printAuthorizeUrl)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const oauthLogin = Effect.fn('Authentication.oauthLogin')(function* () {
|
|
91
|
+
yield* browserLogin((authorizeUrl) =>
|
|
50
92
|
Effect.gen(function* () {
|
|
51
93
|
yield* Console.log(
|
|
52
94
|
String.stripMargin(`
|
|
@@ -70,14 +112,10 @@ export class Authentication extends Effect.Service<Authentication>()('@expandai/
|
|
|
70
112
|
)
|
|
71
113
|
}),
|
|
72
114
|
)
|
|
73
|
-
yield* Console.log(
|
|
74
|
-
String.stripMargin(`
|
|
75
|
-
|
|
|
76
|
-
|Successfully authenticated!`),
|
|
77
|
-
)
|
|
78
115
|
})
|
|
79
116
|
|
|
80
117
|
const login = Effect.fn('Authentication.login')(function* () {
|
|
118
|
+
yield* configManager.ensureSecureStorage()
|
|
81
119
|
const existing = yield* Effect.either(configManager.loadConfig())
|
|
82
120
|
|
|
83
121
|
if (Either.isRight(existing)) {
|
|
@@ -144,7 +182,7 @@ export class Authentication extends Effect.Service<Authentication>()('@expandai/
|
|
|
144
182
|
yield* Console.log('Logged out.')
|
|
145
183
|
})
|
|
146
184
|
|
|
147
|
-
return { login, whoami, logout } as const
|
|
185
|
+
return { hasLocallyUsableCredentials, loginWithBrowser, login, whoami, logout } as const
|
|
148
186
|
}),
|
|
149
187
|
}) {}
|
|
150
188
|
|
package/src/BrowserOAuth.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
2
1
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
3
2
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from '@effect/platform'
|
|
4
3
|
import { NodeHttpClient } from '@effect/platform-node'
|
|
5
4
|
import { DateTime, Duration, Effect, Inspectable, Redacted, Schema } from 'effect'
|
|
5
|
+
import { codeChallengeFor, createHighEntropySecret } from './Auth/Pkce.js'
|
|
6
6
|
import { ConfigManager, OAuthConfig } from './Config.js'
|
|
7
7
|
|
|
8
8
|
const CLIENT_ID = 'expandai-cli'
|
|
@@ -107,16 +107,8 @@ interface LoopbackServer {
|
|
|
107
107
|
readonly close: Effect.Effect<void>
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function base64Url(bytes: Uint8Array): string {
|
|
111
|
-
return Buffer.from(bytes).toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function createHighEntropySecret(): string {
|
|
115
|
-
return base64Url(randomBytes(32))
|
|
116
|
-
}
|
|
117
|
-
|
|
118
110
|
export const createCodeChallenge = Effect.fn('BrowserOAuth.createCodeChallenge')(function* (codeVerifier: string) {
|
|
119
|
-
return
|
|
111
|
+
return codeChallengeFor(Redacted.make(codeVerifier))
|
|
120
112
|
})
|
|
121
113
|
|
|
122
114
|
export const buildAuthorizeUrl = Effect.fn('BrowserOAuth.buildAuthorizeUrl')(function* ({
|
|
@@ -253,6 +245,7 @@ export class BrowserOAuthService extends Effect.Service<BrowserOAuthService>()('
|
|
|
253
245
|
const exchangeToken = Effect.fn('BrowserOAuthService.exchangeToken')(function* (
|
|
254
246
|
form: ReadonlyArray<readonly [string, string]>,
|
|
255
247
|
) {
|
|
248
|
+
yield* configManager.ensureSecureStorage()
|
|
256
249
|
const apiUrl = yield* configManager.getApiUrl()
|
|
257
250
|
const resource = apiUrl.toString()
|
|
258
251
|
const request = HttpClientRequest.post(new URL(OAUTH_TOKEN_ENDPOINT, apiUrl)).pipe(
|
|
@@ -301,10 +294,13 @@ export class BrowserOAuthService extends Effect.Service<BrowserOAuthService>()('
|
|
|
301
294
|
])
|
|
302
295
|
})
|
|
303
296
|
|
|
304
|
-
const refreshToken = Effect.fn('BrowserOAuthService.refreshToken')(function* (
|
|
297
|
+
const refreshToken = Effect.fn('BrowserOAuthService.refreshToken')(function* (
|
|
298
|
+
refreshToken: string,
|
|
299
|
+
clientId: string = CLIENT_ID,
|
|
300
|
+
) {
|
|
305
301
|
return yield* exchangeToken([
|
|
306
302
|
['grant_type', 'refresh_token'],
|
|
307
|
-
['client_id',
|
|
303
|
+
['client_id', clientId],
|
|
308
304
|
['refresh_token', refreshToken],
|
|
309
305
|
])
|
|
310
306
|
})
|
|
@@ -319,6 +315,7 @@ export class BrowserOAuthService extends Effect.Service<BrowserOAuthService>()('
|
|
|
319
315
|
refreshToken:
|
|
320
316
|
tokenResponse.refresh_token === undefined ? undefined : Redacted.make(tokenResponse.refresh_token),
|
|
321
317
|
expiresAt,
|
|
318
|
+
clientId: CLIENT_ID,
|
|
322
319
|
}),
|
|
323
320
|
})
|
|
324
321
|
})
|
|
@@ -326,6 +323,7 @@ export class BrowserOAuthService extends Effect.Service<BrowserOAuthService>()('
|
|
|
326
323
|
const login = Effect.fn('BrowserOAuthService.login')(function* <R>(
|
|
327
324
|
openAuthorizeUrl: (authorizeUrl: URL) => Effect.Effect<void, unknown, R>,
|
|
328
325
|
) {
|
|
326
|
+
yield* configManager.ensureSecureStorage()
|
|
329
327
|
const codeVerifier = createHighEntropySecret()
|
|
330
328
|
const state = createHighEntropySecret()
|
|
331
329
|
const codeChallenge = yield* createCodeChallenge(codeVerifier)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry-safe command-path extraction. Only tokens that sit in legal
|
|
3
|
+
* command-path positions of the tree below are ever reported — never
|
|
4
|
+
* free-form args, URLs, or queries. The walk stops at the first token that
|
|
5
|
+
* isn't a subcommand of the current node, so positional user input can't
|
|
6
|
+
* leak even when it happens to equal a command word
|
|
7
|
+
* (`expandai search snap_abc login` reports just `search`). Choice-arg
|
|
8
|
+
* values (hosts, scopes) are positional data and deliberately absent.
|
|
9
|
+
*/
|
|
10
|
+
const COMMAND_TREE = new Map<string, ReadonlySet<string>>([
|
|
11
|
+
['fetch', new Set()],
|
|
12
|
+
['search', new Set()],
|
|
13
|
+
['login', new Set()],
|
|
14
|
+
['whoami', new Set()],
|
|
15
|
+
['logout', new Set()],
|
|
16
|
+
['init', new Set()],
|
|
17
|
+
['status', new Set()],
|
|
18
|
+
['uninstall', new Set()],
|
|
19
|
+
['mcp', new Set(['run', 'install', 'uninstall'])],
|
|
20
|
+
['skill', new Set(['install', 'uninstall'])],
|
|
21
|
+
['hook', new Set(['claude-code', 'opencode', 'install', 'uninstall'])],
|
|
22
|
+
['telemetry', new Set(['status', 'enable', 'disable'])],
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export function commandFromArgv(argv: readonly string[]): string {
|
|
26
|
+
const [first, second] = argv.slice(2)
|
|
27
|
+
const subcommands = first === undefined ? undefined : COMMAND_TREE.get(first)
|
|
28
|
+
if (first === undefined || subcommands === undefined) return '(none)'
|
|
29
|
+
return second !== undefined && subcommands.has(second) ? `${first} ${second}` : first
|
|
30
|
+
}
|
package/src/Commands/Fetch.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { Args, Command, HelpDoc, Options } from '@effect/cli'
|
|
|
2
2
|
import { DEFAULT_PLAYGROUND_HOST, resolvePlaygroundHost } from '@expandai/sdk/Playground'
|
|
3
3
|
import { Console, Effect, Match, Option, Schema, String } from 'effect'
|
|
4
4
|
import { ApiClient } from '../ApiClient.js'
|
|
5
|
-
import {
|
|
5
|
+
import { withCitationFooter } from '../Hook/SnapshotNote.js'
|
|
6
|
+
import { highlightMarkdownAnsi, shouldColorizeMarkdown } from '../MarkdownAnsi.js'
|
|
7
|
+
import { renderSnippets, withCitationUrls } from '../Snippets.js'
|
|
6
8
|
|
|
7
9
|
export class MarkdownUnavailable extends Schema.TaggedError<MarkdownUnavailable>()('MarkdownUnavailable', {
|
|
8
10
|
url: Schema.String,
|
|
@@ -121,15 +123,21 @@ export const FetchCommand: Command.Command<'fetch', never, unknown, FetchCommand
|
|
|
121
123
|
|
|
122
124
|
const output =
|
|
123
125
|
format === 'text' && query === undefined
|
|
124
|
-
? yield* client
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)
|
|
126
|
+
? yield* client.fetchFetch(fetchRequest).pipe(
|
|
127
|
+
Effect.flatMap((markdown) =>
|
|
128
|
+
String.isNonEmpty(markdown)
|
|
129
|
+
? Effect.succeed(markdown)
|
|
130
|
+
: Effect.fail(new MarkdownUnavailable({ url: url.toString() })),
|
|
131
|
+
),
|
|
132
|
+
// Interactive terminals get ANSI-highlighted markdown. Piped output is
|
|
133
|
+
// being read by an agent, so it also carries the citation contract —
|
|
134
|
+
// this is the third fetch path (with the hook and the MCP tools) and
|
|
135
|
+
// was the only one that shipped evidence ids without telling anyone
|
|
136
|
+
// to use them.
|
|
137
|
+
Effect.map((markdown) =>
|
|
138
|
+
shouldColorizeMarkdown() ? highlightMarkdownAnsi(markdown) : withCitationFooter(markdown),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
133
141
|
: yield* client.fetchFetchResult(fetchRequest).pipe(
|
|
134
142
|
Effect.flatMap((fetched) =>
|
|
135
143
|
Match.value(format).pipe(
|
|
@@ -145,7 +153,30 @@ export const FetchCommand: Command.Command<'fetch', never, unknown, FetchCommand
|
|
|
145
153
|
),
|
|
146
154
|
)
|
|
147
155
|
}),
|
|
148
|
-
Match.when('json', () =>
|
|
156
|
+
Match.when('json', () => {
|
|
157
|
+
// Snippets carry a citation link in every other output path
|
|
158
|
+
// (text here, and both MCP tools). Without it a JSON consumer
|
|
159
|
+
// cannot cite what it just searched.
|
|
160
|
+
const search = fetched.data?.search
|
|
161
|
+
const cited =
|
|
162
|
+
search == null
|
|
163
|
+
? fetched
|
|
164
|
+
: {
|
|
165
|
+
...fetched,
|
|
166
|
+
data: {
|
|
167
|
+
...fetched.data,
|
|
168
|
+
search: {
|
|
169
|
+
...search,
|
|
170
|
+
snippets: withCitationUrls(
|
|
171
|
+
fetched.meta.snapshotId,
|
|
172
|
+
resolvePlaygroundHost(fetched.meta.playground, DEFAULT_PLAYGROUND_HOST),
|
|
173
|
+
search.snippets,
|
|
174
|
+
),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
return Schema.encode(Schema.parseJson(Schema.Unknown, { space: 2 }))(cited)
|
|
179
|
+
}),
|
|
149
180
|
Match.exhaustive,
|
|
150
181
|
),
|
|
151
182
|
),
|