clementine-agent 1.0.45 → 1.0.47

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.
@@ -1052,26 +1052,45 @@ Obsidian vault with YAML frontmatter, [[wikilinks]], #tags.
1052
1052
  **Remembering:** Durable facts → memory_write(action="update_memory"). Daily context → note_take / memory_write(action="append_daily"). New person → note_create. New task → task_add.
1053
1053
  Save important facts immediately; a background agent also extracts after each exchange.
1054
1054
 
1055
- ## Self-Configuration (don't make the owner edit config files)
1055
+ ## Self-Configuration (never tell ${owner} to edit a config file)
1056
1056
 
1057
- When ${owner} gives you an API key, access token, or similar credential in chat, **save it yourself** with \`env_set\`. Same for channel tokens, Salesforce creds, OAuth client IDs — anything that would otherwise live in \`.env\`. \`env_set(key, value)\` writes to \`~/.clementine/.env\` and hot-reloads into \`process.env\` so the next tool call can use it. No daemon restart needed for most cases (restart only if a channel adapter needs to re-auth).
1057
+ Clementine is self-configuring. Every credential, every integration, every tool permission can be set by calling a tool no hand-editing.
1058
1058
 
1059
- Use \`env_list\` to show what's configured (values masked) and \`env_unset\` to remove one. All three are owner-DM only — they'll refuse in channel messages or cron runs.
1059
+ ### Integrations (Slack, Notion, Stripe, Salesforce, etc.)
1060
1060
 
1061
- ### When a tool call is refused
1061
+ You have a declarative registry of every integration you can configure. Use it:
1062
1062
 
1063
- If any tool call fails with "not in my function schema" / "tool not allowed" / "unknown tool" while you can see the tool exists in your SDK inventory:
1063
+ - \`list_integrations\` shows every integration you know about
1064
+ - \`integration_status [slug]\` — reports which are configured, partial (some creds missing), or missing entirely
1065
+ - \`setup_integration <slug>\` — returns the required env vars, doc URLs, and current status — use this BEFORE asking ${owner} for any credential
1066
+ - \`auth_profile_status\` — shows stored OAuth profiles and token expiry
1064
1067
 
1065
- 1. Call \`allow_tool("<exact_tool_name>")\` persists to your own whitelist
1066
- 2. Retry the original call
1068
+ **When ${owner} says "set up X":** always call \`setup_integration(x)\` first. It returns the exact env var names and where to get each one. Then walk ${owner} through each missing credential one at a time, saving each with \`env_set\` as they provide it. After the last one, call \`integration_status\` to confirm "configured".
1069
+
1070
+ **Never invent env var names.** If an integration isn't in the registry, say so and ask ${owner} to confirm which one they mean — don't guess \`STRIPE_SECRET\` when the registry says \`STRIPE_SECRET_KEY\`.
1071
+
1072
+ ### Saving credentials
1073
+
1074
+ \`env_set(key, value)\` — the one tool for saving any API key, token, or config. On macOS it defaults to the login Keychain (secure); elsewhere it falls back to plaintext \`~/.clementine/.env\`. \`process.env\` is updated immediately — the next tool call can use the value. No restart needed unless a long-lived channel adapter needs re-auth.
1067
1075
 
1068
- Takes effect on your next query. No restart, no config editing, no asking ${owner} to help.
1076
+ Companion tools: \`env_list\` (masked values + backend), \`env_unset\` (removes + clears Keychain entry).
1069
1077
 
1070
- **Never** respond with "this tool isn't available to agents" or "you need to edit my config" or "try it in a different app instead." If the tool name appears in your inventory, you can add it. If you genuinely can't find a tool name, ask ${owner} which tool they meant — don't invent rationalizations.
1078
+ ### Self-update (when ${owner} asks you to update yourself)
1071
1079
 
1072
- \`list_allowed_tools\` shows what you've added. \`disallow_tool\` removes one.
1080
+ Call \`self_update\` **never** manually \`cd ~/clementine && git pull\` or hunt for a source directory. There may be multiple clementine-related directories in home (stale \`~/clementine\`, the real \`~/clementine-dev\`, the data dir \`~/.clementine\`). \`self_update\` knows which source tree this daemon is actually running from — the others are stale or irrelevant and touching them will produce nothing useful while creating dangerous diverging state.
1073
1081
 
1074
- For \`.env\` credentials, same self-service pattern: \`env_set(KEY, value)\` never tell ${owner} to edit the file.
1082
+ If you're unsure what's happening first, run \`where_is_source\` — it reports the absolute source path, current branch/commit, and whether there are uncommitted changes. \`self_update\` does git pull + npm install (if lockfile changed) + npm run build + SIGUSR1 restart, all in the right place.
1083
+
1084
+ ### When a tool call is refused
1085
+
1086
+ If any tool call fails with "not in my function schema" / "tool not allowed" / "unknown tool" while the tool appears in your SDK inventory:
1087
+
1088
+ 1. Call \`allow_tool("<exact_tool_name>")\` — persists to your whitelist
1089
+ 2. Retry the original call
1090
+
1091
+ Takes effect on your next query. **Never** respond with "this tool isn't available to agents," "you need to edit my config," or "try it in a different app." If the tool name exists, you can add it.
1092
+
1093
+ \`list_allowed_tools\` / \`disallow_tool\` manage the list.
1075
1094
 
1076
1095
  ## Context Window Management
1077
1096
 
@@ -1304,6 +1323,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1304
1323
  }
1305
1324
  // Security rules are now appended to systemPrompt in buildOptions()
1306
1325
  // Volatile suffix — put last so the stable prefix above stays cache-friendly.
1326
+ // Integration status — injected here (not in the stable prefix) because
1327
+ // it changes as ${owner} configures new credentials, and we don't want
1328
+ // every env_set to invalidate the cache.
1329
+ if (!isAutonomous) {
1330
+ try {
1331
+ const { summarizeIntegrationStatus } = require('../config/integrations-registry.js');
1332
+ const summary = summarizeIntegrationStatus(process.env);
1333
+ if (summary)
1334
+ parts.push(`## Integration Status\n\n${summary}\n\nCall \`integration_status\`, \`list_integrations\`, or \`setup_integration\` for details.`);
1335
+ }
1336
+ catch { /* non-fatal */ }
1337
+ }
1307
1338
  const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
1308
1339
  const resolvedModel = resolveModel(model) ?? MODEL;
1309
1340
  const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
@@ -1360,12 +1391,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1360
1391
  mcpTool('env_set'),
1361
1392
  mcpTool('env_list'),
1362
1393
  mcpTool('env_unset'),
1394
+ // Integration registry — proactive configuration
1395
+ mcpTool('integration_status'),
1396
+ mcpTool('list_integrations'),
1397
+ mcpTool('setup_integration'),
1398
+ mcpTool('auth_profile_status'),
1363
1399
  // Self-service tool whitelist — Clementine can add tools she discovers
1364
1400
  // in the SDK init inventory but that aren't in her baseline allowedTools
1365
1401
  mcpTool('allow_tool'),
1366
1402
  mcpTool('list_allowed_tools'),
1367
1403
  mcpTool('disallow_tool'),
1368
1404
  mcpTool('self_restart'),
1405
+ mcpTool('self_update'),
1406
+ mcpTool('where_is_source'),
1369
1407
  mcpTool('cron_list'),
1370
1408
  mcpTool('add_cron_job'),
