Compare commits

...

5 Commits

Author SHA1 Message Date
Dickson Tsai
986deab6a1 fix: invoke .sh hooks via bash prefix; add version for cache invalidation
Fixes #993 (Permission denied on hook scripts) without relying on
client-side +x preservation.

The hook executor spawns commands via /bin/sh -c, which requires +x
to execute a script directly. Prefixing with bash reads the script
as data — mode bits are irrelevant. This works on all Claude Code
versions, whereas the client-side chmod fix (claude-cli #24666) only
shipped in v2.1.86.

All 3 scripts declare #!/bin/bash and use bashisms ([[ ]], =~), so
bash (not sh) is the correct interpreter.

The version field forces a fresh cache path (1.0.0/ instead of
unknown/), ensuring the new hooks.json reaches users with stale
caches.
2026-03-28 08:40:34 -07:00
russell-coleman
548bfa8375 Merge pull request #1008 from anthropics/russell/imessage-conversational-format
feat(imessage): conversational format for chat_messages
2026-03-27 14:24:14 -07:00
Russell Coleman
a1ffbcc771 feat(imessage): add IMESSAGE_DB_PATH env var for testing
Mirrors the existing IMESSAGE_STATE_DIR override. Lets a mock sqlite
chat.db stand in for ~/Library/Messages/chat.db so chat_messages can be
tested without macOS + Full Disk Access + real iMessage history.
2026-03-27 13:45:56 -07:00
Noah Zweben
451c481b2b Update flint plugin sha to f3d56e3 (#1077)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-27 09:51:27 -07:00
Russell Coleman
28f7434384 feat(imessage): conversational format for chat_messages
Reformat chat_messages output from flat per-message lines to grouped
conversation threads. Each thread gets a header labelling it DM or Group
with its participant list, date-separator lines when the calendar day
rolls over, and [HH:MM] local-time stamps instead of full ISO.

chat_guid is now optional — omit to dump every allowlisted chat at once
for a quick multi-thread overview. Default limit raised 20→100 per chat,
capped at 500.

New queries: qChatParticipants (handle list per chat) and qChatInfo
(display_name + style to distinguish DM/group). renderMsg replaced by
conversationHeader + renderConversation.
2026-03-25 16:50:00 -07:00
9 changed files with 88 additions and 24 deletions

View File

@@ -464,7 +464,7 @@
"source": {
"source": "url",
"url": "https://github.com/tryflint/claude-code-plugin.git",
"sha": "8f57ef474f59817c7e5930dd6c295232d43c3711"
"sha": "f3d56e33ed2fb3ed9b4f02e0fc65d0a79b24bf4d"
},
"homepage": "https://www.tryflint.com/docs/claude-code-plugin"
},

View File

@@ -76,7 +76,7 @@ Quick reference: IDs are **handle addresses** (`+15551234567` or `someone@icloud
| Tool | Purpose |
| --- | --- |
| `reply` | Send to a chat. `chat_id` + `text`, optional `files` (absolute paths). Auto-chunks text; files send as separate messages. |
| `chat_messages` | Fetch recent history from a chat (oldest-first). Reads `chat.db` directly — full native history. Scoped to allowlisted chats. |
| `chat_messages` | Fetch recent history as conversation threads. Each thread is labelled **DM** or **Group** with its participant list, then timestamped messages (oldest-first). Omit `chat_guid` to see every allowlisted chat at once, or pass one to drill in. Default 100 messages per chat. Reads `chat.db` directly — full native history. |
## What you don't get

View File

@@ -33,7 +33,8 @@ import { join, basename, sep } from 'path'
const STATIC = process.env.IMESSAGE_ACCESS_MODE === 'static'
const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false'
const SIGNATURE = '\nSent by Claude'
const CHAT_DB = join(homedir(), 'Library', 'Messages', 'chat.db')
const CHAT_DB =
process.env.IMESSAGE_DB_PATH ?? join(homedir(), 'Library', 'Messages', 'chat.db')
const STATE_DIR = process.env.IMESSAGE_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'imessage')
const ACCESS_FILE = join(STATE_DIR, 'access.json')
@@ -141,6 +142,21 @@ const qChatsForHandle = db.query<{ guid: string }, [string]>(`
WHERE c.style = 45 AND LOWER(h.id) = ?
`)
// Participants of a chat (other than yourself). For DMs this is one handle;
// for groups it's everyone in chat_handle_join.
const qChatParticipants = db.query<{ id: string }, [string]>(`
SELECT DISTINCT h.id FROM handle h
JOIN chat_handle_join chj ON chj.handle_id = h.ROWID
JOIN chat c ON c.ROWID = chj.chat_id
WHERE c.guid = ?
`)
// Group-chat display name and style. display_name is NULL for DMs and
// unnamed groups; populated when the user has named the group in Messages.
const qChatInfo = db.query<{ display_name: string | null; style: number }, [string]>(`
SELECT display_name, style FROM chat WHERE guid = ?
`)
type AttRow = { filename: string | null; mime_type: string | null; transfer_name: string | null }
const qAttachments = db.query<AttRow, [number]>(`
SELECT a.filename, a.mime_type, a.transfer_name
@@ -476,15 +492,43 @@ function messageText(r: Row): string {
return r.text ?? parseAttributedBody(r.attributedBody) ?? ''
}
function renderMsg(r: Row): string {
const who = r.is_from_me ? 'me' : (r.handle_id ?? 'unknown')
const ts = appleDate(r.date).toISOString()
const atts = r.cache_has_attachments ? ' +att' : ''
// Tool results are newline-joined; a multi-line message would forge
// adjacent rows. chat_messages is allowlist-scoped, but a configured group
// can still have untrusted members.
const text = messageText(r).replace(/[\r\n]+/g, ' ⏎ ')
return `[${ts}] ${who}: ${text} (id: ${r.guid}${atts})`
// Build a human-readable header for one conversation. Labels DM vs group and
// lists participants so the assistant can tell threads apart at a glance.
function conversationHeader(guid: string): string {
const info = qChatInfo.get(guid)
const participants = qChatParticipants.all(guid).map(p => p.id)
const who = participants.length > 0 ? participants.join(', ') : guid
if (info?.style === 43) {
const name = info.display_name ? `"${info.display_name}" ` : ''
return `=== Group ${name}(${who}) ===`
}
return `=== DM with ${who} ===`
}
// Render one chat's messages as a conversation block: header, then one line
// per message with a local-time stamp. A date line is inserted whenever the
// calendar day rolls over so long histories stay readable without repeating
// the full date on every row.
function renderConversation(guid: string, rows: Row[]): string {
const lines: string[] = [conversationHeader(guid)]
let lastDay = ''
for (const r of rows) {
const d = appleDate(r.date)
const day = d.toDateString()
if (day !== lastDay) {
lines.push(`-- ${day} --`)
lastDay = day
}
const hhmm = d.toTimeString().slice(0, 5)
const who = r.is_from_me ? 'me' : (r.handle_id ?? 'unknown')
const atts = r.cache_has_attachments ? ' [attachment]' : ''
// Tool results are newline-joined; a multi-line message would forge
// adjacent rows. chat_messages is allowlist-scoped, but a configured group
// can still have untrusted members.
const text = messageText(r).replace(/[\r\n]+/g, ' ⏎ ')
lines.push(`[${hhmm}] ${who}: ${text}${atts}`)
}
return lines.join('\n')
}
// --- mcp ---------------------------------------------------------------------
@@ -584,14 +628,19 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
{
name: 'chat_messages',
description:
'Fetch recent messages from an iMessage chat. Reads chat.db directly — full native history. Scoped to allowlisted chats only.',
'Fetch recent iMessage history as readable conversation threads. Each thread is labelled DM or Group with its participant list, followed by timestamped messages. Omit chat_guid to see all allowlisted chats at once; pass a specific chat_guid to drill into one thread. Reads chat.db directly — full native history, scoped to allowlisted chats only.',
inputSchema: {
type: 'object',
properties: {
chat_guid: { type: 'string', description: 'The chat_id from the inbound message.' },
limit: { type: 'number', description: 'Max messages (default 20).' },
chat_guid: {
type: 'string',
description: 'A specific chat_id to read. Omit to read from every allowlisted chat.',
},
limit: {
type: 'number',
description: 'Max messages per chat (default 100, max 500).',
},
},
required: ['chat_guid'],
},
},
],
@@ -639,13 +688,25 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
return { content: [{ type: 'text', text: sent === 1 ? 'sent' : `sent ${sent} parts` }] }
}
case 'chat_messages': {
const guid = args.chat_guid as string
const limit = (args.limit as number) ?? 20
if (!allowedChatGuids().has(guid)) {
const guid = args.chat_guid as string | undefined
const limit = Math.min((args.limit as number) ?? 100, 500)
const allowed = allowedChatGuids()
const targets = guid == null ? [...allowed] : [guid]
if (guid != null && !allowed.has(guid)) {
throw new Error(`chat ${guid} is not allowlisted — add via /imessage:access`)
}
const rows = qHistory.all(guid, limit).reverse()
const out = rows.length === 0 ? '(no messages)' : rows.map(renderMsg).join('\n')
if (targets.length === 0) {
return { content: [{ type: 'text', text: '(no allowlisted chats — configure via /imessage:access)' }] }
}
const blocks: string[] = []
for (const g of targets) {
const rows = qHistory.all(g, limit).reverse()
if (rows.length === 0 && guid == null) continue
blocks.push(rows.length === 0
? `${conversationHeader(g)}\n(no messages)`
: renderConversation(g, rows))
}
const out = blocks.length === 0 ? '(no messages)' : blocks.join('\n\n')
return { content: [{ type: 'text', text: out }] }
}
default:

View File

@@ -1,5 +1,6 @@
{
"name": "explanatory-output-style",
"version": "1.0.0",
"description": "Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style)",
"author": {
"name": "Anthropic",

View File

@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh\""
}
]
}

View File

@@ -1,5 +1,6 @@
{
"name": "learning-output-style",
"version": "1.0.0",
"description": "Interactive learning mode that requests meaningful code contributions at decision points (mimics the unshipped Learning output style)",
"author": {
"name": "Anthropic",

View File

@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh\""
}
]
}

View File

@@ -1,5 +1,6 @@
{
"name": "ralph-loop",
"version": "1.0.0",
"description": "Continuous self-referential AI loops for interactive iterative development, implementing the Ralph Wiggum technique. Run Claude in a while-true loop with the same prompt until task completion.",
"author": {
"name": "Anthropic",

View File

@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh\""
}
]
}