@tryfridayai/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * chat/smartAffordances.js — Intent detection and contextual hints
3
+ *
4
+ * Pre-query: Scans user input for image/voice/video/schedule keywords.
5
+ * If the matching capability requires a missing API key, shows a hint.
6
+ *
7
+ * Post-response: After `complete`, if agent mentioned missing keys or
8
+ * unavailable plugins, shows a tip.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+ import { ORANGE, DIM, RESET, BOLD } from './ui.js';
15
+
16
+ const CONFIG_DIR = process.env.FRIDAY_CONFIG_DIR || path.join(os.homedir(), '.friday');
17
+ const ENV_FILE = path.join(CONFIG_DIR, '.env');
18
+
19
+ // ── Intent patterns ──────────────────────────────────────────────────────
20
+
21
+ const INTENT_PATTERNS = [
22
+ {
23
+ capability: 'image',
24
+ patterns: [
25
+ /\b(generate|create|make|draw|design)\s+(an?\s+)?image/i,
26
+ /\b(generate|create|make|draw|design)\s+(an?\s+)?(picture|illustration|photo|artwork|graphic)/i,
27
+ /\bimage\s+(of|for|with)\b/i,
28
+ ],
29
+ requiredKeys: ['OPENAI_API_KEY', 'GOOGLE_API_KEY'],
30
+ keyLabel: 'OpenAI or Google',
31
+ hint: 'images',
32
+ },
33
+ {
34
+ capability: 'voice',
35
+ patterns: [
36
+ /\b(text.to.speech|tts|speak|say|read\s+aloud|voice|narrate)\b/i,
37
+ /\bconvert\s+.*\s+to\s+speech/i,
38
+ /\b(generate|create|make)\s+.*\s+(audio|voice|speech)/i,
39
+ ],
40
+ requiredKeys: ['OPENAI_API_KEY', 'ELEVENLABS_API_KEY', 'GOOGLE_API_KEY'],
41
+ keyLabel: 'OpenAI, ElevenLabs, or Google',
42
+ hint: 'voice',
43
+ },
44
+ {
45
+ capability: 'video',
46
+ patterns: [
47
+ /\b(generate|create|make)\s+(a\s+)?video/i,
48
+ /\bvideo\s+(of|for|with)\b/i,
49
+ ],
50
+ requiredKeys: ['OPENAI_API_KEY', 'GOOGLE_API_KEY'],
51
+ keyLabel: 'OpenAI or Google',
52
+ hint: 'video',
53
+ },
54
+ {
55
+ capability: 'schedule',
56
+ patterns: [
57
+ /\bschedule\b/i,
58
+ /\bevery\s+(day|morning|evening|hour|week|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i,
59
+ /\b(remind|recurring|automate|cron)\b/i,
60
+ ],
61
+ requiredKeys: null, // no key needed, just a tip
62
+ keyLabel: null,
63
+ hint: null,
64
+ },
65
+ ];
66
+
67
+ // ── Helpers ──────────────────────────────────────────────────────────────
68
+
69
+ function envHasAnyKey(keyNames) {
70
+ for (const keyName of keyNames) {
71
+ if (process.env[keyName]) return true;
72
+ }
73
+ // Check ~/.friday/.env
74
+ try {
75
+ if (fs.existsSync(ENV_FILE)) {
76
+ const content = fs.readFileSync(ENV_FILE, 'utf8');
77
+ for (const line of content.split('\n')) {
78
+ const trimmed = line.trim();
79
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
80
+ const [key, ...rest] = trimmed.split('=');
81
+ if (keyNames.includes(key.trim()) && rest.join('=').trim().length > 0) return true;
82
+ }
83
+ }
84
+ } catch { /* ignore */ }
85
+ return false;
86
+ }
87
+
88
+ // ── Pre-query hint ───────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Check user input for intent patterns and return a hint string if
92
+ * a required API key is missing. Returns null if no hint needed.
93
+ *
94
+ * @param {string} input - User's message text
95
+ * @returns {string|null} Hint message to display, or null
96
+ */
97
+ export function checkPreQueryHint(input) {
98
+ for (const intent of INTENT_PATTERNS) {
99
+ const matches = intent.patterns.some(p => p.test(input));
100
+ if (!matches) continue;
101
+
102
+ // If this intent needs keys, check if any are available
103
+ if (intent.requiredKeys && !envHasAnyKey(intent.requiredKeys)) {
104
+ return ` ${ORANGE}You need a ${intent.keyLabel} key for ${intent.hint}. Try ${BOLD}/keys${RESET}`;
105
+ }
106
+
107
+ // Schedule intent — offer the /schedule command
108
+ if (intent.capability === 'schedule') {
109
+ return ` ${DIM}Tip: Use ${BOLD}/schedule${RESET}${DIM} to manage recurring tasks.${RESET}`;
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ // ── Post-response hint ───────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Check the accumulated response text for patterns that suggest
119
+ * the user should take an action. Returns a hint string or null.
120
+ *
121
+ * @param {string} responseText - The accumulated text from the agent response
122
+ * @returns {string|null} Hint message to display, or null
123
+ */
124
+ export function checkPostResponseHint(responseText) {
125
+ if (!responseText) return null;
126
+ const lower = responseText.toLowerCase();
127
+
128
+ // Agent mentioned missing API key
129
+ if (lower.includes('api key') && (lower.includes('missing') || lower.includes('not set') || lower.includes('not configured') || lower.includes('need'))) {
130
+ return ` ${DIM}Tip: Use ${BOLD}/keys${RESET}${DIM} to add API keys.${RESET}`;
131
+ }
132
+
133
+ // Agent mentioned a plugin not being available
134
+ if (lower.includes('plugin') && (lower.includes('not installed') || lower.includes('not available') || lower.includes('not enabled'))) {
135
+ return ` ${DIM}Tip: Use ${BOLD}/plugins${RESET}${DIM} to install plugins.${RESET}`;
136
+ }
137
+
138
+ return null;
139
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * chat/ui.js — Visual system for Friday CLI chat
3
+ *
4
+ * Brand palette (ANSI 256-color), box drawing, prompt styling,
5
+ * and formatting helpers shared across all chat modules.
6
+ */
7
+
8
+ // ── Brand palette (ANSI 256-color) ──────────────────────────────────────
9
+
10
+ export const PURPLE = '\x1b[38;5;141m'; // brand primary — prompt, headers
11
+ export const BLUE = '\x1b[38;5;75m'; // links, URLs
12
+ export const TEAL = '\x1b[38;5;43m'; // success, active status
13
+ export const ORANGE = '\x1b[38;5;215m'; // warnings, costs
14
+ export const PINK = '\x1b[38;5;211m'; // media capabilities
15
+
16
+ // ── Standard ANSI ────────────────────────────────────────────────────────
17
+
18
+ export const DIM = '\x1b[2m';
19
+ export const RESET = '\x1b[0m';
20
+ export const BOLD = '\x1b[1m';
21
+ export const YELLOW = '\x1b[33m';
22
+ export const RED = '\x1b[31m';
23
+ export const CYAN = '\x1b[36m';
24
+ export const GREEN = '\x1b[32m';
25
+
26
+ // ── Prompt ───────────────────────────────────────────────────────────────
27
+
28
+ export const PROMPT_STRING = `${PURPLE}f${RESET} ${BOLD}>${RESET} `;
29
+
30
+ // ── Box drawing ──────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Draw a box around content lines.
34
+ * @param {string} title - Box title (shown in top border)
35
+ * @param {string[]} lines - Content lines to render inside the box
36
+ * @param {number} [width=60] - Outer box width
37
+ * @returns {string} Rendered box string
38
+ */
39
+ export function drawBox(title, lines, width = 60) {
40
+ const innerWidth = width - 2; // account for side borders
41
+ const titleStr = title ? ` ${title} ` : '';
42
+ const dashCount = innerWidth - titleStr.length;
43
+ const topBorder = `${PURPLE}\u256d\u2500${titleStr}${'\u2500'.repeat(Math.max(0, dashCount))}\u256e${RESET}`;
44
+ const bottomBorder = `${PURPLE}\u2570${'\u2500'.repeat(innerWidth)}\u256f${RESET}`;
45
+
46
+ const boxLines = [topBorder];
47
+ for (const line of lines) {
48
+ // Calculate visible length (strip ANSI codes)
49
+ const visible = stripAnsi(line);
50
+ const padding = Math.max(0, innerWidth - visible.length);
51
+ boxLines.push(`${PURPLE}\u2502${RESET}${line}${' '.repeat(padding)}${PURPLE}\u2502${RESET}`);
52
+ }
53
+ boxLines.push(bottomBorder);
54
+ return boxLines.join('\n');
55
+ }
56
+
57
+ /**
58
+ * Strip ANSI escape codes from a string to get visible length.
59
+ */
60
+ export function stripAnsi(str) {
61
+ // eslint-disable-next-line no-control-regex
62
+ return str.replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\]8;;[^\x07]*\x07[^\x1b]*\x1b\]8;;\x07/g, (match) => {
63
+ // OSC 8 hyperlinks — extract display text
64
+ const displayMatch = match.match(/\x07([^\x1b]*)\x1b/);
65
+ return displayMatch ? displayMatch[1] : '';
66
+ });
67
+ }
68
+
69
+ // ── Formatting helpers ───────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Format a label + value pair with consistent alignment.
73
+ */
74
+ export function labelValue(label, value, labelWidth = 14) {
75
+ const padded = (label + ':').padEnd(labelWidth);
76
+ return ` ${DIM}${padded}${RESET} ${value}`;
77
+ }
78
+
79
+ /**
80
+ * Render a status badge: green dot for active, dim dot for inactive.
81
+ */
82
+ export function statusBadge(active, label) {
83
+ if (active) {
84
+ return `${TEAL}\u25cf${RESET} ${label}`;
85
+ }
86
+ return `${DIM}\u25cb ${label}${RESET}`;
87
+ }
88
+
89
+ /**
90
+ * Format a capability icon with optional dim for unconfigured.
91
+ */
92
+ export function capabilityIcon(emoji, label, configured) {
93
+ if (configured) {
94
+ return `${emoji} ${label}`;
95
+ }
96
+ return `${DIM}${emoji} ${label}${RESET}`;
97
+ }
98
+
99
+ /**
100
+ * Print a section header.
101
+ */
102
+ export function sectionHeader(text) {
103
+ return `\n${PURPLE}${BOLD}${text}${RESET}`;
104
+ }
105
+
106
+ /**
107
+ * Print a hint message (orange, indented).
108
+ */
109
+ export function hint(text) {
110
+ return ` ${ORANGE}${text}${RESET}`;
111
+ }
112
+
113
+ /**
114
+ * Print a success message.
115
+ */
116
+ export function success(text) {
117
+ return ` ${TEAL}${text}${RESET}`;
118
+ }
119
+
120
+ /**
121
+ * Print an error message.
122
+ */
123
+ export function error(text) {
124
+ return ` ${RED}${text}${RESET}`;
125
+ }
126
+
127
+ /**
128
+ * Mask a secret value, showing only last 4 chars.
129
+ */
130
+ export function maskSecret(value) {
131
+ if (!value || value.length <= 4) return '****';
132
+ return '*'.repeat(value.length - 4) + value.slice(-4);
133
+ }
134
+
135
+ /**
136
+ * Group items by a key.
137
+ */
138
+ export function groupBy(items, keyFn) {
139
+ const groups = {};
140
+ for (const item of items) {
141
+ const key = keyFn(item);
142
+ if (!groups[key]) groups[key] = [];
143
+ groups[key].push(item);
144
+ }
145
+ return groups;
146
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * chat/welcomeScreen.js — Branded welcome screen for Friday CLI chat
3
+ *
4
+ * Renders on `ready` message. Reads local config files directly
5
+ * (instant, no server round-trip) to show capabilities and plugin status.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { createRequire } from 'module';
12
+ import {
13
+ PURPLE, TEAL, DIM, RESET, BOLD, ORANGE,
14
+ drawBox, capabilityIcon, hint,
15
+ } from './ui.js';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ // ── Resolve paths ────────────────────────────────────────────────────────
20
+
21
+ let runtimeDir;
22
+ try {
23
+ const runtimePkg = require.resolve('friday-runtime/package.json');
24
+ runtimeDir = path.dirname(runtimePkg);
25
+ } catch {
26
+ runtimeDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', '..', 'runtime');
27
+ }
28
+
29
+ const CONFIG_DIR = process.env.FRIDAY_CONFIG_DIR || path.join(os.homedir(), '.friday');
30
+ const PLUGINS_FILE = path.join(CONFIG_DIR, 'plugins.json');
31
+ const ENV_FILE = path.join(CONFIG_DIR, '.env');
32
+
33
+ // ── Helpers ──────────────────────────────────────────────────────────────
34
+
35
+ function readJsonSafe(filePath) {
36
+ try {
37
+ if (fs.existsSync(filePath)) {
38
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
39
+ }
40
+ } catch { /* ignore */ }
41
+ return null;
42
+ }
43
+
44
+ function envHasKey(keyName) {
45
+ // Check process.env first, then ~/.friday/.env
46
+ if (process.env[keyName]) return true;
47
+ try {
48
+ if (fs.existsSync(ENV_FILE)) {
49
+ const content = fs.readFileSync(ENV_FILE, 'utf8');
50
+ for (const line of content.split('\n')) {
51
+ const trimmed = line.trim();
52
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
53
+ const [key, ...rest] = trimmed.split('=');
54
+ if (key.trim() === keyName && rest.join('=').trim().length > 0) return true;
55
+ }
56
+ }
57
+ } catch { /* ignore */ }
58
+ // Also check the project-level .env
59
+ try {
60
+ const projectEnv = path.join(runtimeDir, '..', '.env');
61
+ if (fs.existsSync(projectEnv)) {
62
+ const content = fs.readFileSync(projectEnv, 'utf8');
63
+ for (const line of content.split('\n')) {
64
+ const trimmed = line.trim();
65
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
66
+ const [key, ...rest] = trimmed.split('=');
67
+ if (key.trim() === keyName && rest.join('=').trim().length > 0) return true;
68
+ }
69
+ }
70
+ } catch { /* ignore */ }
71
+ return false;
72
+ }
73
+
74
+ // ── Main render ──────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Render the branded welcome screen.
78
+ * @returns {string} The welcome screen string to print
79
+ */
80
+ export function renderWelcome() {
81
+ // Read version from package.json
82
+ let version = '0.2.0';
83
+ try {
84
+ const cliPkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'package.json');
85
+ const pkg = JSON.parse(fs.readFileSync(cliPkgPath, 'utf8'));
86
+ version = pkg.version || version;
87
+ } catch { /* ignore */ }
88
+
89
+ // Check API key availability
90
+ const hasAnthropic = envHasKey('ANTHROPIC_API_KEY');
91
+ const hasOpenAI = envHasKey('OPENAI_API_KEY');
92
+ const hasGoogle = envHasKey('GOOGLE_API_KEY');
93
+ const hasElevenLabs = envHasKey('ELEVENLABS_API_KEY');
94
+
95
+ // Determine capabilities
96
+ const chatOk = hasAnthropic || hasOpenAI || hasGoogle;
97
+ const imageOk = hasOpenAI || hasGoogle;
98
+ const voiceOk = hasOpenAI || hasElevenLabs || hasGoogle;
99
+ const videoOk = hasOpenAI || hasGoogle;
100
+
101
+ // Read installed plugins
102
+ const pluginsData = readJsonSafe(PLUGINS_FILE);
103
+ const installedPlugins = pluginsData?.plugins ? Object.keys(pluginsData.plugins) : [];
104
+
105
+ // Read catalog for total count
106
+ let totalPlugins = 0;
107
+ let installedNames = [];
108
+ try {
109
+ const catalogPath = path.join(runtimeDir, 'src', 'plugins', 'catalog.json');
110
+ const catalog = readJsonSafe(catalogPath);
111
+ if (catalog?.plugins) {
112
+ totalPlugins = Object.keys(catalog.plugins).length;
113
+ installedNames = installedPlugins
114
+ .map(id => catalog.plugins[id]?.name || id)
115
+ .filter(Boolean);
116
+ }
117
+ } catch { /* ignore */ }
118
+
119
+ // Build capability line
120
+ const caps = [
121
+ capabilityIcon('\ud83d\udcac', 'Chat', chatOk),
122
+ capabilityIcon('\ud83c\udfa8', 'Images', imageOk),
123
+ capabilityIcon('\ud83d\udd0a', 'Voice', voiceOk),
124
+ capabilityIcon('\ud83c\udfac', 'Video', videoOk),
125
+ ].join(' ');
126
+
127
+ // Build plugin line
128
+ let pluginLine;
129
+ if (installedNames.length > 0) {
130
+ const names = installedNames.slice(0, 4).join(', ');
131
+ const suffix = installedNames.length > 4 ? ` +${installedNames.length - 4} more` : '';
132
+ pluginLine = ` Plugins: ${TEAL}${names}${suffix}${RESET}`;
133
+ pluginLine += `${DIM} ${installedNames.length} of ${totalPlugins} installed${RESET}`;
134
+ } else {
135
+ pluginLine = ` ${DIM}Plugins: none installed${RESET}${DIM} 0 of ${totalPlugins} available${RESET}`;
136
+ }
137
+
138
+ // Build missing-key hints
139
+ const hints = [];
140
+ if (!imageOk || !voiceOk || !videoOk) {
141
+ hints.push(` ${DIM}\u2191 ${ORANGE}/keys${DIM} to enable more capabilities${RESET}`);
142
+ }
143
+
144
+ // Assemble box content
145
+ const boxLines = [
146
+ '',
147
+ ` ${caps}`,
148
+ '',
149
+ pluginLine,
150
+ '',
151
+ ` Type ${BOLD}/help${RESET} for commands, or just start talking.`,
152
+ ];
153
+ if (hints.length > 0) {
154
+ boxLines.push('');
155
+ boxLines.push(...hints);
156
+ }
157
+ boxLines.push('');
158
+
159
+ const title = `Friday v${version}`;
160
+ return '\n' + drawBox(title, boxLines);
161
+ }