wispy-cli 2.7.13 → 2.7.15

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/core/harness.mjs CHANGED
@@ -19,6 +19,99 @@ import os from "node:os";
19
19
 
20
20
  import { EVENT_TYPES } from "./audit.mjs";
21
21
 
22
+ // ── Tool allow/deny patterns ───────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Claude Code compatible tool name aliases.
26
+ * Maps user-facing alias to internal tool name.
27
+ */
28
+ export const TOOL_ALIASES = {
29
+ "Bash": "run_command",
30
+ "Edit": "file_edit",
31
+ "Write": "write_file",
32
+ "Read": "read_file",
33
+ "Grep": "file_search",
34
+ "LS": "list_directory",
35
+ "WebSearch": "web_search",
36
+ };
37
+
38
+ /**
39
+ * Parse a tool pattern string like:
40
+ * "Bash(git:*)" → { tool: "run_command", argPattern: "git *" }
41
+ * "Edit" → { tool: "file_edit", argPattern: "*" }
42
+ * "Read(*.ts)" → { tool: "read_file", argPattern: "*.ts" }
43
+ * "run_command" → { tool: "run_command", argPattern: "*" }
44
+ *
45
+ * @param {string} pattern
46
+ * @returns {{ tool: string, argPattern: string }}
47
+ */
48
+ export function parseToolPattern(pattern) {
49
+ const m = pattern.match(/^([^(]+)(?:\(([^)]*)\))?$/);
50
+ if (!m) return null;
51
+
52
+ const rawName = m[1].trim();
53
+ const argSpec = m[2] ?? "*";
54
+
55
+ // Resolve alias
56
+ const toolName = TOOL_ALIASES[rawName] ?? rawName;
57
+
58
+ // Normalize "git:*" style to "git *" for command matching
59
+ const argPattern = argSpec.replace(/:/g, " ").trim() || "*";
60
+
61
+ return { tool: toolName, argPattern };
62
+ }
63
+
64
+ /**
65
+ * Parse a space-separated list of tool patterns.
66
+ * @param {string} patternsStr - e.g. "Bash(git:*) read_file Edit"
67
+ * @returns {Array<{ tool: string, argPattern: string }>}
68
+ */
69
+ export function parseToolPatternList(patternsStr) {
70
+ if (!patternsStr) return [];
71
+ // Split on whitespace but respect parentheses
72
+ const patterns = [];
73
+ let current = "";
74
+ let depth = 0;
75
+ for (const ch of patternsStr) {
76
+ if (ch === "(") { depth++; current += ch; }
77
+ else if (ch === ")") { depth--; current += ch; }
78
+ else if ((ch === " " || ch === ",") && depth === 0) {
79
+ if (current.trim()) patterns.push(current.trim());
80
+ current = "";
81
+ } else {
82
+ current += ch;
83
+ }
84
+ }
85
+ if (current.trim()) patterns.push(current.trim());
86
+ return patterns.map(parseToolPattern).filter(Boolean);
87
+ }
88
+
89
+ /**
90
+ * Check whether a tool call matches a pattern.
91
+ * @param {string} toolName
92
+ * @param {object} args
93
+ * @param {{ tool: string, argPattern: string }} pattern
94
+ */
95
+ export function matchesPattern(toolName, args, pattern) {
96
+ // Tool name must match
97
+ if (pattern.tool !== toolName && pattern.tool !== "*") return false;
98
+ // If no arg pattern, it's a match
99
+ if (pattern.argPattern === "*") return true;
100
+ // Get the relevant arg string
101
+ const argStr = _getArgString(toolName, args);
102
+ return _globMatch(argStr, pattern.argPattern);
103
+ }
104
+
105
+ /**
106
+ * Simple glob matching (supports * wildcard).
107
+ */
108
+ function _globMatch(str, pattern) {
109
+ if (pattern === "*") return true;
110
+ // Convert glob to regex
111
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
112
+ return new RegExp(`^${escaped}`, "i").test(str);
113
+ }
114
+
22
115
  // ── Approval gate constants ────────────────────────────────────────────────────
23
116
 
24
117
  // Tools that require approval depending on security mode
