Compare commits

..

6 Commits

Author SHA1 Message Date
thomas 85632c2e29 hook notifs 2026-03-31 12:30:12 +01:00
thomas 63caa82199 compat with claude skills + hooks support 2026-03-31 12:07:17 +01:00
thomas 39e7bddb35 update pi 2026-03-27 14:22:06 +00:00
thomas d5b4042b06 add gsf 2026-03-25 19:12:49 +00:00
thomas 4d19e7d320 fix nvim 2026-03-25 13:41:16 +00:00
thomas 227c1638f6 linear skill 2026-03-19 16:37:27 +00:00
35 changed files with 1566 additions and 95 deletions
+1 -1
View File
@@ -377,7 +377,7 @@
"osdPosition": 5, "osdPosition": 5,
"osdVolumeEnabled": true, "osdVolumeEnabled": true,
"osdMediaVolumeEnabled": true, "osdMediaVolumeEnabled": true,
"osdMediaPlaybackEnabled": true, "osdMediaPlaybackEnabled": false,
"osdBrightnessEnabled": true, "osdBrightnessEnabled": true,
"osdIdleInhibitorEnabled": true, "osdIdleInhibitorEnabled": true,
"osdMicMuteEnabled": true, "osdMicMuteEnabled": true,
+18
View File
@@ -0,0 +1,18 @@
---@class SigilConfig
---@field target table<string, string|boolean>
---@field ignore? string[]
---@type SigilConfig
local config = {
target = {
linux = "~/.config/gsf",
default = "~/.config/gsf",
},
ignore = {
-- "**/.DS_Store",
-- "**/*.tmp",
-- "cache/**",
},
}
return config
+17
View File
@@ -0,0 +1,17 @@
{
"mode": 0,
"sens_mult": 1.5,
"yx_ratio": 1.0,
"input_dpi": 400.0,
"angle_rotation": 0.0,
"accel": 2.0,
"offset_linear": 3.5,
"output_cap": 30.0,
"decay_rate": 0.1,
"offset_natural": 0.0,
"limit": 2.0,
"gamma": 1.0,
"smooth": 0.5,
"motivity": 1.5,
"sync_speed": 5.0
}
+1 -2
View File
@@ -89,8 +89,7 @@ vim.o.confirm = true
-- vim.o.winborder = "rounded" -- vim.o.winborder = "rounded"
-- Clipboard: prefer system clipboard, and over SSH use OSC52 so yanks can reach local clipboard -- Clipboard: keep default y/p behavior; over SSH, route + register through OSC52
vim.opt.clipboard = "unnamedplus"
if vim.env.SSH_TTY then if vim.env.SSH_TTY then
vim.g.clipboard = "osc52" vim.g.clipboard = "osc52"
end end
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"activePack": "glados", "activePack": "glados",
"volume": 1, "volume": 1,
"muted": true, "muted": false,
"enabledCategories": { "enabledCategories": {
"session.start": true, "session.start": true,
"task.acknowledge": true, "task.acknowledge": true,
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"lastChangelogVersion": "0.60.0", "lastChangelogVersion": "0.63.1",
"defaultProvider": "openai-codex", "defaultProvider": "openai-codex",
"defaultModel": "gpt-5.3-codex", "defaultModel": "gpt-5.3-codex",
"defaultThinkingLevel": "medium", "defaultThinkingLevel": "high",
"theme": "matugen", "theme": "matugen",
"lsp": { "lsp": {
"hookMode": "edit_write" "hookMode": "edit_write"
@@ -1,3 +1,8 @@
---
name: attio-frontend-rules
description: Styling conventions and component guidelines for the Attio frontend codebase. Covers styled-components patterns, transient props, data attributes, spacing, color tokens, and design system usage. Use when modifying frontend UI code in the Attio monorepo.
---
# Attio Frontend Rules # Attio Frontend Rules
Guidelines and conventions for working on the Attio frontend codebase. Use whenever modifying the frontend. Guidelines and conventions for working on the Attio frontend codebase. Use whenever modifying the frontend.
@@ -52,6 +57,26 @@ export function Stack({..., className}: {..., className: string | undefined}) {
If the same re-styling is applied multiple times, it should become its own reusable component (or component variant). If the same re-styling is applied multiple times, it should become its own reusable component (or component variant).
### Layout.Stack defaults
`Layout.Stack` defaults `align` to `"center"` (i.e. `align-items: center`). **Always explicitly set `align="flex-start"`** when you need left/top alignment — don't assume it will be the default.
```tsx
// Good — explicit alignment
<Layout.Stack direction="column" align="flex-start">
<Typography.Body.Standard.Component>Title</Typography.Body.Standard.Component>
<Typography.Caption.Standard.Component>Description</Typography.Caption.Standard.Component>
</Layout.Stack>
// Bad — text will be centered, not left-aligned
<Layout.Stack direction="column">
<Typography.Body.Standard.Component>Title</Typography.Body.Standard.Component>
<Typography.Caption.Standard.Component>Description</Typography.Caption.Standard.Component>
</Layout.Stack>
```
Other useful `Layout.Stack` props: `direction`, `justify`, `gap`, `flex`, `shrink`, `minWidth`, `width`, `height`, and all spacing props (`p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`, `m`, `mx`, `my`, etc.). **Always prefer these props over writing custom styled divs with `display: flex`.**
### Avoid layout assumptions ### Avoid layout assumptions
Components should not generally include external layout styles such as `width`, `z-index`, `margin` or `flex`. These properties should instead be set by the parent component using a `styled(MyComponent)` override. Components should not generally include external layout styles such as `width`, `z-index`, `margin` or `flex`. These properties should instead be set by the parent component using a `styled(MyComponent)` override.
+1
View File
@@ -0,0 +1 @@
{}
+4 -4
View File
@@ -9,8 +9,8 @@
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'. * The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
*/ */
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
import type { TUI, Theme, KeybindingsManager, Component } from "@mariozechner/pi-tui"; import type { TUI, KeybindingsManager, Component } from "@mariozechner/pi-tui";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
export default function editSessionExtension(pi: ExtensionAPI) { export default function editSessionExtension(pi: ExtensionAPI) {
@@ -59,7 +59,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
ctx.ui.notify(`Editor exited with code ${result.status}`, "warning"); ctx.ui.notify(`Editor exited with code ${result.status}`, "warning");
} }
done(); done(undefined);
// Return dummy component // Return dummy component
return createDummyComponent(); return createDummyComponent();
@@ -69,7 +69,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
await ctx.ui.custom<void>(factory); await ctx.ui.custom<void>(factory);
// Signal that we're about to reload the session (so confirm-destructive skips) // Signal that we're about to reload the session (so confirm-destructive skips)
pi.events.emit("edit-session:reload"); pi.events.emit("edit-session:reload", undefined);
// Reload the session by switching to the same file (forces re-read from disk) // Reload the session by switching to the same file (forces re-read from disk)
ctx.ui.notify("Reloading session...", "info"); ctx.ui.notify("Reloading session...", "info");
+3 -2
View File
@@ -80,7 +80,8 @@ export default function (pi: ExtensionAPI) {
loader.onAbort = () => done(null); loader.onAbort = () => done(null);
const doGenerate = async () => { const doGenerate = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
if (!auth.ok) throw new Error(auth.error);
const userMessage: Message = { const userMessage: Message = {
role: "user", role: "user",
@@ -96,7 +97,7 @@ export default function (pi: ExtensionAPI) {
const response = await complete( const response = await complete(
ctx.model!, ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal }, { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
@@ -0,0 +1,535 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { spawn } from "node:child_process";
import { basename, dirname, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@mariozechner/pi-coding-agent";
const HOOK_TIMEOUT_MS = 10 * 60 * 1000;
type HookEventName = "PostToolUse" | "PostToolUseFailure";
type ResolvedCommandHook = {
eventName: HookEventName;
matcher?: RegExp;
matcherText?: string;
command: string;
source: string;
};
type HookState = {
projectDir: string;
hooks: ResolvedCommandHook[];
};
type CommandRunResult = {
code: number;
stdout: string;
stderr: string;
elapsedMs: number;
timedOut: boolean;
};
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
return value as Record<string, unknown>;
}
function walkUpDirectories(startDir: string, stopDir?: string): string[] {
const directories: string[] = [];
const hasStopDir = stopDir !== undefined;
let current = resolve(startDir);
let parent = dirname(current);
let reachedStopDir = hasStopDir && current === stopDir;
let reachedFilesystemRoot = parent === current;
directories.push(current);
while (!reachedStopDir && !reachedFilesystemRoot) {
current = parent;
parent = dirname(current);
reachedStopDir = hasStopDir && current === stopDir;
reachedFilesystemRoot = parent === current;
directories.push(current);
}
return directories;
}
function findNearestGitRoot(startDir: string): string | undefined {
for (const directory of walkUpDirectories(startDir)) {
if (existsSync(join(directory, ".git"))) {
return directory;
}
}
return undefined;
}
function hasHooksConfig(directory: string): boolean {
const claudeSettingsPath = join(directory, ".claude", "settings.json");
const ruleSyncHooksPath = join(directory, ".rulesync", "hooks.json");
const piHooksPath = join(directory, ".pi", "hooks.json");
return isFile(claudeSettingsPath) || isFile(ruleSyncHooksPath) || isFile(piHooksPath);
}
function findProjectDir(cwd: string): string {
const gitRoot = findNearestGitRoot(cwd);
for (const directory of walkUpDirectories(cwd, gitRoot)) {
if (hasHooksConfig(directory)) {
return directory;
}
}
return gitRoot ?? resolve(cwd);
}
function readJsonFile(path: string): unknown | undefined {
if (!isFile(path)) {
return undefined;
}
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch {
return undefined;
}
}
function resolveHookCommand(command: string, projectDir: string): string {
return command.replace(/\$CLAUDE_PROJECT_DIR\b/g, projectDir);
}
function compileMatcher(matcherText: string | undefined): RegExp | undefined {
if (matcherText === undefined) {
return undefined;
}
try {
return new RegExp(matcherText);
} catch {
return undefined;
}
}
function createHook(
eventName: HookEventName,
matcherText: string | undefined,
command: string,
source: string,
projectDir: string,
): ResolvedCommandHook | undefined {
const matcher = compileMatcher(matcherText);
if (matcherText !== undefined && matcher === undefined) {
return undefined;
}
return {
eventName,
matcher,
matcherText,
command: resolveHookCommand(command, projectDir),
source,
};
}
function getHookEntries(
hooksRecord: Record<string, unknown>,
eventName: HookEventName,
): unknown[] {
const keys =
eventName === "PostToolUse"
? ["PostToolUse", "postToolUse"]
: ["PostToolUseFailure", "postToolUseFailure"];
for (const key of keys) {
const value = hooksRecord[key];
if (Array.isArray(value)) {
return value;
}
}
return [];
}
function parseClaudeSettingsHooks(
config: unknown,
source: string,
projectDir: string,
): ResolvedCommandHook[] {
const root = asRecord(config);
const hooksRoot = root ? asRecord(root.hooks) : undefined;
if (!hooksRoot) {
return [];
}
const hooks: ResolvedCommandHook[] = [];
const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"];
for (const eventName of events) {
const entries = getHookEntries(hooksRoot, eventName);
for (const entry of entries) {
const entryRecord = asRecord(entry);
if (!entryRecord || !Array.isArray(entryRecord.hooks)) {
continue;
}
const matcherText =
typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined;
for (const nestedHook of entryRecord.hooks) {
const nestedHookRecord = asRecord(nestedHook);
if (!nestedHookRecord) {
continue;
}
if (nestedHookRecord.type !== "command") {
continue;
}
if (typeof nestedHookRecord.command !== "string") {
continue;
}
const hook = createHook(
eventName,
matcherText,
nestedHookRecord.command,
source,
projectDir,
);
if (hook) {
hooks.push(hook);
}
}
}
}
return hooks;
}
function parseSimpleHooksFile(
config: unknown,
source: string,
projectDir: string,
): ResolvedCommandHook[] {
const root = asRecord(config);
const hooksRoot = root ? asRecord(root.hooks) : undefined;
if (!hooksRoot) {
return [];
}
const hooks: ResolvedCommandHook[] = [];
const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"];
for (const eventName of events) {
const entries = getHookEntries(hooksRoot, eventName);
for (const entry of entries) {
const entryRecord = asRecord(entry);
if (!entryRecord || typeof entryRecord.command !== "string") {
continue;
}
const matcherText =
typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined;
const hook = createHook(
eventName,
matcherText,
entryRecord.command,
source,
projectDir,
);
if (hook) {
hooks.push(hook);
}
}
}
return hooks;
}
function loadHooks(cwd: string): HookState {
const projectDir = findProjectDir(cwd);
const claudeSettingsPath = join(projectDir, ".claude", "settings.json");
const ruleSyncHooksPath = join(projectDir, ".rulesync", "hooks.json");
const piHooksPath = join(projectDir, ".pi", "hooks.json");
const hooks: ResolvedCommandHook[] = [];
const claudeSettings = readJsonFile(claudeSettingsPath);
if (claudeSettings !== undefined) {
hooks.push(...parseClaudeSettingsHooks(claudeSettings, claudeSettingsPath, projectDir));
}
const ruleSyncHooks = readJsonFile(ruleSyncHooksPath);
if (ruleSyncHooks !== undefined) {
hooks.push(...parseSimpleHooksFile(ruleSyncHooks, ruleSyncHooksPath, projectDir));
}
const piHooks = readJsonFile(piHooksPath);
if (piHooks !== undefined) {
hooks.push(...parseSimpleHooksFile(piHooks, piHooksPath, projectDir));
}
return {
projectDir,
hooks,
};
}
function toClaudeToolName(toolName: string): string {
if (toolName === "ls") {
return "LS";
}
if (toolName.length === 0) {
return toolName;
}
return toolName[0].toUpperCase() + toolName.slice(1);
}
function matchesHook(hook: ResolvedCommandHook, toolName: string): boolean {
if (!hook.matcher) {
return true;
}
const claudeToolName = toClaudeToolName(toolName);
hook.matcher.lastIndex = 0;
if (hook.matcher.test(toolName)) {
return true;
}
hook.matcher.lastIndex = 0;
return hook.matcher.test(claudeToolName);
}
function extractTextContent(content: unknown): string {
if (!Array.isArray(content)) {
return "";
}
const parts: string[] = [];
for (const item of content) {
if (!item || typeof item !== "object") {
continue;
}
const itemRecord = item as Record<string, unknown>;
if (itemRecord.type === "text" && typeof itemRecord.text === "string") {
parts.push(itemRecord.text);
}
}
return parts.join("\n");
}
function normalizeToolInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...input };
const pathValue = typeof input.path === "string" ? input.path : undefined;
if (pathValue !== undefined) {
normalized.file_path = pathValue;
normalized.filePath = pathValue;
}
return normalized;
}
function buildToolResponse(
event: ToolResultEvent,
normalizedInput: Record<string, unknown>,
): Record<string, unknown> {
const response: Record<string, unknown> = {
is_error: event.isError,
isError: event.isError,
content: event.content,
text: extractTextContent(event.content),
details: event.details ?? null,
};
const filePath =
typeof normalizedInput.file_path === "string" ? normalizedInput.file_path : undefined;
if (filePath !== undefined) {
response.file_path = filePath;
response.filePath = filePath;
}
return response;
}
function buildHookPayload(
event: ToolResultEvent,
eventName: HookEventName,
ctx: ExtensionContext,
projectDir: string,
): Record<string, unknown> {
const normalizedInput = normalizeToolInput(event.input);
const sessionId = ctx.sessionManager.getSessionFile() ?? "ephemeral";
return {
session_id: sessionId,
cwd: ctx.cwd,
claude_project_dir: projectDir,
hook_event_name: eventName,
tool_name: toClaudeToolName(event.toolName),
tool_call_id: event.toolCallId,
tool_input: normalizedInput,
tool_response: buildToolResponse(event, normalizedInput),
};
}
function runCommandHook(
command: string,
cwd: string,
payload: Record<string, unknown>,
): Promise<CommandRunResult> {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn("bash", ["-lc", command], {
cwd,
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
let resolved = false;
const finish = (code: number) => {
if (resolved) {
return;
}
resolved = true;
resolve({
code,
stdout,
stderr,
elapsedMs: Date.now() - startedAt,
timedOut,
});
};
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
const killTimer = setTimeout(() => {
child.kill("SIGKILL");
}, 1000);
(killTimer as NodeJS.Timeout & { unref?: () => void }).unref?.();
}, HOOK_TIMEOUT_MS);
(timeout as NodeJS.Timeout & { unref?: () => void }).unref?.();
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error) => {
clearTimeout(timeout);
stderr += `${error.message}\n`;
finish(-1);
});
child.on("close", (code) => {
clearTimeout(timeout);
finish(code ?? -1);
});
try {
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
stderr += `${message}\n`;
}
});
}
function hookEventNameForResult(event: ToolResultEvent): HookEventName {
return event.isError ? "PostToolUseFailure" : "PostToolUse";
}
function formatDuration(elapsedMs: number): string {
if (elapsedMs < 1000) {
return `${elapsedMs}ms`;
}
return `${(elapsedMs / 1000).toFixed(1)}s`;
}
function hookName(command: string): string {
const shPathMatch = command.match(/[^\s|;&]+\.sh\b/);
if (shPathMatch) {
return basename(shPathMatch[0]);
}
const firstToken = command.trim().split(/\s+/)[0] ?? "hook";
return basename(firstToken);
}
export default function(pi: ExtensionAPI) {
let state: HookState = {
projectDir: process.cwd(),
hooks: [],
};
const refreshHooks = (cwd: string) => {
state = loadHooks(cwd);
};
pi.on("session_start", (_event, ctx) => {
refreshHooks(ctx.cwd);
});
pi.on("session_switch", (_event, ctx) => {
refreshHooks(ctx.cwd);
});
pi.on("tool_result", async (event, ctx) => {
if (state.hooks.length === 0) {
return;
}
const eventName = hookEventNameForResult(event);
const matchingHooks = state.hooks.filter(
(hook) => hook.eventName === eventName && matchesHook(hook, event.toolName),
);
if (matchingHooks.length === 0) {
return;
}
const payload = buildHookPayload(event, eventName, ctx, state.projectDir);
const executedCommands = new Set<string>();
for (const hook of matchingHooks) {
if (executedCommands.has(hook.command)) {
continue;
}
executedCommands.add(hook.command);
const result = await runCommandHook(hook.command, state.projectDir, payload);
const name = hookName(hook.command);
const duration = formatDuration(result.elapsedMs);
if (result.code === 0) {
ctx.ui.notify(`󰛢 Hook \`${name}\` executed, took ${duration}`, "info");
continue;
}
const matcherLabel = hook.matcherText ?? "*";
const errorLine =
result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
ctx.ui.notify(
`󰛢 Hook \`${name}\` failed after ${duration} (${matcherLabel}) from ${hook.source}: ${errorLine}`,
"warning",
);
}
});
}
+4 -3
View File
@@ -13,10 +13,11 @@
"vscode-languageserver-protocol": "^3.17.5" "vscode-languageserver-protocol": "^3.17.5"
}, },
"devDependencies": { "devDependencies": {
"@mariozechner/pi-ai": "^0.56.3", "@mariozechner/pi-ai": "^0.63.1",
"@mariozechner/pi-coding-agent": "^0.56.3", "@mariozechner/pi-coding-agent": "^0.63.1",
"@mariozechner/pi-tui": "^0.56.3", "@mariozechner/pi-tui": "^0.63.1",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/turndown": "^5.0.6",
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },
"pi": {}, "pi": {},
@@ -211,12 +211,12 @@ function updateWidget(ctx: ExtensionContext): void {
(resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""), (resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""),
); );
ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0)); ctx.ui.setWidget("web-activity", lines);
} }
function formatEntryLine( function formatEntryLine(
entry: ActivityEntry, entry: ActivityEntry,
theme: { fg: (color: string, text: string) => string }, theme: ExtensionContext["ui"]["theme"],
): string { ): string {
const typeStr = entry.type === "api" ? "API" : "GET"; const typeStr = entry.type === "api" ? "API" : "GET";
const target = const target =
@@ -550,7 +550,7 @@ export default function (pi: ExtensionAPI) {
} else { } else {
widgetUnsubscribe?.(); widgetUnsubscribe?.();
widgetUnsubscribe = null; widgetUnsubscribe = null;
ctx.ui.setWidget("web-activity", null); ctx.ui.setWidget("web-activity", undefined);
} }
}, },
}); });
@@ -598,7 +598,7 @@ export default function (pi: ExtensionAPI) {
})), })),
}), }),
async execute(_toolCallId, params, signal, onUpdate, ctx) { async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<any> {
const queryList = params.queries ?? (params.query ? [params.query] : []); const queryList = params.queries ?? (params.query ? [params.query] : []);
const isMultiQuery = queryList.length > 1; const isMultiQuery = queryList.length > 1;
const shouldCurate = params.curate !== false && ctx?.hasUI !== false; const shouldCurate = params.curate !== false && ctx?.hasUI !== false;
@@ -613,7 +613,10 @@ export default function (pi: ExtensionAPI) {
if (shouldCurate) { if (shouldCurate) {
closeCurator(); closeCurator();
const { promise, resolve: resolvePromise } = Promise.withResolvers<unknown>(); let resolvePromise!: (value: unknown) => void;
const promise = new Promise<unknown>((resolve) => {
resolvePromise = resolve;
});
const includeContent = params.includeContent ?? false; const includeContent = params.includeContent ?? false;
const searchResults = new Map<number, QueryResultData>(); const searchResults = new Map<number, QueryResultData>();
const allUrls: string[] = []; const allUrls: string[] = [];
@@ -637,7 +640,7 @@ export default function (pi: ExtensionAPI) {
queryList, queryList,
includeContent, includeContent,
numResults: params.numResults, numResults: params.numResults,
recencyFilter: params.recencyFilter, recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
domainFilter: params.domainFilter, domainFilter: params.domainFilter,
availableProviders, availableProviders,
defaultProvider, defaultProvider,
@@ -684,7 +687,7 @@ export default function (pi: ExtensionAPI) {
const { answer, results } = await search(queryList[qi], { const { answer, results } = await search(queryList[qi], {
provider: defaultProvider as SearchProvider | undefined, provider: defaultProvider as SearchProvider | undefined,
numResults: params.numResults, numResults: params.numResults,
recencyFilter: params.recencyFilter, recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
domainFilter: params.domainFilter, domainFilter: params.domainFilter,
signal, signal,
}); });
@@ -754,7 +757,7 @@ export default function (pi: ExtensionAPI) {
text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`; text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`;
} }
return { return {
content: [{ type: "text", text }], content: [{ type: "text" as const, text }],
details: { details: {
phase: "curate-window", phase: "curate-window",
searchCount: searchResults.size, searchCount: searchResults.size,
@@ -824,7 +827,7 @@ export default function (pi: ExtensionAPI) {
const { answer, results } = await search(query, { const { answer, results } = await search(query, {
provider: resolvedProvider as SearchProvider | undefined, provider: resolvedProvider as SearchProvider | undefined,
numResults: params.numResults, numResults: params.numResults,
recencyFilter: params.recencyFilter, recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
domainFilter: params.domainFilter, domainFilter: params.domainFilter,
signal, signal,
}); });
@@ -1117,7 +1120,10 @@ export default function (pi: ExtensionAPI) {
`Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`; `Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`;
} }
const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = []; const content: Array<
| { type: "image"; data: string; mimeType: string }
| { type: "text"; text: string }
> = [];
if (result.frames?.length) { if (result.frames?.length) {
for (const frame of result.frames) { for (const frame of result.frames) {
content.push({ type: "image", data: frame.data, mimeType: frame.mimeType }); content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
@@ -1290,7 +1296,7 @@ export default function (pi: ExtensionAPI) {
urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })), urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })),
}), }),
async execute(_toolCallId, params) { async execute(_toolCallId, params, _signal, _onUpdate, _ctx): Promise<any> {
const data = getResult(params.responseId); const data = getResult(params.responseId);
if (!data) { if (!data) {
return { return {
@@ -1477,7 +1483,7 @@ export default function (pi: ExtensionAPI) {
pi.sendMessage({ pi.sendMessage({
customType: "web-search-results", customType: "web-search-results",
content: [{ type: "text", text }], content: [{ type: "text", text }],
display: "tool", display: true,
details: { queryCount: results.length, totalResults: urls.length }, details: { queryCount: results.length, totalResults: urls.length },
}, { triggerTurn: true, deliverAs: "followUp" }); }, { triggerTurn: true, deliverAs: "followUp" });
} }
@@ -42,9 +42,10 @@ export async function extractPDFToMarkdown(
const pdf = await getDocumentProxy(new Uint8Array(buffer)); const pdf = await getDocumentProxy(new Uint8Array(buffer));
const metadata = await pdf.getMetadata(); const metadata = await pdf.getMetadata();
const info = (metadata.info ?? {}) as Record<string, unknown>;
// Extract title from metadata or URL // Extract title from metadata or URL
const metaTitle = metadata.info?.Title as string | undefined; const metaTitle = typeof info.Title === "string" ? info.Title : undefined;
const urlTitle = extractTitleFromURL(url); const urlTitle = extractTitleFromURL(url);
const title = metaTitle?.trim() || urlTitle; const title = metaTitle?.trim() || urlTitle;
@@ -79,8 +80,9 @@ export async function extractPDFToMarkdown(
lines.push(""); lines.push("");
lines.push(`> Source: ${url}`); lines.push(`> Source: ${url}`);
lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`); lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`);
if (metadata.info?.Author) { const author = typeof info.Author === "string" ? info.Author : undefined;
lines.push(`> Author: ${metadata.info.Author}`); if (author) {
lines.push(`> Author: ${author}`);
} }
lines.push(""); lines.push("");
lines.push("---"); lines.push("---");
@@ -245,8 +245,8 @@ export async function condenseSearchResults(
const model = ctx.modelRegistry.find(provider, modelId); const model = ctx.modelRegistry.find(provider, modelId);
if (!model) return null; if (!model) return null;
const apiKey = await ctx.modelRegistry.getApiKey(model); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!apiKey) return null; if (!auth.ok) return null;
const queryData = [...results.entries()] const queryData = [...results.entries()]
.sort((a, b) => a[0] - b[0]) .sort((a, b) => a[0] - b[0])
@@ -281,7 +281,8 @@ export async function condenseSearchResults(
: timeoutSignal; : timeoutSignal;
const response = await complete(model, aiContext, { const response = await complete(model, aiContext, {
apiKey, apiKey: auth.apiKey,
headers: auth.headers,
signal: combinedSignal, signal: combinedSignal,
max_tokens: MAX_TOKENS, max_tokens: MAX_TOKENS,
} as any); } as any);
+31 -22
View File
@@ -34,17 +34,20 @@ importers:
version: 3.17.5 version: 3.17.5
devDependencies: devDependencies:
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: ^0.56.3 specifier: ^0.63.1
version: 0.56.3(ws@8.19.0)(zod@4.3.6) version: 0.63.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: ^0.56.3 specifier: ^0.63.1
version: 0.56.3(ws@8.19.0)(zod@4.3.6) version: 0.63.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': '@mariozechner/pi-tui':
specifier: ^0.56.3 specifier: ^0.63.1
version: 0.56.3 version: 0.63.1
'@types/node': '@types/node':
specifier: ^25.3.3 specifier: ^25.3.3
version: 25.3.3 version: 25.3.3
'@types/turndown':
specifier: ^5.0.6
version: 5.0.6
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
@@ -289,22 +292,22 @@ packages:
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
hasBin: true hasBin: true
'@mariozechner/pi-agent-core@0.56.3': '@mariozechner/pi-agent-core@0.63.1':
resolution: {integrity: sha512-TsI1zENf3wqqKPaERnj486Q4i6Y/y6lAZipLNcfDYUDxDrLwNfQ9EW9xukkbJfTZ8zjG3VZ2pBZe3C7wM51dVQ==} resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.56.3': '@mariozechner/pi-ai@0.63.1':
resolution: {integrity: sha512-l4J+cVyVeBLAlGOY/osGDvsbTz0DySCQmR171G6SdbPvIeLGhIi6siZ+zHwq91GJYjv/wtu/08M08ag2mGZKeA==} resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.56.3': '@mariozechner/pi-coding-agent@0.63.1':
resolution: {integrity: sha512-yHgnadye+TT/4NWKBirZUjw/LWdNWTa7M4HJdX2RxRbwuj4q7RZ0Aqy+lQbOHEPDQYhxK3kZb9hjiAbbGficZQ==} resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
engines: {node: '>=20.6.0'} engines: {node: '>=20.6.0'}
hasBin: true hasBin: true
'@mariozechner/pi-tui@0.56.3': '@mariozechner/pi-tui@0.63.1':
resolution: {integrity: sha512-eZ1P9QRKHp78hwx+lITr/mujZqe+eCwL/bOS9vXXkFP070RW4VYum0j7TJ4BrFEH/nNkXRS1tYCXYU05une1bA==} resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.14.1': '@mistralai/mistralai@1.14.1':
@@ -568,6 +571,9 @@ packages:
'@types/retry@0.12.0': '@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -1722,9 +1728,9 @@ snapshots:
std-env: 3.10.0 std-env: 3.10.0
yoctocolors: 2.1.2 yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.56.3(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-agent-core@0.63.1(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.56.3(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.63.1(ws@8.19.0)(zod@4.3.6)
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
- aws-crt - aws-crt
@@ -1734,7 +1740,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.56.3(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-ai@0.63.1(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1002.0 '@aws-sdk/client-bedrock-runtime': 3.1002.0
@@ -1758,13 +1764,14 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-coding-agent@0.56.3(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-coding-agent@0.63.1(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/jiti': 2.6.5 '@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.56.3(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-agent-core': 0.63.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.56.3(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.63.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.56.3 '@mariozechner/pi-tui': 0.63.1
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
ajv: 8.18.0
chalk: 5.6.2 chalk: 5.6.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
diff: 8.0.3 diff: 8.0.3
@@ -1790,7 +1797,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-tui@0.56.3': '@mariozechner/pi-tui@0.63.1':
dependencies: dependencies:
'@types/mime-types': 2.1.4 '@types/mime-types': 2.1.4
chalk: 5.6.2 chalk: 5.6.2
@@ -2166,6 +2173,8 @@ snapshots:
'@types/retry@0.12.0': {} '@types/retry@0.12.0': {}
'@types/turndown@5.0.6': {}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 25.3.3 '@types/node': 25.3.3
+8 -7
View File
@@ -135,11 +135,11 @@ export default function(pi: ExtensionAPI) {
// Fire-and-forget: run auto-naming in background without blocking // Fire-and-forget: run auto-naming in background without blocking
const doAutoName = async () => { const doAutoName = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL);
log(`Got API key: ${apiKey ? "yes" : "no"}`); log(`Got API key: ${auth.ok ? "yes" : "no"}`);
if (!apiKey) { if (!auth.ok) {
log("No API key available, aborting"); log(`No API key available, aborting: ${auth.error}`);
return; return;
} }
@@ -157,7 +157,7 @@ export default function(pi: ExtensionAPI) {
const response = await complete( const response = await complete(
AUTO_NAME_MODEL, AUTO_NAME_MODEL,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey }, { apiKey: auth.apiKey, headers: auth.headers },
); );
log(`Response received, stopReason: ${response.stopReason}`); log(`Response received, stopReason: ${response.stopReason}`);
@@ -273,7 +273,8 @@ export default function(pi: ExtensionAPI) {
loader.onAbort = () => done(null); loader.onAbort = () => done(null);
const doGenerate = async () => { const doGenerate = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL);
if (!auth.ok) throw new Error(auth.error);
const userMessage: Message = { const userMessage: Message = {
role: "user", role: "user",
@@ -289,7 +290,7 @@ export default function(pi: ExtensionAPI) {
const response = await complete( const response = await complete(
AUTO_NAME_MODEL, AUTO_NAME_MODEL,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal }, { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
@@ -0,0 +1,60 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function walkUpDirectories(startDir: string, stopDir?: string): string[] {
const directories: string[] = [];
const hasStopDir = stopDir !== undefined;
let current = resolve(startDir);
let parent = dirname(current);
let reachedStopDir = hasStopDir && current === stopDir;
let reachedFilesystemRoot = parent === current;
directories.push(current);
while (!reachedStopDir && !reachedFilesystemRoot) {
current = parent;
parent = dirname(current);
reachedStopDir = hasStopDir && current === stopDir;
reachedFilesystemRoot = parent === current;
directories.push(current);
}
return directories;
}
function findNearestGitRoot(startDir: string): string | undefined {
for (const directory of walkUpDirectories(startDir)) {
if (existsSync(join(directory, ".git"))) {
return directory;
}
}
return undefined;
}
function findClaudeSkillDirs(cwd: string): string[] {
const gitRoot = findNearestGitRoot(cwd);
return walkUpDirectories(cwd, gitRoot)
.map((directory) => join(directory, ".claude", "skills"))
.filter(isDirectory);
}
export default function(pi: ExtensionAPI) {
pi.on("resources_discover", (event) => {
const skillPaths = findClaudeSkillDirs(event.cwd);
if (skillPaths.length === 0) {
return;
}
return { skillPaths };
});
}
+2 -7
View File
@@ -6,7 +6,7 @@
* - Injects timestamp markers without triggering extra turns * - Injects timestamp markers without triggering extra turns
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Box, Text } from "@mariozechner/pi-tui"; import { Box, Text } from "@mariozechner/pi-tui";
// Track session time // Track session time
@@ -41,12 +41,7 @@ function formatDuration(ms: number): string {
} }
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
const updateStatus = (ctx: { const updateStatus = (ctx: ExtensionContext) => {
ui: {
setStatus: (id: string, text: string | undefined) => void;
theme: { fg: (color: string, text: string) => string };
};
}) => {
const elapsed = Date.now() - sessionStart; const elapsed = Date.now() - sessionStart;
let status = ctx.ui.theme.fg("dim", `${formatElapsed(elapsed)}`); let status = ctx.ui.theme.fg("dim", `${formatElapsed(elapsed)}`);
if (lastTurnDuration !== null) { if (lastTurnDuration !== null) {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"selectModel": "ctrl+space" "app.model.select": "ctrl+space"
} }
+105
View File
@@ -0,0 +1,105 @@
---
name: linear
description: Access Linear issue tracker - search, view, create, update issues, list teams/projects, and manage comments. Use when the user asks about Linear issues, tasks, tickets, or project management in Linear.
---
# Linear
Manage Linear issues, projects, and teams via the Linear SDK.
## Setup
Run once before first use:
```bash
cd {baseDir} && npm install
```
Requires a `LINEAR_API_KEY` environment variable. Generate one at: https://linear.app/settings/api (Personal API keys).
Set it in your shell profile or pi settings:
```bash
export LINEAR_API_KEY=lin_api_...
```
## Current User
```bash
node {baseDir}/linear-me.js # Show authenticated user
node {baseDir}/linear-me.js --issues # Show user + their active issues
```
## Search Issues
```bash
node {baseDir}/linear-search.js "query" # Text search
node {baseDir}/linear-search.js "query" -n 20 # More results
node {baseDir}/linear-search.js "query" --team ENG # Filter by team
node {baseDir}/linear-search.js "query" --state "In Progress" # Filter by state
```
## List Issues (with filters)
```bash
node {baseDir}/linear-issues.js # All recent issues
node {baseDir}/linear-issues.js --team ENG # By team
node {baseDir}/linear-issues.js --state "In Progress" # By state
node {baseDir}/linear-issues.js --assignee me # My issues
node {baseDir}/linear-issues.js --assignee "John" # By assignee name
node {baseDir}/linear-issues.js --label "Bug" # By label
node {baseDir}/linear-issues.js --project "Q1 Goals" # By project
node {baseDir}/linear-issues.js --team ENG --state Todo -n 50 # Combined filters
```
## View Issue Details
```bash
node {baseDir}/linear-issue.js ATT-1234 # Full issue details
node {baseDir}/linear-issue.js ATT-1234 --comments # Include comments
```
## Create Issue
```bash
node {baseDir}/linear-create.js --team ENG --title "Fix login bug"
node {baseDir}/linear-create.js --team ENG --title "New feature" --description "Details here" --state Todo --priority 2 --assignee me --label "Feature"
node {baseDir}/linear-create.js --team ENG --title "Sub-task" --parent ATT-100
```
Priority values: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low
## Update Issue
```bash
node {baseDir}/linear-update.js ATT-1234 --state "In Progress"
node {baseDir}/linear-update.js ATT-1234 --assignee me --priority 2
node {baseDir}/linear-update.js ATT-1234 --title "New title" --description "Updated desc"
```
## Add Comment
```bash
node {baseDir}/linear-comment.js ATT-1234 "This is done in PR #567"
```
## List Teams
```bash
node {baseDir}/linear-teams.js
```
## List Projects
```bash
node {baseDir}/linear-projects.js # All projects
node {baseDir}/linear-projects.js --team ENG # By team
```
## Tips
- Use `--assignee me` to filter by the authenticated user
- Issue identifiers follow the pattern `TEAM-NUMBER` (e.g. `ATT-1234`, `ENG-567`)
- Descriptions support markdown formatting
- State names are case-insensitive (e.g. "todo", "Todo", "TODO" all work)
- When creating issues, the team key is required; use `linear-teams.js` to find available teams
+23
View File
@@ -0,0 +1,23 @@
import { LinearClient } from "@linear/sdk";
export function getClient() {
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey) {
console.error("Error: LINEAR_API_KEY environment variable is required.");
console.error(
"Generate one at: https://linear.app/settings/api (Personal API keys)"
);
process.exit(1);
}
return new LinearClient({ apiKey });
}
export function formatDate(date) {
if (!date) return "";
return new Date(date).toISOString().split("T")[0];
}
export function truncate(str, len = 120) {
if (!str) return "";
return str.length > len ? str.slice(0, len) + "…" : str;
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Add a comment to a Linear issue
// Usage: linear-comment.js <identifier> <body>
import { getClient } from "./lib.js";
const args = process.argv.slice(2);
const identifier = args[0];
const body = args.slice(1).join(" ");
if (!identifier || !body) {
console.log("Usage: linear-comment.js <identifier> <body>");
console.log("\nExamples:");
console.log(' linear-comment.js ATT-1234 "This is fixed in the latest PR"');
process.exit(1);
}
const client = getClient();
const results = await client.searchIssues(identifier, { first: 1 });
const issue = results.nodes[0];
if (!issue) {
console.error(`Issue '${identifier}' not found.`);
process.exit(1);
}
await client.createComment({ issueId: issue.id, body });
console.log(`Comment added to ${issue.identifier}.`);
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
// Create a new Linear issue
// Usage: linear-create.js --team <key> --title <title> [--description <desc>] [--state <name>] [--priority <0-4>] [--assignee <name|me>] [--label <name>] [--parent <identifier>]
import { getClient } from "./lib.js";
const args = process.argv.slice(2);
function extractArg(flag) {
const idx = args.indexOf(flag);
if (idx !== -1 && args[idx + 1]) {
const val = args[idx + 1];
args.splice(idx, 2);
return val;
}
return null;
}
const teamKey = extractArg("--team");
const title = extractArg("--title");
const description = extractArg("--description");
const stateName = extractArg("--state");
const priority = extractArg("--priority");
const assigneeName = extractArg("--assignee");
const labelName = extractArg("--label");
const parentId = extractArg("--parent");
if (!teamKey || !title) {
console.log("Usage: linear-create.js --team <key> --title <title> [options]");
console.log("\nRequired:");
console.log(" --team <key> Team key (e.g. ENG)");
console.log(' --title <title> Issue title');
console.log("\nOptional:");
console.log(" --description <text> Issue description (markdown)");
console.log(" --state <name> Initial state (e.g. 'Todo')");
console.log(" --priority <0-4> Priority: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low");
console.log(" --assignee <name|me> Assignee name or 'me'");
console.log(" --label <name> Label name");
console.log(" --parent <id> Parent issue identifier (e.g. ATT-100)");
process.exit(1);
}
const client = getClient();
// Resolve team
const teams = await client.teams({ filter: { key: { eq: teamKey.toUpperCase() } } });
const team = teams.nodes[0];
if (!team) {
console.error(`Team '${teamKey}' not found.`);
process.exit(1);
}
const input = {
teamId: team.id,
title,
};
if (description) input.description = description;
if (priority) input.priority = parseInt(priority, 10);
// Resolve state
if (stateName) {
const states = await team.states();
const state = states.nodes.find(
(s) => s.name.toLowerCase() === stateName.toLowerCase()
);
if (state) input.stateId = state.id;
else console.warn(`Warning: State '${stateName}' not found, using default.`);
}
// Resolve assignee
if (assigneeName) {
if (assigneeName.toLowerCase() === "me") {
const me = await client.viewer;
input.assigneeId = me.id;
} else {
const users = await client.users({ filter: { name: { containsIgnoreCase: assigneeName } } });
if (users.nodes[0]) input.assigneeId = users.nodes[0].id;
else console.warn(`Warning: User '${assigneeName}' not found.`);
}
}
// Resolve label
if (labelName) {
const labels = await client.issueLabels({ filter: { name: { eqIgnoreCase: labelName } } });
if (labels.nodes[0]) input.labelIds = [labels.nodes[0].id];
else console.warn(`Warning: Label '${labelName}' not found.`);
}
// Resolve parent
if (parentId) {
const parentSearch = await client.searchIssues(parentId, { first: 1 });
if (parentSearch.nodes[0]) input.parentId = parentSearch.nodes[0].id;
else console.warn(`Warning: Parent '${parentId}' not found.`);
}
const result = await client.createIssue(input);
const issue = await result.issue;
console.log(`Created: ${issue.identifier} - ${issue.title}`);
console.log(`URL: ${issue.url}`);
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
// Get details for a specific Linear issue
// Usage: linear-issue.js <identifier> [--comments]
import { getClient, formatDate } from "./lib.js";
const args = process.argv.slice(2);
const showComments = args.includes("--comments");
const filtered = args.filter((a) => a !== "--comments");
const identifier = filtered[0];
if (!identifier) {
console.log("Usage: linear-issue.js <identifier> [--comments]");
console.log("\nExamples:");
console.log(" linear-issue.js ATT-1234");
console.log(" linear-issue.js ATT-1234 --comments");
process.exit(1);
}
const client = getClient();
// Parse team key and issue number from identifier (e.g. "SIP-1205")
const parts = identifier.match(/^([A-Za-z]+)-(\d+)$/);
if (!parts) {
console.error(`Invalid identifier format: ${identifier}. Expected format: TEAM-123`);
process.exit(1);
}
const teamKey = parts[1].toUpperCase();
const issueNumber = parseInt(parts[2], 10);
// Find the issue by team key + number
const issues = await client.issues({
filter: {
team: { key: { eq: teamKey } },
number: { eq: issueNumber },
},
first: 1,
});
const issue = issues.nodes[0];
if (!issue) {
console.error(`Issue ${identifier} not found.`);
process.exit(1);
}
const state = await issue.state;
const team = await issue.team;
const assignee = await issue.assignee;
const labels = await issue.labels();
const parent = await issue.parent;
const project = await issue.project;
const cycle = await issue.cycle;
console.log(`=== ${issue.identifier}: ${issue.title} ===`);
console.log(`URL: ${issue.url}`);
console.log(`State: ${state?.name || "Unknown"}`);
console.log(`Priority: ${issue.priorityLabel}`);
console.log(`Team: ${team?.key || "?"}`);
console.log(`Assignee: ${assignee?.name || "Unassigned"}`);
if (project) console.log(`Project: ${project.name}`);
if (cycle) console.log(`Cycle: ${cycle.name || cycle.number}`);
if (parent) console.log(`Parent: ${parent.identifier} - ${parent.title}`);
if (labels.nodes.length > 0) {
console.log(`Labels: ${labels.nodes.map((l) => l.name).join(", ")}`);
}
console.log(`Created: ${formatDate(issue.createdAt)}`);
console.log(`Updated: ${formatDate(issue.updatedAt)}`);
if (issue.dueDate) console.log(`Due: ${issue.dueDate}`);
console.log(`\nDescription:\n${issue.description || "(empty)"}`);
if (showComments) {
const comments = await issue.comments();
if (comments.nodes.length > 0) {
console.log(`\n--- Comments (${comments.nodes.length}) ---`);
for (const comment of comments.nodes) {
const author = await comment.user;
console.log(`\n[${formatDate(comment.createdAt)}] ${author?.name || "Unknown"}:`);
console.log(comment.body);
}
} else {
console.log("\nNo comments.");
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
// List Linear issues with filters
// Usage: linear-issues.js [--team <key>] [--state <name>] [--assignee <name|me>] [--label <name>] [--project <name>] [-n <num>]
import { getClient, formatDate, truncate } from "./lib.js";
const args = process.argv.slice(2);
function extractArg(flag) {
const idx = args.indexOf(flag);
if (idx !== -1 && args[idx + 1]) {
const val = args[idx + 1];
args.splice(idx, 2);
return val;
}
return null;
}
const numResults = parseInt(extractArg("-n") || "25", 10);
const teamKey = extractArg("--team");
const stateName = extractArg("--state");
const assigneeName = extractArg("--assignee");
const labelName = extractArg("--label");
const projectName = extractArg("--project");
if (args.includes("--help") || args.includes("-h")) {
console.log("Usage: linear-issues.js [options]");
console.log("\nOptions:");
console.log(" --team <key> Filter by team key (e.g. ENG)");
console.log(" --state <name> Filter by state (e.g. 'In Progress', 'Todo')");
console.log(" --assignee <name> Filter by assignee name or 'me'");
console.log(" --label <name> Filter by label name");
console.log(" --project <name> Filter by project name");
console.log(" -n <num> Number of results (default: 25)");
process.exit(0);
}
const client = getClient();
// Build filter
const filter = {};
if (teamKey) {
filter.team = { key: { eq: teamKey.toUpperCase() } };
}
if (stateName) {
filter.state = { name: { eqIgnoreCase: stateName } };
}
if (assigneeName) {
if (assigneeName.toLowerCase() === "me") {
const me = await client.viewer;
filter.assignee = { id: { eq: me.id } };
} else {
filter.assignee = { name: { containsIgnoreCase: assigneeName } };
}
}
if (labelName) {
filter.labels = { name: { eqIgnoreCase: labelName } };
}
if (projectName) {
filter.project = { name: { containsIgnoreCase: projectName } };
}
const issues = await client.issues({
filter,
first: numResults,
orderBy: "updatedAt",
});
if (issues.nodes.length === 0) {
console.log("No issues found matching filters.");
process.exit(0);
}
for (const issue of issues.nodes) {
const state = await issue.state;
const team = await issue.team;
const assignee = await issue.assignee;
console.log(
`${issue.identifier.padEnd(12)} ${(state?.name || "?").padEnd(14)} ${(issue.priorityLabel || "").padEnd(8)} ${(assignee?.name || "Unassigned").padEnd(20)} ${truncate(issue.title, 80)}`
);
}
console.log(`\n${issues.nodes.length} issue(s) shown.`);
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// Show current authenticated user and their assigned issues
// Usage: linear-me.js [--issues]
import { getClient, truncate } from "./lib.js";
const showIssues = process.argv.includes("--issues");
const client = getClient();
const me = await client.viewer;
console.log(`User: ${me.name}`);
console.log(`Email: ${me.email}`);
console.log(`ID: ${me.id}`);
if (showIssues) {
const issues = await me.assignedIssues({
first: 25,
filter: {
state: { type: { nin: ["completed", "canceled"] } },
},
orderBy: "updatedAt",
});
console.log(`\n--- Active Assigned Issues (${issues.nodes.length}) ---`);
for (const issue of issues.nodes) {
const state = await issue.state;
console.log(
`${issue.identifier.padEnd(12)} ${(state?.name || "?").padEnd(14)} ${(issue.priorityLabel || "").padEnd(8)} ${truncate(issue.title, 80)}`
);
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
// List Linear projects
// Usage: linear-projects.js [--team <key>] [-n <num>]
import { getClient, formatDate } from "./lib.js";
const args = process.argv.slice(2);
function extractArg(flag) {
const idx = args.indexOf(flag);
if (idx !== -1 && args[idx + 1]) {
const val = args[idx + 1];
args.splice(idx, 2);
return val;
}
return null;
}
const numResults = parseInt(extractArg("-n") || "25", 10);
const teamKey = extractArg("--team");
const client = getClient();
const filter = {};
if (teamKey) {
filter.accessibleTeams = { key: { eq: teamKey.toUpperCase() } };
}
const projects = await client.projects({ filter, first: numResults });
if (projects.nodes.length === 0) {
console.log("No projects found.");
process.exit(0);
}
for (const project of projects.nodes) {
const lead = await project.lead;
console.log(`--- ${project.name} ---`);
console.log(`State: ${project.state} | Progress: ${Math.round(project.progress * 100)}%`);
if (lead) console.log(`Lead: ${lead.name}`);
if (project.targetDate) console.log(`Target: ${project.targetDate}`);
console.log(`URL: ${project.url}`);
console.log("");
}
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Search Linear issues by text query
// Usage: linear-search.js <query> [-n <num>] [--team <key>] [--state <name>]
import { getClient, formatDate, truncate } from "./lib.js";
const args = process.argv.slice(2);
let numResults = 10;
const nIdx = args.indexOf("-n");
if (nIdx !== -1 && args[nIdx + 1]) {
numResults = parseInt(args[nIdx + 1], 10);
args.splice(nIdx, 2);
}
let teamFilter = null;
const teamIdx = args.indexOf("--team");
if (teamIdx !== -1 && args[teamIdx + 1]) {
teamFilter = args[teamIdx + 1];
args.splice(teamIdx, 2);
}
let stateFilter = null;
const stateIdx = args.indexOf("--state");
if (stateIdx !== -1 && args[stateIdx + 1]) {
stateFilter = args[stateIdx + 1];
args.splice(stateIdx, 2);
}
const query = args.join(" ");
if (!query) {
console.log("Usage: linear-search.js <query> [-n <num>] [--team <key>] [--state <name>]");
console.log("\nOptions:");
console.log(" -n <num> Number of results (default: 10)");
console.log(" --team <key> Filter by team key (e.g. ENG)");
console.log(" --state <name> Filter by state name (e.g. 'In Progress')");
process.exit(1);
}
const client = getClient();
const results = await client.searchIssues(query, { first: numResults });
for (const issue of results.nodes) {
const state = await issue.state;
const team = await issue.team;
const assignee = await issue.assignee;
if (teamFilter && team?.key?.toLowerCase() !== teamFilter.toLowerCase()) continue;
if (stateFilter && state?.name?.toLowerCase() !== stateFilter.toLowerCase()) continue;
console.log(`--- ${issue.identifier} ---`);
console.log(`Title: ${issue.title}`);
console.log(`State: ${state?.name || "Unknown"}`);
console.log(`Priority: ${issue.priorityLabel}`);
console.log(`Team: ${team?.key || "?"} | Assignee: ${assignee?.name || "Unassigned"}`);
console.log(`Created: ${formatDate(issue.createdAt)} | Updated: ${formatDate(issue.updatedAt)}`);
if (issue.description) console.log(`Description: ${truncate(issue.description, 200)}`);
console.log(`URL: ${issue.url}`);
console.log("");
}
if (results.nodes.length === 0) {
console.log("No results found.");
}
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
// List all Linear teams
// Usage: linear-teams.js
import { getClient } from "./lib.js";
const client = getClient();
const teams = await client.teams();
console.log("Teams:");
for (const team of teams.nodes) {
console.log(` ${team.key.padEnd(8)} ${team.name}`);
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
// Update an existing Linear issue
// Usage: linear-update.js <identifier> [--title <title>] [--state <name>] [--priority <0-4>] [--assignee <name|me>] [--description <text>]
import { getClient } from "./lib.js";
const args = process.argv.slice(2);
const identifier = args[0];
if (!identifier || identifier.startsWith("--")) {
console.log("Usage: linear-update.js <identifier> [options]");
console.log("\nOptions:");
console.log(" --title <title> New title");
console.log(" --state <name> New state (e.g. 'In Progress')");
console.log(" --priority <0-4> New priority");
console.log(" --assignee <name|me> New assignee");
console.log(" --description <text> New description");
process.exit(1);
}
args.shift();
function extractArg(flag) {
const idx = args.indexOf(flag);
if (idx !== -1 && args[idx + 1]) {
const val = args[idx + 1];
args.splice(idx, 2);
return val;
}
return null;
}
const title = extractArg("--title");
const stateName = extractArg("--state");
const priority = extractArg("--priority");
const assigneeName = extractArg("--assignee");
const description = extractArg("--description");
const client = getClient();
// Find the issue
const results = await client.searchIssues(identifier, { first: 1 });
const issue = results.nodes[0];
if (!issue) {
console.error(`Issue '${identifier}' not found.`);
process.exit(1);
}
const input = {};
if (title) input.title = title;
if (description) input.description = description;
if (priority) input.priority = parseInt(priority, 10);
// Resolve state
if (stateName) {
const team = await issue.team;
const states = await team.states();
const state = states.nodes.find(
(s) => s.name.toLowerCase() === stateName.toLowerCase()
);
if (state) input.stateId = state.id;
else {
console.error(`State '${stateName}' not found. Available states:`);
for (const s of states.nodes) console.error(` - ${s.name}`);
process.exit(1);
}
}
// Resolve assignee
if (assigneeName) {
if (assigneeName.toLowerCase() === "me") {
const me = await client.viewer;
input.assigneeId = me.id;
} else {
const users = await client.users({ filter: { name: { containsIgnoreCase: assigneeName } } });
if (users.nodes[0]) input.assigneeId = users.nodes[0].id;
else {
console.error(`User '${assigneeName}' not found.`);
process.exit(1);
}
}
}
if (Object.keys(input).length === 0) {
console.log("No updates specified. Use --title, --state, --priority, --assignee, or --description.");
process.exit(1);
}
await client.updateIssue(issue.id, input);
console.log(`Updated ${issue.identifier}: ${issue.title}`);
console.log(`URL: ${issue.url}`);
+107
View File
@@ -0,0 +1,107 @@
{
"name": "linear-skill",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "linear-skill",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@linear/sdk": "^37.0.0"
}
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@linear/sdk": {
"version": "37.0.0",
"resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-37.0.0.tgz",
"integrity": "sha512-EAZCXtV414Nwtvrwn7Ucu3E8BbYYKsc3HqZCGf1mHUE7FhZGtfISu295DOVv89WhhXlp2N344EMg3K0nnhLxtA==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.1.0",
"graphql": "^15.4.0",
"isomorphic-unfetch": "^3.1.0"
},
"engines": {
"node": ">=12.x",
"yarn": "1.x"
}
},
"node_modules/graphql": {
"version": "15.10.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz",
"integrity": "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==",
"license": "MIT",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/isomorphic-unfetch": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz",
"integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"unfetch": "^4.2.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/unfetch": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
"integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "linear-skill",
"version": "1.0.0",
"type": "module",
"description": "Linear API skill for pi - manage issues, projects, and teams",
"license": "MIT",
"dependencies": {
"@linear/sdk": "^37.0.0"
}
}
+18 -24
View File
@@ -1,28 +1,22 @@
layout { layout {
default_tab_template { default_tab_template {
children children
pane size=1 borderless=true { pane size=1 borderless=true {
plugin location="zellij:compact-bar" plugin location="zellij:compact-bar"
} }
} }
tab name="dotfiles" cwd="/home/thomasgl/.dotfiles" { tab name="nvim + jjui" {
pane split_direction="vertical" { pane stacked=true {
pane stacked=true { pane command="nvim"
pane pane command="jjui"
pane command="nvim" }
} }
pane size="40%" command="pi"
}
}
tab name="NixOS" cwd="/home/thomasgl/etc/nixos" { tab name="pi + shell" {
pane split_direction="vertical" { pane stacked=true {
pane stacked=true { pane command="pi"
pane pane
pane command="nvim" }
} }
pane size="40%" command="pi"
}
}
} }