@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.
- package/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/main.js
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
// aipictury — the AIPictury agent in a terminal, as Claude Code's `claude`: the extension's own
|
|
4
|
+
// agent (its tools, permission rules, hooks, MCP servers, skills, sandbox), on your AIPictury
|
|
5
|
+
// server's model, working in the folder it is started in. Interactive, or one prompt with -p.
|
|
6
|
+
// Conversations are the extension's too: one started here can be carried on in VS Code, and the
|
|
7
|
+
// other way round (--continue, --resume).
|
|
8
|
+
const Module = require('module')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const os = require('os')
|
|
12
|
+
const readline = require('readline')
|
|
13
|
+
|
|
14
|
+
// ---- `vscode` is the stand-in, for every module of the agent ----
|
|
15
|
+
const SHIM = require.resolve('./vscode-shim.js')
|
|
16
|
+
const resolveFilename = Module._resolveFilename
|
|
17
|
+
Module._resolveFilename = function (request, ...rest) { return request === 'vscode' ? SHIM : resolveFilename.call(this, request, ...rest) }
|
|
18
|
+
const vscode = require('./vscode-shim.js')
|
|
19
|
+
|
|
20
|
+
// The agent's modules: packed beside the CLI (npm: ext/, see pack.mjs), or the extension's own
|
|
21
|
+
// folder when run from the repository.
|
|
22
|
+
const EXT = fs.existsSync(path.join(__dirname, 'ext', 'agent.js')) ? path.join(__dirname, 'ext') : path.join(__dirname, '..', 'vscode')
|
|
23
|
+
const pkg = require(path.join(EXT, 'package.json'))
|
|
24
|
+
const VERSION = pkg.version
|
|
25
|
+
|
|
26
|
+
const HELP = `aipictury ${VERSION} — the AIPictury agent in a terminal
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
aipictury login [--no-browser] sign in to your AIPictury account (needed), in the browser
|
|
30
|
+
aipictury logout | whoami sign out on this machine | who is signed in
|
|
31
|
+
aipictury [prompt] interactive (a first prompt is optional)
|
|
32
|
+
aipictury -p "prompt" one prompt, the answer printed, then exit
|
|
33
|
+
cat log.txt | aipictury -p "why does it fail?"
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
-p, --print print mode: no questions asked (what needs a yes is refused)
|
|
37
|
+
--output-format text|json|stream-json print mode's output (default text)
|
|
38
|
+
-c, --continue carry on the latest conversation of this folder
|
|
39
|
+
-r, --resume [id] carry on a conversation (none given: list them)
|
|
40
|
+
--permission-mode <mode> default (asks; the start) | acceptEdits | auto | plan | bypassPermissions
|
|
41
|
+
--dangerously-skip-permissions bypassPermissions, for this run
|
|
42
|
+
--allowedTools "<rule>,…" allowed without asking, as permission rules: "Bash(npm test:*),Edit"
|
|
43
|
+
--disallowedTools "<rule>,…" denied, as permission rules; a bare tool ("Bash", "WebFetch") is not even offered
|
|
44
|
+
--mcp-config <file|json> MCP servers for this run (a file, or JSON with "mcpServers"); repeatable
|
|
45
|
+
--add-dir <folder> another folder the agent may work in, besides this one; repeatable
|
|
46
|
+
--append-system-prompt "<text>" added at the end of the system prompt
|
|
47
|
+
--model <name> the model (default: the server's agent model)
|
|
48
|
+
--effort low|medium|high|max|ultra how hard it reasons
|
|
49
|
+
--max-turns <n> stop after this many model turns (print mode)
|
|
50
|
+
--server <url> the AIPictury server (default: aipictury.server, or AIPICTURY_URL)
|
|
51
|
+
-v, --version the version
|
|
52
|
+
-h, --help this help
|
|
53
|
+
|
|
54
|
+
In a conversation: / for commands (/help lists them; Tab completes), @path mentions a file,
|
|
55
|
+
!command runs it here, a line ending in \\ continues on the next. Esc or Ctrl+C stops a run;
|
|
56
|
+
Ctrl+C twice at the prompt quits. Up and down bring back what you typed, across runs.
|
|
57
|
+
Settings: ~/.aipictury/cli.json (the extension's names, e.g. {"aipictury.approval": "ask"}).
|
|
58
|
+
Copyright © 2026 Davait, Inc. All rights reserved. See LICENSE.md.
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
const MODE_OF = { default: 'ask', acceptEdits: 'edits', auto: 'never', plan: 'plan', bypassPermissions: 'bypass', ask: 'ask', edits: 'edits', never: 'never', bypass: 'bypass' }
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv) {
|
|
64
|
+
const o = { prompt: [], format: 'text' }
|
|
65
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
66
|
+
const a = argv[i]
|
|
67
|
+
const next = () => { if (i + 1 >= argv.length) throw new Error(`${a} needs a value`); i += 1; return argv[i] }
|
|
68
|
+
if (a === '-p' || a === '--print') o.print = true
|
|
69
|
+
else if (a === '-c' || a === '--continue') o.continue = true
|
|
70
|
+
else if (a === '-r' || a === '--resume') o.resume = argv[i + 1] && !argv[i + 1].startsWith('-') ? next() : true
|
|
71
|
+
else if (a === '--output-format') o.format = next()
|
|
72
|
+
else if (a === '--permission-mode') o.mode = next()
|
|
73
|
+
else if (a === '--dangerously-skip-permissions') o.mode = 'bypassPermissions'
|
|
74
|
+
else if (a === '--allowedTools' || a === '--allowed-tools') o.allowed = next().split(/[,\s]+(?![^(]*\))/).filter(Boolean)
|
|
75
|
+
else if (a === '--disallowedTools' || a === '--disallowed-tools') o.denied = next().split(/[,\s]+(?![^(]*\))/).filter(Boolean)
|
|
76
|
+
else if (a === '--mcp-config') (o.mcp ||= []).push(next())
|
|
77
|
+
else if (a === '--add-dir') (o.dirs ||= []).push(next())
|
|
78
|
+
else if (a === '--append-system-prompt') o.appendSystem = next()
|
|
79
|
+
else if (a === '--model') o.model = next()
|
|
80
|
+
else if (a === '--effort') o.effort = next()
|
|
81
|
+
else if (a === '--max-turns') o.maxTurns = Number(next())
|
|
82
|
+
else if (a === '--server') o.server = next()
|
|
83
|
+
else if (a === '-v' || a === '--version') o.version = true
|
|
84
|
+
else if (a === '-h' || a === '--help') o.help = true
|
|
85
|
+
else if (a.startsWith('-')) throw new Error(`unknown option ${a} — aipictury --help`)
|
|
86
|
+
else o.prompt.push(a)
|
|
87
|
+
}
|
|
88
|
+
o.prompt = o.prompt.join(' ').trim()
|
|
89
|
+
if (o.mode && !MODE_OF[o.mode]) throw new Error(`--permission-mode is one of: default, acceptEdits, auto, plan, bypassPermissions`)
|
|
90
|
+
if (!['text', 'json', 'stream-json'].includes(o.format)) throw new Error('--output-format is text, json or stream-json')
|
|
91
|
+
return o
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- the terminal ----
|
|
95
|
+
const tty = process.stdout.isTTY
|
|
96
|
+
const paint = (code) => (t) => (tty ? `\x1b[${code}m${t}\x1b[0m` : String(t))
|
|
97
|
+
const dim = paint(2); const bold = paint(1); const red = paint(31); const green = paint(32); const yellow = paint(33); const cyan = paint(36)
|
|
98
|
+
|
|
99
|
+
function setup(o) {
|
|
100
|
+
const home = process.env.AIPICTURY_HOME || path.join(os.homedir(), '.aipictury')
|
|
101
|
+
vscode.state.root = process.cwd()
|
|
102
|
+
vscode.state.settingsFile = path.join(home, 'cli.json')
|
|
103
|
+
vscode.state.logFile = path.join(home, 'logs', 'cli.log')
|
|
104
|
+
for (const [k, v] of Object.entries(pkg.contributes.configuration.properties)) if ('default' in v) vscode.state.defaults[k] = v.default
|
|
105
|
+
// The terminal starts as Claude Code's does: asking before changes and commands (in print
|
|
106
|
+
// mode, refusing them) — a script gets only what it allows. cli.json or --permission-mode
|
|
107
|
+
// choose otherwise.
|
|
108
|
+
vscode.state.defaults['aipictury.approval'] = 'ask'
|
|
109
|
+
try { Object.assign(vscode.state.settings, JSON.parse(fs.readFileSync(vscode.state.settingsFile, 'utf8'))) } catch { /* no settings yet */ }
|
|
110
|
+
// This run's choices: never written to the settings file.
|
|
111
|
+
if (o.server || process.env.AIPICTURY_URL) vscode.state.settings['aipictury.server'] = (o.server || process.env.AIPICTURY_URL).replace(/\/+$/, '')
|
|
112
|
+
if (o.mode) {
|
|
113
|
+
vscode.state.settings['aipictury.approval'] = MODE_OF[o.mode]
|
|
114
|
+
if (MODE_OF[o.mode] === 'bypass') vscode.state.settings['aipictury.allowDangerouslySkipPermissions'] = true
|
|
115
|
+
}
|
|
116
|
+
if (o.effort) vscode.state.settings['aipictury.effort'] = o.effort
|
|
117
|
+
// Tools that only an editor has are not offered here.
|
|
118
|
+
vscode.cli = true
|
|
119
|
+
RUN.allowed = o.allowed
|
|
120
|
+
RUN.denied = o.denied
|
|
121
|
+
for (const dir of o.dirs || []) {
|
|
122
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new Error(`--add-dir: ${dir} is not a folder`)
|
|
123
|
+
}
|
|
124
|
+
if (o.dirs?.length) require(path.join(EXT, 'paths.js')).addRoots(o.dirs)
|
|
125
|
+
if (o.mcp?.length) {
|
|
126
|
+
require(path.join(EXT, 'mcp.js')).addConfigs(o.mcp.map((given) => {
|
|
127
|
+
if (String(given).trim().startsWith('{')) {
|
|
128
|
+
try { return { servers: JSON.parse(given).mcpServers || {} } } catch { throw new Error('--mcp-config: that JSON does not parse') }
|
|
129
|
+
}
|
|
130
|
+
if (!fs.existsSync(given)) throw new Error(`--mcp-config: ${given} does not exist`)
|
|
131
|
+
return path.resolve(given)
|
|
132
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
if (o.appendSystem) require(path.join(EXT, 'agent.js')).appendSystem(o.appendSystem)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The GPU server: --server, AIPICTURY_URL or the settings file; else the one chosen with your account.
|
|
138
|
+
const server = () => require(path.join(EXT, 'gpuServer.js')).url()
|
|
139
|
+
|
|
140
|
+
async function serverConfig() {
|
|
141
|
+
if (!server()) throw new Error('no GPU server is set — choose it at https://aipictury.com (it is kept with your account), or pass --server')
|
|
142
|
+
const r = await fetch(`${server()}/api/vscode/config`, { signal: AbortSignal.timeout(15_000) })
|
|
143
|
+
if (!r.ok) throw new Error(`the server answered ${r.status}${r.status === 401 ? `: ${(await r.json().catch(() => ({}))).detail || 'your account is refused'}` : ''}`)
|
|
144
|
+
return r.json()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One conversation, saved where the extension keeps this folder's conversations. */
|
|
148
|
+
// What this run was started with that every conversation of it carries (setup).
|
|
149
|
+
const RUN = {}
|
|
150
|
+
|
|
151
|
+
class Conversation {
|
|
152
|
+
constructor(store, entry = null) {
|
|
153
|
+
this.store = store
|
|
154
|
+
this.id = entry?.id || `c${Date.now().toString(36)}`
|
|
155
|
+
this.created = entry?.created || Date.now()
|
|
156
|
+
this.title = entry?.title && entry.renamed ? entry.title : null
|
|
157
|
+
this.aiTitle = entry?.aiTitle || null
|
|
158
|
+
this.history = entry?.history || []
|
|
159
|
+
this.transcript = entry?.transcript || []
|
|
160
|
+
this.todos = entry?.todos || []
|
|
161
|
+
// This run's own rules (--allowedTools, --disallowedTools): every conversation, both modes.
|
|
162
|
+
this.session = { allowAll: false, ...(RUN.allowed?.length ? { commandAllow: RUN.allowed } : {}), ...(RUN.denied?.length ? { commandDeny: RUN.denied } : {}) }
|
|
163
|
+
this.usage = entry?.usage || { turns: 0, prompt: 0, output: 0 }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get displayTitle() {
|
|
167
|
+
const first = this.history.find((m) => m.role === 'user')
|
|
168
|
+
return this.title || this.aiTitle || (first ? String(first.content).replace(/\[[^\]]*\]\n?/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) : 'New conversation')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
save(model) {
|
|
172
|
+
if (!this.history.some((m) => m.role === 'user')) return
|
|
173
|
+
this.store.save({ id: this.id, title: this.displayTitle, renamed: !!this.title, aiTitle: this.aiTitle || undefined, created: this.created,
|
|
174
|
+
updated: Date.now(), server: server(), model: model || null, history: this.history, transcript: this.transcript, todos: this.todos,
|
|
175
|
+
usage: this.usage, origin: 'cli' })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One turn of the conversation: the agent runs until it answers. Returns the run's result. */
|
|
180
|
+
async function runTurn(convo, text, ui, { model, maxTurns, content } = {}) {
|
|
181
|
+
const agent = require(path.join(EXT, 'agent.js'))
|
|
182
|
+
convo.history.push({ role: 'user', content: content || text })
|
|
183
|
+
convo.transcript.push({ kind: 'user', text, at: Date.now() })
|
|
184
|
+
const controller = new AbortController()
|
|
185
|
+
ui.onStart?.(controller)
|
|
186
|
+
let turns = 0
|
|
187
|
+
let lastText = ''
|
|
188
|
+
try {
|
|
189
|
+
const result = await agent.run({
|
|
190
|
+
server: server(), model, history: convo.history, session: convo.session, signal: controller.signal,
|
|
191
|
+
onDelta: (d) => ui.delta?.(d),
|
|
192
|
+
onThinking: (d) => ui.thinking?.(d),
|
|
193
|
+
onTool: (event) => {
|
|
194
|
+
const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
|
|
195
|
+
const existing = convo.transcript.find((t) => t.kind === 'tool' && t.key === key)
|
|
196
|
+
if (existing) Object.assign(existing, event); else convo.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
|
|
197
|
+
ui.tool?.(event)
|
|
198
|
+
},
|
|
199
|
+
onNudge: (reason, detail) => ui.nudge?.(reason, detail),
|
|
200
|
+
askUser: (questions, signal) => ui.askUser(questions, signal),
|
|
201
|
+
approve: (request, signal) => ui.approve(request, signal),
|
|
202
|
+
approvePlan: (plan, signal) => ui.approvePlan(plan, signal),
|
|
203
|
+
onTodos: (todos) => { convo.todos = todos; ui.todos?.(todos) },
|
|
204
|
+
trustHooks: (groups) => ui.trust('hooks', groups),
|
|
205
|
+
trustMcp: (servers) => ui.trust('MCP servers', servers),
|
|
206
|
+
onMessages: (messages) => { convo.history = messages; convo.save(model) },
|
|
207
|
+
onTurn: ({ text: said, meta }) => {
|
|
208
|
+
turns += 1
|
|
209
|
+
if (String(said || '').trim()) { convo.transcript.push({ kind: 'assistant', text: said, meta, at: Date.now() }); lastText = said }
|
|
210
|
+
convo.usage.turns += 1
|
|
211
|
+
convo.usage.prompt += meta?.context_used || 0
|
|
212
|
+
convo.usage.output += meta?.eval_tokens || meta?.tokens || 0
|
|
213
|
+
ui.turn?.({ text: said, meta })
|
|
214
|
+
if (maxTurns && turns >= maxTurns) controller.abort()
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
convo.history = result.messages
|
|
218
|
+
if (result.error) convo.transcript.push({ kind: 'error', text: result.error, at: Date.now() })
|
|
219
|
+
convo.save(model)
|
|
220
|
+
return { ...result, text: lastText, turns }
|
|
221
|
+
} finally { ui.onEnd?.() }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- print mode: one prompt, no questions ----
|
|
225
|
+
async function printMode(o, convo, text) {
|
|
226
|
+
const started = Date.now()
|
|
227
|
+
const events = o.format === 'stream-json'
|
|
228
|
+
const emit = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`)
|
|
229
|
+
if (events) emit({ type: 'system', subtype: 'init', session_id: convo.id, cwd: process.cwd(), model: o.model || null, version: VERSION })
|
|
230
|
+
let planText = null
|
|
231
|
+
const ui = {
|
|
232
|
+
delta: () => {},
|
|
233
|
+
tool: (e) => { if (events && (e.status === 'done' || e.status === 'failed' || e.status === 'refused')) emit({ type: 'tool', tool: e.tool, args: e.args, status: e.status, result: typeof e.result === 'string' ? e.result.slice(0, 4000) : undefined }) },
|
|
234
|
+
turn: ({ text: said }) => { if (events && String(said || '').trim()) emit({ type: 'assistant', text: said }) },
|
|
235
|
+
// Nobody to ask: what needs a yes is refused, a question is left to the agent.
|
|
236
|
+
approve: async (request) => ({ decision: 'deny', feedback: `not allowed in print mode (-p): ${request.kind === 'command' ? request.command : request.path || request.tool} — allow it with --allowedTools or --permission-mode` }),
|
|
237
|
+
askUser: async () => ({ dismissed: true }),
|
|
238
|
+
// The plan is the answer: the run ends there, with no further turn.
|
|
239
|
+
approvePlan: async (plan) => { planText = plan; ui.controller?.abort(); return { approved: false, feedback: 'Stop here: the plan is the answer.' } },
|
|
240
|
+
trust: async () => false,
|
|
241
|
+
}
|
|
242
|
+
const result = await runTurn(convo, text, { ...ui, onStart: (c) => { ui.controller = c; if (planText === null) process.once('SIGINT', () => c.abort()) } }, { model: o.model, maxTurns: o.maxTurns })
|
|
243
|
+
const answer = planText ?? result.text
|
|
244
|
+
const failed = !!result.error && !result.stopped
|
|
245
|
+
if (o.format === 'text') process.stdout.write(`${answer || (result.error ? `error: ${result.error}` : '')}\n`)
|
|
246
|
+
else emit({ type: 'result', subtype: failed ? 'error' : 'success', is_error: failed, result: answer, session_id: convo.id, num_turns: result.turns,
|
|
247
|
+
duration_ms: Date.now() - started, error: result.error || undefined, usage: { prompt_tokens: convo.usage.prompt, output_tokens: convo.usage.output } })
|
|
248
|
+
return failed ? 1 : 0
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- interactive ----
|
|
252
|
+
// What can be typed after / here — the extension's names, where a terminal can do them.
|
|
253
|
+
// The project's custom commands (.aipictury/commands, .claude/commands) and skills follow.
|
|
254
|
+
const SLASH = [
|
|
255
|
+
['help', 'What can be typed here', ''],
|
|
256
|
+
['clear', 'Start a new conversation', ''],
|
|
257
|
+
['compact', 'Summarize the conversation so far, to free context', '[what to focus on]'],
|
|
258
|
+
['btw', 'Ask a side question without adding it to the conversation', '<question>'],
|
|
259
|
+
['resume', 'Carry on an earlier conversation of this folder', ''],
|
|
260
|
+
['rename', 'Rename this conversation', '<title>'],
|
|
261
|
+
['export', 'Save the conversation as Markdown', '[file]'],
|
|
262
|
+
['todos', 'The agent\'s to-do list', ''],
|
|
263
|
+
['model', 'Switch the model', '[name]'],
|
|
264
|
+
['effort', 'How hard the model reasons', '[low|medium|high|max|ultra]'],
|
|
265
|
+
['mode', 'When the agent asks first', '[default|acceptEdits|auto|plan]'],
|
|
266
|
+
['permissions', 'What it may do without asking, must ask about, may never do', '[allow|ask|deny <rule>]'],
|
|
267
|
+
['mcp', 'MCP servers and their tools', ''],
|
|
268
|
+
['status', 'Version, account server, model, mode, folder', ''],
|
|
269
|
+
['usage', 'Tokens and time this conversation has used', ''],
|
|
270
|
+
['init', 'Write an AIPICTURY.md that explains this codebase to the agent', ''],
|
|
271
|
+
['review', 'Review the uncommitted changes', '[what to focus on]'],
|
|
272
|
+
['exit', 'Quit', ''],
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
async function interactive(o, convo, first, cfg) {
|
|
276
|
+
const T = require('./terminal.js')
|
|
277
|
+
const agent = require(path.join(EXT, 'agent.js'))
|
|
278
|
+
const context = require(path.join(EXT, 'context.js'))
|
|
279
|
+
const ext = require(path.join(EXT, 'extension.js'))
|
|
280
|
+
// A terminal gets line editing, completion and a remembered history; a pipe (a script, a
|
|
281
|
+
// test) plain lines, each one a message.
|
|
282
|
+
const terminal = !!(process.stdin.isTTY && process.stdout.isTTY)
|
|
283
|
+
const historyFile = path.join(HOME(), 'cli-history.json')
|
|
284
|
+
let remembered = []
|
|
285
|
+
try { remembered = terminal ? JSON.parse(fs.readFileSync(historyFile, 'utf8')) : [] } catch { /* none yet */ }
|
|
286
|
+
const names = () => [...SLASH.map(([n]) => n), ...context.commands().map((c) => c.name), ...context.skills().map((s) => s.name)]
|
|
287
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal, historySize: 500, history: remembered,
|
|
288
|
+
removeHistoryDuplicates: true,
|
|
289
|
+
completer: terminal ? T.completer({ commands: names, files: async (q) => (await ext.mentionCandidates(q)).map((c) => c.path) }) : undefined })
|
|
290
|
+
|
|
291
|
+
// Every line typed comes here; a question waiting takes the next one, else it is a message.
|
|
292
|
+
const inbox = []
|
|
293
|
+
let waiting = null
|
|
294
|
+
let closed = false
|
|
295
|
+
rl.on('line', (line) => {
|
|
296
|
+
if (waiting?.question) { const w = waiting; waiting = null; w.resolve(line); return }
|
|
297
|
+
inbox.push({ line, at: Date.now() })
|
|
298
|
+
if (running && terminal && line.trim()) say(dim(` queued: ${line.trim().slice(0, 80)}`))
|
|
299
|
+
if (waiting) { const w = waiting; waiting = null; w.resolve() }
|
|
300
|
+
})
|
|
301
|
+
rl.on('close', () => { closed = true; if (waiting) { const w = waiting; waiting = null; w.resolve(null) } })
|
|
302
|
+
const nextLine = () => new Promise((resolve) => {
|
|
303
|
+
if (inbox.length) { resolve(inbox.shift().line); return }
|
|
304
|
+
if (closed) { resolve(null); return }
|
|
305
|
+
waiting = { resolve: () => resolve(inbox.length ? inbox.shift().line : null) }
|
|
306
|
+
})
|
|
307
|
+
/** A question at the prompt: its answer, or null when it is called off (Ctrl+C, Ctrl+D). */
|
|
308
|
+
const ask = (q) => new Promise((resolve) => {
|
|
309
|
+
spinner.pause()
|
|
310
|
+
if (streamed) { md.end(); process.stdout.write('\n'); streamed = false }
|
|
311
|
+
// A script answers with the lines it has already sent.
|
|
312
|
+
if (!terminal && inbox.length) { process.stdout.write(q); resolve(inbox.shift().line); return }
|
|
313
|
+
if (closed) { resolve(null); return }
|
|
314
|
+
waiting = { question: true, resolve: (a) => { rl.setPrompt(PROMPT); resolve(a) } }
|
|
315
|
+
rl.setPrompt(q)
|
|
316
|
+
rl.prompt()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
let running = null
|
|
320
|
+
let model = o.model || null
|
|
321
|
+
let streamed = false
|
|
322
|
+
let shellContext = [] // what was run with ! since the last message: the next one carries it
|
|
323
|
+
const shown = new Map()
|
|
324
|
+
const PROMPT = `${bold('›')} `
|
|
325
|
+
const md = new T.MarkdownStream()
|
|
326
|
+
const spinner = new T.Spinner({ busyTyping: () => !!rl.line })
|
|
327
|
+
const say = (t) => { spinner.pause(); if (streamed) { md.end(); process.stdout.write('\n'); streamed = false } process.stdout.write(`${t}\n`); if (running) spinner.start() }
|
|
328
|
+
const mode = () => agent.modeNow()
|
|
329
|
+
const label = (e) => {
|
|
330
|
+
const a = e.args || {}
|
|
331
|
+
return String(a.command || a.path || a.pattern || a.url || a.query || a.description || a.title || a.action || '').replace(/\s+/g, ' ').slice(0, 100)
|
|
332
|
+
}
|
|
333
|
+
const ui = {
|
|
334
|
+
onStart: (c) => { running = c; streamed = false; spinner.start('thinking') },
|
|
335
|
+
onEnd: () => { running = null; spinner.stop(); if (streamed) { md.end(); process.stdout.write('\n'); streamed = false } },
|
|
336
|
+
delta: (d) => {
|
|
337
|
+
spinner.pause()
|
|
338
|
+
if (!streamed) { process.stdout.write('\n'); streamed = true }
|
|
339
|
+
md.push(d)
|
|
340
|
+
},
|
|
341
|
+
thinking: () => { if (!streamed) spinner.start('thinking') },
|
|
342
|
+
tool: (e) => {
|
|
343
|
+
const key = e.id || `${e.tool}:${JSON.stringify(e.args)}`
|
|
344
|
+
if (e.status === 'running' && !shown.has(key)) { shown.set(key, true); say(`${cyan('⏺')} ${bold(e.tool)} ${dim(label(e))}`); spinner.start(e.tool) }
|
|
345
|
+
if (['done', 'failed', 'refused', 'plan'].includes(e.status)) {
|
|
346
|
+
const out = typeof e.result === 'string' ? e.result.trim().split('\n') : []
|
|
347
|
+
const mark = e.status === 'done' ? green('⎿') : red('⎿')
|
|
348
|
+
if (!shown.has(key)) say(`${cyan('⏺')} ${bold(e.tool)} ${dim(label(e))}`)
|
|
349
|
+
shown.set(key, 'end')
|
|
350
|
+
say(` ${mark} ${dim(`${out[0]?.slice(0, 160) || e.status}${out.length > 1 ? ` (+${out.length - 1} lines)` : ''}`)}`)
|
|
351
|
+
spinner.start('thinking')
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
nudge: (reason, detail) => { if (detail?.text) say(dim(` · ${detail.text}`)) },
|
|
355
|
+
todos: (todos) => say(dim(todos.map((t) => ` ${t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▸' : '☐'} ${t.content}`).join('\n'))),
|
|
356
|
+
async approve(request) {
|
|
357
|
+
const what = request.kind === 'command' ? `Run ${bold(request.command)}`
|
|
358
|
+
: request.kind === 'edit' ? `${request.before == null ? 'Create' : 'Change'} ${bold(request.path)}`
|
|
359
|
+
: request.tool === 'browser' ? `Use the browser on ${bold(request.site || 'a page')}` : `Use ${bold(request.tool)} ${dim(JSON.stringify(request.args || {}).slice(0, 200))}`
|
|
360
|
+
say(`\n${yellow('?')} ${what}${request.because ? dim(` — ${request.because}`) : ''}`)
|
|
361
|
+
if (request.kind === 'edit') for (const h of (request.hunks || []).slice(0, 6)) say([...h.removed.slice(0, 8).map((l) => red(` - ${l}`)), ...h.added.slice(0, 12).map((l) => green(` + ${l}`))].join('\n'))
|
|
362
|
+
const answer = await ask(` ${bold('y')}es · ${bold('a')}lways${request.rule ? dim(` (${request.rule}, this conversation)`) : ' (this conversation)'} · ${bold('n')}o, or say what to do instead › `)
|
|
363
|
+
spinner.start()
|
|
364
|
+
if (answer === null) return { decision: 'deny', feedback: '' }
|
|
365
|
+
const a = answer.trim()
|
|
366
|
+
if (/^y(es)?$/i.test(a) || a === '') return { decision: 'allow' }
|
|
367
|
+
if (/^a(lways)?$/i.test(a)) return { decision: 'allow_all', destination: 'conversation', prefix: request.prefix }
|
|
368
|
+
return { decision: 'deny', feedback: /^no?$/i.test(a) ? '' : a }
|
|
369
|
+
},
|
|
370
|
+
async askUser(questions) {
|
|
371
|
+
const answers = []
|
|
372
|
+
for (const q of questions) {
|
|
373
|
+
say(`\n${yellow('?')} ${bold(q.question)}`)
|
|
374
|
+
q.options.forEach((opt, i) => say(` ${i + 1}. ${opt.label}${opt.description ? dim(` — ${opt.description}`) : ''}`))
|
|
375
|
+
const answer = await ask(` ${q.multiSelect ? 'numbers (1,3)' : 'a number'}, your own answer, or Enter to let the agent decide › `)
|
|
376
|
+
const a = (answer || '').trim()
|
|
377
|
+
if (!a) { spinner.start(); return { dismissed: true } }
|
|
378
|
+
const nums = a.split(/[,\s]+/).map(Number)
|
|
379
|
+
if (nums.every((n) => Number.isInteger(n) && n >= 1 && n <= q.options.length)) answers.push({ header: q.header, question: q.question, picked: nums.map((n) => q.options[n - 1].label) })
|
|
380
|
+
else answers.push({ header: q.header, question: q.question, picked: [], other: a })
|
|
381
|
+
}
|
|
382
|
+
spinner.start()
|
|
383
|
+
return { answers }
|
|
384
|
+
},
|
|
385
|
+
async approvePlan(plan) {
|
|
386
|
+
say(`\n${bold('The plan:')}\n`)
|
|
387
|
+
const view = new T.MarkdownStream()
|
|
388
|
+
view.push(`${plan}\n`)
|
|
389
|
+
view.end()
|
|
390
|
+
const answer = await ask(`\n ${bold('y')}es, go ahead (asking as now) · ${bold('a')}uto (go ahead, asking only when risky) · or say what to change › `)
|
|
391
|
+
spinner.start()
|
|
392
|
+
if (answer === null) return { approved: false, feedback: '' }
|
|
393
|
+
const a = answer.trim()
|
|
394
|
+
if (/^y(es)?$/i.test(a) || a === '') return { approved: true, mode: 'ask' }
|
|
395
|
+
if (/^a(uto)?$/i.test(a)) return { approved: true, mode: 'never' }
|
|
396
|
+
return { approved: false, feedback: a }
|
|
397
|
+
},
|
|
398
|
+
async trust(what, items) {
|
|
399
|
+
say(`\n${yellow('?')} This project has ${what} of its own: ${dim(JSON.stringify(items).slice(0, 300))}`)
|
|
400
|
+
const answer = await ask(` trust and use them? ${bold('y')}/${bold('n')} › `)
|
|
401
|
+
spinner.start()
|
|
402
|
+
return /^y/i.test((answer || '').trim())
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
const contextShare = () => Math.min(100, Math.round((agent.estimateTokens(convo.history) / Math.max(1, agent.budget())) * 100))
|
|
406
|
+
const status = () => `${dim(`${server()} · ${model || cfg?.roles?.agent || 'the agent model'} · ${agent.MODES[mode()]?.label || mode()} · ${process.cwd()}`)}`
|
|
407
|
+
say(`${bold('AIPictury')} ${VERSION} — ${convo.history.length ? `carrying on: ${convo.displayTitle}` : 'a new conversation'}\n${status()}`)
|
|
408
|
+
say(dim(terminal ? '/ for commands (Tab completes) · @ to mention a file · ! runs a command · end a line with \\ for another · Esc stops a run · Ctrl+C twice quits'
|
|
409
|
+
: '/help for commands · Ctrl+C stops a run · Ctrl+C twice quits'))
|
|
410
|
+
let quitArmed = false
|
|
411
|
+
rl.on('SIGINT', () => {
|
|
412
|
+
if (running) { running.abort(); say(dim('\n stopped')); return }
|
|
413
|
+
if (waiting?.question) { const w = waiting; waiting = null; process.stdout.write('\n'); w.resolve(null); return }
|
|
414
|
+
if (quitArmed) { rl.close(); return }
|
|
415
|
+
quitArmed = true
|
|
416
|
+
rl.line && rl.write(null, { ctrl: true, name: 'u' })
|
|
417
|
+
say(dim('\n (Ctrl+C again to quit)'))
|
|
418
|
+
rl.prompt()
|
|
419
|
+
})
|
|
420
|
+
if (terminal) process.stdin.on('keypress', (_, key) => { if (key?.name === 'escape' && running) running.abort() })
|
|
421
|
+
|
|
422
|
+
const turn = async (text, extra = []) => {
|
|
423
|
+
const started = Date.now()
|
|
424
|
+
try {
|
|
425
|
+
// @mentions are read now, as the editor does; what ! ran goes with the message too.
|
|
426
|
+
const mentioned = await ext.resolveMentions(text, convo.session).catch(() => [])
|
|
427
|
+
const parts = [text, ...shellContext.splice(0)]
|
|
428
|
+
if (mentioned.length) parts.push(`Files the user referenced (already read for you):\n\n${mentioned.join('\n\n')}`)
|
|
429
|
+
const r = await runTurn(convo, text, ui, { model, content: [...parts, ...extra].join('\n\n') })
|
|
430
|
+
if (r.error && !r.stopped) say(red(`\n error: ${r.error}`))
|
|
431
|
+
} catch (error) { say(red(`\n error: ${error.message}`)) }
|
|
432
|
+
if (terminal) say(dim(` ${Math.round((Date.now() - started) / 100) / 10}s · ${convo.usage.output} tokens written · context ${contextShare()}% used`))
|
|
433
|
+
}
|
|
434
|
+
const shell = (command) => {
|
|
435
|
+
const { spawnSync } = require('child_process')
|
|
436
|
+
const r = spawnSync(process.env.SHELL || '/bin/sh', ['-c', command], { cwd: process.cwd(), encoding: 'utf8', timeout: 120_000, maxBuffer: 8 * 1024 * 1024 })
|
|
437
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`.replace(/\s+$/, '')
|
|
438
|
+
const lines = out ? out.split('\n') : []
|
|
439
|
+
lines.slice(0, 40).forEach((l) => say(` ${dim('⎿')} ${l}`))
|
|
440
|
+
if (lines.length > 40) say(dim(` … ${lines.length - 40} more lines (all of it goes with your next message)`))
|
|
441
|
+
if (r.status) say(red(` exit ${r.status}`))
|
|
442
|
+
if (r.error) say(red(` ${r.error.message}`))
|
|
443
|
+
shellContext.push(`I ran this myself in the terminal: \`${command}\`${r.status ? ` (exit ${r.status})` : ''}\n\n\`\`\`\n${out.slice(-20_000)}\n\`\`\``)
|
|
444
|
+
}
|
|
445
|
+
const command = async (line) => {
|
|
446
|
+
const [name, ...rest] = line.slice(1).split(/\s+/)
|
|
447
|
+
const arg = rest.join(' ').trim()
|
|
448
|
+
const cfgSet = (k, v) => { vscode.state.settings[`aipictury.${k}`] = v }
|
|
449
|
+
const folder = process.cwd()
|
|
450
|
+
switch (name) {
|
|
451
|
+
case 'help': {
|
|
452
|
+
const width = Math.max(...SLASH.map(([n, , h]) => `/${n} ${h}`.length))
|
|
453
|
+
for (const [n, d, h] of SLASH) say(` ${cyan(`/${n}`)} ${dim(h)}${' '.repeat(width - `/${n} ${h}`.length)} ${d}`)
|
|
454
|
+
const custom = context.commands()
|
|
455
|
+
if (custom.length) say(dim(` this project's commands: ${custom.map((c) => `/${c.name}`).join(', ')}`))
|
|
456
|
+
say(dim(' @path mentions a file · !command runs it here (its output goes with your next message) · a line ending in \\ continues'))
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
case 'exit': case 'quit': rl.close(); return true
|
|
460
|
+
case 'clear': convo.save(model); Object.assign(convo, new Conversation(convo.store)); shellContext = []; say(dim(' a new conversation')); return true
|
|
461
|
+
case 'model': if (arg) { model = arg } say(dim(` model: ${model || cfg?.roles?.agent}${arg ? '' : ` (installed: ${(cfg?.models || []).filter((m) => m.tools).map((m) => m.name).join(', ')})`}`)); return true
|
|
462
|
+
case 'effort': if (arg) { if (!agent.EFFORT[arg]) { say(red(` one of: ${Object.keys(agent.EFFORT).join(', ')}`)); return true } cfgSet('effort', arg) } say(dim(` effort: ${vscode.workspace.getConfiguration('aipictury').get('effort')}`)); return true
|
|
463
|
+
case 'mode': if (arg) { if (!MODE_OF[arg] || MODE_OF[arg] === 'bypass') { say(red(' one of: default, acceptEdits, auto, plan')); return true } cfgSet('approval', MODE_OF[arg]) } say(status()); return true
|
|
464
|
+
case 'status': say(` AIPictury ${VERSION}`); say(status()); say(dim(` conversation ${convo.id}: ${convo.displayTitle}`)); return true
|
|
465
|
+
case 'usage': case 'cost': case 'context':
|
|
466
|
+
say(dim(` ${convo.usage.turns} model turns · ${convo.usage.prompt} tokens read · ${convo.usage.output} written · context ${contextShare()}% used (${agent.estimateTokens(convo.history)} of ${agent.budget()} tokens)`))
|
|
467
|
+
return true
|
|
468
|
+
case 'rename': if (!arg) { say(red(' /rename <title>')); return true } convo.title = arg; convo.save(model); say(dim(` renamed: ${arg}`)); return true
|
|
469
|
+
case 'todos':
|
|
470
|
+
if (!convo.todos.length) say(dim(' no to-do list in this conversation'))
|
|
471
|
+
else ui.todos(convo.todos)
|
|
472
|
+
return true
|
|
473
|
+
case 'export': {
|
|
474
|
+
if (!convo.transcript.length) { say(dim(' nothing to export yet')); return true }
|
|
475
|
+
const file = path.resolve(arg || `aipictury-${convo.id}.md`)
|
|
476
|
+
fs.writeFileSync(file, ext.toMarkdown(convo.displayTitle, convo.transcript))
|
|
477
|
+
say(dim(` saved ${file}`))
|
|
478
|
+
return true
|
|
479
|
+
}
|
|
480
|
+
case 'permissions': case 'allowed-tools': {
|
|
481
|
+
const prefs = require(path.join(EXT, 'prefs.js'))
|
|
482
|
+
const m = /^(allow|ask|deny)\s+(.+)$/.exec(arg)
|
|
483
|
+
if (m) {
|
|
484
|
+
if (!require(path.join(EXT, 'permissions.js')).parseRule(m[2])) { say(red(' write a rule like Bash(npm test:*) or Read(./.env)')); return true }
|
|
485
|
+
const file = prefs.addRule(folder, 'local', m[1], m[2])
|
|
486
|
+
say(dim(` added ${m[2]} to ${m[1]} (${file.replace(os.homedir(), '~')})`))
|
|
487
|
+
return true
|
|
488
|
+
}
|
|
489
|
+
const merged = prefs.load(folder).merged.permissions
|
|
490
|
+
for (const kind of ['allow', 'ask', 'deny']) {
|
|
491
|
+
const rules = merged[kind] || []
|
|
492
|
+
say(` ${bold(kind)} ${dim(`(${rules.length})`)}`)
|
|
493
|
+
for (const r of rules.slice(0, 30)) say(` ${r.rule} ${dim(r.file.replace(os.homedir(), '~'))}`)
|
|
494
|
+
if (rules.length > 30) say(dim(` … ${rules.length - 30} more`))
|
|
495
|
+
}
|
|
496
|
+
if (RUN.allowed?.length || RUN.denied?.length) say(dim(` this run: allowed ${RUN.allowed?.join(', ') || '—'} · denied ${RUN.denied?.join(', ') || '—'}`))
|
|
497
|
+
say(dim(' add one: /permissions allow|ask|deny <rule> (kept for this project, just you)'))
|
|
498
|
+
return true
|
|
499
|
+
}
|
|
500
|
+
case 'mcp': {
|
|
501
|
+
const mcp = require(path.join(EXT, 'mcp.js'))
|
|
502
|
+
const live = new Map(mcp.status().map((s) => [s.name, s]))
|
|
503
|
+
const configured = [...mcp.detailed(folder).entries()]
|
|
504
|
+
if (!configured.length && !live.size) { say(dim(' no MCP servers configured (.mcp.json, ~/.aipictury/mcp.json, --mcp-config)')); return true }
|
|
505
|
+
for (const [n, c] of configured) {
|
|
506
|
+
const s = live.get(n)
|
|
507
|
+
say(` ${bold(n)} ${dim(c.disabled ? 'disabled' : s ? `${s.state}${s.error ? ` — ${s.error}` : ''}` : 'connects when the agent first needs it')}`)
|
|
508
|
+
if (s?.tools?.length) say(dim(` ${s.tools.join(', ')}`))
|
|
509
|
+
}
|
|
510
|
+
return true
|
|
511
|
+
}
|
|
512
|
+
case 'compact': {
|
|
513
|
+
if (convo.history.length < 3) { say(dim(' nothing to summarize yet')); return true }
|
|
514
|
+
const before = agent.estimateTokens(convo.history)
|
|
515
|
+
spinner.start('summarizing'); running = new AbortController()
|
|
516
|
+
try {
|
|
517
|
+
const next = await agent.summarizeEarlier(server(), [{ role: 'system', content: '' }, ...convo.history], { model: model || undefined, force: true, focus: arg, signal: running.signal })
|
|
518
|
+
running = null; spinner.stop()
|
|
519
|
+
if (!next) say(dim(' the conversation is already short — nothing to summarize'))
|
|
520
|
+
else { convo.history = next.slice(1); convo.save(model); say(dim(` summarized: ${before} → ${agent.estimateTokens(convo.history)} tokens`)) }
|
|
521
|
+
} catch (error) { running = null; spinner.stop(); say(red(` could not summarize: ${error.message}`)) }
|
|
522
|
+
return true
|
|
523
|
+
}
|
|
524
|
+
case 'btw': {
|
|
525
|
+
if (!arg) { say(red(' /btw <question>')); return true }
|
|
526
|
+
const side = new T.MarkdownStream()
|
|
527
|
+
spinner.start('thinking'); running = new AbortController()
|
|
528
|
+
let started = false
|
|
529
|
+
try {
|
|
530
|
+
const answer = await agent.sideQuestion(server(), convo.history, arg, { model: model || undefined, session: convo.session, signal: running.signal,
|
|
531
|
+
onDelta: (d) => { spinner.pause(); if (!started) { process.stdout.write(`\n${dim('(aside)')} `); started = true } side.push(d) } })
|
|
532
|
+
if (!started && answer) { spinner.pause(); process.stdout.write(`\n${dim('(aside)')} `); side.push(answer) }
|
|
533
|
+
side.end(); process.stdout.write('\n')
|
|
534
|
+
} catch (error) { say(red(` error: ${error.message}`)) } finally { running = null; spinner.stop() }
|
|
535
|
+
return true
|
|
536
|
+
}
|
|
537
|
+
case 'init': await turn(ext.INIT_PROMPT); return true
|
|
538
|
+
case 'review': await turn(arg ? `${ext.REVIEW_PROMPT}\n\nFocus on: ${arg}` : ext.REVIEW_PROMPT); return true
|
|
539
|
+
case 'resume': {
|
|
540
|
+
const list = convo.store.list().slice(0, 15)
|
|
541
|
+
if (!list.length) { say(dim(' no conversations in this folder yet')); return true }
|
|
542
|
+
list.forEach((e, i) => say(` ${i + 1}. ${e.title} ${dim(new Date(e.updated).toLocaleString())}`))
|
|
543
|
+
const a = ((await ask(' which (number), or Enter › ')) || '').trim()
|
|
544
|
+
const entry = list[Number(a) - 1] && convo.store.get(list[Number(a) - 1].id)
|
|
545
|
+
if (entry) { convo.save(model); Object.assign(convo, new Conversation(convo.store, entry)); say(dim(` carrying on: ${convo.displayTitle}`)) }
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
default: {
|
|
549
|
+
// The project's own commands, in Claude Code's format, and its skills.
|
|
550
|
+
const prompt = context.expandCommand(line)
|
|
551
|
+
if (prompt) {
|
|
552
|
+
const meta = context.commandMeta(line)
|
|
553
|
+
const permissions = require(path.join(EXT, 'permissions.js'))
|
|
554
|
+
const shellMod = require(path.join(EXT, 'shell.js'))
|
|
555
|
+
const allowed = (cmd) => {
|
|
556
|
+
const verdict = permissions.decide('run_command', { command: cmd }, { folder })
|
|
557
|
+
if (verdict.decision === 'deny') return false
|
|
558
|
+
const granted = permissions.decide('run_command', { command: cmd }, { rules: { allow: meta.allowedTools, ask: [], deny: [] } }).decision === 'allow'
|
|
559
|
+
return granted || verdict.decision === 'allow' || shellMod.isReadOnly(cmd)
|
|
560
|
+
}
|
|
561
|
+
const before = convo.session.commandAllow
|
|
562
|
+
convo.session.commandAllow = [...(RUN.allowed || []), ...meta.allowedTools]
|
|
563
|
+
try { await turn(await context.runInline(prompt, { cwd: folder, allowed })) } finally { convo.session.commandAllow = before }
|
|
564
|
+
return true
|
|
565
|
+
}
|
|
566
|
+
const skill = context.skills().find((s) => s.name === name)
|
|
567
|
+
if (skill) { await turn(`Use the ${skill.name} skill${arg ? ` on: ${arg}` : ''}.`); return true }
|
|
568
|
+
return false
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The next message: a line, the lines after it while each ends in \, or a paste of many. */
|
|
574
|
+
const nextMessage = async () => {
|
|
575
|
+
rl.setPrompt(PROMPT)
|
|
576
|
+
rl.prompt()
|
|
577
|
+
let line = await nextLine()
|
|
578
|
+
if (line === null) return null
|
|
579
|
+
const lines = []
|
|
580
|
+
for (;;) {
|
|
581
|
+
if (line.endsWith('\\')) {
|
|
582
|
+
lines.push(line.slice(0, -1))
|
|
583
|
+
rl.setPrompt(dim('… '))
|
|
584
|
+
rl.prompt()
|
|
585
|
+
line = await nextLine()
|
|
586
|
+
if (line === null) break
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
lines.push(line)
|
|
590
|
+
// Pasted lines come in one burst: they are one message. A person types slower.
|
|
591
|
+
if (!terminal) break
|
|
592
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
593
|
+
if (!inbox.length) break
|
|
594
|
+
line = inbox.shift().line
|
|
595
|
+
}
|
|
596
|
+
return lines.join('\n')
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (first) await turn(first)
|
|
600
|
+
for (;;) {
|
|
601
|
+
const message = await nextMessage()
|
|
602
|
+
if (message === null) break
|
|
603
|
+
quitArmed = false
|
|
604
|
+
const text = message.trim()
|
|
605
|
+
if (!text) continue
|
|
606
|
+
if (text.startsWith('!') && text.length > 1) { shell(text.slice(1).trim()); continue }
|
|
607
|
+
if (!(text.startsWith('/') && !text.includes('\n') && await command(text))) await turn(text)
|
|
608
|
+
if (closed) break
|
|
609
|
+
}
|
|
610
|
+
convo.save(model)
|
|
611
|
+
if (terminal) {
|
|
612
|
+
try { fs.mkdirSync(HOME(), { recursive: true }); fs.writeFileSync(historyFile, JSON.stringify(rl.history.slice(0, 500)), { mode: 0o600 }) } catch { /* not remembered */ }
|
|
613
|
+
}
|
|
614
|
+
if (!closed) rl.close()
|
|
615
|
+
return 0
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- the AIPictury account (needed, as in VS Code) ----
|
|
619
|
+
const HOME = () => process.env.AIPICTURY_HOME || path.join(os.homedir(), '.aipictury')
|
|
620
|
+
|
|
621
|
+
function accountFor() {
|
|
622
|
+
const { Account, fileStore } = require(path.join(EXT, 'account.js'))
|
|
623
|
+
const config = require(path.join(EXT, 'media', 'vendor', 'firebase-config.json'))
|
|
624
|
+
return new Account({ store: fileStore(path.join(HOME(), 'credentials.json')), config, client: 'the aipictury CLI' })
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The conversations of this run, kept with the account: what was saved is pushed before exiting. */
|
|
628
|
+
function cloudFor(account) {
|
|
629
|
+
const { Cloud } = require(path.join(EXT, 'cloud.js'))
|
|
630
|
+
const { emulatorHosts } = require(path.join(EXT, 'account.js'))
|
|
631
|
+
const config = require(path.join(EXT, 'media', 'vendor', 'firebase-config.json'))
|
|
632
|
+
const deviceFile = path.join(HOME(), 'device.json')
|
|
633
|
+
let id
|
|
634
|
+
try { id = JSON.parse(fs.readFileSync(deviceFile, 'utf8')).id } catch { /* first run */ }
|
|
635
|
+
if (!id) { id = require('crypto').randomUUID(); fs.mkdirSync(HOME(), { recursive: true }); fs.writeFileSync(deviceFile, JSON.stringify({ id })) }
|
|
636
|
+
const cloud = new Cloud({ account, projectId: config.projectId, emulator: emulatorHosts().firestore,
|
|
637
|
+
bucket: config.storageBucket, storageEmulator: emulatorHosts().storage,
|
|
638
|
+
device: { id, name: os.hostname(), app: 'cli', platform: process.platform, version: VERSION } })
|
|
639
|
+
const pending = new Map()
|
|
640
|
+
const removed = []
|
|
641
|
+
const on = (kind) => vscode.workspace.getConfiguration('aipictury').get(`cloud.${kind}`, true)
|
|
642
|
+
require(path.join(EXT, 'sessions.js')).onWrite((e) => {
|
|
643
|
+
if (!e.folder || !on('conversations')) return
|
|
644
|
+
if (e.type === 'remove') { pending.delete(e.id); removed.push([e.folder, e.id]); return }
|
|
645
|
+
pending.set(e.id, [e.folder, { ...e.entry, origin: 'cli' }])
|
|
646
|
+
})
|
|
647
|
+
// Every render the agent makes is kept with the account; the run waits for it before it ends.
|
|
648
|
+
const keeping = []
|
|
649
|
+
require(path.join(EXT, 'mediagen.js')).onRendered((base, jobId) => {
|
|
650
|
+
keeping.push(cloud.keepRender(base, jobId).catch((err) => process.stderr.write(dim(` (render not kept with your account: ${err.message})\n`))))
|
|
651
|
+
})
|
|
652
|
+
return {
|
|
653
|
+
cloud,
|
|
654
|
+
async flush() {
|
|
655
|
+
await Promise.all(keeping)
|
|
656
|
+
if (!(await account.user())) return
|
|
657
|
+
for (const [folder, entry] of pending.values()) await cloud.pushConversation(folder, entry).catch((err) => process.stderr.write(dim(` (not kept with your account: ${err.message})\n`)))
|
|
658
|
+
for (const [folder, id] of removed) await cloud.deleteConversation(folder, id).catch(() => {})
|
|
659
|
+
pending.clear()
|
|
660
|
+
},
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function openBrowser(url) {
|
|
665
|
+
const { spawn } = require('child_process')
|
|
666
|
+
const cmd = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
|
|
667
|
+
try { spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true }).unref() } catch { /* the address is printed anyway */ }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function accountCommand(name, argv) {
|
|
671
|
+
const account = accountFor()
|
|
672
|
+
if (name === 'whoami') {
|
|
673
|
+
const u = await account.user()
|
|
674
|
+
if (!u) { process.stdout.write('Not signed in — aipictury login\n'); return 1 }
|
|
675
|
+
process.stdout.write(`${u.email || u.displayName} (${u.uid})\n`)
|
|
676
|
+
return 0
|
|
677
|
+
}
|
|
678
|
+
if (name === 'logout') {
|
|
679
|
+
await account.signOut()
|
|
680
|
+
process.stdout.write('Signed out: nothing of the account is kept on this machine.\n')
|
|
681
|
+
return 0
|
|
682
|
+
}
|
|
683
|
+
const noBrowser = argv.includes('--no-browser')
|
|
684
|
+
try {
|
|
685
|
+
const u = await account.signIn({ open: async (url) => {
|
|
686
|
+
process.stderr.write(`Sign in to AIPictury in your browser:\n ${url}\n`)
|
|
687
|
+
if (!noBrowser) openBrowser(url)
|
|
688
|
+
} })
|
|
689
|
+
process.stdout.write(`Signed in as ${u.email || u.displayName}.\n`)
|
|
690
|
+
return 0
|
|
691
|
+
} catch (error) { process.stderr.write(`aipictury: not signed in — ${error.message}\n`); return 1 }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function main() {
|
|
695
|
+
const first = process.argv[2]
|
|
696
|
+
if (['login', 'logout', 'whoami'].includes(first)) return accountCommand(first, process.argv.slice(3))
|
|
697
|
+
let o
|
|
698
|
+
try { o = parseArgs(process.argv.slice(2)) } catch (error) { process.stderr.write(`aipictury: ${error.message}\n`); return 2 }
|
|
699
|
+
if (o.help) { process.stdout.write(`${HELP}\n`); return 0 }
|
|
700
|
+
if (o.version) { process.stdout.write(`${VERSION}\n`); return 0 }
|
|
701
|
+
setup(o)
|
|
702
|
+
// AIPictury needs the account, here as in VS Code.
|
|
703
|
+
const account = accountFor()
|
|
704
|
+
if (!(await account.user())) { process.stderr.write('aipictury: AIPictury needs your account — run: aipictury login\n'); return 1 }
|
|
705
|
+
try { await account.idToken() } catch (error) {
|
|
706
|
+
if (!(await account.user())) { process.stderr.write('aipictury: your session has ended — run: aipictury login\n'); return 1 }
|
|
707
|
+
process.stderr.write(dim(` (the account cannot be reached now: ${error.message})\n`))
|
|
708
|
+
}
|
|
709
|
+
const sync = cloudFor(account)
|
|
710
|
+
// No server set here: the one chosen with the account (aipictury.com), as the editor does.
|
|
711
|
+
if (!require(path.join(EXT, 'gpuServer.js')).explicit()) {
|
|
712
|
+
try { await require(path.join(EXT, 'gpuServer.js')).adopt((await sync.cloud.getPreferences())?.values?.gpuServer) } catch { /* said below if none */ }
|
|
713
|
+
}
|
|
714
|
+
// Every call to the GPU server carries the account's token (the server may require it), and only those.
|
|
715
|
+
const { serverFetch } = require(path.join(EXT, 'account.js'))
|
|
716
|
+
globalThis.fetch = serverFetch({ fetch: globalThis.fetch, account, server })
|
|
717
|
+
try { return await mainRun(o) } finally { await sync.flush() }
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function mainRun(o) {
|
|
721
|
+
const { SessionStore } = require(path.join(EXT, 'sessions.js'))
|
|
722
|
+
const store = new SessionStore(process.cwd())
|
|
723
|
+
let entry = null
|
|
724
|
+
if (o.continue) entry = store.list()[0] ? store.get(store.list()[0].id) : null
|
|
725
|
+
if (typeof o.resume === 'string') {
|
|
726
|
+
entry = store.get(o.resume)
|
|
727
|
+
if (!entry) { process.stderr.write(`aipictury: no conversation ${o.resume} in this folder\n`); return 1 }
|
|
728
|
+
}
|
|
729
|
+
if (o.resume === true && o.print) { process.stderr.write('aipictury: --resume needs an id in print mode\n'); return 2 }
|
|
730
|
+
const convo = new Conversation(store, entry)
|
|
731
|
+
let text = o.prompt
|
|
732
|
+
if (!process.stdin.isTTY && o.print) {
|
|
733
|
+
const piped = fs.readFileSync(0, 'utf8')
|
|
734
|
+
if (piped.trim()) text = text ? `${text}\n\n${piped}` : piped
|
|
735
|
+
}
|
|
736
|
+
let cfg
|
|
737
|
+
try { cfg = await serverConfig() } catch (error) {
|
|
738
|
+
process.stderr.write(server() ? `aipictury: cannot use the server at ${server()} — ${error.message}\n` : `aipictury: ${error.message}\n`)
|
|
739
|
+
return 1
|
|
740
|
+
}
|
|
741
|
+
if (o.print) {
|
|
742
|
+
if (!text) { process.stderr.write('aipictury: -p needs a prompt\n'); return 2 }
|
|
743
|
+
return printMode(o, convo, text)
|
|
744
|
+
}
|
|
745
|
+
if (o.resume === true) {
|
|
746
|
+
const list = store.list().slice(0, 15)
|
|
747
|
+
if (!list.length) process.stdout.write('No conversations in this folder yet.\n')
|
|
748
|
+
else list.forEach((e) => process.stdout.write(`${e.id} ${e.title}\n`))
|
|
749
|
+
return 0
|
|
750
|
+
}
|
|
751
|
+
return interactive(o, convo, text, cfg)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
main().then((code) => {
|
|
755
|
+
require(path.join(EXT, 'agent.js')).stopBackground?.()
|
|
756
|
+
try { require(path.join(EXT, 'mcp.js')).closeAll() } catch { /* none */ }
|
|
757
|
+
require(path.join(EXT, 'browser.js')).close().finally(() => process.exit(code))
|
|
758
|
+
}, (error) => { process.stderr.write(`aipictury: ${error.stack || error.message}\n`); process.exit(1) })
|