Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
24 KiB
Relicario Credential Capture Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add experimental credential capture that detects login form submissions and prompts the user to save or update credentials, with configurable bar/toast prompt style and per-site blacklist.
Architecture: Content script hooks form submissions, captures credentials, asks the service worker to check against the manifest, then injects a prompt (bar or toast) into the page. New settings view in the popup for configuration. Feature is off by default.
Tech Stack: TypeScript, Chrome extension APIs, DOM injection
Spec: docs/superpowers/specs/2026-04-12-relicario-credential-capture-design.md
File Structure
New files
extension/src/content/capture.ts # Form submission detection + prompt injection
extension/src/popup/components/settings.ts # Settings view
Modified files
extension/src/shared/types.ts # Add RelicarioSettings interface
extension/src/shared/messages.ts # Add new message types
extension/src/service-worker/index.ts # Handle new messages
extension/src/content/detector.ts # Import and init capture
extension/src/popup/popup.ts # Add 'settings' view
extension/src/popup/components/unlock.ts # Wire settings button to settings view
Task 1: Add Types and Message Definitions
Files:
-
Modify:
extension/src/shared/types.ts -
Modify:
extension/src/shared/messages.ts -
Step 1: Add RelicarioSettings to types.ts
Add at the end of extension/src/shared/types.ts:
export interface RelicarioSettings {
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export const DEFAULT_SETTINGS: RelicarioSettings = {
captureEnabled: false,
captureStyle: 'bar',
};
- Step 2: Add new message types to messages.ts
Add these to the Request union in extension/src/shared/messages.ts:
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
- Step 3: Commit
git add extension/src/shared/types.ts extension/src/shared/messages.ts
git commit -m "feat: add settings and credential capture message types"
Task 2: Service Worker Message Handlers
Files:
-
Modify:
extension/src/service-worker/index.ts -
Step 1: Add settings and blacklist storage helpers
Add these helper functions to extension/src/service-worker/index.ts, after the existing storage helpers:
import type { RelicarioSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';
async function loadSettings(): Promise<RelicarioSettings> {
const data = await chrome.storage.local.get(['settings']);
if (!data.settings) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...data.settings };
}
async function saveSettings(settings: RelicarioSettings): Promise<void> {
await chrome.storage.local.set({ settings });
}
async function loadBlacklist(): Promise<string[]> {
const data = await chrome.storage.local.get(['captureBlacklist']);
return data.captureBlacklist ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
- Step 2: Add message handlers
Add these cases to the switch statement in the message handler, before the default case:
case 'get_settings': {
const settings = await loadSettings();
return { ok: true, data: settings };
}
case 'update_settings': {
const current = await loadSettings();
const updated = { ...current, ...req.settings };
await saveSettings(updated);
return { ok: true };
}
case 'get_blacklist': {
const list = await loadBlacklist();
return { ok: true, data: { blacklist: list } };
}
case 'remove_blacklist': {
const list = await loadBlacklist();
await saveBlacklist(list.filter(h => h !== req.hostname));
return { ok: true };
}
case 'blacklist_site': {
const list = await loadBlacklist();
if (!list.includes(req.hostname)) {
list.push(req.hostname);
await saveBlacklist(list);
}
return { ok: true };
}
case 'check_credential': {
// If vault is locked, skip
if (!masterKey || !gitHost || !manifest) {
return { ok: true, data: { action: 'skip' } };
}
// Check settings
const settings = await loadSettings();
if (!settings.captureEnabled) {
return { ok: true, data: { action: 'skip' } };
}
// Check blacklist
let hostname: string;
try {
hostname = new URL(req.url).hostname;
} catch {
return { ok: true, data: { action: 'skip' } };
}
const blacklist = await loadBlacklist();
if (blacklist.includes(hostname)) {
return { ok: true, data: { action: 'skip' } };
}
// Find matching entries by hostname
const matches = vault.findByUrl(manifest, req.url);
if (matches.length === 0) {
return { ok: true, data: { action: 'save' } };
}
// Check if any match has the same username
for (const [id, entry] of matches) {
if (entry.username === req.username) {
// Same username — check if password changed
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, id);
if (fullEntry.password === req.password) {
// Exact match, already saved
return { ok: true, data: { action: 'skip' } };
} else {
// Password changed
return { ok: true, data: { action: 'update', entryId: id, entryName: entry.name } };
}
}
}
// Different username on same site — new account
return { ok: true, data: { action: 'save' } };
}
- Step 3: Verify build
cd extension && bun run build
Expected: Compiles with no errors.
- Step 4: Commit
git add extension/src/service-worker/index.ts
git commit -m "feat: add settings, blacklist, and credential check handlers"
Task 3: Credential Capture Content Script
Files:
-
Create:
extension/src/content/capture.ts -
Modify:
extension/src/content/detector.ts -
Step 1: Create capture.ts
Create extension/src/content/capture.ts:
/// Credential capture — detects login form submissions and prompts
/// the user to save or update credentials in the vault.
///
/// This module hooks form submit events and submit button clicks,
/// captures username + password, asks the service worker whether to
/// save/update/skip, and injects a prompt (bar or toast) into the page.
// --- Types ---
interface CaptureResult {
action: 'save' | 'update' | 'skip';
entryId?: string;
entryName?: string;
}
type PromptStyle = 'bar' | 'toast';
// Track forms we've already hooked to avoid duplicates.
const hookedForms = new WeakSet<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();
// --- Form Submission Detection ---
/// Find the username field associated with a password field.
/// Same priority as detector.ts but inlined to avoid circular deps.
function findUsername(pwField: HTMLInputElement): string {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// autocomplete="username"
for (const input of inputs) {
if (input !== pwField && input.autocomplete === 'username' && input.value) return input.value;
}
// autocomplete="email"
for (const input of inputs) {
if (input !== pwField && input.autocomplete === 'email' && input.value) return input.value;
}
// type="email"
for (const input of inputs) {
if (input !== pwField && input.type === 'email' && input.value) return input.value;
}
// name/id pattern
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value;
}
// Nearest preceding visible text input
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField);
for (let i = pwIndex - 1; i >= 0; i--) {
const input = allInputs[i];
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value;
}
return '';
}
/// Capture credentials from a form that contains a password field.
function captureFromForm(form: HTMLFormElement): { username: string; password: string } | null {
const pwField = form.querySelector<HTMLInputElement>('input[type="password"]');
if (!pwField || !pwField.value) return null;
const username = findUsername(pwField);
return { username, password: pwField.value };
}
/// Handle a form submission — capture credentials and check with service worker.
async function handleSubmission(form: HTMLFormElement): Promise<void> {
const creds = captureFromForm(form);
if (!creds || !creds.password) return;
const url = window.location.href;
try {
const response = await chrome.runtime.sendMessage({
type: 'check_credential',
url,
username: creds.username,
password: creds.password,
});
if (!response?.ok) return;
const result = response.data as CaptureResult;
if (result.action === 'skip') return;
// Get the prompt style from settings
const settingsResp = await chrome.runtime.sendMessage({ type: 'get_settings' });
const style: PromptStyle = settingsResp?.ok ? (settingsResp.data as { captureStyle: PromptStyle }).captureStyle : 'bar';
showPrompt(style, result, url, creds.username, creds.password);
} catch {
// Extension not available or vault locked — silently skip
}
}
/// Hook form submit events and submit button clicks.
export function hookForms(): void {
const forms = document.querySelectorAll<HTMLFormElement>('form');
for (const form of forms) {
// Only hook forms that contain a password field.
if (!form.querySelector('input[type="password"]')) continue;
if (hookedForms.has(form)) continue;
hookedForms.add(form);
// Hook form submit event.
form.addEventListener('submit', () => {
handleSubmission(form);
});
// Hook submit button clicks (some sites don't use form submit).
const submitBtns = form.querySelectorAll<HTMLElement>(
'button[type="submit"], input[type="submit"], button:not([type])'
);
for (const btn of submitBtns) {
if (hookedButtons.has(btn)) continue;
hookedButtons.add(btn);
btn.addEventListener('click', () => {
handleSubmission(form);
});
}
}
}
// --- Prompt UI ---
/// Remove any existing relicario prompt from the page.
function removePrompt(): void {
document.getElementById('relicario-capture-prompt')?.remove();
}
/// Show a save/update prompt.
function showPrompt(
style: PromptStyle,
result: CaptureResult,
url: string,
username: string,
password: string,
): void {
removePrompt();
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
hostname = url;
}
const isUpdate = result.action === 'update';
const actionLabel = isUpdate ? 'Update' : 'Save';
const message = isUpdate
? `Update password for ${hostname}?`
: `Save login for ${hostname}?`;
const container = document.createElement('div');
container.id = 'relicario-capture-prompt';
// Common styles
const baseStyles = `
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
color: #c9d1d9;
background: #161b22;
border: 1px solid #30363d;
z-index: 2147483647;
box-sizing: border-box;
`;
if (style === 'bar') {
container.style.cssText = `
${baseStyles}
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border-top: none;
border-left: none;
border-right: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
transform: translateY(-100%);
transition: transform 0.2s ease-out;
`;
// Slide in after a frame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
container.style.transform = 'translateY(0)';
});
});
} else {
container.style.cssText = `
${baseStyles}
position: fixed;
bottom: 16px;
right: 16px;
padding: 12px 16px;
border-radius: 4px;
min-width: 260px;
max-width: 340px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.2s ease-out;
`;
requestAnimationFrame(() => {
container.style.opacity = '1';
});
// Auto-dismiss after 15 seconds.
setTimeout(() => {
if (container.isConnected) {
container.style.opacity = '0';
setTimeout(removePrompt, 200);
}
}, 15000);
}
// Brand label
const brand = document.createElement('span');
brand.textContent = 'relicario';
brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;';
// Message text
const msg = document.createElement('span');
msg.style.cssText = 'flex: 1;';
if (style === 'bar') {
msg.textContent = `${message} ${username ? `(${username})` : ''}`;
} else {
msg.innerHTML = `${escapeHtml(message)}<br><span style="color:#8b949e;font-size:11px;">${escapeHtml(username)}</span>`;
}
// Buttons
const btnStyle = `
font-family: inherit;
font-size: 11px;
padding: 4px 12px;
border-radius: 2px;
cursor: pointer;
border: none;
`;
const saveBtn = document.createElement('button');
saveBtn.textContent = actionLabel;
saveBtn.style.cssText = `${btnStyle} background: #1f6feb; color: #fff;`;
const neverBtn = document.createElement('button');
neverBtn.textContent = 'Never';
neverBtn.style.cssText = `${btnStyle} background: #21262d; color: #8b949e; border: 1px solid #30363d;`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = `${btnStyle} background: transparent; color: #484f58; font-size: 14px; padding: 4px 8px;`;
// Event handlers
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.textContent = '...';
try {
if (isUpdate && result.entryId) {
// Fetch existing entry, update password
const getResp = await chrome.runtime.sendMessage({ type: 'get_entry', id: result.entryId });
if (getResp?.ok) {
const existing = (getResp.data as { entry: Record<string, unknown> }).entry;
await chrome.runtime.sendMessage({
type: 'update_entry',
id: result.entryId,
entry: { ...existing, password },
});
}
} else {
await chrome.runtime.sendMessage({
type: 'add_entry',
entry: {
name: hostname,
url,
username: username || undefined,
password,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
});
}
// Brief confirmation
msg.textContent = isUpdate ? '✓ Updated' : '✓ Saved';
saveBtn.remove();
neverBtn.remove();
setTimeout(removePrompt, 1500);
} catch {
saveBtn.textContent = 'Error';
setTimeout(removePrompt, 2000);
}
});
neverBtn.addEventListener('click', async () => {
await chrome.runtime.sendMessage({ type: 'blacklist_site', hostname });
removePrompt();
});
closeBtn.addEventListener('click', removePrompt);
// Assemble
if (style === 'bar') {
container.appendChild(brand);
container.appendChild(msg);
container.appendChild(saveBtn);
container.appendChild(neverBtn);
container.appendChild(closeBtn);
} else {
const header = document.createElement('div');
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;';
header.appendChild(brand);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.style.cssText = 'margin-bottom:10px;';
body.appendChild(msg);
const actions = document.createElement('div');
actions.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
actions.appendChild(neverBtn);
actions.appendChild(saveBtn);
container.appendChild(header);
container.appendChild(body);
container.appendChild(actions);
}
document.body.appendChild(container);
}
function escapeHtml(s: string): string {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
- Step 2: Import and init capture in detector.ts
Add to the top of extension/src/content/detector.ts, after the existing imports:
import { hookForms } from './capture';
Add hookForms() calls in two places:
After the scan() call near the bottom (line ~91):
// Initial scan.
scan();
hookForms();
Inside the MutationObserver callback (line ~95):
const observer = new MutationObserver(() => {
scan();
hookForms();
});
- Step 3: Build and verify
cd extension && bun run build
Expected: Compiles with no errors.
- Step 4: Commit
git add extension/src/content/capture.ts extension/src/content/detector.ts
git commit -m "feat: add credential capture with bar/toast prompts"
Task 4: Settings View in Popup
Files:
-
Create:
extension/src/popup/components/settings.ts -
Modify:
extension/src/popup/popup.ts -
Modify:
extension/src/popup/components/unlock.ts -
Step 1: Create settings.ts
Create extension/src/popup/components/settings.ts:
/// Settings view — configure credential capture and manage blacklist.
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { RelicarioSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {
// Load current settings and blacklist in parallel.
const [settingsResp, blacklistResp] = await Promise.all([
sendMessage({ type: 'get_settings' }),
sendMessage({ type: 'get_blacklist' }),
]);
const settings: RelicarioSettings = settingsResp.ok
? settingsResp.data as RelicarioSettings
: { captureEnabled: false, captureStyle: 'bar' };
const blacklist: string[] = blacklistResp.ok
? (blacklistResp.data as { blacklist: string[] }).blacklist
: [];
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div style="margin-bottom:16px;">
<span class="secondary" style="cursor:pointer;font-size:11px;" id="back-btn">← back</span>
</div>
<div class="brand" style="margin-bottom:16px;">settings</div>
<div class="form-group" style="margin-bottom:16px;">
<div class="label">CREDENTIAL CAPTURE <span class="muted">(experimental)</span></div>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
<input type="checkbox" id="capture-toggle" ${settings.captureEnabled ? 'checked' : ''}>
auto-detect logins
</label>
</div>
</div>
<div class="form-group" style="margin-bottom:16px;">
<div class="label">PROMPT STYLE</div>
<div style="display:flex;gap:8px;margin-top:6px;">
<button class="group-tab ${settings.captureStyle === 'bar' ? 'active' : ''}" data-style="bar">bar</button>
<button class="group-tab ${settings.captureStyle === 'toast' ? 'active' : ''}" data-style="toast">toast</button>
</div>
</div>
${blacklist.length > 0 ? `
<div class="form-group">
<div class="label">BLACKLISTED SITES</div>
<div style="margin-top:6px;">
${blacklist.map(h => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:11px;">
<span class="secondary">${escapeHtml(h)}</span>
<span class="muted" style="cursor:pointer;" data-remove-host="${escapeHtml(h)}">✕</span>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
// --- Event listeners ---
document.getElementById('back-btn')?.addEventListener('click', () => {
navigate('locked');
});
document.getElementById('capture-toggle')?.addEventListener('change', async (e) => {
const enabled = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
});
document.querySelectorAll('[data-style]').forEach(btn => {
btn.addEventListener('click', async () => {
const style = (btn as HTMLElement).dataset.style as 'bar' | 'toast';
await sendMessage({ type: 'update_settings', settings: { captureStyle: style } });
// Re-render to update active state.
renderSettings(app);
});
});
document.querySelectorAll('[data-remove-host]').forEach(btn => {
btn.addEventListener('click', async () => {
const hostname = (btn as HTMLElement).dataset.removeHost!;
await sendMessage({ type: 'remove_blacklist', hostname });
// Re-render to remove from list.
renderSettings(app);
});
});
}
- Step 2: Add 'settings' view to popup.ts
In extension/src/popup/popup.ts:
Add the import at the top with the other component imports:
import { renderSettings } from './components/settings';
Update the View type:
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
Add the case to the render() switch:
case 'settings':
renderSettings(app);
break;
- Step 3: Wire settings button in unlock.ts
In extension/src/popup/components/unlock.ts, change the settings button handler (line ~55):
From:
settingsBtn?.addEventListener('click', () => navigate('setup'));
To:
settingsBtn?.addEventListener('click', () => navigate('settings'));
- Step 4: Build and verify
cd extension && bun run build
Expected: Compiles with no errors.
- Step 5: Commit
git add extension/src/popup/components/settings.ts extension/src/popup/popup.ts extension/src/popup/components/unlock.ts
git commit -m "feat: add settings view with capture toggle and blacklist management"
Task 5: Build and Manual Test
Files: None (integration testing)
- Step 1: Full build
cd extension && bun run build
Expected: Compiles with no errors (warnings about WASM size are fine).
- Step 2: Reload extension in Chrome
Open chrome://extensions/, reload the unpacked extension.
- Step 3: Test settings view
- Open popup → click "settings" from unlock screen
- Verify toggle for "auto-detect logins" (should be off by default)
- Toggle it on
- Verify bar/toast style selector works
- Go back to unlock screen
- Step 4: Test credential capture (bar mode)
- Enable capture in settings, set style to "bar"
- Unlock the vault
- Navigate to a login page (e.g. GitHub)
- Enter credentials and submit the form
- Verify: notification bar slides down from top with "Save login for github.com?"
- Click "Save" — verify entry appears in vault
- Submit same credentials again — verify no prompt (already saved)
- Change password and submit — verify "Update password?" prompt
- Step 5: Test credential capture (toast mode)
- Change style to "toast" in settings
- Submit a login form on a new site
- Verify: floating toast appears in bottom-right
- Verify: auto-dismisses after ~15 seconds if ignored
- Step 6: Test blacklist
- Click "Never" on a capture prompt
- Submit login on same site again — verify no prompt
- Open settings — verify site appears in blacklist
- Remove site from blacklist — verify it's gone
-
Step 7: Fix any issues found
-
Step 8: Final commit
git add -A
git commit -m "feat: complete credential capture feature"
Task Summary
| Task | Description | Dependencies |
|---|---|---|
| 1 | Add types and message definitions | None |
| 2 | Service worker message handlers | Task 1 |
| 3 | Capture content script + detector integration | Task 1 |
| 4 | Settings view in popup | Task 1 |
| 5 | Build and manual test | All |
Tasks 2, 3, and 4 can run in parallel after Task 1. Task 5 is final integration.