@davait/aipictury 1.9.2

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.
Files changed (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
package/ext/paths.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict'
2
+ // Where the agent may work: inside the workspace folder, and nowhere else.
3
+
4
+ const vscode = require('vscode')
5
+ const path = require('path')
6
+
7
+ /** The workspace folder everything is resolved against: the one holding the file the
8
+ * user is working in, when several are open (a multi-root workspace), else the first. */
9
+ function root() {
10
+ const folders = vscode.workspace.workspaceFolders
11
+ if (!folders || !folders.length) throw new Error('no folder is open in this window')
12
+ const active = vscode.window.activeTextEditor?.document?.uri
13
+ const holding = active && vscode.workspace.getWorkspaceFolder(active)
14
+ return (holding || folders[0]).uri
15
+ }
16
+
17
+ // More folders the agent may work in, besides the workspace (the CLI's --add-dir): absolute.
18
+ const extra = []
19
+ function addRoots(dirs) {
20
+ for (const d of dirs || []) {
21
+ const abs = path.resolve(String(d))
22
+ if (!extra.includes(abs)) extra.push(abs)
23
+ }
24
+ }
25
+ const underExtra = (abs) => extra.find((r) => abs === r || abs.startsWith(r + path.sep))
26
+
27
+ /** Resolves a path from the model, refusing anything outside the workspace (and the folders
28
+ * added with --add-dir). The model is not malicious, but it is wrong often enough to matter. */
29
+ function resolve(relative) {
30
+ const base = root()
31
+ let given = String(relative || '.').trim()
32
+ // An added folder is named by its absolute path (the model is told which ones there are).
33
+ if (path.isAbsolute(given) && underExtra(path.resolve(given))) return vscode.Uri.file(path.resolve(given))
34
+ // A model often hands back the absolute path it was shown: inside the workspace, fine.
35
+ if (given.startsWith(`${base.fsPath}${path.sep}`) || given === base.fsPath) given = path.relative(base.fsPath, given) || '.'
36
+ // Any other absolute path is refused rather than quietly read as a relative one: the
37
+ // model asking for /etc/passwd should be told no, not handed <workspace>/etc/passwd.
38
+ if (given.startsWith('/') || given.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(given)) {
39
+ throw new Error(`${given} is an absolute path outside the workspace; use a path relative to the workspace root`)
40
+ }
41
+ const target = vscode.Uri.joinPath(base, given)
42
+ const inside = target.fsPath === base.fsPath || target.fsPath.startsWith(base.fsPath + path.sep)
43
+ if (!inside) throw new Error(`${relative} is outside the workspace`)
44
+ return target
45
+ }
46
+
47
+ /** A path as the model and the user see it: relative to the root (absolute in an added folder). */
48
+ const rel = (uri) => (underExtra(uri.fsPath) ? uri.fsPath : path.relative(root().fsPath, uri.fsPath) || '.')
49
+
50
+ module.exports = { root, resolve, rel, addRoots, extraRoots: () => [...extra] }
@@ -0,0 +1,229 @@
1
+ 'use strict'
2
+ // Permission rules — allow, ask, deny — in Claude Code's syntax, from the settings files
3
+ // (prefs.js: yours, the project's, your local one, and Claude Code's own, read-only):
4
+ //
5
+ // Bash every command Read(./.env) one file (read tools)
6
+ // Bash(npm run test:*) a command and its args Edit(src/**) files the agent may change
7
+ // Bash(git commit *) a glob WebFetch(domain:github.com)
8
+ // WebSearch mcp__github, mcp__github__create_issue
9
+ // Browser(domain:github.com) what the browser may open and act on
10
+ //
11
+ // Our own tool names work too (run_command(npm test), read_file(docs/**)). Deny beats ask
12
+ // beats allow. A command with several parts (a && b, a | b, a; b) is allowed only when every
13
+ // part is, and denied or asked when any part is — "git status && rm -rf x" is not "git status".
14
+ // No VS Code here: the rules are checked the same way in tests and, later, in the CLI.
15
+ const os = require('os')
16
+ const path = require('path')
17
+ const prefs = require('./prefs')
18
+
19
+ // Which family a tool belongs to, and what its rule's (…) is matched against.
20
+ const FAMILY = {
21
+ run_command: 'Bash', monitor: 'Bash', powershell: 'PowerShell',
22
+ read_file: 'Read', list_files: 'Read', grep: 'Read', glob: 'Read', search: 'Read', code_intel: 'Read',
23
+ write_file: 'Edit', edit_file: 'Edit', notebook_edit: 'Edit',
24
+ fetch_url: 'WebFetch', web_search: 'WebSearch', task: 'Task', generate_media: 'Media', browser: 'Browser',
25
+ }
26
+ // Names a rule may use for a family: Claude Code's and ours.
27
+ const ALIASES = {
28
+ Bash: ['Bash', 'run_command'],
29
+ PowerShell: ['PowerShell', 'powershell'],
30
+ Read: ['Read', 'Grep', 'Glob', 'LS', 'read_file', 'list_files', 'grep', 'glob', 'search', 'code_intel'],
31
+ Edit: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit_file', 'notebook_edit'],
32
+ WebFetch: ['WebFetch', 'fetch_url'],
33
+ WebSearch: ['WebSearch', 'web_search'],
34
+ Task: ['Task', 'task'],
35
+ Media: ['Media', 'generate_media'],
36
+ Browser: ['Browser', 'browser'],
37
+ }
38
+
39
+ /** "Bash(npm run test:*)" → { tool: 'Bash', spec: 'npm run test:*' }; a bare name has no spec. */
40
+ function parseRule(text) {
41
+ const m = /^\s*([\w.*-]+)\s*(?:\(([\s\S]*)\))?\s*$/.exec(String(text || ''))
42
+ if (!m) return null
43
+ return { tool: m[1], spec: m[2] === undefined ? null : m[2].trim() }
44
+ }
45
+
46
+ /** A glob as a regular expression: ** any path, * within a segment, ? one character, {a,b}. */
47
+ function globToRegExp(glob, { anyChar = false } = {}) {
48
+ let out = ''
49
+ for (let i = 0; i < glob.length; i += 1) {
50
+ const c = glob[i]
51
+ if (c === '*') {
52
+ if (glob[i + 1] === '*') {
53
+ i += 1
54
+ if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?' } else out += '.*'
55
+ } else out += anyChar ? '.*' : '[^/]*'
56
+ } else if (c === '?') out += anyChar ? '.' : '[^/]'
57
+ else if (c === '{') {
58
+ const end = glob.indexOf('}', i)
59
+ if (end < 0) { out += '\\{'; continue }
60
+ out += `(?:${glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')).join('|')})`
61
+ i = end
62
+ } else out += c.replace(/[.+^$()|[\]\\{}]/g, '\\$&')
63
+ }
64
+ return new RegExp(`^${out}$`)
65
+ }
66
+
67
+ /** The parts of a shell command that run on their own: split at &&, ||, ;, | and new lines,
68
+ * outside quotes. `$( … )` and backticks count as a part too. */
69
+ function commandParts(command) {
70
+ const text = String(command || '')
71
+ const parts = []
72
+ let cur = ''
73
+ let quote = null
74
+ for (let i = 0; i < text.length; i += 1) {
75
+ const c = text[i]
76
+ if (quote) {
77
+ if (c === quote) quote = null
78
+ else if (c === '\\' && quote === '"') { cur += c + (text[i + 1] || ''); i += 1; continue }
79
+ cur += c
80
+ continue
81
+ }
82
+ if (c === "'" || c === '"') { quote = c; cur += c; continue }
83
+ if (c === '\\') { cur += c + (text[i + 1] || ''); i += 1; continue }
84
+ const two = text.slice(i, i + 2)
85
+ if (two === '&&' || two === '||') { parts.push(cur); cur = ''; i += 1; continue }
86
+ // & runs in the background — unless it is part of a redirection (2>&1, &>file, <&3).
87
+ const redirect = c === '&' && (text[i - 1] === '>' || text[i - 1] === '<' || text[i + 1] === '>')
88
+ if (c === ';' || c === '|' || c === '\n' || (c === '&' && !redirect)) { parts.push(cur); cur = ''; continue }
89
+ cur += c
90
+ }
91
+ parts.push(cur)
92
+ // Substitutions run too: what is inside $( ) and ` ` is judged as a command of its own.
93
+ const inner = []
94
+ for (const m of text.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)) inner.push(m[1] ?? m[2])
95
+ return [...parts, ...inner.flatMap((c) => commandParts(c))]
96
+ .map((p) => p.trim().replace(/\s+/g, ' ')).filter(Boolean)
97
+ }
98
+
99
+ /** Does a Bash rule's (…) cover one command? "x:*" is x and anything after it; a * is a glob. */
100
+ function commandMatches(spec, command) {
101
+ const want = spec.trim().replace(/\s+/g, ' ')
102
+ if (want.endsWith(':*')) {
103
+ const prefix = want.slice(0, -2).trim()
104
+ return command === prefix || command.startsWith(`${prefix} `)
105
+ }
106
+ if (want.includes('*')) return globToRegExp(want, { anyChar: true }).test(command)
107
+ return command === want
108
+ }
109
+
110
+ /** Where a path rule points: //abs, ~/home, and anything else from the workspace root. */
111
+ function rulePath(spec, root) {
112
+ if (spec.startsWith('//')) return spec.slice(1)
113
+ if (spec === '~' || spec.startsWith('~/')) return path.join(os.homedir(), spec.slice(1))
114
+ return path.join(root || '/', spec.replace(/^\.?\//, ''))
115
+ }
116
+
117
+ function pathMatches(spec, file, root) {
118
+ const target = path.resolve(root || '/', file || '.')
119
+ const pattern = rulePath(spec, root)
120
+ if (!/[*?{]/.test(pattern)) return target === path.resolve(pattern) || target.startsWith(`${path.resolve(pattern)}${path.sep}`)
121
+ // dir/** is the folder too: grep or list on it reads what is inside.
122
+ if (pattern.endsWith(`${path.sep}**`) && target === path.resolve(pattern.slice(0, -3))) return true
123
+ return globToRegExp(pattern).test(target)
124
+ }
125
+
126
+ function hostOf(url) { try { return new URL(url).hostname.toLowerCase() } catch { return '' } }
127
+
128
+ /** What a call is, for the rules: its family and what the (…) is matched against. */
129
+ function subjectOf(name, args = {}) {
130
+ const family = String(name).startsWith('mcp__') ? 'mcp' : FAMILY[name] || name
131
+ switch (family) {
132
+ case 'Bash': case 'PowerShell': return { family, commands: commandParts(args.command) }
133
+ case 'Read': return { family, file: args.path || args.pattern_path || '.' }
134
+ case 'Edit': return { family, file: args.path }
135
+ case 'WebFetch': return { family, host: hostOf(args.url) }
136
+ case 'Browser': return { family, host: hostOf(args.page || args.url) } // the site it goes to, or acts on
137
+ case 'Media': return { family, file: args.save_to }
138
+ case 'Task': return { family, value: args.subagent_type || 'explore' }
139
+ default: return { family, value: null }
140
+ }
141
+ }
142
+
143
+ /** Does one rule cover one call (for Bash: one part of the command)? */
144
+ function covers(rule, name, subject, root, command) {
145
+ if (!rule) return false
146
+ if (subject.family === 'mcp') {
147
+ const tool = rule.tool
148
+ return !rule.spec && tool.startsWith('mcp__')
149
+ && (name === tool || name.startsWith(`${tool.replace(/__\*$/, '')}__`))
150
+ }
151
+ const names = ALIASES[subject.family] || [subject.family]
152
+ if (!names.includes(rule.tool)) return false
153
+ if (rule.spec === null || rule.spec === '' || rule.spec === '*') return true
154
+ switch (subject.family) {
155
+ case 'Bash': case 'PowerShell': return commandMatches(rule.spec, command)
156
+ case 'Read': case 'Edit': case 'Media': return !!subject.file && pathMatches(rule.spec, subject.file, root)
157
+ case 'WebFetch': case 'Browser': {
158
+ const domain = rule.spec.replace(/^domain:/, '').toLowerCase()
159
+ return !!subject.host && (subject.host === domain || subject.host.endsWith(`.${domain}`))
160
+ }
161
+ default: return subject.value !== null && rule.spec === subject.value
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The rules' say on a call: { decision: 'deny' | 'ask' | 'allow' | null, rule, level, file }.
167
+ * null: no rule speaks of it, and the mode decides. `rules` defaults to the settings files.
168
+ */
169
+ function decide(name, args, { folder, rules, extraAllow, extraDeny } = {}) {
170
+ let list = rules || prefs.load(folder).merged.permissions
171
+ // This run's own denials (the CLI's --disallowedTools): above every allow.
172
+ if (extraDeny?.length) list = { ...list, deny: [...(list.deny || []), ...extraDeny.map((rule) => ({ rule, level: 'session', file: '(--disallowedTools)' }))] }
173
+ // A slash command's allowed-tools, while it runs: allow rules on top of the files' (never above a deny).
174
+ if (extraAllow?.length) list = { ...list, allow: [...(list.allow || []), ...extraAllow.map((rule) => ({ rule, level: 'command', file: '(the command\'s allowed-tools)' }))] }
175
+ const subject = subjectOf(name, args)
176
+ const parsed = (kind) => (list[kind] || []).map((r) => ({ ...r, parsed: parseRule(typeof r === 'string' ? r : r.rule),
177
+ rule: typeof r === 'string' ? r : r.rule }))
178
+ const hit = (kind, command) => parsed(kind).find((r) => covers(r.parsed, name, subject, folder, command))
179
+ const say = (decision, r) => ({ decision, rule: r.rule, level: r.level, file: r.file })
180
+ if (subject.family === 'Bash' || subject.family === 'PowerShell') {
181
+ const parts = subject.commands.length ? subject.commands : ['']
182
+ for (const kind of ['deny', 'ask']) {
183
+ for (const part of parts) { const r = hit(kind, part); if (r) return say(kind, r) }
184
+ }
185
+ const allowed = parts.map((part) => hit('allow', part))
186
+ if (allowed.every(Boolean)) return say('allow', allowed[0])
187
+ return { decision: null }
188
+ }
189
+ for (const kind of ['deny', 'ask', 'allow']) {
190
+ const r = hit(kind)
191
+ if (r) return say(kind, r)
192
+ }
193
+ return { decision: null }
194
+ }
195
+
196
+ /** The rule a standing yes saves: the command's prefix, the MCP tool, or the tool itself. */
197
+ function ruleFor(name, args, prefix) {
198
+ if (FAMILY[name] === 'Bash' || FAMILY[name] === 'PowerShell') return `${FAMILY[name]}(${prefix || String(args.command || '').trim()}:*)`
199
+ if (String(name).startsWith('mcp__')) return name
200
+ if (FAMILY[name] === 'WebFetch') return `WebFetch(domain:${hostOf(args.url)})`
201
+ if (FAMILY[name] === 'Browser') return `Browser(domain:${hostOf(args.page || args.url)})`
202
+ return FAMILY[name] || name
203
+ }
204
+
205
+ /** Where a standing yes can go: this conversation, or a settings file. */
206
+ const DESTINATIONS = [
207
+ { id: 'conversation', label: 'in this conversation' },
208
+ { id: 'local', label: 'in this project, just for you' },
209
+ { id: 'project', label: 'in this project, for everyone' },
210
+ { id: 'user', label: 'everywhere, just for you' },
211
+ ]
212
+
213
+ /** Saves a standing yes in a settings file (never Claude Code's). Returns the file. */
214
+ function remember(folder, destination, rule) {
215
+ if (destination === 'conversation') return null
216
+ return prefs.addRule(folder, destination, 'allow', rule)
217
+ }
218
+
219
+ /** The tools a bare rule names ("Bash", "Edit", "WebFetch", an mcp__ tool): what a run without them is not offered. */
220
+ function toolsNamed(rule) {
221
+ const r = parseRule(rule)
222
+ if (r.spec) return []
223
+ const family = Object.entries(ALIASES).find(([, names]) => names.includes(r.tool))?.[0]
224
+ if (!family) return [r.tool]
225
+ return Object.entries(FAMILY).filter(([, f]) => f === family).map(([tool]) => tool)
226
+ }
227
+
228
+ module.exports = { toolsNamed, parseRule, globToRegExp, commandParts, commandMatches, pathMatches, subjectOf, decide, ruleFor,
229
+ remember, DESTINATIONS, FAMILY }
package/ext/plans.js ADDED
@@ -0,0 +1,110 @@
1
+ 'use strict'
2
+ // The plan as a document, as in Claude Code: when the agent presents a plan, it can be opened
3
+ // as a Markdown file (kept in ~/.aipictury/plans/<project>/, never in the workspace), edited,
4
+ // and commented on line by line with VS Code's comments. "Revise" sends the comments — each
5
+ // with the line it is on — and any edits back to the agent; "Approve" chooses the mode to
6
+ // carry it out in, with the plan as edited.
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const vscode = require('vscode')
10
+ const { home, projectKey } = require('./sessions')
11
+
12
+ const MODES = [
13
+ { id: 'edits', label: 'Approve — and edit automatically', detail: 'File changes go ahead; commands are asked' },
14
+ { id: 'ask', label: 'Approve — and ask before each change', detail: 'Manual mode' },
15
+ { id: 'never', label: 'Approve — in Auto mode', detail: 'Routine work goes ahead; anything risky is asked' },
16
+ ]
17
+
18
+ function planFile(folder, id) {
19
+ return path.join(home(), 'plans', projectKey(folder || 'no-folder'), `${String(id).replace(/[^\w-]/g, '')}.md`)
20
+ }
21
+
22
+ function register(context, registry) {
23
+ const controller = vscode.comments.createCommentController('aipictury.plan', 'Plan comments')
24
+ controller.options = { prompt: 'Comment on this part of the plan…', placeHolder: 'What should change here?' }
25
+ const threads = new Map() // plan file → its comment threads
26
+ context.subscriptions.push(controller)
27
+
28
+ /** The panel whose pending plan is this file. */
29
+ const owner = (file) => registry.all().find((p) => p.pendingPlan?.file === file)
30
+ controller.commentingRangeProvider = {
31
+ provideCommentingRanges: (doc) => (owner(doc.uri.fsPath) ? [new vscode.Range(0, 0, Math.max(0, doc.lineCount - 1), 0)] : []),
32
+ }
33
+
34
+ /** Adds a comment at a line (the gutter's "+", or a test). */
35
+ function addNote(uri, line, text, thread) {
36
+ const t = thread || controller.createCommentThread(uri, new vscode.Range(line, 0, line, 0), [])
37
+ t.comments = [...t.comments, { body: new vscode.MarkdownString(String(text)), mode: vscode.CommentMode.Preview,
38
+ author: { name: 'You' } }]
39
+ t.canReply = true
40
+ t.contextValue = 'note'
41
+ const list = threads.get(uri.fsPath) || []
42
+ if (!list.includes(t)) list.push(t)
43
+ threads.set(uri.fsPath, list)
44
+ return t
45
+ }
46
+
47
+ /** Every comment, with the line it is on as it reads now — for the agent. */
48
+ function notes(doc) {
49
+ return (threads.get(doc.uri.fsPath) || []).flatMap((t) => t.comments.map((c) => {
50
+ const line = t.range.start.line
51
+ const text = doc.lineAt(Math.min(line, doc.lineCount - 1)).text.trim()
52
+ return `- Line ${line + 1}${text ? ` («${text.slice(0, 120)}»)` : ''}: ${typeof c.body === 'string' ? c.body : c.body.value}`
53
+ }))
54
+ }
55
+
56
+ function clear(file) {
57
+ for (const t of threads.get(file) || []) t.dispose()
58
+ threads.delete(file)
59
+ }
60
+
61
+ /** Opens a panel's pending plan as a document (written once, then edited in place). */
62
+ async function open(panel) {
63
+ const pending = panel?.pendingPlan
64
+ if (!pending) return null
65
+ if (!pending.file) {
66
+ pending.file = planFile(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, `${panel.id || 'plan'}-${pending.id}`)
67
+ fs.mkdirSync(path.dirname(pending.file), { recursive: true })
68
+ fs.writeFileSync(pending.file, `${pending.plan.trim()}\n`)
69
+ }
70
+ const doc = await vscode.workspace.openTextDocument(pending.file)
71
+ pending.doc = doc
72
+ await vscode.window.showTextDocument(doc, { preview: false, viewColumn: vscode.ViewColumn.Beside })
73
+ return doc
74
+ }
75
+
76
+ /** Answers the plan from its document: approved in a mode, or sent back with the comments. */
77
+ async function answer(uri, approved, mode) {
78
+ const file = uri?.fsPath || vscode.window.activeTextEditor?.document.uri.fsPath
79
+ const panel = file && owner(file)
80
+ if (!panel) { vscode.window.showInformationMessage('No plan is waiting for an answer in this file.'); return false }
81
+ const doc = vscode.workspace.textDocuments.find((d) => d.uri.fsPath === file)
82
+ if (doc?.isDirty) await doc.save()
83
+ if (approved && !mode) {
84
+ const pick = await vscode.window.showQuickPick(MODES.map((m) => ({ ...m, description: '' })), { title: 'Approve the plan' })
85
+ if (!pick) return false
86
+ mode = pick.id
87
+ }
88
+ const comments = doc ? notes(doc) : []
89
+ const feedback = approved ? '' : comments.length
90
+ ? `Comments on the plan:\n${comments.join('\n')}` : 'Revise the plan (see the edited version).'
91
+ const id = panel.pendingPlan.id
92
+ panel.post({ type: 'planClosed', id })
93
+ await panel.onMessage({ type: 'planAnswer', id, approved, mode, feedback })
94
+ clear(file)
95
+ return true
96
+ }
97
+
98
+ const add = (d) => context.subscriptions.push(d)
99
+ add(vscode.commands.registerCommand('aipictury.plan.open', () => open(registry.active())))
100
+ add(vscode.commands.registerCommand('aipictury.plan.approve', (uri, mode) => answer(uri, true, mode)))
101
+ add(vscode.commands.registerCommand('aipictury.plan.revise', (uri) => answer(uri, false)))
102
+ add(vscode.commands.registerCommand('aipictury.plan.addNote', (reply) => addNote(reply.thread.uri, reply.thread.range.start.line, reply.text, reply.thread)))
103
+ add(vscode.commands.registerCommand('aipictury.plan.deleteNote', (thread) => {
104
+ for (const [file, list] of threads) threads.set(file, list.filter((t) => t !== thread))
105
+ thread?.dispose()
106
+ }))
107
+ return { open, addNote, notes, answer, planFile }
108
+ }
109
+
110
+ module.exports = { register, planFile, MODES }
package/ext/plugins.js ADDED
@@ -0,0 +1,243 @@
1
+ 'use strict'
2
+ // Plugins in Claude Code's format: a folder with .claude-plugin/plugin.json and, beside it,
3
+ // commands/ (slash commands, as /plugin:command), agents/ (helper agents), skills/,
4
+ // output-styles/, hooks/hooks.json and .mcp.json. Marketplaces in Claude Code's format too
5
+ // (.claude-plugin/marketplace.json): from GitHub (owner/repo), a git URL or a folder. So the
6
+ // plugins written for Claude Code install and work here.
7
+ //
8
+ // ~/.aipictury/plugins/known_marketplaces.json the marketplaces added
9
+ // ~/.aipictury/plugins/marketplaces/<name>/ their files
10
+ // ~/.aipictury/plugins/installed.json what is installed, and where
11
+ // ~/.aipictury/plugins/cache/<market>/<plugin>/ each plugin's files
12
+ // Whether one is on is the settings files' "enabledPlugins": { "name@market": true }, at a
13
+ // level (you, the project, you in the project). Plugins installed by Claude Code itself and
14
+ // enabled in its settings are read too, never changed.
15
+ const fs = require('fs')
16
+ const os = require('os')
17
+ const path = require('path')
18
+ const { execFile } = require('child_process')
19
+ const { home } = require('./sessions')
20
+ const prefs = require('./prefs')
21
+
22
+ const root = () => path.join(home(), 'plugins')
23
+ const readJson = (file, fallback = null) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback } }
24
+ const writeJson = (file, data) => {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true })
26
+ const tmp = `${file}.${process.pid}.tmp`
27
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`)
28
+ fs.renameSync(tmp, file)
29
+ }
30
+ const knownFile = () => path.join(root(), 'known_marketplaces.json')
31
+ const installedFile = () => path.join(root(), 'installed.json')
32
+ const NAME = /^[\w.-]{1,64}$/
33
+
34
+ // Asynchronous: a clone takes seconds to minutes, and a synchronous one would freeze every
35
+ // extension of the window (they share one extension host) while it runs.
36
+ function git(args, cwd, timeout = 120_000) {
37
+ return new Promise((resolve, reject) => {
38
+ execFile('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } },
39
+ (error, stdout, stderr) => {
40
+ if (error) reject(new Error(`git ${args[0]} failed: ${String(stderr || error.message).trim().split('\n').pop()}`, { cause: error }))
41
+ else resolve(stdout)
42
+ })
43
+ })
44
+ }
45
+
46
+ /** "owner/repo" → a GitHub URL; a URL as it is; anything else is a folder. */
47
+ function sourceOf(text) {
48
+ const t = String(text || '').trim()
49
+ if (/^[\w.-]+\/[\w.-]+$/.test(t) && !fs.existsSync(t)) return { kind: 'git', url: `https://github.com/${t}.git`, label: t }
50
+ if (/^(https?:\/\/|git@|ssh:\/\/)/.test(t)) return { kind: 'git', url: t, label: t }
51
+ const dir = path.resolve(t.replace(/^~(?=\/|$)/, os.homedir()))
52
+ if (!fs.existsSync(dir)) throw new Error(`no such marketplace: ${t} (owner/repo, a git URL, or a folder)`)
53
+ return { kind: 'dir', dir, label: dir }
54
+ }
55
+
56
+ /** A clone (or a copy) of `source` in a fresh folder; optionally a ref (branch, tag) or commit. */
57
+ async function fetchInto(source, dest, { ref, sha } = {}) {
58
+ fs.rmSync(dest, { recursive: true, force: true })
59
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
60
+ if (source.kind === 'dir') { fs.cpSync(source.dir, dest, { recursive: true, filter: (p) => !/[\\/]\.git([\\/]|$)/.test(p) }); return }
61
+ if (sha) {
62
+ await git(['clone', '--filter=blob:none', '--no-checkout', source.url, dest])
63
+ await git(['checkout', '--quiet', sha], dest)
64
+ } else {
65
+ await git(['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), source.url, dest])
66
+ }
67
+ fs.rmSync(path.join(dest, '.git'), { recursive: true, force: true })
68
+ }
69
+
70
+ // ---- marketplaces ----
71
+
72
+ function marketplaces() { return readJson(knownFile(), {}) }
73
+
74
+ /** Adds a marketplace (GitHub owner/repo, a git URL or a folder). Returns its name and plugins. */
75
+ async function addMarketplace(text) {
76
+ const source = sourceOf(text)
77
+ const tmp = path.join(root(), 'marketplaces', `.adding-${process.pid}-${Date.now()}`)
78
+ try {
79
+ await fetchInto(source, tmp)
80
+ const manifest = readJson(path.join(tmp, '.claude-plugin', 'marketplace.json'))
81
+ if (!manifest?.name || !Array.isArray(manifest.plugins)) throw new Error('that is not a marketplace: no .claude-plugin/marketplace.json with a name and plugins')
82
+ if (!NAME.test(manifest.name)) throw new Error(`a marketplace name that cannot be used: ${manifest.name}`)
83
+ const dest = path.join(root(), 'marketplaces', manifest.name)
84
+ fs.rmSync(dest, { recursive: true, force: true })
85
+ fs.renameSync(tmp, dest)
86
+ const known = marketplaces()
87
+ known[manifest.name] = { source: source.kind === 'dir' ? { dir: source.dir } : { url: source.url }, label: source.label, path: dest, updated: Date.now() }
88
+ writeJson(knownFile(), known)
89
+ return { name: manifest.name, plugins: manifest.plugins.length }
90
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
91
+ }
92
+
93
+ async function updateMarketplace(name) {
94
+ const m = marketplaces()[name]
95
+ if (!m) throw new Error(`no marketplace ${name}`)
96
+ return addMarketplace(m.source.dir || m.source.url)
97
+ }
98
+
99
+ function removeMarketplace(name) {
100
+ const known = marketplaces()
101
+ if (!known[name]) throw new Error(`no marketplace ${name}`)
102
+ for (const id of Object.keys(installed())) if (id.endsWith(`@${name}`)) uninstall(id)
103
+ fs.rmSync(known[name].path, { recursive: true, force: true })
104
+ delete known[name]
105
+ writeJson(knownFile(), known)
106
+ }
107
+
108
+ /** Every plugin every marketplace offers: { id, name, market, description, version, installed }. */
109
+ function available() {
110
+ const have = installed()
111
+ const out = []
112
+ for (const [market, m] of Object.entries(marketplaces())) {
113
+ const manifest = readJson(path.join(m.path, '.claude-plugin', 'marketplace.json'), { plugins: [] })
114
+ for (const p of manifest.plugins || []) {
115
+ const id = `${p.name}@${market}`
116
+ out.push({ id, name: p.name, market, description: p.description || '', version: p.version || '', category: p.category, installed: !!have[id], entry: p })
117
+ }
118
+ }
119
+ return out
120
+ }
121
+
122
+ // ---- installing ----
123
+
124
+ function installed() { return readJson(installedFile(), {}) }
125
+
126
+ /** Where a plugin's files come from, as a marketplace entry says. */
127
+ function resolveSource(entry, marketDir) {
128
+ const src = entry.source
129
+ if (typeof src === 'string') {
130
+ const dir = path.resolve(marketDir, src)
131
+ if (!dir.startsWith(marketDir)) throw new Error(`${entry.name}: a source outside its marketplace`)
132
+ return { source: { kind: 'dir', dir } }
133
+ }
134
+ if (src?.source === 'github' && src.repo) return { source: { kind: 'git', url: `https://github.com/${src.repo}.git` }, ref: src.ref, sha: src.sha, sub: src.path }
135
+ if ((src?.source === 'url' || src?.source === 'git') && src.url) return { source: { kind: 'git', url: src.url }, ref: src.ref, sha: src.sha, sub: src.path }
136
+ if (src?.source === 'git-subdir' && src.url) return { source: { kind: 'git', url: src.url }, ref: src.ref, sha: src.sha, sub: src.path }
137
+ throw new Error(`${entry.name}: a kind of source not supported here (${src?.source || typeof src})`)
138
+ }
139
+
140
+ /** Installs name@market, and turns it on at a level ('user', 'project' or 'local'). */
141
+ async function install(id, { folder, level = 'user' } = {}) {
142
+ const [name, market] = String(id).split('@')
143
+ const m = marketplaces()[market]
144
+ if (!m) throw new Error(`no marketplace ${market} — add it first`)
145
+ const entry = available().find((p) => p.id === id)?.entry
146
+ if (!entry) throw new Error(`${market} has no plugin ${name}`)
147
+ const how = resolveSource(entry, m.path)
148
+ const dest = path.join(root(), 'cache', market, name)
149
+ if (how.sub) {
150
+ const tmp = `${dest}.fetch-${process.pid}`
151
+ try {
152
+ await fetchInto(how.source, tmp, how)
153
+ const dir = path.resolve(tmp, how.sub)
154
+ if (!dir.startsWith(tmp) || !fs.existsSync(dir)) throw new Error(`${name}: ${how.sub} is not in its repository`)
155
+ fs.rmSync(dest, { recursive: true, force: true })
156
+ fs.cpSync(dir, dest, { recursive: true })
157
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
158
+ } else {
159
+ await fetchInto(how.source, dest, how)
160
+ }
161
+ const manifest = readJson(path.join(dest, '.claude-plugin', 'plugin.json'), {})
162
+ const list = installed()
163
+ list[id] = { path: dest, version: manifest.version || entry.version || '', description: manifest.description || entry.description || '', installedAt: Date.now() }
164
+ writeJson(installedFile(), list)
165
+ setEnabled(id, true, { folder, level })
166
+ return { id, path: dest, version: list[id].version }
167
+ }
168
+
169
+ function uninstall(id, { folder } = {}) {
170
+ const list = installed()
171
+ if (!list[id]) throw new Error(`${id} is not installed`)
172
+ fs.rmSync(list[id].path, { recursive: true, force: true })
173
+ delete list[id]
174
+ writeJson(installedFile(), list)
175
+ for (const level of ['user', 'project', 'local']) {
176
+ try { prefs.edit(folder, level, (data) => { if (data.enabledPlugins) { delete data.enabledPlugins[id]; if (!Object.keys(data.enabledPlugins).length) delete data.enabledPlugins } }) } catch { /* no such level here */ }
177
+ }
178
+ }
179
+
180
+ function setEnabled(id, on, { folder, level = 'user' } = {}) {
181
+ return prefs.edit(folder, level, (data) => { data.enabledPlugins = { ...(data.enabledPlugins || {}), [id]: !!on } })
182
+ }
183
+
184
+ /** Plugins Claude Code installed itself (read-only): id → install path. */
185
+ function claudeInstalled() {
186
+ const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
187
+ const data = readJson(path.join(dir, 'plugins', 'installed_plugins.json'), {})
188
+ const out = {}
189
+ for (const [id, v] of Object.entries(data.plugins || data || {})) {
190
+ const entry = Array.isArray(v) ? v[0] : v
191
+ if (entry?.installPath && fs.existsSync(entry.installPath)) out[id] = entry.installPath
192
+ }
193
+ return out
194
+ }
195
+
196
+ /** The plugins that are on here: [{ id, name, root, fromClaudeCode }]. */
197
+ function enabled(folder) {
198
+ const on = prefs.load(folder).merged.enabledPlugins || {}
199
+ const ours = installed()
200
+ const theirs = claudeInstalled()
201
+ const out = []
202
+ for (const [id, yes] of Object.entries(on)) {
203
+ if (yes !== true) continue
204
+ const dir = ours[id]?.path || theirs[id]
205
+ if (dir && fs.existsSync(dir)) out.push({ id, name: id.split('@')[0], root: dir, fromClaudeCode: !ours[id] })
206
+ }
207
+ return out
208
+ }
209
+
210
+ // ---- what plugins bring ----
211
+
212
+ const withRoot = (value, dir) => JSON.parse(JSON.stringify(value).replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, dir.replace(/\\/g, '\\\\')))
213
+
214
+ /** The folders of one kind (commands, agents, skills, output-styles) of the plugins that are on. */
215
+ function dirs(folder, kind) {
216
+ return enabled(folder).map((p) => ({ dir: path.join(p.root, kind), plugin: p.name })).filter((d) => fs.existsSync(d.dir))
217
+ }
218
+
219
+ /** Their hooks, as the settings files give them (event → groups), ${CLAUDE_PLUGIN_ROOT} filled in. */
220
+ function hooks(folder) {
221
+ const out = {}
222
+ for (const p of enabled(folder)) {
223
+ const data = readJson(path.join(p.root, 'hooks', 'hooks.json'))
224
+ for (const [event, groups] of Object.entries(data?.hooks || {})) {
225
+ for (const g of groups || []) (out[event] ||= []).push({ ...withRoot(g, p.root), level: 'plugin', file: path.join(p.root, 'hooks', 'hooks.json'), editable: false, plugin: p.name })
226
+ }
227
+ }
228
+ return out
229
+ }
230
+
231
+ /** Their MCP servers: name → config, ${CLAUDE_PLUGIN_ROOT} filled in. */
232
+ function mcpServers(folder) {
233
+ const out = {}
234
+ for (const p of enabled(folder)) {
235
+ const manifest = readJson(path.join(p.root, '.claude-plugin', 'plugin.json'), {})
236
+ const servers = readJson(path.join(p.root, '.mcp.json'))?.mcpServers || (typeof manifest.mcpServers === 'object' ? manifest.mcpServers : {})
237
+ for (const [name, config] of Object.entries(servers)) out[name] = { ...withRoot(config, p.root), plugin: p.name }
238
+ }
239
+ return out
240
+ }
241
+
242
+ module.exports = { marketplaces, addMarketplace, updateMarketplace, removeMarketplace, available, installed, install, uninstall,
243
+ setEnabled, enabled, dirs, hooks, mcpServers, sourceOf, root }