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
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { FileSystem, Path } from '@effect/platform'
|
|
3
|
+
import { NodeFileSystem, NodePath } from '@effect/platform-node'
|
|
4
|
+
import { Array, Config, Effect, Either, Layer, Option, Schema } from 'effect'
|
|
5
|
+
import {
|
|
6
|
+
hookCommandFor,
|
|
7
|
+
isExpandaiHookCommand,
|
|
8
|
+
planClaudeHook,
|
|
9
|
+
planClaudeWebFetchDeny,
|
|
10
|
+
SettingsSchema,
|
|
11
|
+
WEBFETCH_MATCHER,
|
|
12
|
+
} from '../Install/Claude/Hook.js'
|
|
13
|
+
import { planClaudeMcp } from '../Install/Claude/Mcp.js'
|
|
14
|
+
import { planClaudeSkill, SKILL_FILE_NAME, SKILL_NAME } from '../Install/Claude/Skill.js'
|
|
15
|
+
import { type CliCommand, CliInvocation, McpServersConfigSchema, SERVER_KEY } from '../Install/Domain.js'
|
|
16
|
+
import { CLI_VERSION } from '../Version.js'
|
|
17
|
+
import type { InitComponent, InitScope } from './Domain.js'
|
|
18
|
+
import type { MutationExpectedState, MutationOperation } from './MutationTransaction.js'
|
|
19
|
+
|
|
20
|
+
export const CLAUDE_PROFILE_VERSION = CLI_VERSION
|
|
21
|
+
|
|
22
|
+
export const CLAUDE_POLICY_MANAGED_HOOKS = 'managed_hooks_only'
|
|
23
|
+
export const CLAUDE_POLICY_MANAGED_MCP = 'managed_mcp_only'
|
|
24
|
+
export const CLAUDE_POLICY_MCP_DENIED = 'managed_mcp_denied'
|
|
25
|
+
export const CLAUDE_POLICY_MCP_NOT_ALLOWED = 'managed_mcp_not_allowed'
|
|
26
|
+
export const CLAUDE_POLICY_READ_ONLY = 'target_read_only'
|
|
27
|
+
|
|
28
|
+
interface ClaudeTargetLocations {
|
|
29
|
+
readonly settingsFile: string
|
|
30
|
+
readonly skillDirectory: string
|
|
31
|
+
readonly skillFile: string
|
|
32
|
+
readonly mcpFile: string
|
|
33
|
+
readonly managedSettingsFiles: string[]
|
|
34
|
+
readonly managedSettingsDirectories: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClaudeManagedSettingsLocationSet {
|
|
38
|
+
readonly files: string[]
|
|
39
|
+
readonly directories: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ClaudeRegistrationInspection {
|
|
43
|
+
readonly current: boolean
|
|
44
|
+
readonly installed: boolean
|
|
45
|
+
readonly legacyVariants: string[]
|
|
46
|
+
readonly version?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ClaudeProfileInspection {
|
|
50
|
+
readonly scope: InitScope
|
|
51
|
+
readonly targets: ClaudeTargetLocations
|
|
52
|
+
readonly hook: ClaudeRegistrationInspection
|
|
53
|
+
readonly skill: ClaudeRegistrationInspection
|
|
54
|
+
readonly mcp: ClaudeRegistrationInspection
|
|
55
|
+
readonly components: InitComponent[]
|
|
56
|
+
readonly configured: boolean
|
|
57
|
+
readonly profileVersion: string
|
|
58
|
+
readonly componentVersions: {
|
|
59
|
+
readonly hook?: string
|
|
60
|
+
readonly skill?: string
|
|
61
|
+
readonly mcp?: string
|
|
62
|
+
}
|
|
63
|
+
readonly policyBlocks: string[]
|
|
64
|
+
readonly remotePolicyMayApply: true
|
|
65
|
+
readonly reloadMayBeRequired: boolean
|
|
66
|
+
readonly consentMayBeRequired: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ClaudeProfileMutationPlan {
|
|
70
|
+
readonly changed: boolean
|
|
71
|
+
readonly inspection: ClaudeProfileInspection
|
|
72
|
+
readonly resultInspection: ClaudeProfileInspection
|
|
73
|
+
readonly operations: MutationOperation[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface LoadedClaudeProfile {
|
|
77
|
+
readonly targets: ClaudeTargetLocations
|
|
78
|
+
readonly settings: typeof SettingsSchema.Type
|
|
79
|
+
readonly settingsExpected: MutationExpectedState
|
|
80
|
+
readonly settingsText?: string
|
|
81
|
+
readonly skillExpected: MutationExpectedState
|
|
82
|
+
readonly skillText?: string
|
|
83
|
+
readonly bundledSkillText: string
|
|
84
|
+
readonly mcp: typeof McpServersConfigSchema.Type
|
|
85
|
+
readonly mcpExpected: MutationExpectedState
|
|
86
|
+
readonly mcpText?: string
|
|
87
|
+
readonly managedSettings: ReadonlyArray<Record<string, unknown>>
|
|
88
|
+
readonly readOnlyTargets: string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const JsonRecord = Schema.parseJson(Schema.Record({ key: Schema.String, value: Schema.Unknown }))
|
|
92
|
+
const PROFILE_VERSION_PATTERN = /^\s*expandai-profile-version:\s*([^\s]+)\s*$/mu
|
|
93
|
+
const textEncoder = new TextEncoder()
|
|
94
|
+
const textDecoder = new TextDecoder()
|
|
95
|
+
const MODE_MAX = 0o7777
|
|
96
|
+
|
|
97
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
98
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function defaultManagedSettingsLocations(): ClaudeManagedSettingsLocationSet {
|
|
102
|
+
if (process.platform === 'darwin') {
|
|
103
|
+
const root = '/Library/Application Support/ClaudeCode'
|
|
104
|
+
return {
|
|
105
|
+
files: [`${root}/managed-settings.json`, `${root}/managed-mcp.json`],
|
|
106
|
+
directories: [`${root}/managed-settings.d`],
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (process.platform === 'win32') {
|
|
110
|
+
const root = 'C:\\Program Files\\ClaudeCode'
|
|
111
|
+
return {
|
|
112
|
+
files: [`${root}\\managed-settings.json`, `${root}\\managed-mcp.json`],
|
|
113
|
+
directories: [`${root}\\managed-settings.d`],
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
files: ['/etc/claude-code/managed-settings.json', '/etc/claude-code/managed-mcp.json'],
|
|
118
|
+
directories: ['/etc/claude-code/managed-settings.d'],
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Discoverable file-based Claude managed-policy locations for the current platform. */
|
|
123
|
+
export class ClaudeManagedSettingsLocations extends Effect.Service<ClaudeManagedSettingsLocations>()(
|
|
124
|
+
'@expandai/cli/Init/ClaudeManagedSettingsLocations',
|
|
125
|
+
{
|
|
126
|
+
sync: defaultManagedSettingsLocations,
|
|
127
|
+
},
|
|
128
|
+
) {
|
|
129
|
+
static testLayer(locations: ClaudeManagedSettingsLocationSet) {
|
|
130
|
+
return Layer.succeed(ClaudeManagedSettingsLocations, ClaudeManagedSettingsLocations.make(locations))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function listContainsExpandai(value: unknown): boolean {
|
|
135
|
+
if (!Array.isArray(value)) return false
|
|
136
|
+
return value.some((entry) => {
|
|
137
|
+
if (entry === SERVER_KEY) return true
|
|
138
|
+
if (!isUnknownRecord(entry)) return false
|
|
139
|
+
return entry.name === SERVER_KEY || entry.serverName === SERVER_KEY
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function managedPolicyBlocks(settings: ReadonlyArray<Record<string, unknown>>): string[] {
|
|
144
|
+
const blocks = new Set<string>()
|
|
145
|
+
const managedMcpOnly = settings.some((value) => value.allowManagedMcpServersOnly === true)
|
|
146
|
+
const allowedMcpConfigured = settings.some((value) => Array.isArray(value.allowedMcpServers))
|
|
147
|
+
const expandaiAllowed = settings.some((value) => listContainsExpandai(value.allowedMcpServers))
|
|
148
|
+
for (const value of settings) {
|
|
149
|
+
if (value.allowManagedHooksOnly === true) blocks.add(CLAUDE_POLICY_MANAGED_HOOKS)
|
|
150
|
+
if (listContainsExpandai(value.deniedMcpServers)) blocks.add(CLAUDE_POLICY_MCP_DENIED)
|
|
151
|
+
}
|
|
152
|
+
if (managedMcpOnly && !expandaiAllowed) blocks.add(CLAUDE_POLICY_MANAGED_MCP)
|
|
153
|
+
if (allowedMcpConfigured && !expandaiAllowed) blocks.add(CLAUDE_POLICY_MCP_NOT_ALLOWED)
|
|
154
|
+
return [...blocks].sort()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function skillVersion(text: string | undefined): string | undefined {
|
|
158
|
+
if (text === undefined) return undefined
|
|
159
|
+
return PROFILE_VERSION_PATTERN.exec(text)?.[1]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function mcpLegacyVariant(value: unknown): string[] {
|
|
163
|
+
if (!isUnknownRecord(value)) return value === undefined ? [] : ['owned-entry']
|
|
164
|
+
const command = value.command
|
|
165
|
+
const args = value.args
|
|
166
|
+
if (command === 'npx' && Array.isArray(args) && args[0] === '-y') return ['npx-y']
|
|
167
|
+
if (typeof command === 'string' && command !== 'expandai' && /(?:^|[\\/])expandai(?:\.exe)?$/u.test(command)) {
|
|
168
|
+
return ['absolute-path']
|
|
169
|
+
}
|
|
170
|
+
return ['owned-entry']
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hookLegacyVariant(command: string): string {
|
|
174
|
+
if (/^npx\s+-y\s+/u.test(command.trim())) return 'npx-y'
|
|
175
|
+
if (/\shook\s+claude\s*$/u.test(command)) return 'old-command-spelling'
|
|
176
|
+
if (/^(?:"|'|\/|[A-Za-z]:\\)/u.test(command.trim())) return 'absolute-path'
|
|
177
|
+
if (/^if\s+command\s+-v\s+expandai/u.test(command.trim())) return 'npx-shell-fallback'
|
|
178
|
+
return 'unversioned'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function makeInspection(scope: InitScope, loaded: LoadedClaudeProfile, cli: CliCommand): ClaudeProfileInspection {
|
|
182
|
+
const hookPlan = planClaudeHook(loaded.settings, CLAUDE_PROFILE_VERSION, cli)
|
|
183
|
+
const denyPlan = planClaudeWebFetchDeny(hookPlan.settings)
|
|
184
|
+
const hookEntries = (loaded.settings.hooks.PreToolUse ?? []).flatMap((entry) =>
|
|
185
|
+
entry.hooks.filter((hook) => isExpandaiHookCommand(hook.command)).map((hook) => ({ entry, hook })),
|
|
186
|
+
)
|
|
187
|
+
const hookVersion = hookEntries.length === 1 ? hookEntries[0]?.hook.expandaiProfileVersion : undefined
|
|
188
|
+
const hookCurrent = !hookPlan.changed && !denyPlan.changed
|
|
189
|
+
const hookLegacy = hookCurrent
|
|
190
|
+
? []
|
|
191
|
+
: hookEntries.map(({ entry, hook }) =>
|
|
192
|
+
entry.matcher === WEBFETCH_MATCHER && hook.command === hookCommandFor(cli)
|
|
193
|
+
? 'unversioned'
|
|
194
|
+
: hookLegacyVariant(hook.command),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const plannedSkill = planClaudeSkill(loaded.skillText, loaded.bundledSkillText, CLAUDE_PROFILE_VERSION)
|
|
198
|
+
const currentSkillVersion = skillVersion(loaded.skillText)
|
|
199
|
+
const skillCurrent = loaded.skillText !== undefined && !plannedSkill.changed
|
|
200
|
+
|
|
201
|
+
const mcpValue = loaded.mcp.mcpServers[SERVER_KEY]
|
|
202
|
+
const mcpCurrent = !planClaudeMcp(loaded.mcp, cli).changed
|
|
203
|
+
const components: InitComponent[] = []
|
|
204
|
+
if (hookCurrent) components.push('webfetch-hook')
|
|
205
|
+
if (skillCurrent) components.push('runtime-skill')
|
|
206
|
+
if (mcpCurrent) components.push('mcp')
|
|
207
|
+
|
|
208
|
+
const policyBlocks = managedPolicyBlocks(loaded.managedSettings)
|
|
209
|
+
if (loaded.readOnlyTargets.length > 0) policyBlocks.push(CLAUDE_POLICY_READ_ONLY)
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
scope,
|
|
213
|
+
targets: loaded.targets,
|
|
214
|
+
hook: {
|
|
215
|
+
current: hookCurrent,
|
|
216
|
+
installed: hookEntries.length > 0,
|
|
217
|
+
legacyVariants: hookLegacy,
|
|
218
|
+
...(hookVersion === undefined ? {} : { version: hookVersion }),
|
|
219
|
+
},
|
|
220
|
+
skill: {
|
|
221
|
+
current: skillCurrent,
|
|
222
|
+
installed: loaded.skillText !== undefined,
|
|
223
|
+
legacyVariants: loaded.skillText === undefined || skillCurrent ? [] : ['outdated-runtime-skill'],
|
|
224
|
+
...(currentSkillVersion === undefined ? {} : { version: currentSkillVersion }),
|
|
225
|
+
},
|
|
226
|
+
mcp: {
|
|
227
|
+
current: mcpCurrent,
|
|
228
|
+
installed: mcpValue !== undefined,
|
|
229
|
+
legacyVariants: mcpCurrent ? [] : mcpLegacyVariant(mcpValue),
|
|
230
|
+
},
|
|
231
|
+
components,
|
|
232
|
+
configured: components.length === 3,
|
|
233
|
+
profileVersion: CLAUDE_PROFILE_VERSION,
|
|
234
|
+
componentVersions: {
|
|
235
|
+
...(hookVersion === undefined ? {} : { hook: hookVersion }),
|
|
236
|
+
...(currentSkillVersion === undefined ? {} : { skill: currentSkillVersion }),
|
|
237
|
+
...(mcpCurrent ? { mcp: CLAUDE_PROFILE_VERSION } : {}),
|
|
238
|
+
},
|
|
239
|
+
policyBlocks: [...new Set(policyBlocks)].sort(),
|
|
240
|
+
remotePolicyMayApply: true,
|
|
241
|
+
reloadMayBeRequired: !hookCurrent || !mcpCurrent,
|
|
242
|
+
consentMayBeRequired: scope === 'project',
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const makeClaudeProfile = Effect.gen(function* () {
|
|
247
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
248
|
+
const path = yield* Path.Path
|
|
249
|
+
const managedSettingsLocations = yield* ClaudeManagedSettingsLocations
|
|
250
|
+
// Resolved once per service: whether `expandai` is on PATH decides what the
|
|
251
|
+
// written hook and MCP entries invoke, and it cannot change mid-install.
|
|
252
|
+
const cli = yield* (yield* CliInvocation).resolve()
|
|
253
|
+
const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
|
|
254
|
+
const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
|
|
255
|
+
const moduleFile = yield* path.fromFileUrl(new URL(import.meta.url))
|
|
256
|
+
const bundledSkillFile = path.resolve(path.dirname(moduleFile), '..', '..', 'skills', SKILL_NAME, SKILL_FILE_NAME)
|
|
257
|
+
|
|
258
|
+
const readOptional = Effect.fnUntraced(function* (file: string) {
|
|
259
|
+
return (yield* fileSystem.exists(file)) ? yield* fileSystem.readFileString(file) : undefined
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const readPlannedFile = Effect.fnUntraced(function* (file: string) {
|
|
263
|
+
if (!(yield* fileSystem.exists(file))) {
|
|
264
|
+
return { expected: { exists: false } as const }
|
|
265
|
+
}
|
|
266
|
+
const info = yield* fileSystem.stat(file)
|
|
267
|
+
const bytes = yield* fileSystem.readFile(file)
|
|
268
|
+
return {
|
|
269
|
+
expected: {
|
|
270
|
+
exists: true,
|
|
271
|
+
type: 'File',
|
|
272
|
+
bytes,
|
|
273
|
+
mode: info.mode & MODE_MAX,
|
|
274
|
+
} as const,
|
|
275
|
+
text: textDecoder.decode(bytes),
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const expectedDirectory = Effect.fnUntraced(function* (directory: string) {
|
|
280
|
+
if (!(yield* fileSystem.exists(directory))) return { exists: false } as const
|
|
281
|
+
const info = yield* fileSystem.stat(directory)
|
|
282
|
+
if (info.type !== 'Directory') throw new Error('Planned directory target is not a directory')
|
|
283
|
+
return {
|
|
284
|
+
exists: true,
|
|
285
|
+
type: 'Directory',
|
|
286
|
+
mode: info.mode & MODE_MAX,
|
|
287
|
+
} as const
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const writableAncestor = Effect.fnUntraced(function* (target: string) {
|
|
291
|
+
function ancestors(candidate: string): string[] {
|
|
292
|
+
const parent = path.dirname(candidate)
|
|
293
|
+
return parent === candidate ? [candidate] : [candidate, ...ancestors(parent)]
|
|
294
|
+
}
|
|
295
|
+
const candidates = ancestors(target)
|
|
296
|
+
const states = yield* Effect.forEach(candidates, (candidate) =>
|
|
297
|
+
fileSystem.exists(candidate).pipe(Effect.map((exists) => ({ candidate, exists }))),
|
|
298
|
+
)
|
|
299
|
+
const candidate = states.find((state) => state.exists)?.candidate ?? candidates[candidates.length - 1]
|
|
300
|
+
if (candidate === undefined) return false
|
|
301
|
+
const info = yield* fileSystem.stat(candidate)
|
|
302
|
+
return (info.mode & 0o222) !== 0
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const mutationRequirementsWritable = Effect.fnUntraced(function* (operation: MutationOperation) {
|
|
306
|
+
if (
|
|
307
|
+
operation.type === 'write' &&
|
|
308
|
+
operation.expected?.exists === true &&
|
|
309
|
+
operation.expected.type === 'File' &&
|
|
310
|
+
!(yield* Effect.either(fileSystem.access(operation.path, { writable: true }))).pipe(Either.isRight)
|
|
311
|
+
) {
|
|
312
|
+
return false
|
|
313
|
+
}
|
|
314
|
+
if (operation.type === 'mkdir' && operation.expected?.exists === true) return true
|
|
315
|
+
|
|
316
|
+
const parent = path.dirname(operation.path)
|
|
317
|
+
function ancestors(candidate: string): string[] {
|
|
318
|
+
const next = path.dirname(candidate)
|
|
319
|
+
return next === candidate ? [candidate] : [candidate, ...ancestors(next)]
|
|
320
|
+
}
|
|
321
|
+
const candidates = ancestors(parent)
|
|
322
|
+
const states = yield* Effect.forEach(candidates, (candidate) =>
|
|
323
|
+
fileSystem.exists(candidate).pipe(Effect.map((exists) => ({ candidate, exists }))),
|
|
324
|
+
)
|
|
325
|
+
const nearest = states.find((state) => state.exists)?.candidate
|
|
326
|
+
if (nearest === undefined) return false
|
|
327
|
+
const existing = states.filter((state) => state.exists)
|
|
328
|
+
const traversable = yield* Effect.forEach(existing, ({ candidate }) =>
|
|
329
|
+
fileSystem.stat(candidate).pipe(
|
|
330
|
+
Effect.map((info) => info.type === 'Directory' && (process.platform === 'win32' || (info.mode & 0o111) !== 0)),
|
|
331
|
+
Effect.orElseSucceed(() => false),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
if (!traversable.every(Boolean)) return false
|
|
335
|
+
// FileSystem.access performs the platform's effective writable check.
|
|
336
|
+
// Windows has no separate directory-traverse/ACL primitive here, so the
|
|
337
|
+
// transaction remains the final fail-closed enforcement for that facet.
|
|
338
|
+
return (yield* Effect.either(fileSystem.access(nearest, { writable: true }))).pipe(Either.isRight)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const load = Effect.fn('ClaudeProfile.load')(function* (scope: InitScope) {
|
|
342
|
+
const root = scope === 'global' ? home : cwd
|
|
343
|
+
const skillDirectory = path.join(root, '.claude', 'skills', SKILL_NAME)
|
|
344
|
+
const managed = managedSettingsLocations
|
|
345
|
+
const dropInFiles = yield* Effect.flatMap(
|
|
346
|
+
Effect.forEach(managed.directories, (directory) =>
|
|
347
|
+
fileSystem.exists(directory).pipe(
|
|
348
|
+
Effect.flatMap((exists) => (exists ? fileSystem.readDirectory(directory) : Effect.succeed([]))),
|
|
349
|
+
Effect.map((entries) =>
|
|
350
|
+
entries
|
|
351
|
+
.filter((entry) => !entry.startsWith('.') && entry.endsWith('.json'))
|
|
352
|
+
.sort()
|
|
353
|
+
.map((entry) => path.join(directory, entry)),
|
|
354
|
+
),
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
(files) => Effect.succeed(files.flat()),
|
|
358
|
+
)
|
|
359
|
+
const targets: ClaudeTargetLocations = {
|
|
360
|
+
settingsFile: path.join(root, '.claude', 'settings.json'),
|
|
361
|
+
skillDirectory,
|
|
362
|
+
skillFile: path.join(skillDirectory, SKILL_FILE_NAME),
|
|
363
|
+
mcpFile: scope === 'global' ? path.join(home, '.claude.json') : path.join(cwd, '.mcp.json'),
|
|
364
|
+
managedSettingsFiles: [...managed.files, ...dropInFiles],
|
|
365
|
+
managedSettingsDirectories: managed.directories,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const settingsFile = yield* readPlannedFile(targets.settingsFile)
|
|
369
|
+
const skillFile = yield* readPlannedFile(targets.skillFile)
|
|
370
|
+
const mcpFile = yield* readPlannedFile(targets.mcpFile)
|
|
371
|
+
const settingsText = settingsFile.text
|
|
372
|
+
const skillText = skillFile.text
|
|
373
|
+
const mcpText = mcpFile.text
|
|
374
|
+
const bundledSkillText = yield* fileSystem.readFileString(bundledSkillFile)
|
|
375
|
+
const settings = yield* Schema.decode(SettingsSchema)(settingsText ?? '{}')
|
|
376
|
+
const mcp = yield* Schema.decode(McpServersConfigSchema)(mcpText ?? '{}')
|
|
377
|
+
const managedSettings = yield* Effect.forEach(targets.managedSettingsFiles, (file) =>
|
|
378
|
+
readOptional(file).pipe(
|
|
379
|
+
Effect.flatMap((text) =>
|
|
380
|
+
text === undefined
|
|
381
|
+
? Effect.succeed(Option.none())
|
|
382
|
+
: Schema.decode(JsonRecord)(text).pipe(Effect.map(Option.some)),
|
|
383
|
+
),
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
const readOnlyTargets = yield* Effect.filter([targets.settingsFile, targets.skillFile, targets.mcpFile], (target) =>
|
|
387
|
+
writableAncestor(target).pipe(Effect.map((writable) => !writable)),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
targets,
|
|
392
|
+
settings,
|
|
393
|
+
settingsExpected: settingsFile.expected,
|
|
394
|
+
...(settingsText === undefined ? {} : { settingsText }),
|
|
395
|
+
skillExpected: skillFile.expected,
|
|
396
|
+
...(skillText === undefined ? {} : { skillText }),
|
|
397
|
+
bundledSkillText,
|
|
398
|
+
mcp,
|
|
399
|
+
mcpExpected: mcpFile.expected,
|
|
400
|
+
...(mcpText === undefined ? {} : { mcpText }),
|
|
401
|
+
managedSettings: Array.getSomes(managedSettings),
|
|
402
|
+
readOnlyTargets,
|
|
403
|
+
} satisfies LoadedClaudeProfile
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const inspect = Effect.fn('ClaudeProfile.inspect')(function* (scope: InitScope) {
|
|
407
|
+
return makeInspection(scope, yield* load(scope), cli)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const plan = Effect.fn('ClaudeProfile.plan')(function* (scope: InitScope) {
|
|
411
|
+
const loaded = yield* load(scope)
|
|
412
|
+
const hookPlan = planClaudeHook(loaded.settings, CLAUDE_PROFILE_VERSION, cli)
|
|
413
|
+
// One settings file, one write: the deny rule is layered onto the hook plan's
|
|
414
|
+
// output so both land together or neither does.
|
|
415
|
+
const denyPlan = planClaudeWebFetchDeny(hookPlan.settings)
|
|
416
|
+
const settingsChanged = hookPlan.changed || denyPlan.changed
|
|
417
|
+
const skillPlan = planClaudeSkill(loaded.skillText, loaded.bundledSkillText, CLAUDE_PROFILE_VERSION)
|
|
418
|
+
const mcpPlan = planClaudeMcp(loaded.mcp, cli)
|
|
419
|
+
const settingsText = `${yield* Schema.encode(SettingsSchema)(denyPlan.settings)}\n`
|
|
420
|
+
const mcpText = `${yield* Schema.encode(McpServersConfigSchema)(mcpPlan.config)}\n`
|
|
421
|
+
const settingsDirectory = path.dirname(loaded.targets.settingsFile)
|
|
422
|
+
const skillsDirectory = path.dirname(loaded.targets.skillDirectory)
|
|
423
|
+
const operations: MutationOperation[] = []
|
|
424
|
+
if (settingsChanged || skillPlan.changed) {
|
|
425
|
+
operations.push({
|
|
426
|
+
type: 'mkdir',
|
|
427
|
+
path: settingsDirectory,
|
|
428
|
+
expected: yield* expectedDirectory(settingsDirectory),
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
if (settingsChanged) {
|
|
432
|
+
operations.push({
|
|
433
|
+
type: 'write',
|
|
434
|
+
path: loaded.targets.settingsFile,
|
|
435
|
+
bytes: textEncoder.encode(settingsText),
|
|
436
|
+
expected: loaded.settingsExpected,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
if (skillPlan.changed) {
|
|
440
|
+
operations.push({
|
|
441
|
+
type: 'mkdir',
|
|
442
|
+
path: skillsDirectory,
|
|
443
|
+
expected: yield* expectedDirectory(skillsDirectory),
|
|
444
|
+
})
|
|
445
|
+
operations.push({
|
|
446
|
+
type: 'mkdir',
|
|
447
|
+
path: loaded.targets.skillDirectory,
|
|
448
|
+
expected: yield* expectedDirectory(loaded.targets.skillDirectory),
|
|
449
|
+
})
|
|
450
|
+
operations.push({
|
|
451
|
+
type: 'write',
|
|
452
|
+
path: loaded.targets.skillFile,
|
|
453
|
+
bytes: textEncoder.encode(skillPlan.text),
|
|
454
|
+
expected: loaded.skillExpected,
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
if (mcpPlan.changed) {
|
|
458
|
+
operations.push({
|
|
459
|
+
type: 'write',
|
|
460
|
+
path: loaded.targets.mcpFile,
|
|
461
|
+
bytes: textEncoder.encode(mcpText),
|
|
462
|
+
expected: loaded.mcpExpected,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
const requirements = yield* Effect.forEach(operations, mutationRequirementsWritable)
|
|
466
|
+
const inspectedLoaded = requirements.every(Boolean)
|
|
467
|
+
? loaded
|
|
468
|
+
: { ...loaded, readOnlyTargets: [...loaded.readOnlyTargets, 'mutation-requirement'] }
|
|
469
|
+
const inspection = makeInspection(scope, inspectedLoaded, cli)
|
|
470
|
+
const resultInspection = makeInspection(
|
|
471
|
+
scope,
|
|
472
|
+
{
|
|
473
|
+
...inspectedLoaded,
|
|
474
|
+
settings: denyPlan.settings,
|
|
475
|
+
settingsText,
|
|
476
|
+
skillText: skillPlan.text,
|
|
477
|
+
mcp: mcpPlan.config,
|
|
478
|
+
mcpText,
|
|
479
|
+
},
|
|
480
|
+
cli,
|
|
481
|
+
)
|
|
482
|
+
return {
|
|
483
|
+
changed: settingsChanged || skillPlan.changed || mcpPlan.changed,
|
|
484
|
+
inspection,
|
|
485
|
+
resultInspection,
|
|
486
|
+
operations,
|
|
487
|
+
} satisfies ClaudeProfileMutationPlan
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
return { inspect, plan } as const
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
export class ClaudeProfile extends Effect.Service<ClaudeProfile>()('@expandai/cli/Init/ClaudeProfile', {
|
|
494
|
+
dependencies: [NodeFileSystem.layer, NodePath.layer, ClaudeManagedSettingsLocations.Default, CliInvocation.Default],
|
|
495
|
+
effect: makeClaudeProfile,
|
|
496
|
+
}) {}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Schema } from 'effect'
|
|
2
|
+
|
|
3
|
+
// Relayed verbatim by the agent, and printed as-is in the terminal, so it has to
|
|
4
|
+
// carry the whole browser round-trip on its own — the guide no longer restates it.
|
|
5
|
+
export const AUTHORIZATION_REQUIRED_MESSAGE =
|
|
6
|
+
'Open the approval page in your browser, type in the code shown here, and approve. Only enter this code on the Expand approval page your terminal named — never a code someone sent you, and never on a page from a link you did not expect. When you have approved, say so, and the same command will pick the authorization up.'
|
|
7
|
+
|
|
8
|
+
export const AUTHORIZATION_CONSUMED_MESSAGE =
|
|
9
|
+
'Authorization complete. That setup code has been consumed and cannot be reused.'
|
|
10
|
+
|
|
11
|
+
export const ALREADY_AUTHENTICATED_MESSAGE = 'Expand is already authorized. Agent setup is complete.'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Some harnesses were configured and some were not. This has to be its own
|
|
15
|
+
* message: the agent relays `userMessage` verbatim, so reporting a partial run
|
|
16
|
+
* with "Agent setup is complete" tells the user something untrue about a
|
|
17
|
+
* machine they are about to rely on.
|
|
18
|
+
*/
|
|
19
|
+
export const PARTIAL_CONFIGURATION_MESSAGE =
|
|
20
|
+
'Expand is authorized, but not every harness could be configured. The harnesses that failed are listed under `failures`; the ones under `installations` are ready to use.'
|
|
21
|
+
|
|
22
|
+
export const HARNESS_SELECTION_MESSAGE =
|
|
23
|
+
'Show these harnesses to the user and ask which to configure. Pass the chosen ones to `expandai init --harnesses`. Harnesses that are not chosen are left untouched.'
|
|
24
|
+
|
|
25
|
+
/** There is nothing to choose from, so the agent must not present an empty list. */
|
|
26
|
+
export const NO_HARNESS_DETECTED_MESSAGE =
|
|
27
|
+
'No supported agent harness was found on this machine. Do not ask the user to choose from an empty list. Tell them Expand supports claude-code, cursor, opencode, and codex, and that setup cannot continue until one of them is installed.'
|
|
28
|
+
|
|
29
|
+
export const InitHarness = Schema.Literal('claude-code', 'cursor', 'opencode', 'codex')
|
|
30
|
+
export type InitHarness = typeof InitHarness.Type
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Answer to `init --detect`: what setup *would* touch, before it touches
|
|
34
|
+
* anything. Separate from `InitResult` because detection never authorizes,
|
|
35
|
+
* never writes, and can legitimately find nothing.
|
|
36
|
+
*/
|
|
37
|
+
export class InitDetectionResult extends Schema.Class<InitDetectionResult>('InitDetectionResult')({
|
|
38
|
+
schemaVersion: Schema.Literal(1),
|
|
39
|
+
status: Schema.Literal('detected'),
|
|
40
|
+
harnesses: Schema.Array(InitHarness),
|
|
41
|
+
current: Schema.optional(InitHarness),
|
|
42
|
+
userMessage: Schema.Literal(HARNESS_SELECTION_MESSAGE, NO_HARNESS_DETECTED_MESSAGE),
|
|
43
|
+
}) {}
|
|
44
|
+
|
|
45
|
+
export const InitHarnesses = Schema.NonEmptyArray(InitHarness)
|
|
46
|
+
export type InitHarnesses = typeof InitHarnesses.Type
|
|
47
|
+
|
|
48
|
+
export const InitScope = Schema.Literal('project', 'global')
|
|
49
|
+
export type InitScope = typeof InitScope.Type
|
|
50
|
+
|
|
51
|
+
export const InitComponent = Schema.Literal('webfetch-hook', 'runtime-skill', 'mcp')
|
|
52
|
+
export type InitComponent = typeof InitComponent.Type
|
|
53
|
+
|
|
54
|
+
export const InitRuntimeState = Schema.Literal('unverified', 'observed', 'reload_required', 'blocked')
|
|
55
|
+
export type InitRuntimeState = typeof InitRuntimeState.Type
|
|
56
|
+
|
|
57
|
+
export const InitStatus = Schema.Literal('authorization_required', 'completed')
|
|
58
|
+
export type InitStatus = typeof InitStatus.Type
|
|
59
|
+
|
|
60
|
+
// A dashboard setup token is a pre-approved RFC 8628 device code minted by
|
|
61
|
+
// better-auth: opaque, bounded, single-use.
|
|
62
|
+
const SetupToken = Schema.String.pipe(Schema.minLength(20), Schema.maxLength(256))
|
|
63
|
+
|
|
64
|
+
const SafeSetupToken = Schema.transform(SetupToken, Schema.Redacted(Schema.String), {
|
|
65
|
+
strict: true,
|
|
66
|
+
decode: (setupToken) => setupToken,
|
|
67
|
+
// Long enough to satisfy the base schema on re-encode; still obviously not a token.
|
|
68
|
+
encode: () => '<redacted-redacted-setup-token>',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export class InitRequest extends Schema.Class<InitRequest>('InitRequest')({
|
|
72
|
+
harnesses: InitHarnesses,
|
|
73
|
+
scope: InitScope,
|
|
74
|
+
setupToken: Schema.optional(SafeSetupToken),
|
|
75
|
+
}) {}
|
|
76
|
+
|
|
77
|
+
const InitAuthorizationRequiredResult = Schema.Struct({
|
|
78
|
+
schemaVersion: Schema.Literal(1),
|
|
79
|
+
status: Schema.Literal('authorization_required'),
|
|
80
|
+
harnesses: InitHarnesses,
|
|
81
|
+
scope: InitScope,
|
|
82
|
+
userMessage: Schema.Literal(AUTHORIZATION_REQUIRED_MESSAGE),
|
|
83
|
+
authorization: Schema.Struct({
|
|
84
|
+
url: Schema.URL,
|
|
85
|
+
// Typed by the human into the approval page. Deliberately not embedded in
|
|
86
|
+
// the URL: entering it is the one moment a phished user can notice the
|
|
87
|
+
// request is not theirs.
|
|
88
|
+
userCode: Schema.String,
|
|
89
|
+
approvalExpiresAt: Schema.DateTimeUtc,
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/** Whether the harness has actually loaded what we wrote, and what to do if not. */
|
|
94
|
+
const InitVerification = Schema.Struct({
|
|
95
|
+
runtimeState: InitRuntimeState,
|
|
96
|
+
nextAction: Schema.String,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/** One harness that setup wrote to, and what it now reports about itself. */
|
|
100
|
+
const InitInstallation = Schema.Struct({
|
|
101
|
+
harness: InitHarness,
|
|
102
|
+
configured: Schema.Boolean,
|
|
103
|
+
changed: Schema.Boolean,
|
|
104
|
+
components: Schema.Array(InitComponent),
|
|
105
|
+
version: Schema.String,
|
|
106
|
+
verification: InitVerification,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A harness that was attempted and did not land. Named separately because the
|
|
111
|
+
* agent is asked to tell the user *which* of their agents is not set up, and it
|
|
112
|
+
* has no other way to know: `harnesses` lists what was requested and
|
|
113
|
+
* `installations` what worked, but the difference alone says neither why nor
|
|
114
|
+
* whether retrying helps.
|
|
115
|
+
*/
|
|
116
|
+
const InitFailure = Schema.Struct({
|
|
117
|
+
harness: InitHarness,
|
|
118
|
+
code: Schema.String,
|
|
119
|
+
recoverable: Schema.Boolean,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const InitCompletedResult = Schema.Struct({
|
|
123
|
+
schemaVersion: Schema.Literal(1),
|
|
124
|
+
status: Schema.Literal('completed'),
|
|
125
|
+
harnesses: InitHarnesses,
|
|
126
|
+
scope: InitScope,
|
|
127
|
+
userMessage: Schema.Literal(
|
|
128
|
+
AUTHORIZATION_CONSUMED_MESSAGE,
|
|
129
|
+
ALREADY_AUTHENTICATED_MESSAGE,
|
|
130
|
+
PARTIAL_CONFIGURATION_MESSAGE,
|
|
131
|
+
),
|
|
132
|
+
auth: Schema.Struct({
|
|
133
|
+
healthy: Schema.Boolean,
|
|
134
|
+
}),
|
|
135
|
+
// Which project `scope: 'project'` actually meant. It resolves to the working
|
|
136
|
+
// directory, which for an agent may be anywhere — a repo, a home directory,
|
|
137
|
+
// a temp folder — and nothing else in this result says where config landed.
|
|
138
|
+
projectPath: Schema.optional(Schema.String),
|
|
139
|
+
installations: Schema.NonEmptyArray(InitInstallation),
|
|
140
|
+
// Absent when every selected harness succeeded.
|
|
141
|
+
failures: Schema.optional(Schema.Array(InitFailure)),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
export const InitResult = Schema.Union(InitAuthorizationRequiredResult, InitCompletedResult)
|
|
145
|
+
export type InitResult = typeof InitResult.Type
|
|
146
|
+
|
|
147
|
+
export class InitErrorEnvelope extends Schema.Class<InitErrorEnvelope>('InitErrorEnvelope')({
|
|
148
|
+
schemaVersion: Schema.Literal(1),
|
|
149
|
+
status: Schema.Literal('error'),
|
|
150
|
+
code: Schema.String,
|
|
151
|
+
message: Schema.String,
|
|
152
|
+
recoverable: Schema.Boolean,
|
|
153
|
+
}) {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Inspectable, Schema } from 'effect'
|
|
2
|
+
import { type InitComponent, InitHarness, type InitRuntimeState, InitScope } from './Domain.js'
|
|
3
|
+
|
|
4
|
+
export interface HarnessInspection {
|
|
5
|
+
readonly configured: boolean
|
|
6
|
+
readonly changed?: boolean
|
|
7
|
+
readonly components: InitComponent[]
|
|
8
|
+
readonly integrationVersion?: string
|
|
9
|
+
readonly policyBlock?: string
|
|
10
|
+
readonly nextAction: string
|
|
11
|
+
readonly runtimeState?: InitRuntimeState
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class HarnessInspectionError extends Schema.TaggedError<HarnessInspectionError>()('HarnessInspectionError', {
|
|
15
|
+
harness: InitHarness,
|
|
16
|
+
scope: InitScope,
|
|
17
|
+
error: Schema.Defect,
|
|
18
|
+
}) {
|
|
19
|
+
get message() {
|
|
20
|
+
return `Failed to inspect ${this.harness} (${this.scope}): ${Inspectable.format(this.error)}`
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HarnessConfigurationError extends Schema.TaggedError<HarnessConfigurationError>()(
|
|
25
|
+
'HarnessConfigurationError',
|
|
26
|
+
{
|
|
27
|
+
harness: InitHarness,
|
|
28
|
+
scope: InitScope,
|
|
29
|
+
error: Schema.Defect,
|
|
30
|
+
},
|
|
31
|
+
) {
|
|
32
|
+
get message() {
|
|
33
|
+
return `Failed to configure ${this.harness} (${this.scope}): ${Inspectable.format(this.error)}`
|
|
34
|
+
}
|
|
35
|
+
}
|