subconscious-cli 5.0.1 → 5.0.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/bin/cli.js CHANGED
@@ -46,6 +46,7 @@ import { showUpdateNotice } from './update-check.js';
46
46
  import { runTui } from './tui.js';
47
47
  import { sessionsCommand } from './sessions.js';
48
48
  import { printUsageHelp, usageCommand } from './usage.js';
49
+ import { feedbackCommand, printFeedbackHelp } from './feedback.js';
49
50
 
50
51
  function isHelpArg(arg) {
51
52
  return arg === 'help' || arg === '-h' || arg === '--help';
@@ -70,6 +71,7 @@ function printHelp() {
70
71
  ${c.cyan}logout${c.reset} Remove saved credentials
71
72
  ${c.cyan}whoami${c.reset} Show current authentication status
72
73
  ${c.cyan}usage${c.reset} Show billing, quota, and model usage
74
+ ${c.cyan}feedback${c.reset} Send feedback or get help from support
73
75
  ${c.cyan}upgrade${c.reset} Upgrade this CLI to the latest version
74
76
 
75
77
  ${c.bold}Profiles${c.reset}
@@ -100,6 +102,7 @@ ${agents}
100
102
  ${c.dim}$${c.reset} subc sessions resume claude:SESSION_ID --harness codex
101
103
  ${c.dim}$${c.reset} subc usage
102
104
  ${c.dim}$${c.reset} subc usage --json
105
+ ${c.dim}$${c.reset} subc feedback --subject "Bug" --message "Playground won't load"
103
106
  ${c.dim}$${c.reset} subc claude
104
107
  ${c.dim}$${c.reset} subc claude help
105
108
  ${c.dim}$${c.reset} subc cursor install
@@ -179,6 +182,14 @@ Usage:
179
182
  subc usage help
180
183
 
181
184
  Show billing mode, daily token allowance, credit balance, and per-model usage.
185
+ `,
186
+ feedback: `
187
+ Usage:
188
+ subc feedback
189
+ subc feedback -s "..." -m "..."
190
+ subc feedback help
191
+
192
+ Send a message to the Subconscious support team.
182
193
  `,
183
194
  models: `
184
195
  Usage:
@@ -445,6 +456,16 @@ async function main() {
445
456
  return;
446
457
  }
447
458
 
459
+ if (command === 'feedback') {
460
+ if (isHelpArg(args[1])) {
461
+ printFeedbackHelp();
462
+ return;
463
+ }
464
+ const profile = await loadProfile(profileName);
465
+ await feedbackCommand(args.slice(1), { profile, profileName });
466
+ return;
467
+ }
468
+
448
469
  const agent = resolveAgent(command);
449
470
  if (agent) {
450
471
  const agentArgs = args.slice(1);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Reads a PNG or JPEG from the OS clipboard. Terminal Ctrl+V does not deliver
3
+ * image pixels, so the feedback prompt calls this when that key is pressed.
4
+ */
5
+
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
12
+
13
+ export function imageContentType(buffer) {
14
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
15
+ return 'image/jpeg';
16
+ }
17
+ if (buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
18
+ return 'image/png';
19
+ }
20
+ return null;
21
+ }
22
+
23
+ async function readDarwinClipboard() {
24
+ const script = `
25
+ try
26
+ set pngData to the clipboard as «class PNGf»
27
+ on error
28
+ return "NONE"
29
+ end try
30
+ set outPath to POSIX path of (path to temporary items from user domain) & "subc-clipboard.png"
31
+ set outFile to open for access POSIX file outPath with write permission
32
+ set eof of outFile to 0
33
+ write pngData to outFile
34
+ close access outFile
35
+ return outPath
36
+ `;
37
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
38
+ const outPath = String(stdout).trim();
39
+ if (!outPath || outPath === 'NONE') return null;
40
+ const { readFile, rm } = await import('node:fs/promises');
41
+ try {
42
+ return await readFile(outPath);
43
+ } finally {
44
+ await rm(outPath, { force: true });
45
+ }
46
+ }
47
+
48
+ async function readLinuxClipboard() {
49
+ const commands = [
50
+ ['wl-paste', ['--type', 'image/png']],
51
+ ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']],
52
+ ];
53
+ for (const [command, args] of commands) {
54
+ try {
55
+ const { stdout } = await execFileAsync(command, args, { encoding: 'buffer', maxBuffer: 8 * 1024 * 1024 });
56
+ if (stdout?.length) return stdout;
57
+ } catch {
58
+ // Try the next clipboard tool.
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ async function readWindowsClipboard() {
65
+ const script = `
66
+ Add-Type -AssemblyName System.Windows.Forms
67
+ Add-Type -AssemblyName System.Drawing
68
+ $image = [System.Windows.Forms.Clipboard]::GetImage()
69
+ if ($null -eq $image) { exit 2 }
70
+ $path = Join-Path $env:TEMP 'subc-clipboard.png'
71
+ $image.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
72
+ Write-Output $path
73
+ `;
74
+ try {
75
+ const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script]);
76
+ const outPath = String(stdout).trim();
77
+ if (!outPath) return null;
78
+ const { readFile, rm } = await import('node:fs/promises');
79
+ try {
80
+ return await readFile(outPath);
81
+ } finally {
82
+ await rm(outPath, { force: true });
83
+ }
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export async function readClipboardImage() {
90
+ if (process.platform === 'darwin') return readDarwinClipboard();
91
+ if (process.platform === 'win32') return readWindowsClipboard();
92
+ return readLinuxClipboard();
93
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * `subc feedback` — send a message to the Subconscious support team.
3
+ *
4
+ * POSTs to the platform's /api/cli/feedback endpoint with the profile's API
5
+ * key; the platform emails support@subconscious.dev and posts to Slack.
6
+ */
7
+
8
+ import fs from 'node:fs/promises';
9
+ import os from 'node:os';
10
+ import readline from 'node:readline';
11
+ import { createInterface } from 'node:readline/promises';
12
+ import { c } from './colors.js';
13
+ import { getApiKey, getPlatformUrl } from './auth.js';
14
+ import { imageContentType, readClipboardImage } from './clipboard-image.js';
15
+ import { DEFAULT_PROFILE } from './profiles.js';
16
+
17
+ export const FEEDBACK_API_PATH = '/api/cli/feedback';
18
+ export const SUPPORT_EMAIL = 'support@subconscious.dev';
19
+
20
+ /**
21
+ * Parses feedback command arguments. Shared with tests.
22
+ *
23
+ * -s, --subject <text> One-line summary (default: "CLI feedback")
24
+ * -m, --message <text> The message body (prompts when omitted on a TTY)
25
+ * --image <path> JPEG or PNG to attach (repeatable)
26
+ */
27
+ export function parseFeedbackArgs(argv = []) {
28
+ let subject = '';
29
+ let message = '';
30
+ const images = [];
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const arg = argv[i];
33
+ if (arg === '-s' || arg === '--subject') {
34
+ subject = argv[++i] ?? '';
35
+ } else if (arg.startsWith('--subject=')) {
36
+ subject = arg.slice('--subject='.length);
37
+ } else if (arg === '-m' || arg === '--message') {
38
+ message = argv[++i] ?? '';
39
+ } else if (arg.startsWith('--message=')) {
40
+ message = arg.slice('--message='.length);
41
+ } else if (arg === '--image') {
42
+ images.push(argv[++i] ?? '');
43
+ } else if (arg.startsWith('--image=')) {
44
+ images.push(arg.slice('--image='.length));
45
+ } else if (arg !== '') {
46
+ throw new Error(`Unknown option: ${arg}`);
47
+ }
48
+ }
49
+ return { subject: subject.trim(), message: message.trim(), images };
50
+ }
51
+
52
+ /** Triage context sent alongside the message. Never includes the API key. */
53
+ export async function collectFeedbackContext(options = {}) {
54
+ const pkgPath = new URL('../package.json', import.meta.url);
55
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
56
+ const platformUrl = getPlatformUrl(options.profile);
57
+ return {
58
+ 'CLI version': pkg.version,
59
+ Profile: options.profileName ?? DEFAULT_PROFILE,
60
+ 'Platform URL': platformUrl,
61
+ 'Gateway URL': options.profile?.values?.GATEWAY_URL?.trim() ?? '',
62
+ Node: process.version,
63
+ OS: `${os.platform()} ${os.release()}`,
64
+ };
65
+ }
66
+
67
+ export function buildFeedbackPayload({ subject, message, context, attachments }) {
68
+ const payload = {
69
+ subject: subject || 'CLI feedback',
70
+ message,
71
+ context: Object.fromEntries(
72
+ Object.entries(context ?? {}).filter(([, value]) => Boolean(String(value).trim())),
73
+ ),
74
+ };
75
+ if (attachments?.length) payload.attachments = attachments;
76
+ return payload;
77
+ }
78
+
79
+ export async function loadFeedbackImages(paths) {
80
+ const attachments = [];
81
+ for (const filePath of paths) {
82
+ if (!filePath?.trim()) throw new Error('An image path is required after --image');
83
+ const buffer = await fs.readFile(filePath);
84
+ const contentType = imageContentType(buffer);
85
+ if (!contentType) throw new Error(`${filePath} is not a JPEG or PNG`);
86
+ attachments.push({ contentType, data: buffer.toString('base64') });
87
+ }
88
+ return attachments;
89
+ }
90
+
91
+ export async function submitFeedback(apiKey, platformUrl, payload, fetchImpl = fetch) {
92
+ const res = await fetchImpl(
93
+ `${platformUrl.replace(/\/$/, '')}${FEEDBACK_API_PATH}`,
94
+ {
95
+ method: 'POST',
96
+ headers: {
97
+ Authorization: `Bearer ${apiKey}`,
98
+ 'Content-Type': 'application/json',
99
+ },
100
+ body: JSON.stringify(payload),
101
+ signal: AbortSignal.timeout(payload.attachments?.length ? 30000 : 8000),
102
+ },
103
+ );
104
+ return res;
105
+ }
106
+
107
+ const MAX_FEEDBACK_IMAGES = 3;
108
+
109
+ function readAttachmentLine(onPaste) {
110
+ return new Promise((resolve) => {
111
+ const rl = readline.createInterface({
112
+ input: process.stdin,
113
+ output: process.stdout,
114
+ prompt: ' Attachments: ',
115
+ });
116
+ readline.emitKeypressEvents(process.stdin, rl);
117
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
118
+ rl.prompt();
119
+ let line = '';
120
+ const finish = (value) => {
121
+ process.stdin.off('keypress', onKey);
122
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
123
+ rl.close();
124
+ resolve(value);
125
+ };
126
+ const onKey = (str, key) => {
127
+ if (!key) return;
128
+ if (key.ctrl && key.name === 'c') {
129
+ finish(null);
130
+ return;
131
+ }
132
+ if (key.ctrl && key.name === 'v') {
133
+ void onPaste();
134
+ return;
135
+ }
136
+ if (key.name === 'return') {
137
+ process.stdout.write('\n');
138
+ if (line.trim()) {
139
+ console.log(
140
+ ` ${c.dim}Paste a screenshot with Ctrl+V. Press Enter with nothing typed to continue.${c.reset}`,
141
+ );
142
+ line = '';
143
+ rl.prompt();
144
+ return;
145
+ }
146
+ finish('');
147
+ return;
148
+ }
149
+ if (key.name === 'backspace') {
150
+ if (!line.length) return;
151
+ line = line.slice(0, -1);
152
+ process.stdout.write('\b \b');
153
+ return;
154
+ }
155
+ if (str && !key.ctrl && !key.meta) {
156
+ line += str;
157
+ process.stdout.write(str);
158
+ }
159
+ };
160
+ process.stdin.on('keypress', onKey);
161
+ });
162
+ }
163
+
164
+ async function promptForAttachments(already = 0) {
165
+ if (process.stdin.isTTY !== true || already >= MAX_FEEDBACK_IMAGES) return [];
166
+ const attachments = [];
167
+ console.log(`\n Attachments (optional). Ctrl+V pastes a screenshot from the clipboard.`);
168
+ console.log(` ${c.dim}Press Enter when you are done.${c.reset}`);
169
+ while (already + attachments.length < MAX_FEEDBACK_IMAGES) {
170
+ const line = await readAttachmentLine(async () => {
171
+ if (already + attachments.length >= MAX_FEEDBACK_IMAGES) return;
172
+ const buffer = await readClipboardImage();
173
+ const contentType = buffer ? imageContentType(buffer) : null;
174
+ if (!contentType || !buffer) {
175
+ console.log(`\n ${c.yellow}No JPEG or PNG image on the clipboard.${c.reset}`);
176
+ return;
177
+ }
178
+ attachments.push({ contentType, data: buffer.toString('base64') });
179
+ console.log(` ${c.green}Added screenshot ${already + attachments.length}.${c.reset}`);
180
+ });
181
+ if (line == null || !line.trim()) break;
182
+ }
183
+ return attachments;
184
+ }
185
+
186
+ async function promptForFeedback(argv) {
187
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
188
+ try {
189
+ const subject = argv.subject || (await rl.question(`\n Subject (optional): `)).trim();
190
+ let message = '';
191
+ while (!message) {
192
+ message = (await rl.question(` Message: `)).trim();
193
+ if (!message) console.log(` ${c.dim}Message cannot be empty.${c.reset}`);
194
+ }
195
+ return { subject, message };
196
+ } finally {
197
+ rl.close();
198
+ }
199
+ }
200
+
201
+ export async function feedbackCommand(argv = [], options = {}) {
202
+ const parsed = parseFeedbackArgs(argv);
203
+ const profileFlag =
204
+ options.profileName && options.profileName !== DEFAULT_PROFILE
205
+ ? `--profile ${options.profileName} `
206
+ : '';
207
+
208
+ const auth = await getApiKey(options.profile);
209
+ if (!auth) {
210
+ console.log(`\n ${c.dim}Not logged in.${c.reset}`);
211
+ console.log(` Run ${c.cyan}subc ${profileFlag}login${c.reset} to get started.\n`);
212
+ return;
213
+ }
214
+
215
+ let feedback = parsed;
216
+ if (!feedback.message) {
217
+ if (process.stdin.isTTY !== true) {
218
+ console.error(`\n ${c.red}A message is required.${c.reset}`);
219
+ console.error(` Pass one with ${c.cyan}subc feedback --message "..."${c.reset}\n`);
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+ feedback = await promptForFeedback(parsed);
224
+ }
225
+
226
+ const fromFlags = await loadFeedbackImages(parsed.images);
227
+ const fromPrompt = feedback.message && parsed.message ? [] : await promptForAttachments(fromFlags.length);
228
+ const platformUrl = getPlatformUrl(options.profile);
229
+ const payload = buildFeedbackPayload({
230
+ subject: feedback.subject,
231
+ message: feedback.message,
232
+ context: await collectFeedbackContext(options),
233
+ attachments: [...fromFlags, ...fromPrompt],
234
+ });
235
+
236
+ try {
237
+ const res = await submitFeedback(auth.key, platformUrl, payload);
238
+
239
+ if (res.status === 404) {
240
+ console.error(`\n ${c.yellow}This platform has no feedback endpoint (404).${c.reset}`);
241
+ console.error(` ${c.dim}Host: ${platformUrl}${c.reset}`);
242
+ console.error(` ${c.dim}Point PLATFORM_URL at a platform that includes /api/cli/feedback.${c.reset}\n`);
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+
247
+ let data;
248
+ try {
249
+ data = await res.json();
250
+ } catch {
251
+ data = {};
252
+ }
253
+
254
+ if (!res.ok) {
255
+ const detail = typeof data?.error === 'string' ? data.error : '';
256
+ const invalidKey = detail === 'Invalid or revoked API key' || detail === 'No API key provided';
257
+ console.log(`\n ${c.red}✗ Couldn't send your message.${c.reset}`);
258
+ if (invalidKey) {
259
+ console.log(
260
+ ` Run ${c.cyan}subc ${profileFlag}logout${c.reset} then ${c.cyan}subc ${profileFlag}login${c.reset} to re-authenticate.`,
261
+ );
262
+ }
263
+ console.log(` Please email ${c.cyan}${SUPPORT_EMAIL}${c.reset} directly.\n`);
264
+ process.exitCode = 1;
265
+ return;
266
+ }
267
+
268
+ console.log(`\n ${c.green}✓${c.reset} Feedback sent to ${SUPPORT_EMAIL}.`);
269
+ console.log(` ${c.dim}Our support team will get back to you.\n`);
270
+ } catch (error) {
271
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') {
272
+ console.log(`\n ${c.yellow}Could not reach the platform (timed out).${c.reset}`);
273
+ console.log(` ${c.dim}Host: ${platformUrl}${c.reset}`);
274
+ } else {
275
+ console.log(`\n ${c.red}✗ Couldn't send your message.${c.reset}`);
276
+ }
277
+ console.log(` Please email ${c.cyan}${SUPPORT_EMAIL}${c.reset} directly.\n`);
278
+ process.exitCode = 1;
279
+ }
280
+ }
281
+
282
+ export function printFeedbackHelp() {
283
+ console.log(`
284
+ Usage:
285
+ subc feedback
286
+ subc feedback --subject "..." --message "..."
287
+ subc feedback --image ./screenshot.png
288
+ subc -p NAME feedback
289
+ subc feedback help
290
+
291
+ Send a message to the Subconscious support team (${SUPPORT_EMAIL}).
292
+ Your CLI version, profile, and environment are included so support can
293
+ triage without a back-and-forth.
294
+
295
+ -s, --subject <text> One-line summary (prompted when omitted on a TTY)
296
+ -m, --message <text> The message body (prompted when omitted on a TTY)
297
+ --image <path> JPEG or PNG to attach (repeatable). With -m, skips the paste prompt.
298
+ `);
299
+ }
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subconscious-cli",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "CLI for Subconscious — run Subconscious Code, Claude Code, Codex, OpenCode, DeepSeek Harness, Cursor, Copilot, and Pi",
5
5
  "bin": {
6
6
  "subc": "bin/cli.js"
@@ -9,7 +9,7 @@
9
9
  "build:tui": "node scripts/build-tui.js",
10
10
  "build:tui:host": "node scripts/build-tui.js --host",
11
11
  "test": "node --test",
12
- "test:windows": "node --test test/windows.test.js test/models.test.js test/model-capabilities.test.js test/auth.test.js test/tui.test.js test/update-check.test.js test/sessions.test.js test/branding.test.js test/claude-version.test.js test/usage.test.js test/release.test.js",
12
+ "test:windows": "node --test test/windows.test.js test/models.test.js test/model-capabilities.test.js test/auth.test.js test/tui.test.js test/update-check.test.js test/sessions.test.js test/branding.test.js test/claude-version.test.js test/usage.test.js test/feedback.test.js test/release.test.js",
13
13
  "test:tui": "go -C tui test ./...",
14
14
  "prepack": "npm run build:tui",
15
15
  "prepublishOnly": "node scripts/require-publish-script.js"