Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
},
"metadata": {
"description": "Basalt — the trust layer for AI-generated work. Connect your AI to publish, update and share governed docs by URL.",
"version": "0.3.0"
"version": "0.3.1"
},
"plugins": [
{
"name": "basalt",
"source": "./plugins/basalt",
"description": "Connect your AI to Basalt — publish, update and share governed docs by URL. Bundles the Basalt MCP server (OAuth) + a skill that converges on it when you ask to publish or share written work.",
"version": "0.3.0",
"version": "0.3.1",
"author": {
"name": "Cofoundy SAC"
},
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ above the prompt keeps them: every `app.basalt.cofoundy.ai` link that enters the
session — from the CLI, the MCP, the model's own text or a subagent — lands there.

<p align="center">
<img src="./assets/readme/links-band.svg" width="100%" alt="A Claude Code session where the agent published three docs. Above the prompt, the Basalt band shows the mark, 3 docs and the latest one, architecture-v2, with the buttons Abrir, Copiar and Todos; a side pane lists every doc of the session with its space, folder and link.">
<img src="./assets/readme/links-band.svg" width="100%" alt="A Claude Code session where the agent published three docs. Above the prompt, the Basalt band shows the mark, 3 docs and the latest one, architecture-v2, with the buttons Open, Copy and All; a side pane lists every doc of the session with its space, folder and link.">
</p>

`o` opens the latest doc in your browser, `c` copies its URL, `l` (or `/basalt-links`)
Expand All @@ -142,6 +142,9 @@ The mark is the real logo where the surface can draw it: the SVG on the desktop
a PNG on kitty or Ghostty run directly, and `⬣` in Molten elsewhere (inside tmux,
screen, zellij or herdr Claude Code draws no pictures).

Labels are in English, and in Spanish when your locale is (`LC_ALL`, `LC_MESSAGES` or
`LANG` starting with `es`).

It is a Claude Code function-hooks module (early access); clients without function
hooks keep the three shell hooks above, unchanged. Already installed? Update the
plugin (`/plugin` → basalt → update) and run `/reload-plugins`.
Expand Down
28 changes: 14 additions & 14 deletions assets/readme/links-band.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion plugins/basalt/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "basalt",
"description": "Connect your AI to Basalt — publish, update and share governed docs by URL. Bundles the Basalt MCP server (OAuth), a skill that converges on it when you ask to publish or share written work, and quiet session hooks (auth probe · edit tracking · publish nudge).",
"version": "0.3.0",
"version": "0.3.1",
"author": {
"name": "Cofoundy SAC",
"url": "https://cofoundy.dev"
Expand Down
62 changes: 47 additions & 15 deletions plugins/basalt/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const URL_RE = /https:\/\/app\.basalt\.cofoundy\.ai\/[^\s)\]"'<>`\\]+/g
const BAND_CHROME = 24 + 44
const links = atom({ plugin: 'basalt', key: 'links' } as const, [])

// Junta todos los strings de un content (texto del modelo, tool results, anidados).
// Every string in a row's content (model text, tool results, nested blocks).
const strings = (value: unknown, out: string[] = []): string[] => {
if (typeof value === 'string') out.push(value)
else if (Array.isArray(value)) value.forEach(v => strings(v, out))
Expand All @@ -39,7 +39,7 @@ export const toLink = (url: string, by: string, at: number): BasaltLink => {
return { url, title, trail: trail.slice(0, -1), by, at }
}

// Rows stored by an earlier version of this mod carry no trail.
// Rows stored by an earlier version carry no trail.
const fresh = (link: BasaltLink) => (Array.isArray(link.trail) ? link : toLink(link.url, link.by, link.at))

const cut = (text: string, room: number) => {
Expand Down Expand Up @@ -84,15 +84,45 @@ async function openUrl($: EngineInterface, url: string) {
if (mac.exitCode !== 0) await $.process.run(['xdg-open', url]).catch(() => undefined)
}

// English by default; Spanish when the locale says so (POSIX precedence:
// LC_ALL, then LC_MESSAGES, then LANG).
const STRINGS = {
en: {
open: 'Open', copy: 'Copy', all: 'All', copied: 'Link copied',
pane: 'Basalt · session docs', empty: 'No Basalt docs in this session yet.',
command: 'List the Basalt docs of this session', opened: 'Basalt docs of this session opened in the pane.',
session: 'session', subagent: 'subagent',
},
es: {
open: 'Abrir', copy: 'Copiar', all: 'Todos', copied: 'Link copiado',
pane: 'Basalt · docs de la sesión', empty: 'Ningún doc de Basalt en esta sesión todavía.',
command: 'Lista los docs de Basalt de esta sesión', opened: 'Docs de Basalt de esta sesión abiertos en el panel.',
session: 'sesión', subagent: 'subagente',
},
} as const
type Strings = (typeof STRINGS)[keyof typeof STRINGS]

export const pickLanguage = (lcAll?: string, lcMessages?: string, lang?: string): keyof typeof STRINGS => {
const locale = lcAll || lcMessages || lang || ''

return locale.toLowerCase().startsWith('es') ? 'es' : 'en'
}

async function labels($: EngineInterface): Promise<Strings> {
const language = pickLanguage(await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG'))

return STRINGS[language]
}

// The pane stays open across reloads and sessions until the person closes it.
async function openPane($: EngineInterface, focus?: true) {
await $.store.set('isPinned', true)
await $.ui.open({ id: PANE, title: 'Basalt · docs de la sesión', ...(focus ? { focus } : {}) })
await $.ui.open({ id: PANE, title: (await labels($)).pane, ...(focus ? { focus } : {}) })
}

async function copyUrl($: EngineInterface, url: string, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) {
await $.ui.copy({ text: url, surface })
$.ui.toast('Link copiado')
$.ui.toast((await labels($)).copied)
}

// Whether this terminal shows kitty graphics: kitty or Ghostty, run directly.
Expand Down Expand Up @@ -131,7 +161,7 @@ async function mark($: EngineInterface, e: Parameters<EngineInterface['ui']['res

export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'basalt-links', description: 'Lista los docs de Basalt de esta sesión' })
await $.command.register({ name: 'basalt-links', description: (await labels($)).command })
if ((await $.store.get('isPinned')) === true) void openPane($)

return next(e)
Expand All @@ -140,16 +170,16 @@ export const register: Register = on => {
on('command.run', { command: 'basalt-links' }, async $ => {
await openPane($)

return { text: 'Docs de Basalt de esta sesión abiertos en el panel.' }
return { text: (await labels($)).opened }
})

// Cada fila que entra a la conversación (tuya, del modelo, de un tool, de un subagente) se escanea.
// Every row the conversation keeps (the person's, the model's, a tool's, a subagent's) is scanned.
on('session.append', async ($, e, next) => {
const stored = await next(e)
const found = strings(e.message.content).flatMap(text => text.match(URL_RE) ?? [])
if (found.length > 0) {
const now = await $.clock.now()
const by = e.agentId ? 'subagente' : 'sesión'
const by = e.agentId ? 'subagent' : 'session'
await update($, links, list => {
const known = new Set(list.map(link => link.url))
const fresh = [...new Set(found.map(url => url.replace(/[.,;:!?]+$/, '')))]
Expand All @@ -169,6 +199,7 @@ export const register: Register = on => {
if (e.props.hasSurvey || !last) return next(e)

const { Box, Button, Text } = $.ui.resolve(e)
const t = await labels($)
const { path, title } = crumb(last, e.props.bodyColumns - BAND_CHROME)
const below = await next(e)

Expand All @@ -183,11 +214,11 @@ export const register: Register = on => {
<Text color={MUTED}> doc{list.length === 1 ? '' : 's'} </Text>
<Text key="path" color={STONE}>{path}</Text>
<Text key="title" color={ASH} bold>{title} </Text>
<Button key="abrir" hotkey="o" variant="primary" label="Abrir" onPress={() => openUrl($, last.url)} />
<Button key="abrir" hotkey="o" variant="primary" label={t.open} onPress={() => openUrl($, last.url)} />
<Text> </Text>
<Button key="copiar" hotkey="c" label="Copiar" onPress={() => copyUrl($, last.url, e.surface)} />
<Button key="copiar" hotkey="c" label={t.copy} onPress={() => copyUrl($, last.url, e.surface)} />
<Text> </Text>
<Button key="todos" hotkey="l" label="Todos" onPress={() => openPane($, true)} />
<Button key="todos" hotkey="l" label={t.all} onPress={() => openPane($, true)} />
</Box>
{below}
</Box>
Expand All @@ -202,12 +233,13 @@ export const register: Register = on => {

on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
const { Box, Button, Text } = $.ui.resolve(e)
const t = await labels($)
const list = (await read($, links)).map(fresh)
const room = Math.max(16, e.props.bodyColumns - 2)

return (
<Box flexDirection="column">
{list.length === 0 && <Text color={MUTED}>Ningún doc de Basalt en esta sesión todavía.</Text>}
{list.length === 0 && <Text color={MUTED}>{t.empty}</Text>}
{[...list].reverse().map(link => {
const { path } = crumb({ ...link, title: '' }, room)

Expand All @@ -217,10 +249,10 @@ export const register: Register = on => {
{link.trail.length > 0 && <Text color={MUTED} wrap="truncate-start">{path.replace(/ › $/, '')}</Text>}
<Text color={TEAL} wrap="truncate-middle">{link.url}</Text>
<Box>
<Button key={`o-${link.url}`} variant="primary" label="Abrir" onPress={() => openUrl($, link.url)} />
<Button key={`o-${link.url}`} variant="primary" label={t.open} onPress={() => openUrl($, link.url)} />
<Text> </Text>
<Button key={`c-${link.url}`} label="Copiar" onPress={() => copyUrl($, link.url, e.surface)} />
<Text color={MUTED}> · {link.by}</Text>
<Button key={`c-${link.url}`} label={t.copy} onPress={() => copyUrl($, link.url, e.surface)} />
<Text color={MUTED}> · {link.by === 'subagent' || link.by === 'subagente' ? t.subagent : t.session}</Text>
</Box>
</Box>
)
Expand Down
13 changes: 13 additions & 0 deletions plugins/basalt/tests/language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from 'claude-code/testing'

import { pickLanguage } from '../hooks/register'

test('English unless the locale is Spanish, POSIX precedence', () => {
expect(pickLanguage(undefined, undefined, undefined)).toBe('en')
expect(pickLanguage(undefined, undefined, 'en_US.UTF-8')).toBe('en')
expect(pickLanguage(undefined, undefined, 'es_PE.UTF-8')).toBe('es')
expect(pickLanguage('', '', 'es_ES.UTF-8')).toBe('es')
expect(pickLanguage(undefined, 'es_MX.UTF-8', 'en_US.UTF-8')).toBe('es')
expect(pickLanguage('en_US.UTF-8', undefined, 'es_PE.UTF-8')).toBe('en')
expect(pickLanguage('C', undefined, 'es_PE.UTF-8')).toBe('en')
})
Loading