@@ -491,6 +584,55 @@ export class Harness extends EventEmitter {
491
584
  file_edit: "diff",
492
585
  git: "preview",
493
586
  };
587
+
588
+ // Tool allow/deny patterns (from config)
589
+ this._allowedPatterns = config.allowedTools ? parseToolPatternList(config.allowedTools) : null; // null = allow all
590
+ this._disallowedPatterns = config.disallowedTools ? parseToolPatternList(config.disallowedTools) : [];
591
+ }
592
+
593
+ /**
594
+ * Set allowed tools (replaces current filter).
595
+ * @param {string|Array} patterns - Space-separated string or array of pattern strings
596
+ */
597
+ setAllowedTools(patterns) {
598
+ if (!patterns) { this._allowedPatterns = null; return; }
599
+ const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
600
+ this._allowedPatterns = parseToolPatternList(str);
601
+ }
602
+
603
+ /**
604
+ * Set disallowed tools.
605
+ * @param {string|Array} patterns
606
+ */
607
+ setDisallowedTools(patterns) {
608
+ if (!patterns) { this._disallowedPatterns = []; return; }
609
+ const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
610
+ this._disallowedPatterns = parseToolPatternList(str);
611
+ }
612
+
613
+ /**
614
+ * Check if a tool call is allowed by the current allow/deny filters.
615
+ * @param {string} toolName
616
+ * @param {object} args
617
+ * @returns {{ allowed: boolean, reason?: string }}
618
+ */
619
+ checkToolFilter(toolName, args) {
620
+ // Check disallowed patterns first (deny takes precedence)
621
+ for (const pattern of this._disallowedPatterns) {
622
+ if (matchesPattern(toolName, args, pattern)) {
623
+ return { allowed: false, reason: `Tool '${toolName}' is in the disallowed list.` };
624
+ }
625
+ }
626
+
627
+ // Check allowed patterns (if set)
628
+ if (this._allowedPatterns !== null && this._allowedPatterns.length > 0) {
629
+ const allowed = this._allowedPatterns.some(p => matchesPattern(toolName, args, p));
630
+ if (!allowed) {
631
+ return { allowed: false, reason: `Tool '${toolName}' is not in the allowed list.` };
632
+ }
633
+ }
634
+
635
+ return { allowed: true };
494
636
  }
495
637
 
