@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.
- package/bin/friday.js +13 -0
- package/bin/postinstall.js +27 -0
- package/package.json +47 -0
- package/src/cli.js +132 -0
- package/src/commands/chat/slashCommands.js +976 -0
- package/src/commands/chat/smartAffordances.js +139 -0
- package/src/commands/chat/ui.js +146 -0
- package/src/commands/chat/welcomeScreen.js +161 -0
- package/src/commands/chat.js +763 -0
- package/src/commands/install.js +152 -0
- package/src/commands/plugins.js +65 -0
- package/src/commands/schedule.js +278 -0
- package/src/commands/serve.js +206 -0
- package/src/commands/setup.js +187 -0
- package/src/commands/uninstall.js +76 -0
|
@@ -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
|
+
}
|