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.
- package/CHANGELOG.md +13 -0
- package/README.md +107 -3
- package/dist/{chunk-r37n7w97.js → chunk-c1kw6ktv.js} +3 -4
- package/dist/chunk-cmmnfes2.js +7 -0
- package/dist/layout-preset-actions.js +438 -0
- package/dist/mcp-groups.js +4 -4
- package/dist/project-profile.js +280 -0
- package/dist/provider-limits.js +739 -0
- package/dist/quick-actions-settings.js +2 -5
- package/dist/settings-import.js +40 -0
- package/dist/skill-groups.js +4 -4
- package/dist/trusted-skills.js +22 -0
- package/dist/tui.js +389 -395
- package/package.json +4 -2
- package/scripts/save-project-profile.mjs +41 -0
|
@@ -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
|
+
}
|