1371
1409
  mcpTool('memory_report'),
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Clementine TypeScript — Integration registry.
3
+ *
4
+ * Declarative metadata for every integration Clementine knows how to set up.
5
+ * The registry is the single source of truth for:
6
+ * - What env vars an integration needs
7
+ * - Where to get each credential (doc URLs)
8
+ * - Whether the integration is configured right now
9
+ * - How to surface gaps to the owner proactively
10
+ *
11
+ * Used by:
12
+ * - integration_status() — reports configured/partial/missing per integration
13
+ * - setup_integration() — walks the owner through setup conversationally
14
+ * - list_integrations() — enumerates what's available
15
+ * - System prompt — "Notion is configured, Stripe needs STRIPE_API_KEY"
16
+ *
17
+ * Replaces the previous pattern where the agent had to recall from chat
18
+ * history or invent which env vars a provider needs — the registry tells her
19
+ * exactly, with docs links, so she never has to guess.
20
+ */
21
+ export type IntegrationKind = 'api-key' | 'oauth' | 'hybrid' | 'channel';
22
+ export interface IntegrationRequirement {
23
+ /** Env var name (uppercase, underscores). */
24
+ envVar: string;
25
+ /** One-line description shown during setup. */
26
+ label: string;
27
+ /** Whether this credential is required (false = optional). */
28
+ required: boolean;
29
+ /** Doc link where the owner can find or generate this credential. */
30
+ docUrl?: string;
31
+ /** Optional regex the value must match (for early validation). */
32
+ pattern?: RegExp;
33
+ /** If true, value is long-lived (API key). If false, short-lived (OAuth token). */
34
+ persistent?: boolean;
35
+ }
36
+ export interface IntegrationDefinition {
37
+ /** Machine-readable slug (kebab-case). */
38
+ slug: string;
39
+ /** Human-friendly label. */
40
+ label: string;
41
+ /** One-line summary shown in status output. */
42
+ description: string;
43
+ /** Credential style. */
44
+ kind: IntegrationKind;
45
+ /** Required + optional env vars. */
46
+ requirements: IntegrationRequirement[];
47
+ /** Top-level docs URL. */
48
+ docUrl?: string;
49
+ /** Tools/capabilities this integration unlocks, for the status panel. */
50
+ capabilities?: string[];
51
+ }
52
+ /**
53
+ * The curated registry. Keep entries alphabetical within each kind for
54
+ * easier scanning. Adding a new integration: declare it here, and both
55
+ * integration_status and setup_integration automatically pick it up.
56
+ */
57
+ export declare const INTEGRATIONS: IntegrationDefinition[];
58
+ export type IntegrationStatus = 'configured' | 'partial' | 'missing';
59
+ export interface IntegrationStatusReport {
60
+ slug: string;
61
+ label: string;
62
+ status: IntegrationStatus;
63
+ /** All required env vars that are currently set. */
64
+ have: string[];
65
+ /** Required env vars that are missing. */
66
+ missing: string[];
67
+ /** Optional env vars that are missing (informational only). */
68
+ optionalMissing: string[];
69
+ /** Helpful setup link. */
70
+ docUrl?: string;
71
+ }
72
+ /**
73
+ * Classify each integration against the current environment.
74
+ * Pure function — caller passes in env so this is testable.
75
+ */
76
+ export declare function classifyIntegrations(env: NodeJS.ProcessEnv, slugs?: string[]): IntegrationStatusReport[];
77
+ export declare function findIntegration(slug: string): IntegrationDefinition | undefined;
78
+ /** A short one-line summary for prompt injection. */
79
+ export declare function summarizeIntegrationStatus(env: NodeJS.ProcessEnv): string;
80
+ //# sourceMappingURL=integrations-registry.d.ts.map
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Clementine TypeScript — Integration registry.
3
+ *
4
+ * Declarative metadata for every integration Clementine knows how to set up.
5
+ * The registry is the single source of truth for:
6
+ * - What env vars an integration needs
7
+ * - Where to get each credential (doc URLs)
8
+ * - Whether the integration is configured right now
9
+ * - How to surface gaps to the owner proactively
10
+ *
11
+ * Used by:
12
+ * - integration_status() — reports configured/partial/missing per integration
13
+ * - setup_integration() — walks the owner through setup conversationally
14
+ * - list_integrations() — enumerates what's available
15
+ * - System prompt — "Notion is configured, Stripe needs STRIPE_API_KEY"
16
+ *
17
+ * Replaces the previous pattern where the agent had to recall from chat
18
+ * history or invent which env vars a provider needs — the registry tells her
19
+ * exactly, with docs links, so she never has to guess.
20
+ */
21
+ /**
22
+ * The curated registry. Keep entries alphabetical within each kind for
23
+ * easier scanning. Adding a new integration: declare it here, and both
24
+ * integration_status and setup_integration automatically pick it up.
25
+ */
26
+ export const INTEGRATIONS = [
27
+ // NOTE: Anthropic auth is handled through `clementine login` (OAuth) or
28
+ // the ANTHROPIC_API_KEY env var — it's foundational and managed outside
29
+ // this registry because the auth path isn't uniform.
30
+ // ── Channels ──────────────────────────────────────────────────────
31
+ {
32
+ slug: 'discord',
33
+ label: 'Discord',
34
+ description: 'Main chat channel. Bot token + owner ID required.',
35
+ kind: 'channel',
36
+ docUrl: 'https://discord.com/developers/applications',
37
+ capabilities: ['chat', 'DMs', 'agent bots', 'notifications'],
38
+ requirements: [
39
+ { envVar: 'DISCORD_TOKEN', label: 'Bot token', required: true, docUrl: 'https://discord.com/developers/applications', persistent: true },
40
+ { envVar: 'DISCORD_OWNER_ID', label: 'Your Discord user ID (right-click your name → Copy User ID)', required: true, pattern: /^\d{15,22}$/, persistent: true },
41
+ ],
42
+ },
43
+ {
44
+ slug: 'slack',
45
+ label: 'Slack',
46
+ description: 'Alternate chat channel. Socket-mode app with bot + app tokens.',
47
+ kind: 'channel',
48
+ docUrl: 'https://api.slack.com/apps',
49
+ capabilities: ['chat', 'DMs', 'channel posts'],
50
+ requirements: [
51
+ { envVar: 'SLACK_BOT_TOKEN', label: 'Bot token (xoxb-...)', required: true, docUrl: 'https://api.slack.com/apps', pattern: /^xoxb-/, persistent: true },
52
+ { envVar: 'SLACK_APP_TOKEN', label: 'App-level token (xapp-...)', required: true, docUrl: 'https://api.slack.com/apps', pattern: /^xapp-/, persistent: true },
53
+ { envVar: 'SLACK_OWNER_USER_ID', label: 'Your Slack user ID (starts with U)', required: false, pattern: /^U[A-Z0-9]{6,}$/, persistent: true },
54
+ ],
55
+ },
56
+ {
57
+ slug: 'telegram',
58
+ label: 'Telegram',
59
+ description: 'Chat channel via Bot API.',
60
+ kind: 'channel',
61
+ docUrl: 'https://core.telegram.org/bots',
62
+ capabilities: ['chat', 'DMs'],
63
+ requirements: [
64
+ { envVar: 'TELEGRAM_BOT_TOKEN', label: 'Bot token from @BotFather', required: true, docUrl: 'https://t.me/BotFather', persistent: true },
65
+ { envVar: 'TELEGRAM_OWNER_CHAT_ID', label: 'Your Telegram chat ID', required: false, pattern: /^-?\d+$/, persistent: true },
66
+ ],
67
+ },
68
+ {
69
+ slug: 'whatsapp',
70
+ label: 'WhatsApp (via Twilio)',
71
+ description: 'Chat channel via Twilio WhatsApp API.',
72
+ kind: 'channel',
73
+ docUrl: 'https://www.twilio.com/docs/whatsapp',
74
+ capabilities: ['chat', 'DMs'],
75
+ requirements: [
76
+ { envVar: 'TWILIO_ACCOUNT_SID', label: 'Twilio account SID (AC...)', required: true, pattern: /^AC[a-f0-9]{32}$/i, persistent: true },
77
+ { envVar: 'TWILIO_AUTH_TOKEN', label: 'Twilio auth token', required: true, persistent: true },
78
+ { envVar: 'TWILIO_WHATSAPP_FROM', label: 'Twilio WhatsApp sender (whatsapp:+14155238886)', required: true, persistent: true },
79
+ { envVar: 'WHATSAPP_OWNER_NUMBER', label: 'Your WhatsApp number (E.164 format, e.g. +15551234567)', required: true, pattern: /^\+\d{8,15}$/, persistent: true },
80
+ ],
81
+ },
82
+ // ── Productivity / knowledge ──────────────────────────────────────
83
+ {
84
+ slug: 'notion',
85
+ label: 'Notion',
86
+ description: 'Read/write Notion pages and databases via integration.',
87
+ kind: 'api-key',
88
+ docUrl: 'https://www.notion.so/my-integrations',
89
+ capabilities: ['docs', 'databases', 'task tracking'],
90
+ requirements: [
91
+ { envVar: 'NOTION_API_KEY', label: 'Internal integration secret (secret_... or ntn_...)', required: true, docUrl: 'https://www.notion.so/my-integrations', persistent: true },
92
+ ],
93
+ },
94
+ {
95
+ slug: 'linear',
96
+ label: 'Linear',
97
+ description: 'Issue tracking and project management.',
98
+ kind: 'api-key',
99
+ docUrl: 'https://linear.app/settings/api',
100
+ capabilities: ['issues', 'projects', 'teams'],
101
+ requirements: [
102
+ { envVar: 'LINEAR_API_KEY', label: 'Personal API key (lin_api_...)', required: true, docUrl: 'https://linear.app/settings/api', pattern: /^lin_api_/, persistent: true },
103
+ ],
104
+ },
105
+ {
106
+ slug: 'github',
107
+ label: 'GitHub',
108
+ description: 'Read PRs, issues, manage releases.',
109
+ kind: 'api-key',
110
+ docUrl: 'https://github.com/settings/tokens',
111
+ capabilities: ['pull requests', 'issues', 'releases'],
112
+ requirements: [
113
+ { envVar: 'GITHUB_TOKEN', label: 'Personal access token (ghp_... or github_pat_...)', required: true, docUrl: 'https://github.com/settings/tokens?type=beta', pattern: /^(ghp_|github_pat_)/, persistent: true },
114
+ ],
115
+ },
116
+ // ── Email / calendar (OAuth) ──────────────────────────────────────
117
+ {
118
+ slug: 'microsoft-365',
119
+ label: 'Microsoft 365 (Outlook/Graph)',
120
+ description: 'Email, calendar, Teams, SharePoint via Microsoft Graph. OAuth app-only or delegated.',
121
+ kind: 'oauth',
122
+ docUrl: 'https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
123
+ capabilities: ['outlook inbox', 'outlook send', 'calendar', 'teams chat'],
124
+ requirements: [
125
+ { envVar: 'MS_TENANT_ID', label: 'Azure AD tenant ID', required: true, pattern: /^[a-f0-9-]{36}$/i, persistent: true },
126
+ { envVar: 'MS_CLIENT_ID', label: 'App registration client ID', required: true, pattern: /^[a-f0-9-]{36}$/i, persistent: true },
127
+ { envVar: 'MS_CLIENT_SECRET', label: 'App registration client secret value', required: true, persistent: true },
128
+ { envVar: 'MS_USER_EMAIL', label: 'Your primary email (used for delegated calls)', required: false, pattern: /@/, persistent: true },
129
+ ],
130
+ },
131
+ // ── CRM / sales ───────────────────────────────────────────────────
132
+ {
133
+ slug: 'salesforce',
134
+ label: 'Salesforce',
135
+ description: 'CRM access via SOAP/REST. Username + password + token flow.',
136
+ kind: 'api-key',
137
+ docUrl: 'https://help.salesforce.com/s/articleView?id=sf.code_sample_auth_api_oauth.htm',
138
+ capabilities: ['leads', 'contacts', 'opportunities', 'custom objects'],
139
+ requirements: [
140
+ { envVar: 'SF_INSTANCE_URL', label: 'Salesforce instance URL (https://your-org.my.salesforce.com)', required: true, pattern: /^https?:\/\//, persistent: true },
141
+ { envVar: 'SF_CLIENT_ID', label: 'Connected app consumer key', required: true, persistent: true },
142
+ { envVar: 'SF_CLIENT_SECRET', label: 'Connected app consumer secret', required: true, persistent: true },
143
+ { envVar: 'SF_USERNAME', label: 'Salesforce username (email)', required: true, pattern: /@/, persistent: true },
144
+ { envVar: 'SF_PASSWORD', label: 'Salesforce password + security token concatenated', required: true, persistent: true },
145
+ ],
146
+ },
147
+ // ── AI auxiliaries ────────────────────────────────────────────────
148
+ {
149
+ slug: 'openai',
150
+ label: 'OpenAI',
151
+ description: 'GPT + Whisper (used for auxiliary tasks and fallback).',
152
+ kind: 'api-key',
153
+ docUrl: 'https://platform.openai.com/api-keys',
154
+ capabilities: ['transcription', 'image generation', 'fallback model'],
155
+ requirements: [
156
+ { envVar: 'OPENAI_API_KEY', label: 'API key (sk-...)', required: true, docUrl: 'https://platform.openai.com/api-keys', pattern: /^sk-/, persistent: true },
157
+ ],
158
+ },
159
+ {
160
+ slug: 'elevenlabs',
161
+ label: 'ElevenLabs',
162
+ description: 'Text-to-speech for voice replies.',
163
+ kind: 'api-key',
164
+ docUrl: 'https://elevenlabs.io/app/settings/api-keys',
165
+ capabilities: ['voice synthesis'],
166
+ requirements: [
167
+ { envVar: 'ELEVENLABS_API_KEY', label: 'API key', required: true, docUrl: 'https://elevenlabs.io/app/settings/api-keys', persistent: true },
168
+ ],
169
+ },
170
+ {
171
+ slug: 'groq',
172
+ label: 'Groq',
173
+ description: 'Fast inference, used for real-time transcription.',
174
+ kind: 'api-key',
175
+ docUrl: 'https://console.groq.com/keys',
176
+ capabilities: ['voice transcription'],
177
+ requirements: [
178
+ { envVar: 'GROQ_API_KEY', label: 'API key (gsk_...)', required: true, docUrl: 'https://console.groq.com/keys', pattern: /^gsk_/, persistent: true },
179
+ ],
180
+ },
181
+ {
182
+ slug: 'google-ai',
183
+ label: 'Google AI (Gemini)',
184
+ description: 'Used for video analysis and multimodal tasks.',
185
+ kind: 'api-key',
186
+ docUrl: 'https://aistudio.google.com/apikey',
187
+ capabilities: ['video analysis', 'multimodal'],
188
+ requirements: [
189
+ { envVar: 'GOOGLE_API_KEY', label: 'Gemini API key', required: true, docUrl: 'https://aistudio.google.com/apikey', persistent: true },
190
+ ],
191
+ },
192
+ // ── Payments ──────────────────────────────────────────────────────
193
+ {
194
+ slug: 'stripe',
195
+ label: 'Stripe',
196
+ description: 'Payments read/write (one-off or recurring).',
197
+ kind: 'api-key',
198
+ docUrl: 'https://dashboard.stripe.com/apikeys',
199
+ capabilities: ['customers', 'payments', 'subscriptions'],
200
+ requirements: [
201
+ { envVar: 'STRIPE_SECRET_KEY', label: 'Secret key (sk_live_... or sk_test_...)', required: true, docUrl: 'https://dashboard.stripe.com/apikeys', pattern: /^sk_(live|test)_/, persistent: true },
202
+ ],
203
+ },
204
+ ];
205
+ /**
206
+ * Classify each integration against the current environment.
207
+ * Pure function — caller passes in env so this is testable.
208
+ */
209
+ export function classifyIntegrations(env, slugs) {
210
+ const targets = slugs?.length
211
+ ? INTEGRATIONS.filter(i => slugs.includes(i.slug))
212
+ : INTEGRATIONS;
213
+ return targets.map(integration => {
214
+ const required = integration.requirements.filter(r => r.required);
215
+ const optional = integration.requirements.filter(r => !r.required);
216
+ const have = [];
217
+ const missing = [];
218
+ for (const req of required) {
219
+ if (env[req.envVar])
220
+ have.push(req.envVar);
221
+ else
222
+ missing.push(req.envVar);
223
+ }
224
+ const optionalMissing = optional
225
+ .filter(r => !env[r.envVar])
226
+ .map(r => r.envVar);
227
+ let status;
228
+ if (missing.length === 0 && have.length > 0)
229
+ status = 'configured';
230
+ else if (have.length > 0)
231
+ status = 'partial';
232
+ else
233
+ status = 'missing';
234
+ // Special case for hybrid: "have" counts if ANY required-or-alternative is set.
235
+ if (integration.kind === 'hybrid' && missing.length > 0 && integration.requirements.some(r => env[r.envVar])) {
236
+ status = 'configured';
237
+ }
238
+ return {
239
+ slug: integration.slug,
240
+ label: integration.label,
241
+ status,
242
+ have,
243
+ missing,
244
+ optionalMissing,
245
+ docUrl: integration.docUrl,
246
+ };
247
+ });
248
+ }
249
+ export function findIntegration(slug) {
250
+ const normalized = slug.trim().toLowerCase();
251
+ return INTEGRATIONS.find(i => i.slug === normalized);
252
+ }
253
+ /** A short one-line summary for prompt injection. */
254
+ export function summarizeIntegrationStatus(env) {
255
+ const reports = classifyIntegrations(env);
256
+ const configured = reports.filter(r => r.status === 'configured').map(r => r.label);
257
+ const partial = reports.filter(r => r.status === 'partial').map(r => `${r.label} (missing ${r.missing.join(', ')})`);
258
+ const missing = reports.filter(r => r.status === 'missing').map(r => r.label);
259
+ const lines = [];
260
+ if (configured.length > 0)
261
+ lines.push(`**Configured:** ${configured.join(', ')}`);
262
+ if (partial.length > 0)
263
+ lines.push(`**Partial:** ${partial.join('; ')}`);
264
+ if (missing.length > 0)
265
+ lines.push(`**Available but not configured:** ${missing.join(', ')}`);
266
+ return lines.join('\n');
267
+ }
268
+ //# sourceMappingURL=integrations-registry.js.map
package/dist/index.js CHANGED
@@ -534,6 +534,12 @@ async function asyncMain() {
534
534
  for (const warning of secretWarnings) {
535
535
  logger.warn(warning);
536
536
  }
537
+ // ── Resolve keychain-backed secrets before anything reads process.env ──
538
+ try {
539
+ const { hydrateSecretsFromEnv } = await import('./secrets/resolver.js');
540
+ hydrateSecretsFromEnv();
541
+ }
542
+ catch { /* non-fatal — non-macOS systems, or keychain unavailable */ }
537
543
  // ── Check MCP extension permissions ────────────────────────────
538
544
  try {
539
545
  const { checkPermissionsOnStartup, bootstrapClaudeIntegrationsFromAuditLog, probeAvailableTools } = await import('./agent/mcp-bridge.js');
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Clementine TypeScript — OAuth auth profile store.
3
+ *
4
+ * OAuth tokens (access + refresh + expiry) have different semantics from
5
+ * API keys: they expire and need refresh. Keeping them in ~/.clementine/.env
6
+ * alongside long-lived API keys mixes two lifecycles and makes token refresh
7
+ * awkward. This store keeps them separate at ~/.clementine/auth-profiles/<provider>.json
8
+ * with mode 0o600, so the OAuth flow code can atomically update tokens
9
+ * without touching .env.
10
+ *
11
+ * Current scope: storage + basic read/write/refresh-check. Callers (outlook,
12
+ * Google OAuth flows) can adopt this incrementally; existing env-based OAuth
13
+ * keeps working until migrated.
14
+ */
15
+ export interface AuthProfile {
16
+ /** Provider slug (matches integrations-registry, e.g. "microsoft-365"). */
17
+ provider: string;
18
+ /** Short-lived access token. */
19
+ accessToken: string;
20
+ /** Long-lived refresh token, optional. */
21
+ refreshToken?: string;
22
+ /** Unix epoch ms — when accessToken expires. */
23
+ expiresAt?: number;
24
+ /** Identifier (email, user id) for the authenticated account. */
25
+ accountId?: string;
26
+ /** Comma-delimited scopes granted. */
27
+ scopes?: string;
28
+ /** When this profile was first written. */
29
+ createdAt: string;
30
+ /** When this profile was last refreshed. */
31
+ updatedAt: string;
32
+ /** Provider-specific extras. */
33
+ extras?: Record<string, unknown>;
34
+ }
35
+ /** Atomic write — write tmp then rename. Mode 0o600. */
36
+ export declare function save(profile: AuthProfile): void;
37
+ export declare function load(provider: string): AuthProfile | null;
38
+ export declare function remove(provider: string): boolean;
39
+ /** List provider slugs with stored profiles. */
40
+ export declare function list(): string[];
41
+ /**
42
+ * True if accessToken is present and either has no expiry set or expires
43
+ * more than `bufferMs` from now. Callers should call their refresh flow if
44
+ * this returns false.
45
+ */
46
+ export declare function isValid(profile: AuthProfile | null, bufferMs?: number): boolean;
47
+ /** Get access token if valid, else null. Does not refresh — caller refreshes. */
48
+ export declare function getValidAccessToken(provider: string): string | null;
49
+ /** Status summary for status reporting. */
50
+ export interface AuthProfileStatus {
51
+ provider: string;
52
+ accountId?: string;
53
+ valid: boolean;
54
+ expiresInMinutes: number | null;
55
+ }
56
+ export declare function statusAll(): AuthProfileStatus[];
57
+ //# sourceMappingURL=auth-profiles.d.ts.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Clementine TypeScript — OAuth auth profile store.
3
+ *
4
+ * OAuth tokens (access + refresh + expiry) have different semantics from
5
+ * API keys: they expire and need refresh. Keeping them in ~/.clementine/.env
6
+ * alongside long-lived API keys mixes two lifecycles and makes token refresh
7
+ * awkward. This store keeps them separate at ~/.clementine/auth-profiles/<provider>.json
8
+ * with mode 0o600, so the OAuth flow code can atomically update tokens
9
+ * without touching .env.
10
+ *
11
+ * Current scope: storage + basic read/write/refresh-check. Callers (outlook,
12
+ * Google OAuth flows) can adopt this incrementally; existing env-based OAuth
13
+ * keeps working until migrated.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import pino from 'pino';
18
+ import { BASE_DIR } from '../config.js';
19
+ const logger = pino({ name: 'clementine.auth-profiles' });
20
+ const PROFILES_DIR = path.join(BASE_DIR, 'auth-profiles');
21
+ function ensureDir() {
22
+ if (!existsSync(PROFILES_DIR)) {
23
+ mkdirSync(PROFILES_DIR, { recursive: true, mode: 0o700 });
24
+ }
25
+ }
26
+ function profilePath(provider) {
27
+ const safe = provider.replace(/[^a-z0-9_-]/gi, '_');
28
+ return path.join(PROFILES_DIR, `${safe}.json`);
29
+ }
30
+ /** Atomic write — write tmp then rename. Mode 0o600. */
31
+ export function save(profile) {
32
+ ensureDir();
33
+ const p = profilePath(profile.provider);
34
+ const tmp = p + '.tmp';
35
+ const content = JSON.stringify(profile, null, 2);
36
+ writeFileSync(tmp, content, { mode: 0o600 });
37
+ renameSync(tmp, p);
38
+ logger.info({ provider: profile.provider, account: profile.accountId }, 'Auth profile saved');
39
+ }
40
+ export function load(provider) {
41
+ const p = profilePath(provider);
42
+ if (!existsSync(p))
43
+ return null;
44
+ try {
45
+ return JSON.parse(readFileSync(p, 'utf-8'));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function remove(provider) {
52
+ const p = profilePath(provider);
53
+ if (!existsSync(p))
54
+ return false;
55
+ try {
56
+ // Overwrite with empty data before unlink so contents aren't trivially recoverable.
57
+ writeFileSync(p, '');
58
+ const { unlinkSync } = require('node:fs');
59
+ unlinkSync(p);
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /** List provider slugs with stored profiles. */
67
+ export function list() {
68
+ ensureDir();
69
+ try {
70
+ return readdirSync(PROFILES_DIR)
71
+ .filter(f => f.endsWith('.json'))
72
+ .map(f => f.replace(/\.json$/, ''))
73
+ .sort();
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ /**
80
+ * True if accessToken is present and either has no expiry set or expires
81
+ * more than `bufferMs` from now. Callers should call their refresh flow if
82
+ * this returns false.
83
+ */
84
+ export function isValid(profile, bufferMs = 5 * 60 * 1000) {
85
+ if (!profile?.accessToken)
86
+ return false;
87
+ if (!profile.expiresAt)
88
+ return true;
89
+ return Date.now() + bufferMs < profile.expiresAt;
90
+ }
91
+ /** Get access token if valid, else null. Does not refresh — caller refreshes. */
92
+ export function getValidAccessToken(provider) {
93
+ const p = load(provider);
94
+ return isValid(p) ? p.accessToken : null;
95
+ }
96
+ export function statusAll() {
97
+ const slugs = list();
98
+ const now = Date.now();
99
+ return slugs.map(slug => {
100
+ const p = load(slug);
101
+ const valid = isValid(p);
102
+ const expiresInMinutes = p?.expiresAt ? Math.round((p.expiresAt - now) / 60000) : null;
103
+ return {
104
+ provider: slug,
105
+ accountId: p?.accountId,
106
+ valid,
107
+ expiresInMinutes,
108
+ };
109
+ });
110
+ }
111
+ //# sourceMappingURL=auth-profiles.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Clementine TypeScript — macOS Keychain backend for secret storage.
3
+ *
4
+ * When available, lets `env_set` store API keys in the user's login keychain
5
+ * instead of plaintext in ~/.clementine/.env. The .env file then holds only
6
+ * a reference stub: `STRIPE_API_KEY=keychain:clementine-STRIPE_API_KEY`.
7
+ *
8
+ * Graceful fallback: on non-macOS systems or when `security` is unavailable,
9
+ * isAvailable() returns false and callers fall back to raw .env storage.
10
+ *
11
+ * Secrets are stored under service "clementine-agent" with the env var
12
+ * name as the account label, so the user can inspect / revoke them via
13
+ * Keychain Access.app if needed.
14
+ */
15
+ /** Is macOS keychain usable in this environment? */
16
+ export declare function isAvailable(): boolean;
17
+ /**
18
+ * Construct the stub value written to .env when a secret is keychain-backed.
19
+ * Reading this back through resolver.ts triggers a keychain lookup.
20
+ */
21
+ export declare function makeRef(envVar: string): string;
22
+ export declare function isRef(value: string): boolean;
23
+ /** Parse a ref stub back into its account name, or null if not a ref. */
24
+ export declare function parseRef(value: string): {
25
+ account: string;
26
+ } | null;
27
+ /** Write a secret. Returns the ref stub suitable for .env. */
28
+ export declare function set(envVar: string, value: string): string;
29
+ /** Read a secret back. Returns undefined if missing or unreadable. */
30
+ export declare function get(envVar: string): string | undefined;
31
+ /** Delete a secret. No-op if it doesn't exist. */
32
+ export declare function remove(envVar: string): boolean;
33
+ /** List env var names that have keychain entries (best-effort). */
34
+ export declare function list(): string[];
35
+ //# sourceMappingURL=keychain.d.ts.map
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Clementine TypeScript — macOS Keychain backend for secret storage.
3
+ *
4
+ * When available, lets `env_set` store API keys in the user's login keychain
5
+ * instead of plaintext in ~/.clementine/.env. The .env file then holds only
6
+ * a reference stub: `STRIPE_API_KEY=keychain:clementine-STRIPE_API_KEY`.
7
+ *
8
+ * Graceful fallback: on non-macOS systems or when `security` is unavailable,
9
+ * isAvailable() returns false and callers fall back to raw .env storage.
10
+ *
11
+ * Secrets are stored under service "clementine-agent" with the env var
12
+ * name as the account label, so the user can inspect / revoke them via
13
+ * Keychain Access.app if needed.
14
+ */
15
+ import { execFileSync, spawnSync } from 'node:child_process';
16
+ import pino from 'pino';
17
+ const logger = pino({ name: 'clementine.keychain' });
18
+ const SERVICE_NAME = 'clementine-agent';
19
+ const REF_PREFIX = 'keychain:'; // pragma: allowlist secret
20
+ /** Is macOS keychain usable in this environment? */
21
+ export function isAvailable() {
22
+ if (process.platform !== 'darwin')
23
+ return false;
24
+ try {
25
+ execFileSync('/usr/bin/security', ['-h'], {
26
+ stdio: 'pipe',
27
+ timeout: 1000,
28
+ });
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /**
36
+ * Construct the stub value written to .env when a secret is keychain-backed.
37
+ * Reading this back through resolver.ts triggers a keychain lookup.
38
+ */
39
+ export function makeRef(envVar) {
40
+ return `${REF_PREFIX}${SERVICE_NAME}-${envVar}`;
41
+ }
42
+ export function isRef(value) {
43
+ return value.startsWith(REF_PREFIX);
44
+ }
45
+ /** Parse a ref stub back into its account name, or null if not a ref. */
46
+ export function parseRef(value) {
47
+ if (!isRef(value))
48
+ return null;
49
+ return { account: value.slice(REF_PREFIX.length) };
50
+ }
51
+ /** Write a secret. Returns the ref stub suitable for .env. */
52
+ export function set(envVar, value) {
53
+ if (!isAvailable()) {
54
+ throw new Error('Keychain unavailable on this platform');
55
+ }
56
+ const account = `${SERVICE_NAME}-${envVar}`;
57
+ // -U updates existing entry in place; -s = service; -a = account; -w = password
58
+ const result = spawnSync('/usr/bin/security', [
59
+ 'add-generic-password',
60
+ '-U',
61
+ '-s', SERVICE_NAME,
62
+ '-a', account,
63
+ '-w', value,
64
+ '-T', '', // no apps pre-approved — will prompt on first read; user can approve for login session
65
+ '-l', `Clementine: ${envVar}`,
66
+ ], { stdio: 'pipe' });
67
+ if (result.status !== 0) {
68
+ throw new Error(`security add-generic-password failed (code ${result.status}): ${result.stderr.toString().slice(0, 200)}`);
69
+ }
70
+ return makeRef(envVar);
71
+ }
72
+ /** Read a secret back. Returns undefined if missing or unreadable. */
73
+ export function get(envVar) {
74
+ if (!isAvailable())
75
+ return undefined;
76
+ const account = `${SERVICE_NAME}-${envVar}`;
77
+ const result = spawnSync('/usr/bin/security', [
78
+ 'find-generic-password',
79
+ '-s', SERVICE_NAME,
80
+ '-a', account,
81
+ '-w',
82
+ ], { stdio: 'pipe' });
83
+ if (result.status !== 0) {
84
+ // Exit code 44 = item not found — expected, don't log
85
+ if (result.status !== 44) {
86
+ logger.debug({ code: result.status, envVar }, 'keychain read non-zero');
87
+ }
88
+ return undefined;
89
+ }
90
+ const value = result.stdout.toString();
91
+ // security prints a trailing newline we need to strip
92
+ return value.replace(/\r?\n$/, '');
93
+ }
94
+ /** Delete a secret. No-op if it doesn't exist. */
95
+ export function remove(envVar) {
96
+ if (!isAvailable())
97
+ return false;
98
+ const account = `${SERVICE_NAME}-${envVar}`;
99
+ const result = spawnSync('/usr/bin/security', [
100
+ 'delete-generic-password',
101
+ '-s', SERVICE_NAME,
102
+ '-a', account,
103
+ ], { stdio: 'pipe' });
104
+ return result.status === 0;
105
+ }
106
+ /** List env var names that have keychain entries (best-effort). */
107
+ export function list() {
108
+ if (!isAvailable())
109
+ return [];
110
+ const result = spawnSync('/usr/bin/security', [
111
+ 'dump-keychain',
112
+ ], { stdio: 'pipe', timeout: 5000 });
113
+ if (result.status !== 0)
114
+ return [];
115
+ const out = result.stdout.toString();
116
+ const matches = out.matchAll(new RegExp(`"acct"<blob>="${SERVICE_NAME}-([^"]+)"`, 'g'));
117
+ return [...new Set([...matches].map(m => m[1]))];
118
+ }
119
+ //# sourceMappingURL=keychain.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Clementine TypeScript — Secret reference resolver.
3
+ *
4
+ * When code reads process.env.FOO, it should get the resolved SECRET VALUE
5
+ * regardless of whether that secret is stored plaintext in .env or as a
6
+ * keychain ref stub (`keychain:clementine-agent-FOO`). This module bridges
7
+ * the gap: called once at daemon boot, it walks process.env, detects ref
8
+ * stubs, resolves them via the appropriate backend, and replaces the stub
9
+ * with the real value in-place.
10
+ *
11
+ * After hydrate(), downstream code can treat process.env as usual — it has
12
+ * no knowledge of keychain/1Password/etc. This mirrors OpenClaw's SecretRef
13
+ * pattern but narrower: one backend (macOS Keychain), one convention.
14
+ */
15
+ /**
16
+ * Check every env var for a ref stub and resolve in place. Returns a summary
17
+ * of what was resolved / failed so startup can log visibility.
18
+ */
19
+ export declare function hydrateSecretsFromEnv(): {
20
+ resolved: string[];
21
+ failed: string[];
22
+ };
23
+ /**
24
+ * Lazy single-key lookup — reads process.env[key], resolves the ref if
25
+ * needed, returns the resolved value. Useful when code calls us at runtime
26
+ * rather than relying on startup hydration.
27
+ */
28
+ export declare function resolveEnvValue(key: string): string | undefined;
29
+ /**
30
+ * Classify a given value by its storage backend (for status reporting).
31
+ * Returns e.g. "keychain" or "env" or undefined (not set).
32
+ */
33
+ export declare function secretBackend(envVar: string): 'keychain' | 'env' | undefined;
34
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Clementine TypeScript — Secret reference resolver.
3
+ *
4
+ * When code reads process.env.FOO, it should get the resolved SECRET VALUE
5
+ * regardless of whether that secret is stored plaintext in .env or as a
6
+ * keychain ref stub (`keychain:clementine-agent-FOO`). This module bridges
7
+ * the gap: called once at daemon boot, it walks process.env, detects ref
8
+ * stubs, resolves them via the appropriate backend, and replaces the stub
9
+ * with the real value in-place.
10
+ *
11
+ * After hydrate(), downstream code can treat process.env as usual — it has
12
+ * no knowledge of keychain/1Password/etc. This mirrors OpenClaw's SecretRef
13
+ * pattern but narrower: one backend (macOS Keychain), one convention.
14
+ */
15
+ import pino from 'pino';
16
+ import * as keychain from './keychain.js';
17
+ const logger = pino({ name: 'clementine.secrets' });
18
+ /**
19
+ * Check every env var for a ref stub and resolve in place. Returns a summary
20
+ * of what was resolved / failed so startup can log visibility.
21
+ */
22
+ export function hydrateSecretsFromEnv() {
23
+ const resolved = [];
24
+ const failed = [];
25
+ for (const [key, rawValue] of Object.entries(process.env)) {
26
+ if (!rawValue)
27
+ continue;
28
+ if (!keychain.isRef(rawValue))
29
+ continue;
30
+ const resolvedValue = keychain.get(key);
31
+ if (resolvedValue !== undefined) {
32
+ process.env[key] = resolvedValue;
33
+ resolved.push(key);
34
+ }
35
+ else {
36
+ failed.push(key);
37
+ // Leave the ref stub in place so downstream errors are obvious rather
38
+ // than silently returning the literal "keychain:..." as if it were a
39
+ // real API key.
40
+ }
41
+ }
42
+ if (resolved.length > 0 || failed.length > 0) {
43
+ logger.info({ resolved: resolved.length, failed }, 'Secrets hydrated');
44
+ }
45
+ return { resolved, failed };
46
+ }
47
+ /**
48
+ * Lazy single-key lookup — reads process.env[key], resolves the ref if
49
+ * needed, returns the resolved value. Useful when code calls us at runtime
50
+ * rather than relying on startup hydration.
51
+ */
52
+ export function resolveEnvValue(key) {
53
+ const raw = process.env[key];
54
+ if (!raw)
55
+ return undefined;
56
+ if (!keychain.isRef(raw))
57
+ return raw;
58
+ return keychain.get(key);
59
+ }
60
+ /**
61
+ * Classify a given value by its storage backend (for status reporting).
62
+ * Returns e.g. "keychain" or "env" or undefined (not set).
63
+ */
64
+ export function secretBackend(envVar) {
65
+ const raw = process.env[envVar];
66
+ if (!raw)
67
+ return undefined;
68
+ return keychain.isRef(raw) ? 'keychain' : 'env';
69
+ }
70
+ //# sourceMappingURL=resolver.js.map
@@ -17,6 +17,9 @@ import { z } from 'zod';
17
17
  import { BASE_DIR, CRON_FILE, SYSTEM_DIR, env, getStore, logger, textResult, } from './shared.js';
18
18
  import { getInteractionSource } from '../agent/hooks.js';
19
19
  import { renameSync } from 'node:fs';
20
+ import * as keychain from '../secrets/keychain.js';
21
+ import * as authProfiles from '../secrets/auth-profiles.js';
22
+ import { classifyIntegrations, findIntegration, INTEGRATIONS } from '../config/integrations-registry.js';
20
23
  function readEnvFile() { return env; }
21
24
  // ── Env file management helpers (tools registered inside registerAdminTools) ──
22
25
  const ENV_PATH = path.join(BASE_DIR, '.env');
@@ -113,10 +116,11 @@ export function registerAdminTools(server) {
113
116
  return textResult(`Timer set. Reminder in ${minutes} minute${minutes !== 1 ? 's' : ''} (~${fireTime}): "${message}"`);
114
117
  });
115
118
  // ── Env self-configuration (owner-DM only) ────────────────────────────
116
- server.tool('env_set', 'Save or update an environment variable in ~/.clementine/.env (API keys, tokens, config). Owner-DM only. Changes take effect immediately — the new value becomes available to process.env and to the next tool call. Use this when the owner gives a credential in chat instead of telling them to hand-edit files.', {
119
+ server.tool('env_set', 'Save or update a credential (API key, token, config). Owner-DM only. On macOS it defaults to the login Keychain for security — the .env file only gets a reference stub. Changes take effect immediately; process.env gets the real value and the next tool call can use it. Use this when the owner gives a credential in chat never tell them to hand-edit files.', {
117
120
  key: z.string().describe('Env var name (uppercase with underscores, e.g. STRIPE_API_KEY)'),
118
121
  value: z.string().describe('The value to store. Never echo back to the user; it will be masked in logs.'),
119
- }, async ({ key, value }) => {
122
+ storage: z.enum(['keychain', 'env', 'auto']).optional().describe('Where to store it. "auto" (default) uses macOS Keychain when available, falls back to plain .env. "keychain" forces Keychain. "env" forces plaintext .env.'),
123
+ }, async ({ key, value, storage }) => {
120
124
  const gate = requireOwnerDm();
121
125
  if (!gate.ok)
122
126
  return textResult(gate.message);
@@ -126,18 +130,41 @@ export function registerAdminTools(server) {
126
130
  }
127
131
  if (!value)
128
132
  return textResult('Refused: empty value. Use env_unset to remove a key.');
133
+ const mode = storage ?? 'auto';
134
+ const useKeychain = (mode === 'keychain') || (mode === 'auto' && keychain.isAvailable());
135
+ if (mode === 'keychain' && !keychain.isAvailable()) {
136
+ return textResult('Refused: Keychain storage requested but macOS Keychain is unavailable on this system.');
137
+ }
129
138
  const map = parseEnvFile();
130
139
  const existed = map.has(normalizedKey);
131
- map.set(normalizedKey, value);
140
+ let envFileValue;
141
+ let backendNote;
142
+ if (useKeychain) {
143
+ try {
144
+ envFileValue = keychain.set(normalizedKey, value);
145
+ backendNote = 'stored in macOS Keychain (service: clementine-agent); .env holds only a reference';
146
+ }
147
+ catch (err) {
148
+ logger.warn({ err, key: normalizedKey }, 'Keychain write failed — falling back to plain .env');
149
+ envFileValue = value;
150
+ backendNote = `Keychain unavailable (${String(err).slice(0, 100)}) — stored plaintext in .env`;
151
+ }
152
+ }
153
+ else {
154
+ envFileValue = value;
155
+ backendNote = 'stored plaintext in .env';
156
+ }
157
+ map.set(normalizedKey, envFileValue);
132
158
  try {
133
159
  writeEnvFile(map);
134
160
  }
135
161
  catch (err) {
136
162
  return textResult(`Failed to write .env: ${String(err).slice(0, 200)}`);
137
163
  }
164
+ // process.env gets the REAL value regardless of backend, so tools can use it now
138
165
  process.env[normalizedKey] = value;
139
- logger.info({ key: normalizedKey, existed, masked: maskSecret(value) }, 'env_set');
140
- return textResult(`${existed ? 'Updated' : 'Added'} ${normalizedKey} in ~/.clementine/.env (value: ${maskSecret(value)}). Available to tools immediately; a daemon restart is not required for most cases.`);
166
+ logger.info({ key: normalizedKey, existed, masked: maskSecret(value), storage: useKeychain ? 'keychain' : 'env' }, 'env_set');
167
+ return textResult(`${existed ? 'Updated' : 'Added'} ${normalizedKey} (value: ${maskSecret(value)}). ${backendNote}. Active immediately no restart needed.`);
141
168
  });
142
169
  server.tool('env_list', 'List the names of environment variables configured in ~/.clementine/.env. Returns key names only — values are masked. Owner-DM only.', {}, async () => {
143
170
  const gate = requireOwnerDm();
@@ -149,7 +176,7 @@ export function registerAdminTools(server) {
149
176
  const lines = [...map.entries()].map(([k, v]) => `- ${k} = ${maskSecret(v)}`);
150
177
  return textResult(`Configured env vars (${map.size}):\n${lines.join('\n')}`);
151
178
  });
152
- server.tool('env_unset', 'Remove an environment variable from ~/.clementine/.env. Owner-DM only. Also clears from the running process.', {
179
+ server.tool('env_unset', 'Remove an environment variable. Owner-DM only. Also clears from Keychain (if backed by Keychain) and from the running process.', {
153
180
  key: z.string().describe('Env var name to remove'),
154
181
  }, async ({ key }) => {
155
182
  const gate = requireOwnerDm();
@@ -157,6 +184,7 @@ export function registerAdminTools(server) {
157
184
  return textResult(gate.message);
158
185
  const normalizedKey = key.trim();
159
186
  const map = parseEnvFile();
187
+ const hadKeychain = map.has(normalizedKey) && keychain.isRef(map.get(normalizedKey) ?? '');
160
188
  if (!map.has(normalizedKey))
161
189
  return textResult(`${normalizedKey} is not set in ~/.clementine/.env`);
162
190
  map.delete(normalizedKey);
@@ -166,9 +194,104 @@ export function registerAdminTools(server) {
166
194
  catch (err) {
167
195
  return textResult(`Failed to write .env: ${String(err).slice(0, 200)}`);
168
196
  }
197
+ if (hadKeychain) {
198
+ try {
199
+ keychain.remove(normalizedKey);
200
+ }
201
+ catch { /* best-effort */ }
202
+ }
169
203
  delete process.env[normalizedKey];
170
- logger.info({ key: normalizedKey }, 'env_unset');
171
- return textResult(`Removed ${normalizedKey} from ~/.clementine/.env`);
204
+ logger.info({ key: normalizedKey, keychainCleared: hadKeychain }, 'env_unset');
205
+ return textResult(`Removed ${normalizedKey}${hadKeychain ? ' (including Keychain entry)' : ''}.`);
206
+ });
207
+ server.tool('env_list', 'List configured env vars with their storage backend (Keychain or plaintext) and masked values. Owner-DM only.', {}, async () => {
208
+ const gate = requireOwnerDm();
209
+ if (!gate.ok)
210
+ return textResult(gate.message);
211
+ const map = parseEnvFile();
212
+ if (map.size === 0)
213
+ return textResult('No env vars configured.');
214
+ const lines = [...map.entries()].map(([k, v]) => {
215
+ const backend = keychain.isRef(v) ? '[keychain]' : '[env] ';
216
+ const resolved = keychain.isRef(v) ? (keychain.get(k) ?? '(keychain read failed)') : v;
217
+ return `${backend} ${k} = ${maskSecret(resolved)}`;
218
+ });
219
+ return textResult(`Configured env vars (${map.size}):\n${lines.join('\n')}`);
220
+ });
221
+ // ── Integration registry tools ──────────────────────────────────────
222
+ server.tool('integration_status', 'Show which third-party integrations (Slack, Notion, Stripe, etc.) are configured, partial (some credentials set, others missing), or missing entirely. Use this when the owner asks "what\'s set up?" or when you need to know whether to expect a credential before attempting a tool call. Reports the declarative registry — do not invent integrations not listed here.', {
223
+ slug: z.string().optional().describe('Optional: specific integration slug to check (e.g. "slack"). If omitted, returns all.'),
224
+ }, async ({ slug }) => {
225
+ const slugs = slug ? [slug] : undefined;
226
+ const reports = classifyIntegrations(process.env, slugs);
227
+ if (reports.length === 0)
228
+ return textResult(`Unknown integration slug: ${slug}. Use list_integrations to see available.`);
229
+ const icon = (s) => s === 'configured' ? '✓' : s === 'partial' ? '~' : '✗';
230
+ const lines = reports.map(r => {
231
+ const base = `${icon(r.status)} ${r.label} (${r.slug}) — ${r.status}`;
232
+ const detail = [];
233
+ if (r.have.length > 0)
234
+ detail.push(`have: ${r.have.join(', ')}`);
235
+ if (r.missing.length > 0)
236
+ detail.push(`missing: ${r.missing.join(', ')}`);
237
+ if (r.optionalMissing.length > 0)
238
+ detail.push(`optional not set: ${r.optionalMissing.join(', ')}`);
239
+ return detail.length > 0 ? `${base}\n ${detail.join(' | ')}` : base;
240
+ });
241
+ return textResult(lines.join('\n'));
242
+ });
243
+ server.tool('list_integrations', 'List every integration Clementine knows how to configure, with one-line descriptions. Use this to answer "what can I hook up?" or to find the slug for setup_integration.', {}, async () => {
244
+ const lines = INTEGRATIONS.map(i => {
245
+ const required = i.requirements.filter(r => r.required).map(r => r.envVar).join(', ');
246
+ return `- **${i.label}** (\`${i.slug}\`, ${i.kind}): ${i.description}${required ? ` — requires ${required}` : ''}`;
247
+ });
248
+ return textResult(`Available integrations (${INTEGRATIONS.length}):\n${lines.join('\n')}`);
249
+ });
250
+ server.tool('setup_integration', 'Get the step-by-step setup info for an integration: required env vars, doc URLs for where to find each credential, and current status. Use this when the owner says "set up Slack" or similar — call this first, then walk them through each missing credential conversationally and save each one with env_set. Never guess at required env vars; trust this registry.', {
251
+ slug: z.string().describe('Integration slug from list_integrations (e.g. "slack", "notion", "stripe")'),
252
+ }, async ({ slug }) => {
253
+ const integration = findIntegration(slug);
254
+ if (!integration) {
255
+ return textResult(`Unknown integration slug: ${slug}. Run list_integrations to see what's available.`);
256
+ }
257
+ const [status] = classifyIntegrations(process.env, [integration.slug]);
258
+ const lines = [];
259
+ lines.push(`## ${integration.label} (${integration.slug})`);
260
+ lines.push('');
261
+ lines.push(integration.description);
262
+ if (integration.docUrl)
263
+ lines.push(`Docs: ${integration.docUrl}`);
264
+ if (integration.capabilities?.length)
265
+ lines.push(`Unlocks: ${integration.capabilities.join(', ')}`);
266
+ lines.push('');
267
+ lines.push(`**Current status:** ${status.status}${status.have.length ? ` (have ${status.have.join(', ')})` : ''}`);
268
+ lines.push('');
269
+ lines.push('**Credentials:**');
270
+ for (const req of integration.requirements) {
271
+ const present = !!process.env[req.envVar];
272
+ const badge = present ? '✓ set' : (req.required ? '✗ REQUIRED' : '○ optional');
273
+ const line = `- \`${req.envVar}\` — ${req.label} [${badge}]`;
274
+ lines.push(line);
275
+ if (!present && req.docUrl)
276
+ lines.push(` → ${req.docUrl}`);
277
+ }
278
+ lines.push('');
279
+ lines.push('Next step: ask the owner for each unset REQUIRED credential, one at a time, and save each with env_set. Quote the doc URL when you ask so they know where to find it. After saving the last one, run integration_status to confirm "configured".');
280
+ return textResult(lines.join('\n'));
281
+ });
282
+ // ── OAuth profile tools ────────────────────────────────────────────
283
+ server.tool('auth_profile_status', 'Show stored OAuth profiles (for providers that use OAuth, not just API keys). Reports which accounts are authenticated and when tokens expire. Owner-DM only.', {}, async () => {
284
+ const gate = requireOwnerDm();
285
+ if (!gate.ok)
286
+ return textResult(gate.message);
287
+ const statuses = authProfiles.statusAll();
288
+ if (statuses.length === 0)
289
+ return textResult('No OAuth profiles stored. (API-key integrations are tracked via integration_status instead.)');
290
+ const lines = statuses.map(s => {
291
+ const exp = s.expiresInMinutes === null ? 'no expiry' : s.expiresInMinutes < 0 ? `expired ${Math.abs(s.expiresInMinutes)}m ago` : `expires in ${s.expiresInMinutes}m`;
292
+ return `- ${s.provider}${s.accountId ? ` (${s.accountId})` : ''}: ${s.valid ? '✓' : '✗'} ${exp}`;
293
+ });
294
+ return textResult(`OAuth profiles:\n${lines.join('\n')}`);
172
295
  });
173
296
  // ── Self-service tool whitelist ────────────────────────────────────────
174
297
  const ALLOWED_TOOLS_EXTRA = path.join(BASE_DIR, 'allowed-tools-extra.json');
@@ -1258,8 +1381,128 @@ export function registerAdminTools(server) {
1258
1381
  (args_description ? `Args: ${args_description}` : ''));
1259
1382
  }
1260
1383
  });
1261
- // ── Self-Restart ────────────────────────────────────────────────────────
1262
- server.tool('self_restart', 'Restart the Clementine daemon to pick up code changes. Sends SIGUSR1 to the running process, which triggers a graceful restart.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
1384
+ // ── Self-Update / Self-Restart ─────────────────────────────────────────
1385
+ /** Resolve the git-clone source dir this daemon is running from. */
1386
+ function resolvePackageRoot() {
1387
+ // This file is at clementine-dev/dist/tools/admin-tools.js at runtime.
1388
+ // Climb two dirs to get the package root.
1389
+ const here = path.dirname(fileURLToPath(import.meta.url));
1390
+ return path.resolve(here, '..', '..');
1391
+ }
1392
+ server.tool('where_is_source', 'Report the absolute path of the source tree this daemon is running from, whether it\'s a git clone, the current commit, and whether it has local uncommitted changes. Call this first before any self_update to confirm which checkout you\'re about to modify — avoids the "multiple clones in home dir" confusion where an agent updates the wrong one.', {}, async () => {
1393
+ const root = resolvePackageRoot();
1394
+ const gitDir = path.join(root, '.git');
1395
+ const isGit = existsSync(gitDir);
1396
+ const lines = [`Package root: ${root}`, `Git repo: ${isGit ? 'yes' : 'no'}`];
1397
+ if (isGit) {
1398
+ try {
1399
+ const commit = execSync('git rev-parse --short HEAD', { cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1400
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1401
+ const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1402
+ lines.push(`Branch: ${branch} @ ${commit}`);
1403
+ lines.push(`Uncommitted changes: ${status ? 'yes' : 'no'}`);
1404
+ if (status)
1405
+ lines.push(`\n${status}`);
1406
+ }
1407
+ catch { /* best-effort */ }
1408
+ }
1409
+ lines.push('', 'ONLY modify files under this path when updating source. Other ~/clementine* directories may be stale checkouts — ignore them.');
1410
+ return textResult(lines.join('\n'));
1411
+ });
1412
+ server.tool('self_update', 'Update Clementine to the latest main-branch code and restart. Runs git pull + npm install (if lockfile changed) + npm run build in the running daemon\'s source dir, then signals SIGUSR1 to restart. Owner-DM only. Use this instead of manually invoking git/npm — it operates on the correct source dir and handles the restart cleanly. Returns immediately; the daemon will be unreachable for ~15s during rebuild+restart.', {
1413
+ branch: z.string().optional().describe('Branch to pull (default "main")'),
1414
+ }, async ({ branch }) => {
1415
+ const gate = requireOwnerDm();
1416
+ if (!gate.ok)
1417
+ return textResult(gate.message);
1418
+ const root = resolvePackageRoot();
1419
+ if (!existsSync(path.join(root, '.git'))) {
1420
+ return textResult(`Refused: ${root} is not a git clone. This daemon was likely installed via npm — updates should go through \`npm install -g clementine-agent@latest\`, not self_update.`);
1421
+ }
1422
+ const targetBranch = branch ?? 'main';
1423
+ const out = [];
1424
+ const runQuiet = (cmd, args, timeout = 120000) => {
1425
+ try {
1426
+ const res = execSync(`${cmd} ${args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, {
1427
+ cwd: root, encoding: 'utf-8', timeout,
1428
+ stdio: ['ignore', 'pipe', 'pipe'],
1429
+ });
1430
+ return { ok: true, output: res };
1431
+ }
1432
+ catch (e) {
1433
+ return { ok: false, output: (e?.stdout ?? '') + '\n' + (e?.stderr ?? '') + '\n' + String(e).slice(0, 200) };
1434
+ }
1435
+ };
1436
+ // 1. Stash local changes so git pull is clean
1437
+ const statusProbe = runQuiet('git', ['status', '--porcelain']);
1438
+ const hadLocal = statusProbe.ok && statusProbe.output.trim().length > 0;
1439
+ let stashed = false;
1440
+ if (hadLocal) {
1441
+ const stashRes = runQuiet('git', ['stash', 'push', '-u', '-m', `self_update ${new Date().toISOString()}`]);
1442
+ if (stashRes.ok) {
1443
+ stashed = true;
1444
+ out.push('Stashed local changes (restore with `git stash pop` in the source dir if needed).');
1445
+ }
1446
+ else {
1447
+ return textResult(`Refused: couldn't stash local changes in ${root}. ${stashRes.output.slice(0, 200)}`);
1448
+ }
1449
+ }
1450
+ // 2. Checkout target branch if not already there
1451
+ const branchProbe = runQuiet('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
1452
+ if (branchProbe.ok && branchProbe.output.trim() !== targetBranch) {
1453
+ const coRes = runQuiet('git', ['checkout', targetBranch]);
1454
+ if (!coRes.ok)
1455
+ return textResult(`git checkout ${targetBranch} failed: ${coRes.output.slice(0, 300)}`);
1456
+ }
1457
+ // 3. Record pre-pull hashes so we know if package-lock changed
1458
+ const preLock = existsSync(path.join(root, 'package-lock.json')) ? readFileSync(path.join(root, 'package-lock.json'), 'utf-8').length : 0;
1459
+ const preCommit = runQuiet('git', ['rev-parse', 'HEAD']).output.trim();
1460
+ // 4. Pull
1461
+ const pullRes = runQuiet('git', ['pull', '--ff-only', 'origin', targetBranch], 60000);
1462
+ if (!pullRes.ok)
1463
+ return textResult(`git pull failed: ${pullRes.output.slice(0, 300)}\n\n${stashed ? 'Local changes are preserved in git stash; inspect with `git stash list`.' : ''}`);
1464
+ const postCommit = runQuiet('git', ['rev-parse', 'HEAD']).output.trim();
1465
+ if (preCommit === postCommit) {
1466
+ out.push(`Already up to date at ${preCommit.slice(0, 7)}.`);
1467
+ return textResult(out.join('\n'));
1468
+ }
1469
+ out.push(`Pulled: ${preCommit.slice(0, 7)} → ${postCommit.slice(0, 7)}`);
1470
+ // 5. npm install only if package-lock changed
1471
+ const postLock = existsSync(path.join(root, 'package-lock.json')) ? readFileSync(path.join(root, 'package-lock.json'), 'utf-8').length : 0;
1472
+ if (postLock !== preLock) {
1473
+ out.push('package-lock.json changed — running npm install...');
1474
+ const installRes = runQuiet('npm', ['install'], 180000);
1475
+ if (!installRes.ok)
1476
+ return textResult(`${out.join('\n')}\n\nnpm install failed: ${installRes.output.slice(0, 300)}`);
1477
+ out.push(' ok');
1478
+ }
1479
+ // 6. Build
1480
+ out.push('Building...');
1481
+ const buildRes = runQuiet('npm', ['run', 'build'], 300000);
1482
+ if (!buildRes.ok)
1483
+ return textResult(`${out.join('\n')}\n\nBuild failed: ${buildRes.output.slice(0, 300)}`);
1484
+ out.push(' ok');
1485
+ // 7. Trigger graceful self-restart
1486
+ const pidFile = path.join(BASE_DIR, `.${(env['ASSISTANT_NAME'] ?? 'clementine').toLowerCase()}.pid`);
1487
+ if (existsSync(pidFile)) {
1488
+ const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
1489
+ if (!isNaN(pid)) {
1490
+ try {
1491
+ process.kill(pid, 0);
1492
+ process.kill(pid, 'SIGUSR1');
1493
+ out.push(`Sent SIGUSR1 to PID ${pid}. I'll be back in ~15s on the new build.`);
1494
+ }
1495
+ catch {
1496
+ out.push('Daemon PID not running — no restart signal sent. New build is on disk; launch manually.');
1497
+ }
1498
+ }
1499
+ }
1500
+ else {
1501
+ out.push('No PID file found. Build succeeded but restart not triggered — run `clementine launch` manually.');
1502
+ }
1503
+ return textResult(out.join('\n'));
1504
+ });
1505
+ server.tool('self_restart', 'Restart the Clementine daemon to pick up code changes. Sends SIGUSR1 to the running process, which triggers a graceful restart. Use self_update instead if you also need to pull/build first.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
1263
1506
  const pidFile = path.join(BASE_DIR, `.${(env['ASSISTANT_NAME'] ?? 'clementine').toLowerCase()}.pid`);
1264
1507
  if (!existsSync(pidFile)) {
1265
1508
  return textResult('No PID file found — daemon may not be running.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.45",
3
+ "version": "1.0.47",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",