Apache Magpie — framework install/maintenance: install (adopt), upgrade, verify, override, status, secure-agent setup, shared-config sync.

<!-- SPDX-License-Identifier: Apache-2.0 https://www.apache.org/licenses/LICENSE-2.0 -->
<!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> Table of Contents generated with DocToc
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<!-- SPDX-License-Identifier: Apache-2.0 https://www.apache.org/legal/release-policy.html -->
Adopt a Magpie. Magpie is a curated set of skills for the AI agent you already use — Claude Code, Codex, Gemini CLI, Copilot and others — that take on the repetitive parts of running an open-source project: triaging issues and pull requests, reviewing code, mentoring contributors, drafting fixes, and handling security reports end to end.
Three things make it different from a folder of prompts:
Magpie is currently in development for ASF projects + Python Core team friendlies. Testers welcome!
One security report, start to finish — installed from the marketplace, the agent sandboxed and guarded, then the report imported, triaged against the project's history, fixed, and published as a CVE:
Illustrative — a written transcript, rendered deterministically, not a recording. The interactive version on the project site steps through the same story with the full output of each command.
[!IMPORTANT] The motivation, scope, and design commitments behind this work live in
MISSION.md— the founding mission of the Apache Magpie Top-Level Project, originally filed as its establishment proposal. Read that for the why; this README is the how once you've decided to install.
Start here → Quick start — a handful of commands, in the agent you already use. Nothing is written to your repository.
/plugin marketplace add apache/magpie # Claude Code
/plugin install magpie-setup@apache-magpie # install this one first
/plugin install magpie-agent-guard@apache-magpie # the baseline:
/plugin install magpie-utilities@apache-magpie # take all three
/plugin install magpie-pr-management@apache-magpie # + the families you need
Those first three are the baseline — and the same set a project commits as its floor when it adopts Magpie, so taking them is taking what a project would recommend to every contributor. After that, install one plugin per family you actually want: every installed skill costs context on every turn, used or not (~0.2–2.0k tokens a family), so there is deliberately no install-everything plugin.
Once magpie-setup is in, you never type these again — ask in plain language ("install the Magpie families for PR review") and the setup skill runs the installs for you.
Codex, VS Code / Copilot, and Gemini are one-liners too — the quick start has all four, plus what to run next to put the agent in its sandbox. Nothing is committed to your repository.
Fallback — the pinned snapshot install. Use it when a marketplace is not an option or not enough: your agent has no plugin mechanism, you need the signed ASF source release rather than a git clone, or the project wants every contributor and CI job pinned to one committed version with drift detection.
docs/setup/install-recipes.md/magpie-setup:setup installThe two are complementary, not exclusive.
Magpie is used by interacting with your AI agents. You'll use plain-language prompts like
review PR #5193
or
triage the latest security reports
or skill calls starting with a slash, like
/magpie-repo-health:dependency-audit
(the family-plugin form, assuming the recommended marketplace install above — see Skill names differ by install method if you're on the pinned-snapshot fallback instead).
Marketplace install (the recommended path above):
/plugin marketplace update apache-magpie then /plugin update <plugin>@apache-magpie — refresh the marketplace metadata, then bump the installed plugin(s) to its latest. Auto-update does the same in the background once enabled./magpie-setup:setup reconcile. See Reconciliation when a skill first runs after an upgrade./magpie-setup:isolated-setup-update after an upgrade that changed its files, and weekly otherwise. Run it directly to check now. See Automatic reminders from the pre-flight.Pinned-snapshot install (the fallback):
/magpie-setup upgrade — refresh the snapshot to a newer framework version + reconcile any overrides against the new framework structure./magpie-setup verify — read-only health check (snapshot intact, symlinks live, .gitignore correct, etc.)./magpie-setup:setup override <framework-skill> — open or scaffold an override file for a framework skill.The following skill families ship in the framework, all at experimental or stable, and each skill declares its family in a family: frontmatter key. On the recommended marketplace install, you choose families by which per-family plugin(s) you install (see Install above) — install or uninstall a plugin at any time to add or drop a family. On the pinned-snapshot fallback, /magpie-setup offers the opt-in families — and the optional MCP servers (ponymail, apache-projects, gmail-plaintext) — in a single install choice, and symlinks for the picked families land in the skill directory. Either way, the two always-on families (setup, utilities) are wired unconditionally and never prompted for.
The Modes column maps each family to the MISSION agent-assistance taxonomy — see docs/modes.md for what each mode means and which modes are still proposed vs. shipping today.
| Family | Type | Modes | Purpose | Detail |
|---|---|---|---|---|
| setup | always-on | (infra) | Isolated agent setup, framework install + maintenance, shared-config sync. The prerequisite — at minimum the setup skill itself runs out of this family. | 11 skills, docs/setup/ |
| utilities | always-on | (meta) | Framework meta-skills: author skills (write-skill), restructure them (optimize-skill), reconcile skill state (skill-reconciler), report framework issues (report-framework-issue), and print a live index (list-skills). | 5 skills |
| security | opt-in | Triage, Drafting | 16-step security-issue handling lifecycle — from security@ import through CVE publication, including state sync — plus producing, verifying, and maintaining the project's own security model. Maintainer-only. | 15 skills, docs/security/ |
| pr-management | opt-in | Triage | Maintainer-facing PR-queue management — triage, stats, deep code review, stack review, express-lane merge, stale-sweep, reviewer routing, and pre-first-PR checks. | 9 skills, docs/pr-management/ |
| issue | opt-in | Triage, Drafting | General-issue lifecycle: triage, reproduction, fix drafting, reassess, stale-sweep, deduplication, and backlog reporting. | 8 skills, docs/issue-management/ |
| release-management | opt-in | Triage, Drafting | 14-step ASF release lifecycle, planning issue, RC cut + sign, [VOTE] thread, tally, promote, [ANNOUNCE], archive, audit log. Agent never holds the RM's signing key and never publishes the release. Experimental, all 10 skills shipped. | 10 skills, docs/release-management/ |
| repo-health | opt-in | Triage | Read-only repository-health audits: obsolete runner labels, Actions workflow security, dependency vulnerabilities, dependency licence review, license/NOTICE compliance, flaky-test patterns, plus audit-finding fixes. | 7 skills, docs/repo-health/ |
| pairing | opt-in | Pairing | Pair a change with a structured self-review or a multi-agent adversarial review before it lands. | 2 skills, docs/pairing/ |
| mentoring | opt-in | Mentoring | Newcomer-facing mentoring — first-contact welcome, newcomer-issue explanations, and good-first-issue authoring + backlog curation. Experimental. | 4 skills, docs/mentoring/ |
| contributor-growth | opt-in | Triage, Mentoring | The path-to-committer track: activity sweeps, nomination briefs, contributor-sentiment signals, readiness tracking, threshold calibration, candidate screening, committer / post-vote onboarding, and contributor identity mapping across Slack, Discord, and social media. | 9 skills, docs/contributor-growth/ |
Skill families or individual skills can be pulled from a trusted external source — a repo other than apache/magpie that ships Magpie-shaped skills (with their evals and tests). Where a skill directory would sit, a skills/<name>/source.md redirect names a pinned, verified source the adopter has vouched for; /magpie-setup fetches it into the gitignored snapshot and wires it in exactly like a framework skill. Nothing is fetched unless the adopter commits the pin — see docs/skill-sources/, PRINCIPLES.md §14, and RFC-AI-0006.
Apache Magpie was first developed and proven inside Apache Airflow, and was maintained for a time as the apache/airflow-steward repository under the Airflow PMC before being renamed and established as its own project. It also incorporates early skill work contributed by way of the Apache Groovy community. All of that code carries the same rightsholder — Copyright The Apache Software Foundation, under the Apache License 2.0 — so it is not a third-party inclusion; the required attribution lines from the originating projects' NOTICE files are reproduced in NOTICE.
MISSION.md — founding mission of the established TLP: motivation, scope, design commitments, initial PMC composition target.docs/setup/agentic-overrides.md — the contract between adopters who write overrides and framework skills that read them.docs/prerequisites.md — what a maintainer needs installed before invoking any framework skill (Claude Code, Gmail MCP, GitHub auth, browser, uv, etc.).docs/source-release-contents.md — what ships in the signed apache-magpie-<version>-source.zip (and what is excluded), with the rationale for the repository-root metadata/config files it keeps.docs/release-management/manual-release-process.md — the concrete, as-executed runbook for cutting a Magpie release by hand on the current hybrid SVN-dist + ATR-vote backend (with the abstract per-backend runbooks and the 14-step lifecycle alongside it in docs/release-management/).AGENTS.md — agent instructions, placeholder convention, framework conventions.CONTRIBUTING.md — for framework contributors.hooks/drift-check.ts 609 lines1// SPDX-License-Identifier: Apache-2.0
2//
3// Licensed to the Apache Software Foundation (ASF) under one
4// or more contributor license agreements. See the NOTICE file
5// distributed with this work for additional information
6// regarding copyright ownership. The ASF licenses this file
7// to you under the Apache License, Version 2.0 (the
8// "License"); you may not use this file except in compliance
9// with the License. You may obtain a copy of the License at
10//
11// http://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing,
14// software distributed under the License is distributed on an
15// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16// KIND, either express or implied. See the License for the
17// specific language governing permissions and limitations
18// under the License.
19
20import { atom, read, update } from 'claude-code';
21import { executeMagpieStatus } from './status-command.ts';
22
23export const driftNotice = atom(
24 { plugin: 'magpie-setup', key: 'driftNotice' },
25 null as string | null
26);
27
28/**
29 * Parses a simple subset of YAML used for apache-magpie.lock files.
30 */
31function parseYamlLike(content: string): Record<string, any> {
32 const result: Record<string, any> = {};
33 const lines = content.split('\n');
34
35 let inList = false;
36 let currentListKey = '';
37
38 for (const rawLine of lines) {
39 const line = rawLine.trim();
40 if (!line || line.startsWith('#')) {
41 continue;
42 }
43
44 if (line.startsWith('- ')) {
45 if (inList && currentListKey) {
46 result[currentListKey].push(line.substring(2).trim());
47 }
48 continue;
49 }
50
51 const colonIndex = line.indexOf(':');
52 if (colonIndex > 0) {
53 const key = line.substring(0, colonIndex).trim();
54 const value = line.substring(colonIndex + 1).trim();
55
56 if (!value) {
57 inList = true;
58 currentListKey = key;
59 result[key] = [];
60 } else {
61 inList = false;
62 result[key] = value;
63 }
64 }
65 }
66
67 return result;
68}
69
70export interface LockfileData {
71 method: string;
72 url?: string;
73 min_version?: string;
74 ref?: string;
75 commit?: string;
76 plugins?: string[];
77}
78
79export function parseLockfile(content: string): LockfileData {
80 const parsed = parseYamlLike(content);
81 return {
82 method: parsed.method,
83 url: parsed.url,
84 min_version: parsed.min_version,
85 ref: parsed.ref,
86 commit: parsed.commit,
87 plugins: parsed.plugins,
88 };
89}
90
91export interface LocalLockfileData {
92 method: string;
93 url?: string;
94 source_ref?: string;
95 fetched_commit?: string;
96}
97
98export function parseLocalLockfile(content: string): LocalLockfileData {
99 const parsed = parseYamlLike(content);
100 return {
101 method: parsed.method,
102 url: parsed.url,
103 source_ref: parsed.source_ref,
104 fetched_commit: parsed.fetched_commit,
105 };
106}
107
108export interface DriftCheckResult {
109 isAdopted: boolean;
110 hasDrift: boolean;
111 lockVersion?: string;
112 currentVersion?: string;
113 message?: string;
114}
115
116/**
117 * Joins path segments normalizing slashes.
118 */
119export function joinPath(...segments: string[]): string {
120 if (segments.length === 0) return '.';
121 const parts: string[] = [];
122 for (const seg of segments) {
123 if (!seg) continue;
124 const split = seg.split(/[/\\]+/);
125 for (const p of split) {
126 if (p === '..') {
127 if (parts.length > 0 && parts[parts.length - 1] !== '..') {
128 parts.pop();
129 } else {
130 parts.push('..');
131 }
132 } else if (p !== '.' && p !== '') {
133 parts.push(p);
134 } else if (p === '' && parts.length === 0) {
135 parts.push(''); // leading slash
136 }
137 }
138 }
139 const result = parts.join('/');
140 if (result === '') return '/';
141 if (segments[0].startsWith('/') && !result.startsWith('/')) {
142 return '/' + result;
143 }
144 return result || '.';
145}
146
147export interface Pep440Version {
148 epoch: number;
149 release: number[];
150 pre?: { type: string; num: number };
151 post?: number;
152 dev?: number;
153}
154
155export function parsePep440(v: string): Pep440Version | null {
156 let s = v.trim().replace(/^v/i, '');
157 if (!s) return null;
158
159 let epoch = 0;
160 const epochMatch = s.match(/^(\d+)!/);
161 if (epochMatch) {
162 epoch = parseInt(epochMatch[1], 10);
163 s = s.slice(epochMatch[0].length);
164 }
165
166 // Look for .devN
167 let dev: number | undefined = undefined;
168 const devMatch = s.match(/[.-]?dev(\d+)?$/i);
169 if (devMatch) {
170 dev = devMatch[1] !== undefined ? parseInt(devMatch[1], 10) : 0;
171 s = s.slice(0, devMatch.index);
172 }
173
174 // Look for .postN
175 let post: number | undefined = undefined;
176 const postMatch = s.match(/[.-]?(post|r|rev)(\d+)?$/i);
177 if (postMatch) {
178 post = postMatch[2] !== undefined ? parseInt(postMatch[2], 10) : 0;
179 s = s.slice(0, postMatch.index);
180 }
181
182 // Look for pre-release (a|b|rc|alpha|beta|preview|c)
183 let pre: { type: string; num: number } | undefined = undefined;
184 const preMatch = s.match(/[.-]?(a|alpha|b|beta|rc|c|preview)(\d+)?$/i);
185 if (preMatch) {
186 let type = preMatch[1].toLowerCase();
187 if (type === 'alpha') type = 'a';
188 if (type === 'beta') type = 'b';
189 if (type === 'c' || type === 'preview') type = 'rc';
190 const num = preMatch[2] !== undefined ? parseInt(preMatch[2], 10) : 0;
191 pre = { type, num };
192 s = s.slice(0, preMatch.index);
193 }
194
195 const parts = s.split('.');
196 const release: number[] = [];
197 for (const part of parts) {
198 if (!part || !/^\d+$/.test(part)) return null;
199 release.push(parseInt(part, 10));
200 }
201 if (release.length === 0) return null;
202
203 return { epoch, release, pre, post, dev };
204}
205
206export function comparePep440(aStr: string, bStr: string): number {
207 const a = parsePep440(aStr);
208 const b = parsePep440(bStr);
209
210 if (!a || !b) {
211 return aStr.localeCompare(bStr);
212 }
213
214 if (a.epoch !== b.epoch) {
215 return a.epoch - b.epoch;
216 }
217
218 const maxLen = Math.max(a.release.length, b.release.length);
219 for (let i = 0; i < maxLen; i++) {
220 const aVal = a.release[i] ?? 0;
221 const bVal = b.release[i] ?? 0;
222 if (aVal !== bVal) {
223 return aVal - bVal;
224 }
225 }
226
227 const getPhaseRank = (v: Pep440Version) => {
228 if (v.dev !== undefined && !v.pre && !v.post) return -1; // e.g. 1.0.dev1
229 if (v.pre) {
230 if (v.pre.type === 'a') return 0;
231 if (v.pre.type === 'b') return 1;
232 if (v.pre.type === 'rc') return 2;
233 }
234 if (v.post !== undefined) return 4; // e.g. 1.0.post1
235 return 3; // final release: 1.0
236 };
237
238 const aPhase = getPhaseRank(a);
239 const bPhase = getPhaseRank(b);
240
241 if (aPhase !== bPhase) {
242 return aPhase - bPhase;
243 }
244
245 // Same phase comparisons:
246 if (aPhase === -1) {
247 return (a.dev ?? 0) - (b.dev ?? 0);
248 }
249
250 if (a.pre && b.pre) {
251 if (a.pre.num !== b.pre.num) {
252 return a.pre.num - b.pre.num;
253 }
254 const aDev = a.dev !== undefined ? a.dev : Infinity;
255 const bDev = b.dev !== undefined ? b.dev : Infinity;
256 if (aDev !== bDev) {
257 return aDev - bDev;
258 }
259 }
260
261 if (a.post !== undefined && b.post !== undefined) {
262 if (a.post !== b.post) {
263 return a.post - b.post;
264 }
265 const aDev = a.dev !== undefined ? a.dev : Infinity;
266 const bDev = b.dev !== undefined ? b.dev : Infinity;
267 if (aDev !== bDev) {
268 return aDev - bDev;
269 }
270 }
271
272 return 0;
273}
274
275/**
276 * Resolves the currently executing plugin's version using provided fs adapters.
277 */
278export async function resolvePluginVersion(
279 exists: (p: string) => Promise<boolean>,
280 read: (p: string) => Promise<string>,
281 pluginDir?: string
282): Promise<string | undefined> {
283 if (!pluginDir) return undefined;
284
285 const candidatePaths = [
286 joinPath(pluginDir, '.claude-plugin', 'plugin.json'),
287 joinPath(pluginDir, 'package.json'),
288 joinPath(pluginDir, '..', 'package.json'),
289 joinPath(pluginDir, '..', '..', 'package.json')
290 ];
291
292 for (const p of candidatePaths) {
293 try {
294 if (await exists(p)) {
295 const content = await read(p);
296 const parsed = JSON.parse(content);
297 if (parsed && typeof parsed.version === 'string' && parsed.version.trim()) {
298 return parsed.version.trim();
299 }
300 }
301 } catch {
302 // Continue searching next candidate
303 }
304 }
305
306 return undefined;
307}
308
309/**
310 * Core deterministic drift check between workspace lockfile and installed plugin/snapshot.
311 */
312export async function checkSetupDrift(
313 exists: (p: string) => Promise<boolean>,
314 read: (p: string) => Promise<string>,
315 workspaceDir: string,
316 pluginDir?: string
317): Promise<DriftCheckResult> {
318 try {
319 if (typeof exists !== 'function' || typeof read !== 'function') {
320 return {
321 isAdopted: false,
322 hasDrift: false,
323 };
324 }
325
326 const lockPath = joinPath(workspaceDir, '.apache-magpie.lock');
327
328 if (!(await exists(lockPath))) {
329 return {
330 isAdopted: false,
331 hasDrift: false,
332 };
333 }
334
335 const rawContent = await read(lockPath);
336 const content = rawContent.trim();
337 if (!content) {
338 return {
339 isAdopted: true,
340 hasDrift: true,
341 message:
342 'Apache Magpie: Empty `.apache-magpie.lock` detected. Run `/magpie-setup upgrade` to populate.',
343 };
344 }
345
346 let lockData: LockfileData;
347 try {
348 lockData = parseLockfile(content);
349 } catch {
350 return {
351 isAdopted: true,
352 hasDrift: true,
353 message:
354 'Apache Magpie: Malformed `.apache-magpie.lock` detected. Run `/magpie-setup upgrade` to repair.',
355 };
356 }
357
358 const method = lockData.method;
359
360 // 1. Method: marketplace (Adoption Floor semantics)
361 if (method === 'marketplace') {
362 const minVersion = lockData.min_version;
363 if (!minVersion) {
364 return {
365 isAdopted: true,
366 hasDrift: true,
367 message:
368 'Apache Magpie: Malformed `.apache-magpie.lock` (missing min_version). Run `/magpie-setup upgrade` to repair.',
369 };
370 }
371
372 const currentVersion = await resolvePluginVersion(exists, read, pluginDir);
373 if (currentVersion) {
374 // Floor semantics: drift only occurs when installed < min_version
375 if (comparePep440(currentVersion, minVersion) < 0) {
376 return {
377 isAdopted: true,
378 hasDrift: true,
379 lockVersion: minVersion,
380 currentVersion,
381 message: `Apache Magpie: Installed plugin version (${currentVersion}) is below adoption floor (${minVersion}). Run \`/magpie-setup upgrade\` to reconcile.`,
382 };
383 }
384
385 return {
386 isAdopted: true,
387 hasDrift: false,
388 lockVersion: minVersion,
389 currentVersion,
390 };
391 }
392
393 return {
394 isAdopted: true,
395 hasDrift: false,
396 lockVersion: minVersion,
397 };
398 }
399
400 // 2. Method: local (the framework checkout's self-adoption). Skills link
401 // the in-tree skills/ source, so there is no snapshot that can drift.
402 if (method === 'local') {
403 return {
404 isAdopted: true,
405 hasDrift: false,
406 };
407 }
408
409 // 3. Snapshot methods (git-tag, git-branch, svn-zip)
410 if (method === 'git-tag' || method === 'git-branch' || method === 'svn-zip') {
411 const localLockPath = joinPath(workspaceDir, '.apache-magpie.local.lock');
412 const committedPin = lockData.ref || lockData.commit;
413
414 if (!(await exists(localLockPath))) {
415 return {
416 isAdopted: true,
417 hasDrift: true,
418 lockVersion: committedPin,
419 message:
420 'Apache Magpie: Local snapshot lockfile missing (`.apache-magpie.local.lock`). Run `/magpie-setup upgrade` to fetch snapshot.',
421 };
422 }
423
424 const rawLocalContent = await read(localLockPath);
425 const localContent = rawLocalContent.trim();
426 if (!localContent) {
427 return {
428 isAdopted: true,
429 hasDrift: true,
430 lockVersion: committedPin,
431 message:
432 'Apache Magpie: Empty `.apache-magpie.local.lock` detected. Run `/magpie-setup upgrade` to refresh.',
433 };
434 }
435
436 let localData: LocalLockfileData;
437 try {
438 localData = parseLocalLockfile(localContent);
439 } catch {
440 return {
441 isAdopted: true,
442 hasDrift: true,
443 lockVersion: committedPin,
444 message:
445 'Apache Magpie: Malformed `.apache-magpie.local.lock` detected. Run `/magpie-setup upgrade` to repair.',
446 };
447 }
448
449 if (method === 'git-tag' || method === 'git-branch') {
450 const refMismatch = Boolean(lockData.ref && localData.source_ref && lockData.ref !== localData.source_ref);
451 const commitMismatch = Boolean(
452 lockData.commit && localData.fetched_commit && lockData.commit !== localData.fetched_commit
453 );
454
455 if (refMismatch || commitMismatch) {
456 const localPin = localData.source_ref || localData.fetched_commit;
457 return {
458 isAdopted: true,
459 hasDrift: true,
460 lockVersion: committedPin,
461 message: `Apache Magpie: Snapshot drift detected (${localPin} -> ${committedPin}). Run \`/magpie-setup upgrade\` to reconcile snapshot and overrides.`,
462 };
463 }
464
465 return {
466 isAdopted: true,
467 hasDrift: false,
468 lockVersion: committedPin,
469 };
470 }
471
472 if (method === 'svn-zip') {
473 if (lockData.ref && localData.source_ref && lockData.ref !== localData.source_ref) {
474 return {
475 isAdopted: true,
476 hasDrift: true,
477 lockVersion: lockData.ref,
478 message: `Apache Magpie: Snapshot drift detected (${localData.source_ref} -> ${lockData.ref}). Run \`/magpie-setup upgrade\` to reconcile snapshot and overrides.`,
479 };
480 }
481
482 return {
483 isAdopted: true,
484 hasDrift: false,
485 lockVersion: lockData.ref,
486 };
487 }
488 }
489
490 return {
491 isAdopted: true,
492 hasDrift: true,
493 message:
494 'Apache Magpie: Malformed `.apache-magpie.lock` (unknown or missing method). Run `/magpie-setup upgrade` to repair.',
495 };
496 } catch {
497 return {
498 isAdopted: false,
499 hasDrift: false,
500 };
501 }
502}
503
504/**
505 * Event hook registration entry point for Claude Code.
506 */
507export function register(on: any): void {
508 // 1. Hook session.start to check for lockfile drift and register slash commands
509 on('session.start', async ($: any, e: any, next: any) => {
510 try {
511 await $.command.register({
512 name: 'magpie-status',
513 description: 'Print Apache Magpie adoption and health dashboard (zero-token)',
514 argumentHint: '[--format md|json]',
515 immediate: true,
516 });
517 } catch {
518 // Safe no-op on command register exception
519 }
520
521 try {
522 const workspaceDir = await $.session.root();
523 const pluginDir = $.plugin.root;
524
525 const result = await checkSetupDrift(
526 async (p) => await $.fs.exists(p),
527 async (p) => await $.fs.read(p),
528 workspaceDir,
529 pluginDir
530 );
531
532 if (result.hasDrift && result.message) {
533 await update($, driftNotice, () => result.message);
534 try {
535 $.ui.invalidate('ui.render');
536 } catch {
537 // fallback
538 }
539 } else {
540 await update($, driftNotice, () => null);
541 }
542 } catch {
543 // Safe no-op on exception
544 }
545 if (typeof next === 'function') {
546 return next(e);
547 }
548 });
549
550 // 2. Hook command.run on magpie-status (zero-token status dashboard)
551 on('command.run', { command: 'magpie-status' }, async ($: any, e: any) => {
552 try {
553 const workspaceDir = await $.session.root();
554 const pluginDir = $.plugin.root;
555 const scriptPath = joinPath(
556 pluginDir,
557 'skills',
558 'status',
559 'scripts',
560 'collect_status.py'
561 );
562
563 return await executeMagpieStatus(
564 (argv, init) => $.process.run(argv, init),
565 scriptPath,
566 workspaceDir,
567 e.args
568 );
569 } catch (err: any) {
570 return {
571 text: `⚠️ Apache Magpie: Unexpected error executing /magpie-status: ${err?.message || String(err)}`,
572 };
573 }
574 });
575
576 // 3. Hook ui.render to display AbovePrompt drift banner if detected
577 on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => {
578 const notice = await read($, driftNotice);
579 if (!notice) {
580 if (typeof next === 'function') return next(e);
581 return;
582 }
583
584 try {
585 const { Box, Text } = $.ui.resolve(e);
586 if (Box && Text) {
587 return Box({
588 padding: 0,
589 children: [
590 Text({
591 children: [`\u26A0 ${notice}`],
592 color: 'warning',
593 bold: true,
594 }),
595 ],
596 });
597 }
598 } catch {
599 // Fallback cleanly
600 }
601
602 if (typeof next === 'function') {
603 return next(e);
604 }
605 });
606}
607
608export default register;
609hooks/status-command.ts 102 lines1// SPDX-License-Identifier: Apache-2.0
2//
3// Licensed to the Apache Software Foundation (ASF) under one
4// or more contributor license agreements. See the NOTICE file
5// distributed with this work for additional information
6// regarding copyright ownership. The ASF licenses this file
7// to you under the Apache License, Version 2.0 (the
8// "License"); you may not use this file except in compliance
9// with the License. You may obtain a copy of the License at
10//
11// http://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing,
14// software distributed under the License is distributed on an
15// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16// KIND, either express or implied. See the License for the
17// specific language governing permissions and limitations
18// under the License.
19
20export interface ProcessRunResult {
21 exitCode: number;
22 stdout: string;
23 stderr: string;
24}
25
26export type ProcessRunner = (
27 argv: readonly string[],
28 init?: { cwd?: string; env?: Record<string, string>; timeoutMs?: number }
29) => Promise<ProcessRunResult>;
30
31const PROCESS_TIMEOUT_MS = 30_000;
32
33/**
34 * Executes collect_status.py deterministically via process runner (python3 or python).
35 */
36export async function executeMagpieStatus(
37 runProcess: ProcessRunner,
38 scriptPath: string,
39 workspaceDir: string,
40 rawArgs?: string
41): Promise<{ text: string }> {
42 const args = (rawArgs || '').trim();
43 const isJson = /(?:--format[=\s]+json|--json)(?:\s|$)/i.test(args);
44
45 const collectorArgs: string[] = isJson
46 ? [scriptPath, '--format', 'json', '--pretty', '--repo', workspaceDir]
47 : [scriptPath, '--format', 'md', '--repo', workspaceDir];
48
49 const runInit = {
50 cwd: workspaceDir,
51 env: { PYTHONIOENCODING: 'utf-8' },
52 timeoutMs: PROCESS_TIMEOUT_MS,
53 };
54
55 // Try python3 first, fallback to python
56 let result: ProcessRunResult | undefined;
57 let executionError: Error | undefined;
58
59 try {
60 result = await runProcess(['python3', ...collectorArgs], runInit);
61 } catch (err: any) {
62 executionError = err instanceof Error ? err : new Error(String(err));
63 }
64
65 // If python3 failed to run (e.g. command not found) or returned non-zero, try python
66 if (!result || result.exitCode !== 0) {
67 try {
68 const fallbackResult = await runProcess(['python', ...collectorArgs], runInit);
69 if (fallbackResult.exitCode === 0 || !result || result.exitCode === 127) {
70 result = fallbackResult;
71 if (fallbackResult.exitCode === 0) {
72 executionError = undefined;
73 }
74 }
75 } catch (fallbackErr: any) {
76 if (!executionError) {
77 executionError = fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr));
78 }
79 }
80 }
81
82 if (result && result.exitCode === 0) {
83 return { text: result.stdout.trimEnd() };
84 }
85
86 const errorLines = [
87 '⚠️ Apache Magpie: Unable to execute deterministic status collector (`collect_status.py`).',
88 ];
89 if (result?.stderr?.trim()) {
90 errorLines.push(`Error: ${result.stderr.trim()}`);
91 } else if (executionError?.message) {
92 errorLines.push(`Error: ${executionError.message}`);
93 }
94 errorLines.push(
95 'Please verify that Python 3 is installed and available on PATH, or invoke the full workflow via `/magpie-setup status`.'
96 );
97
98 return { text: errorLines.join('\n\n') };
99}
100
101export default executeMagpieStatus;
102hooks/types.d.ts 27 lines1// SPDX-License-Identifier: Apache-2.0
2//
3// Licensed to the Apache Software Foundation (ASF) under one
4// or more contributor license agreements. See the NOTICE file
5// distributed with this work for additional information
6// regarding copyright ownership. The ASF licenses this file
7// to you under the Apache License, Version 2.0 (the
8// "License"); you may not use this file except in compliance
9// with the License. You may obtain a copy of the License at
10//
11// http://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing,
14// software distributed under the License is distributed on an
15// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16// KIND, either express or implied. See the License for the
17// specific language governing permissions and limitations
18// under the License.
19
20declare module 'claude-code' {
21 interface PluginState {
22 'magpie-setup': {
23 driftNotice: string | null;
24 };
25 }
26}
27