496
638
  /**
@@ -510,6 +652,26 @@ export class Harness extends EventEmitter {
510
652
 
511
653
  const callStart = Date.now();
512
654
 
655
+ // ── 0. Tool allow/deny filter ────────────────────────────────────────────
656
+ const filterResult = this.checkToolFilter(toolName, args);
657
+ if (!filterResult.allowed) {
658
+ receipt.approved = false;
659
+ receipt.success = false;
660
+ receipt.error = filterResult.reason ?? `Tool '${toolName}' is not available.`;
661
+ receipt.duration = 0;
662
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "tool-filter" });
663
+ // Return a clear error that the LLM can understand
664
+ return new HarnessResult({
665
+ result: {
666
+ success: false,
667
+ error: filterResult.reason ?? `Tool '${toolName}' is not available in this session.`,
668
+ tool_not_available: true,
669
+ },
670
+ receipt,
671
+ denied: true,
672
+ });
673
+ }
674
+
513
675
  // ── 1. Permission check ──────────────────────────────────────────────────
514
676
  const permResult = await this.permissions.check(toolName, args, context);
515
677
  receipt.permissionLevel = permResult.level ?? "auto";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * core/project-settings.mjs — Per-project configuration for Wispy
3
+ *
4
+ * Supports .wispy/settings.json in the project root (or any parent dir).
5
+ * Settings precedence (highest to lowest):
6
+ * 1. CLI flags
7
+ * 2. Project settings (.wispy/settings.json)
8
+ * 3. User profile settings (wispy -p <name>)
9
+ * 4. User config (~/.wispy/config.json)
10
+ * 5. Defaults
11
+ *
12
+ * Example .wispy/settings.json:
13
+ * {
14
+ * "model": "gpt-4o",
15
+ * "personality": "pragmatic",
16
+ * "effort": "high",
17
+ * "appendSystemPrompt": "This is a TypeScript project using Next.js 15.",
18
+ * "features": { "browser_integration": true },
19
+ * "tools": {
20
+ * "allow": ["run_command", "read_file", "write_file", "file_edit"],
21
+ * "deny": ["delete_file"]
22
+ * },
23
+ * "agents": {
24
+ * "styler": { "description": "CSS specialist", "prompt": "You are a CSS/Tailwind expert." }
25
+ * }
26
+ * }
27
+ */
28
+
29
+ import path from "node:path";
30
+ import { readFile } from "node:fs/promises";
31
+
32
+ const SETTINGS_FILENAME = "settings.json";
33
+ const SETTINGS_DIR = ".wispy";
34
+
35
+ /**
36
+ * Walk up from startDir looking for .wispy/settings.json.
37
+ * Returns parsed settings object (with _projectRoot added) or null.
38
+ *
39
+ * @param {string} startDir - Directory to start searching from (default: cwd)
40
+ * @returns {Promise<object|null>}
41
+ */
42
+ export async function findProjectSettings(startDir = process.cwd()) {
43
+ let dir = path.resolve(startDir);
44
+ const root = path.parse(dir).root;
45
+
46
+ while (true) {
47
+ const settingsPath = path.join(dir, SETTINGS_DIR, SETTINGS_FILENAME);
48
+ try {
49
+ const raw = await readFile(settingsPath, "utf8");
50
+ const settings = JSON.parse(raw);
51
+ if (settings && typeof settings === "object") {
52
+ settings._projectRoot = dir;
53
+ settings._settingsPath = settingsPath;
54
+ return settings;
55
+ }
56
+ } catch { /* not found or parse error, keep walking */ }
57
+
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir || dir === root) break;
60
+ dir = parent;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Deep-merge settings with correct precedence.
67
+ * CLI flags > project settings > profile settings > base config > defaults.
68
+ *
69
+ * For object values (like "features", "tools", "agents"), performs shallow merge.
70
+ * For scalar values, higher-precedence wins.
71
+ *
72
+ * @param {object} base - Base user config (~/.wispy/config.json)
73
+ * @param {object|null} project - Project settings (.wispy/settings.json)
74
+ * @param {object|null} profile - Named profile from config.profiles
75
+ * @param {object} cli - CLI flag overrides
76
+ * @returns {object} Merged settings
77
+ */
78
+ export function mergeSettings(base = {}, project = null, profile = null, cli = {}) {
79
+ // Start with base config (strip profiles map to avoid leakage)
80
+ const { profiles: _profiles, ...baseClean } = base;
81
+ let merged = { ...baseClean };
82
+
83
+ // Apply profile on top (profile keys override base)
84
+ if (profile && typeof profile === "object") {
85
+ const { profiles: _pp, ...profileClean } = profile;
86
+ for (const [key, value] of Object.entries(profileClean)) {
87
+ if (value !== undefined) merged[key] = value;
88
+ }
89
+ }
90
+
91
+ // Apply project settings on top (project overrides profile + base)
92
+ if (project && typeof project === "object") {
93
+ for (const [key, value] of Object.entries(project)) {
94
+ if (key.startsWith("_")) continue; // skip internal metadata keys
95
+ if (value !== undefined && value !== null) {
96
+ // Deep merge objects (features, tools, agents)
97
+ if (
98
+ typeof value === "object" &&
99
+ !Array.isArray(value) &&
100
+ typeof merged[key] === "object" &&
101
+ !Array.isArray(merged[key]) &&
102
+ merged[key] !== null
103
+ ) {
104
+ merged[key] = { ...merged[key], ...value };
105
+ } else {
106
+ merged[key] = value;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // Apply CLI flags last (highest precedence)
113
+ if (cli && typeof cli === "object") {
114
+ for (const [key, value] of Object.entries(cli)) {
115
+ if (value !== undefined && value !== null) {
116
+ merged[key] = value;
117
+ }
118
+ }
119
+ }
120
+
121
+ return merged;
122
+ }
package/core/session.mjs CHANGED
@@ -23,7 +23,7 @@ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
23
23
  import { SESSIONS_DIR } from "./config.mjs";
24
24
 
25
25
  export class Session {
26
- constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null }) {
26
+ constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null, name = null, model = null, cwd = null }) {
27
27
  this.id = id;
28
28
  this.workstream = workstream;
29
29
  this.channel = channel;
@@ -31,14 +31,20 @@ export class Session {
31
31
  this.messages = messages;
32
32
  this.createdAt = createdAt ?? new Date().toISOString();
33
33
  this.updatedAt = new Date().toISOString();
34
+ this.name = name ?? null; // user-given display name
35
+ this.model = model ?? null; // model used in this session
36
+ this.cwd = cwd ?? process.cwd(); // working directory when session was created
34
37
  }
35
38
 
36
39
  toJSON() {
37
40
  return {
38
41
  id: this.id,
42
+ name: this.name,
39
43
  workstream: this.workstream,
40
44
  channel: this.channel,
41
45
  chatId: this.chatId,
46
+ model: this.model,
47
+ cwd: this.cwd,
42
48
  messages: this.messages,
43
49
  createdAt: this.createdAt,
44
50
  updatedAt: this.updatedAt,
@@ -55,9 +61,9 @@ export class SessionManager {
55
61
  /**
56
62
  * Create a new session
57
63
  */
58
- create({ workstream = "default", channel = null, chatId = null } = {}) {
64
+ create({ workstream = "default", channel = null, chatId = null, name = null, model = null, cwd = null } = {}) {
59
65
  const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
60
- const session = new Session({ id, workstream, channel, chatId });
66
+ const session = new Session({ id, workstream, channel, chatId, name, model, cwd });
61
67
  this._sessions.set(id, session);
62
68
  if (channel && chatId) {
63
69
  this._keyMap.set(`${channel}:${chatId}`, id);
@@ -136,6 +142,7 @@ export class SessionManager {
136
142
 
137
143
  results.push({
138
144
  id: data.id,
145
+ name: data.name ?? null,
139
146
  workstream: data.workstream ?? "default",
140
147
  channel: data.channel ?? null,
141
148
  chatId: data.chatId ?? null,
@@ -184,6 +191,8 @@ export class SessionManager {
184
191
  workstream: opts.workstream ?? source.workstream,
185
192
  channel: opts.channel ?? null,
186
193
  chatId: opts.chatId ?? null,
194
+ name: opts.name ?? null,
195
+ model: opts.model ?? source.model ?? null,
187
196
  });
188
197
 
189
198
  // Copy message history (deep copy)
@@ -196,6 +205,21 @@ export class SessionManager {
196
205
  return forked;
197
206
  }
198
207
 
208
+ /**
209
+ * Set (or update) the display name of a session.
210
+ * Persists immediately to disk.
211
+ *
212
+ * @param {string} id - Session ID
213
+ * @param {string} name - Display name
214
+ */
215
+ async setName(id, name) {
216
+ const session = this._sessions.get(id);
217
+ if (!session) throw new Error(`Session not found: ${id}`);
218
+ session.name = name;
219
+ session.updatedAt = new Date().toISOString();
220
+ await this.save(id);
221
+ }
222
+
199
223
  /**
200
224
  * Add a message to a session.
201
225
  */
@@ -893,6 +893,21 @@ ${bold("Permissions & Audit (v1.1):")}
893
893
  return true;
894
894
  }
895
895
 
896
+ // ── Image attachment ─────────────────────────────────────────────────────────
897
+
898
+ if (cmd === "/image") {
899
+ const imagePath = parts.slice(1).join(" ");
900
+ if (!imagePath) {
901
+ console.log(yellow("Usage: /image <path> — attach image to next message"));
902
+ return true;
903
+ }
904
+ // Store pending image on engine for next message
905
+ if (!engine._pendingImages) engine._pendingImages = [];
906
+ engine._pendingImages.push(imagePath);
907
+ console.log(green(`📎 Image queued: ${imagePath} — send your message to include it`));
908
+ return true;
909
+ }
910
+
896
911
  if (cmd === "/recall") {
897
912
  const query = parts.slice(1).join(" ");
898
913
  if (!query) { console.log(yellow("Usage: /recall <query>")); return true; }
@@ -1262,6 +1277,10 @@ async function runRepl(engine) {
1262
1277
 
1263
1278
  conversation.push({ role: "user", content: input });
1264
1279
 
1280
+ // Consume pending image attachments (from /image command)
1281
+ const pendingImages = engine._pendingImages ?? [];
1282
+ engine._pendingImages = [];
1283
+
1265
1284
  process.stdout.write(cyan("🌿 "));
1266
1285
  try {
1267
1286
  // Build messages from conversation history (keep system prompt + history)
@@ -1273,6 +1292,7 @@ async function runRepl(engine) {
1273
1292
  systemPrompt: await engine._buildSystemPrompt(input),
1274
1293
  noSave: true,
1275
1294
  dryRun: engine.dryRunMode ?? false,
1295
+ images: pendingImages,
1276
1296
  onSkillLearned: (skill) => {
1277
1297
  console.log(cyan(`\n💡 Learned new skill: '${skill.name}' — use /${skill.name} next time`));
1278
1298
  },
@@ -1307,14 +1327,87 @@ async function runRepl(engine) {
1307
1327
  });
1308
1328
  }
1309
1329
 
1330
+ // ---------------------------------------------------------------------------
1331
+ // REPL with pre-populated history (for resume/fork)
1332
+ // ---------------------------------------------------------------------------
1333
+
1334
+ async function runReplWithHistory(engine, existingConversation) {
1335
+ const wsLabel = ACTIVE_WORKSTREAM === "default" ? "" : ` ${dim("·")} ${cyan(ACTIVE_WORKSTREAM)}`;
1336
+ console.log(`
1337
+ ${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${engine.model}`)}
1338
+ ${dim(`${engine.provider} · /help for commands · Ctrl+C to exit`)}
1339
+ `);
1340
+
1341
+ const conversation = [...existingConversation];
1342
+
1343
+ const rl = createInterface({
1344
+ input: process.stdin,
1345
+ output: process.stdout,
1346
+ prompt: green("› "),
1347
+ historySize: 100,
1348
+ completer: makeCompleter(engine),
1349
+ });
1350
+
1351
+ function updatePrompt() {
1352
+ const ws = ACTIVE_WORKSTREAM !== "default" ? `${cyan(ACTIVE_WORKSTREAM)} ` : "";
1353
+ const dry = engine.dryRunMode ? yellow("(dry) ") : "";
1354
+ rl.setPrompt(ws + dry + green("› "));
1355
+ }
1356
+ updatePrompt();
1357
+ rl.prompt();
1358
+
1359
+ rl.on("line", async (line) => {
1360
+ const input = line.trim();
1361
+ if (!input) { rl.prompt(); return; }
1362
+
1363
+ if (input.startsWith("/")) {
1364
+ const handled = await handleSlashCommand(input, engine, conversation);
1365
+ if (handled) { updatePrompt(); rl.prompt(); return; }
1366
+ }
1367
+
1368
+ conversation.push({ role: "user", content: input });
1369
+
1370
+ // Check for pending image attachment from /image command
1371
+ const pendingImages = engine._pendingImages ?? [];
1372
+ engine._pendingImages = [];
1373
+
1374
+ process.stdout.write(cyan("🌿 "));
1375
+ try {
1376
+ const systemPrompt = await engine._buildSystemPrompt(input);
1377
+ const response = await engine.processMessage(null, input, {
1378
+ onChunk: (chunk) => process.stdout.write(chunk),
1379
+ systemPrompt,
1380
+ noSave: true,
1381
+ images: pendingImages,
1382
+ });
1383
+ console.log("\n");
1384
+ conversation.push({ role: "assistant", content: response.content });
1385
+ if (conversation.length > 50) conversation.splice(0, conversation.length - 50);
1386
+ await saveConversation(conversation);
1387
+ console.log(dim(` ${engine.providers.formatCost()}`));
1388
+ } catch (err) {
1389
+ console.log(red(`\n\n❌ Error: ${err.message.slice(0, 200)}`));
1390
+ }
1391
+
1392
+ rl.prompt();
1393
+ });
1394
+
1395
+ rl.on("close", () => {
1396
+ console.log(dim(`\n🌿 Bye! (${engine.providers.formatCost()})`));
1397
+ try { engine.destroy(); } catch {}
1398
+ process.exit(0);
1399
+ });
1400
+ }
1401
+
1310
1402
  // ---------------------------------------------------------------------------
1311
1403
  // One-shot mode
1312
1404
  // ---------------------------------------------------------------------------
1313
1405
 
1314
- async function runOneShot(engine, message) {
1406
+ async function runOneShot(engine, message, opts = {}) {
1315
1407
  try {
1316
1408
  const response = await engine.processMessage(null, message, {
1317
1409
  onChunk: (chunk) => process.stdout.write(chunk),
1410
+ images: opts.images ?? [],
1318
1411
  });
1319
1412
  console.log("");
1320
1413
  console.log(dim(engine.providers.formatCost()));
@@ -1429,9 +1522,54 @@ if (serverStatus.started && !serverStatus.noBinary) {
1429
1522
  if (args[0] === "overview" || args[0] === "dashboard") { await showOverview(); process.exit(0); }
1430
1523
  if (args[0] === "search" && args[1]) { await searchAcrossWorkstreams(args.slice(1).join(" ")); process.exit(0); }
1431
1524
 
1525
+ // Handle session resume/fork from env (set by wispy resume/fork/sessions commands)
1526
+ const sessionAction = process.env.WISPY_SESSION_ACTION;
1527
+ const sessionActionId = process.env.WISPY_SESSION_ID;
1528
+ if (sessionAction && sessionActionId) {
1529
+ delete process.env.WISPY_SESSION_ACTION;
1530
+ delete process.env.WISPY_SESSION_ID;
1531
+
1532
+ if (sessionAction === "resume") {
1533
+ // Load session and start REPL with existing conversation
1534
+ const { SessionManager } = await import("../core/session.mjs");
1535
+ const mgr = new SessionManager();
1536
+ const session = await mgr.load(sessionActionId);
1537
+ if (!session) {
1538
+ console.error(red(` Session not found: ${sessionActionId}`));
1539
+ process.exit(1);
1540
+ }
1541
+ const conversation = session.messages.filter(m => m.role !== "system");
1542
+ console.log(green(` ▶ Resuming session ${sessionActionId} (${conversation.length} messages)`));
1543
+ console.log(dim(` First message: "${(conversation.find(m => m.role === "user")?.content ?? "").slice(0, 60)}"`));
1544
+ console.log("");
1545
+ // Override loadConversation by starting REPL with pre-populated history
1546
+ await runReplWithHistory(engine, conversation);
1547
+ } else if (sessionAction === "fork") {
1548
+ // Fork already done — just start REPL with forked session
1549
+ const { SessionManager } = await import("../core/session.mjs");
1550
+ const mgr = new SessionManager();
1551
+ const session = await mgr.load(sessionActionId);
1552
+ if (!session) {
1553
+ console.error(red(` Forked session not found: ${sessionActionId}`));
1554
+ process.exit(1);
1555
+ }
1556
+ const conversation = session.messages.filter(m => m.role !== "system");
1557
+ console.log(green(` ⑂ Forked session ${sessionActionId} (${conversation.length} messages)`));
1558
+ console.log("");
1559
+ await runReplWithHistory(engine, conversation);
1560
+ }
1561
+ }
1562
+
1563
+ // Parse image flags from env (set by wispy.mjs -i flag parsing)
1564
+ const globalImages = (() => {
1565
+ try {
1566
+ return process.env.WISPY_IMAGES ? JSON.parse(process.env.WISPY_IMAGES) : [];
1567
+ } catch { return []; }
1568
+ })();
1569
+
1432
1570
  if (args.length > 0 && args[0] !== "--help" && args[0] !== "-h") {
1433
- // One-shot mode
1434
- await runOneShot(engine, args.join(" "));
1571
+ // One-shot mode — with optional image attachments
1572
+ await runOneShot(engine, args.join(" "), { images: globalImages });
1435
1573
  } else if (args[0] === "--help" || args[0] === "-h") {
1436
1574
  console.log(`
1437
1575
  ${bold("🌿 Wispy")} — AI workspace assistant
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.13",
3
+ "version": "2.7.15",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",