opencode-navigator 0.19.2 → 0.20.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.
@@ -0,0 +1,280 @@
1
+ import {randomUUID} from "crypto"
2
+ import {lstat,mkdir,readFile,realpath,rename,unlink,writeFile} from "fs/promises"
3
+ import {join,resolve} from "path"
4
+ var SIDEBAR_SECTIONS=["limits","todo","subagents","skills","quick_actions","lsp","mcp"]
5
+ var QUICK_ACTION_IDS=["session.rename","session.timeline","session.copy","session.export","session.compact","session.list","session.new","session.fork","messages.copy","session.first","session.last","session.messages_last_user","session.message.next","session.message.previous","permission.mode"]
6
+ function parseQuickActionOrder(value){if(!Array.isArray(value))
7
+ return
8
+ const order=[...new Set(value.filter((id)=>QUICK_ACTION_IDS.includes(id)))]
9
+ return [...order,...QUICK_ACTION_IDS.filter((id)=>!order.includes(id))]
10
+ }
11
+ function parseQuickActionVisibility(value){if(!value||typeof value!=="object"||Array.isArray(value))
12
+ return{}
13
+ const input=value
14
+ return Object.fromEntries(QUICK_ACTION_IDS.flatMap((id)=>typeof input[id]==="boolean"?[[id,input[id]]]:[]))
15
+ }
16
+ function parseSectionOrder(value){if(!Array.isArray(value))
17
+ return
18
+ const seen=new Set
19
+ const order=[]
20
+ for(const candidate of value){if(!SIDEBAR_SECTIONS.includes(candidate)||seen.has(candidate))
21
+ continue
22
+ seen.add(candidate)
23
+ order.push(candidate)
24
+ }
25
+ for(const section of SIDEBAR_SECTIONS){if(!seen.has(section)){if(section==="limits")
26
+ order.unshift(section)
27
+ else
28
+ order.push(section)
29
+ }}
30
+ return order
31
+ }
32
+ function parseSectionPreferences(value){if(!value||typeof value!=="object"||Array.isArray(value))
33
+ return{}
34
+ const input=value
35
+ return Object.fromEntries(SIDEBAR_SECTIONS.flatMap((name)=>typeof input[name]==="boolean"?[[name,input[name]]]:[]))
36
+ }
37
+ function parseSectionLayout(value){if(!value||typeof value!=="object"||Array.isArray(value))
38
+ return
39
+ const input=value
40
+ const sections=parseSectionPreferences(input.sections)
41
+ const expanded=parseSectionPreferences(input.expanded)
42
+ const order=parseSectionOrder(input.order)
43
+ if(Object.keys(sections).length===0&&Object.keys(expanded).length===0&&!order)
44
+ return
45
+ return{sections,expanded,...order&&{order}}}
46
+ function parseSectionItemLimits(value){if(!value||typeof value!=="object"||Array.isArray(value))
47
+ return{}
48
+ const input=value
49
+ return Object.fromEntries(SIDEBAR_SECTIONS.flatMap((section)=>{const limit=input[section]
50
+ return typeof limit==="number"&&Number.isSafeInteger(limit)&&limit>=0?[[section,limit]]:[]
51
+ }))
52
+ }
53
+ var PLUGIN_ID="opencode-navigator"
54
+ var TOGGLE_COMMAND=`${PLUGIN_ID}.toggle`
55
+ var FOCUS_COMMAND=`${PLUGIN_ID}.focus`
56
+ var SEARCH_COMMAND=`${PLUGIN_ID}.search`;
57
+ function record(value){if(!value||typeof value!=="object"||Array.isArray(value))
58
+ return
59
+ return value
60
+ }
61
+ function parsePluginSettings(value){const input=record(value)
62
+ if(!input)
63
+ return{}
64
+ return{...typeof input.toggleKey==="string"&&input.toggleKey.trim()&&{toggleKey:input.toggleKey.trim()},...typeof input.focusKey==="string"&&input.focusKey.trim()&&{focusKey:input.focusKey.trim()},...typeof input.searchKey==="string"&&input.searchKey.trim()&&{searchKey:input.searchKey.trim()},...typeof input.persistMcp==="boolean"&&{persistMcp:input.persistMcp},...typeof input.startInChat==="boolean"&&{startInChat:input.startInChat},...typeof input.showSessionTitle==="boolean"&&{showSessionTitle:input.showSessionTitle},...typeof input.showSessionDate==="boolean"&&{showSessionDate:input.showSessionDate},...typeof input.cornerFont==="boolean"&&{cornerFont:input.cornerFont},...(input.lspIconStyle==="nerd"||input.lspIconStyle==="text")&&{lspIconStyle:input.lspIconStyle},...(input.rowDensity==="compact"||input.rowDensity==="comfortable")&&{rowDensity:input.rowDensity
65
+ },...Object.keys(parseSectionItemLimits(input.sectionItemLimits)).length>0&&{sectionItemLimits:parseSectionItemLimits(input.sectionItemLimits)
66
+ },...parseQuickActionOrder(input.quickActionOrder)&&{quickActionOrder:parseQuickActionOrder(input.quickActionOrder)
67
+ },...Object.keys(parseQuickActionVisibility(input.quickActionVisibility)).length>0&&{quickActionVisibility:parseQuickActionVisibility(input.quickActionVisibility)
68
+ }}}
69
+ function parseLayoutPresets(value){const input=record(value)
70
+ if(!input)
71
+ return{}
72
+ const presets={}
73
+ const entries=Object.entries(input).sort(([left],[right])=>left.localeCompare(right))
74
+ for(const [candidate,preset] of entries){const name=candidate.trim().slice(0,64)
75
+ const layout=parseSectionLayout(preset)
76
+ if(name&&layout&&Object.keys(presets).length<50)
77
+ presets[name]=layout
78
+ }
79
+ return presets
80
+ }
81
+ function parseDesiredMcpStates(value){const input=record(value)
82
+ if(!input)
83
+ return{}
84
+ return Object.fromEntries(Object.entries(input).filter((entry)=>Boolean(entry[0])&&(entry[1]==="enabled"||entry[1]==="disabled")).sort(([left],[right])=>left.localeCompare(right)))
85
+ }
86
+ function parseMcpPresets(value){const input=record(value)
87
+ if(!input)
88
+ return{}
89
+ const presets={}
90
+ const names=new Set
91
+ const entries=Object.entries(input).sort(([left],[right])=>left.localeCompare(right))
92
+ for(const [candidate,preset] of entries){const name=candidate.trim().slice(0,64)
93
+ const states=parseDesiredMcpStates(preset)
94
+ const key=name.toLocaleLowerCase()
95
+ if(name&&!names.has(key)&&Object.keys(states).length>0&&Object.keys(presets).length<50){names.add(key)
96
+ presets[name]=states
97
+ }}
98
+ return presets
99
+ }
100
+ function parseMcpServerGroups(value){const input=record(value)
101
+ if(!input)
102
+ return{}
103
+ return Object.fromEntries(Object.entries(input).flatMap(([server,rawGroup])=>{const group=typeof rawGroup==="string"?rawGroup.trim().slice(0,64):""
104
+ return server.trim()&&group?[[server,group]]:[]
105
+ }))
106
+ }
107
+ function parseWorkspaceProfiles(value){return Object.fromEntries(Object.entries(record(value)??{}).flatMap(([layout,rawMcp])=>{const layoutName=layout.trim().slice(0,64)
108
+ const mcpName=typeof rawMcp==="string"?rawMcp.trim().slice(0,64):""
109
+ return layoutName&&mcpName?[[layoutName,mcpName]]:[]
110
+ }).slice(0,50))
111
+ }
112
+ function record2(value){if(!value||typeof value!=="object"||Array.isArray(value))
113
+ return
114
+ return value
115
+ }
116
+ function present(value){return value&&Object.keys(value).length>0
117
+ }
118
+ function parseConfiguredDefaults(value){const input=record2(value)
119
+ if(!input)
120
+ throw new Error("Expected a JSON object")
121
+ const behavior=parsePluginSettings(input.behavior)
122
+ const layout=parseSectionLayout(input.layout)
123
+ const desiredMcpStates=parseDesiredMcpStates(input.mcp??input.desiredMcpStates)
124
+ const layoutPresets=parseLayoutPresets(input.layoutPresets)
125
+ const mcpPresets=parseMcpPresets(input.mcpPresets)
126
+ const workspaceProfiles=parseWorkspaceProfiles(input.workspaceProfiles)
127
+ const mcpServerGroups=parseMcpServerGroups(input.mcpServerGroups)
128
+ return{...present(behavior)&&{behavior},...layout&&{layout},...present(desiredMcpStates)&&{desiredMcpStates},...present(layoutPresets)&&{layoutPresets},...present(mcpPresets)&&{mcpPresets},...present(workspaceProfiles)&&{workspaceProfiles},...present(mcpServerGroups)&&{mcpServerGroups}}}
129
+ var PORTABLE_SETTINGS_FORMAT="opencode-navigator/settings"
130
+ function object(value,path){if(!value||typeof value!=="object"||Array.isArray(value))
131
+ throw new Error(`${path} must be an object`)
132
+ return value
133
+ }
134
+ function pointer(path,key){return `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`
135
+ }
136
+ function unknownKeys(input,allowed,path,output){for(const key of Object.keys(input))
137
+ if(!allowed.includes(key))
138
+ output.push(pointer(path,key))
139
+ }
140
+ function parseVisibility(value,path,unsupported){const input=object(value,path)
141
+ return Object.fromEntries(Object.entries(input).flatMap(([name,visible])=>{if(!SIDEBAR_SECTIONS.includes(name)){unsupported.push(pointer(path,name))
142
+ return []
143
+ }
144
+ if(typeof visible!=="boolean")
145
+ throw new Error(`${pointer(path, name)} must be a boolean`)
146
+ return [[name,visible]]
147
+ }))
148
+ }
149
+ function parseLayout(value,unsupported){const input=object(value,"/layout")
150
+ unknownKeys(input,["sections","expanded","order"],"/layout",unsupported)
151
+ const sections=input.sections===undefined?{}:parseVisibility(input.sections,"/layout/sections",unsupported)
152
+ const expanded=input.expanded===undefined?{}:parseVisibility(input.expanded,"/layout/expanded",unsupported)
153
+ let order
154
+ if(input.order!==undefined){if(!Array.isArray(input.order))
155
+ throw new Error("/layout/order must be an array")
156
+ const supported=[]
157
+ const seen=new Set
158
+ for(const [index,name] of input.order.entries()){if(typeof name!=="string")
159
+ throw new Error(`/layout/order/${index} must be a section name`)
160
+ if(!SIDEBAR_SECTIONS.includes(name))
161
+ unsupported.push(`/layout/order/${index}`)
162
+ else if(seen.has(name))
163
+ throw new Error(`/layout/order/${index} duplicates ${name}`)
164
+ else{seen.add(name)
165
+ supported.push(name)
166
+ }}
167
+ order=parseSectionOrder(supported)
168
+ }
169
+ return{sections,expanded,...order&&{order}}}
170
+ function parseMcp(value){const input=object(value,"/mcp")
171
+ return Object.fromEntries(Object.entries(input).map(([name,state])=>{if(!name.trim())
172
+ throw new Error("/mcp contains an empty server name")
173
+ if(state!=="enabled"&&state!=="disabled"){throw new Error(`${pointer("/mcp", name)} must be enabled or disabled`)
174
+ }
175
+ return [name,state]
176
+ }).sort(([left],[right])=>left.localeCompare(right)))
177
+ }
178
+ function parsePortableSettings(source){let value
179
+ try{value=JSON.parse(source)
180
+ } catch(error){throw new Error("Malformed settings JSON",{cause:error})
181
+ }
182
+ const input=object(value,"Settings JSON")
183
+ if(input.format!==PORTABLE_SETTINGS_FORMAT)
184
+ throw new Error(`Expected format ${PORTABLE_SETTINGS_FORMAT}`)
185
+ if(input.version!==1)
186
+ throw new Error(`Unsupported settings version ${String(input.version)}`)
187
+ const unsupported=[]
188
+ unknownKeys(input,["format","version","layout","mcp"],"",unsupported)
189
+ const hasLayout=Object.hasOwn(input,"layout")
190
+ const hasMcp=Object.hasOwn(input,"mcp")
191
+ if(!hasLayout&&!hasMcp)
192
+ throw new Error("Settings JSON contains no supported settings")
193
+ const settings={format:PORTABLE_SETTINGS_FORMAT,version:1,...hasLayout&&{layout:parseLayout(input.layout,unsupported)},...hasMcp&&{mcp:parseMcp(input.mcp)}}
194
+ unsupported.sort()
195
+ return{settings,unsupported}}
196
+ var FILENAME=join(".opencode","navigator.json")
197
+ var PRIVATE_KEYS=new Set(["favoriteSkills","favoriteMcpServers","favoriteQuickActions","skillGroups","recentSkills","recentQuickActions","skippedSkillConfirmations","onboardingCompleted","codexAccountBindings","pendingResetAttempt","lastNavigatorVersion","previousNavigatorVersion","worktrees","user"])
198
+ function object2(value){if(!value||typeof value!=="object"||Array.isArray(value))
199
+ throw new Error("Project settings must be an object")
200
+ return value
201
+ }
202
+ async function readProject(file){const info=await lstat(file).catch((error)=>{if(error.code==="ENOENT")
203
+ return
204
+ throw error
205
+ })
206
+ if(info?.isSymbolicLink()||info&&!info.isFile())
207
+ throw new Error("Project settings must be a regular file")
208
+ try{return await readFile(file,"utf8")
209
+ } catch(error){if(error.code==="ENOENT")
210
+ return
211
+ throw error
212
+ }}
213
+ function parseProject(source){if(source===undefined)
214
+ return{}
215
+ const input=object2(JSON.parse(source))
216
+ const keys=Object.keys(input)
217
+ for(const key of keys){if(PRIVATE_KEYS.has(key))
218
+ throw new Error(`Project settings contain private field ${key}`)
219
+ }
220
+ parseConfiguredDefaults(input)
221
+ return input
222
+ }
223
+ async function previewProjectProfile(root,nameInput,source,replace=false){const directory=await realpath(resolve(root))
224
+ const name=nameInput.trim()
225
+ if(!name||name.length>64)
226
+ throw new Error("Profile names must contain 1\u201364 characters")
227
+ if(name.includes("/")||name.includes("\\")||name.startsWith("~")){throw new Error("Profile names must not contain private paths")
228
+ }
229
+ const parsed=parsePortableSettings(source)
230
+ const layout=parsed.settings.layout&&parseSectionLayout(parsed.settings.layout)
231
+ if(!layout)
232
+ throw new Error("A project profile needs a layout")
233
+ const servers=Object.keys(parsed.settings.mcp??{})
234
+ for(const server of servers){if(server.includes("/")||server.includes("\\")||server.startsWith("~")){throw new Error(`MCP server name looks like a private path: ${server}`)
235
+ }}
236
+ const file=join(directory,FILENAME)
237
+ const before=await readProject(file)
238
+ const existing=parseProject(before)
239
+ const layouts=object2(existing.layoutPresets??{})
240
+ const mcpPresets=object2(existing.mcpPresets??{})
241
+ const profiles=object2(existing.workspaceProfiles??{})
242
+ const collision=Object.keys(layouts).find((candidate)=>candidate.toLocaleLowerCase()===name.toLocaleLowerCase())
243
+ if(collision&&(!replace||collision!==name))
244
+ throw new Error(`Project profile already exists: ${collision}`)
245
+ if(!collision&&Object.keys(layouts).length>=50)
246
+ throw new Error("Project profiles are full")
247
+ const hasMcp=Boolean(parsed.settings.mcp&&servers.length>0)
248
+ if(hasMcp&&!replace&&mcpPresets[name]){throw new Error(`Project MCP preset already exists: ${name}`)
249
+ }
250
+ if(hasMcp&&mcpPresets[name]&&Object.entries(profiles).some(([layoutName,mcpName])=>layoutName!==name&&mcpName===name)){throw new Error(`MCP preset ${name} is linked to another project profile`)
251
+ }
252
+ const next={...existing,layoutPresets:{...layouts,[name]:layout},...hasMcp&&{mcpPresets:{...mcpPresets,[name]:parsed.settings.mcp},workspaceProfiles:{...profiles,[name]:name}}}
253
+ if(!hasMcp&&profiles[name]&&replace){next.workspaceProfiles=Object.fromEntries(Object.entries(profiles).filter(([key])=>key!==name))
254
+ }
255
+ parseConfiguredDefaults(next)
256
+ return{root:directory,file,name,before,after:`${JSON.stringify(next, undefined, 2)}
257
+ `,layout,mcp:parsed.settings.mcp,skipped:parsed.unsupported
258
+ }}
259
+ async function saveProjectProfile(preview,root){if(await realpath(resolve(root))!==preview.root)
260
+ throw new Error("Workspace changed; preview again")
261
+ const configDirectory=join(preview.root,".opencode")
262
+ await mkdir(configDirectory,{recursive:true})
263
+ const directoryInfo=await lstat(configDirectory)
264
+ if(!directoryInfo.isDirectory()||directoryInfo.isSymbolicLink()){throw new Error("Project configuration directory must not be a symlink")
265
+ }
266
+ if(await readProject(preview.file)!==preview.before)
267
+ throw new Error("Project settings changed; preview again")
268
+ const temporary=`${preview.file}.${randomUUID()}.tmp`
269
+ try{await writeFile(temporary,preview.after,{flag:"wx",mode:384})
270
+ if(await readProject(preview.file)!==preview.before)
271
+ throw new Error("Project settings changed; preview again")
272
+ await rename(temporary,preview.file)
273
+ } finally{await unlink(temporary).catch((error)=>{if(error.code!=="ENOENT")
274
+ throw error
275
+ })
276
+ }
277
+ return preview.file
278
+ }
279
+ export{previewProjectProfile,saveProjectProfile
280
+ }