Compare commits
16 Commits
24bad1f5c4
...
nixos
| Author | SHA1 | Date | |
|---|---|---|---|
| 85632c2e29 | |||
| 63caa82199 | |||
| 39e7bddb35 | |||
| d5b4042b06 | |||
| 4d19e7d320 | |||
| 227c1638f6 | |||
| db41ec6e93 | |||
| c44420ce7c | |||
| f74242ed02 | |||
| 335b12b0e4 | |||
| 2e820d38e1 | |||
| 008dac69f5 | |||
| d0b1d3be4a | |||
| c0bbff81a3 | |||
| 58dd9d8c2b | |||
| 3d314d944b |
@@ -377,7 +377,7 @@
|
||||
"osdPosition": 5,
|
||||
"osdVolumeEnabled": true,
|
||||
"osdMediaVolumeEnabled": true,
|
||||
"osdMediaPlaybackEnabled": true,
|
||||
"osdMediaPlaybackEnabled": false,
|
||||
"osdBrightnessEnabled": true,
|
||||
"osdIdleInhibitorEnabled": true,
|
||||
"osdMicMuteEnabled": true,
|
||||
|
||||
@@ -27,6 +27,14 @@ end
|
||||
|
||||
status is-interactive; and begin
|
||||
|
||||
# On macOS SSH sessions, normalize TERM if remote terminfo is missing
|
||||
# (eg. TERM=alacritty from Linux host), otherwise tools like jj/less warn
|
||||
if test (uname) = Darwin; and set -q SSH_TTY
|
||||
if not infocmp "$TERM" >/dev/null 2>&1
|
||||
set -gx TERM xterm-256color
|
||||
end
|
||||
end
|
||||
|
||||
# Abbreviations
|
||||
abbr -a tx 'tmux'
|
||||
abbr -a txa 'tmux attach'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
[templates.ghostty]
|
||||
input_path = '~/.config/matugen/templates/ghostty-theme'
|
||||
output_path = '~/.config/ghostty/themes/matugen'
|
||||
post_hook = 'pkill -SIGUSR2 ghostty'
|
||||
post_hook = "pkill -SIGUSR2 ghostty || true && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/ghostty/themes/matugen ~/.config/ghostty/themes/matugen --remote-cmd 'pkill -SIGUSR2 ghostty || true' >/dev/null 2>&1 &"
|
||||
|
||||
[templates.kitty]
|
||||
input_path = '~/.config/matugen/templates/kitty-colors.conf'
|
||||
output_path = '~/.config/kitty/colors.conf'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/kitty/colors.conf ~/.config/kitty/colors.conf >/dev/null 2>&1 &"
|
||||
|
||||
[templates.foot]
|
||||
input_path = '~/.config/matugen/templates/foot-theme'
|
||||
@@ -24,10 +25,12 @@ output_path = '~/.config/gtk-4.0/colors.css'
|
||||
[templates.fish-prompt]
|
||||
input_path = '~/.config/matugen/templates/fish-prompt-colors.fish'
|
||||
output_path = '~/.config/fish/conf.d/prompt-colors.fish'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/fish/conf.d/prompt-colors.fish ~/.config/fish/conf.d/prompt-colors.fish >/dev/null 2>&1 &"
|
||||
|
||||
[templates.yazi]
|
||||
input_path = '~/.config/matugen/templates/yazi-theme.toml'
|
||||
output_path = '~/.config/yazi/theme.toml'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/yazi/theme.toml ~/.config/yazi/theme.toml >/dev/null 2>&1 &"
|
||||
|
||||
[templates.qt5ct]
|
||||
input_path = '~/.config/matugen/templates/qtct-colors.conf'
|
||||
@@ -44,28 +47,29 @@ output_path = '~/.config/niri/colors.kdl'
|
||||
[templates.tmux]
|
||||
input_path = '~/.config/matugen/templates/tmux-colors.conf'
|
||||
output_path = '~/.config/tmux/colors.conf'
|
||||
post_hook = 'tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true && nohup ~/.config/matugen/scripts/sync-tmux-mac.sh >/dev/null 2>&1 &'
|
||||
post_hook = "tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/tmux/colors.conf ~/.config/tmux/colors.conf --remote-cmd 'export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\" && tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true' >/dev/null 2>&1 &"
|
||||
|
||||
[templates.zellij]
|
||||
input_path = '~/.config/matugen/templates/zellij-colors.kdl'
|
||||
output_path = '~/.config/zellij/themes/matugen.kdl'
|
||||
post_hook = 'touch ~/.config/zellij/config.kdl && nohup ~/.config/matugen/scripts/sync-zellij-mac.sh >/dev/null 2>&1 &'
|
||||
post_hook = "touch ~/.config/zellij/config.kdl && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/zellij/themes/matugen.kdl ~/.config/zellij/themes/matugen.kdl --remote-cmd 'touch ~/.config/zellij/config.kdl' >/dev/null 2>&1 &"
|
||||
|
||||
[templates.jjui]
|
||||
input_path = '~/.config/matugen/templates/jjui-theme.toml'
|
||||
output_path = '~/.config/jjui/themes/matugen.toml'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/jjui/themes/matugen.toml ~/.config/jjui/themes/matugen.toml >/dev/null 2>&1 &"
|
||||
|
||||
[templates.nvim]
|
||||
input_path = '~/.config/matugen/templates/neovim.lua'
|
||||
output_path = '~/.config/nvim/lua/plugins/dankcolors.lua'
|
||||
post_hook = 'nohup ~/.config/matugen/scripts/sync-nvim-mac.sh >/dev/null 2>&1 &'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.config/nvim/lua/plugins/dankcolors.lua ~/.config/nvim/lua/plugins/dankcolors.lua >/dev/null 2>&1 &"
|
||||
|
||||
[templates.pi]
|
||||
input_path = '~/.config/matugen/templates/pi-theme.json'
|
||||
output_path = '~/.pi/agent/themes/matugen.json.tmp'
|
||||
post_hook = 'cat ~/.pi/agent/themes/matugen.json.tmp > ~/.pi/agent/themes/matugen.json && nohup ~/.config/matugen/scripts/sync-pi-mac.sh >/dev/null 2>&1 &'
|
||||
post_hook = "cat ~/.pi/agent/themes/matugen.json.tmp > ~/.pi/agent/themes/matugen.json && nohup ~/.config/matugen/scripts/sync-mac.sh file ~/.pi/agent/themes/matugen.json ~/.pi/agent/themes/matugen.json >/dev/null 2>&1 &"
|
||||
|
||||
[templates.wallpaper]
|
||||
input_path = '~/.config/matugen/templates/wallpaper-path.txt'
|
||||
output_path = '~/.cache/matugen-last-image'
|
||||
post_hook = 'nohup ~/.config/matugen/scripts/sync-wallpaper-mac.sh >/dev/null 2>&1 &'
|
||||
post_hook = "nohup ~/.config/matugen/scripts/sync-mac.sh wallpaper ~/.cache/matugen-last-image >/dev/null 2>&1 &"
|
||||
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
host="${MATUGEN_SYNC_HOST:-mac-attio}"
|
||||
log_file="$HOME/.cache/matugen-sync-mac.log"
|
||||
|
||||
mkdir -p "$HOME/.cache"
|
||||
|
||||
usage() {
|
||||
echo "usage:" >&2
|
||||
echo " sync-mac.sh file <source_path> <remote_path> [--remote-cmd <command>]" >&2
|
||||
echo " sync-mac.sh wallpaper <wallpaper_path_file>" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sync_file() {
|
||||
source_path="$1"
|
||||
remote_path="$2"
|
||||
remote_cmd="${3-}"
|
||||
|
||||
# If caller passes a local absolute path, mirror it under remote $HOME.
|
||||
case "$remote_path" in
|
||||
"$HOME")
|
||||
remote_path="~"
|
||||
;;
|
||||
"$HOME"/*)
|
||||
remote_path="~/${remote_path#"$HOME"/}"
|
||||
;;
|
||||
esac
|
||||
|
||||
remote_dir="$(dirname "$remote_path")"
|
||||
remote_tmp="${remote_path}.tmp"
|
||||
|
||||
ssh "$host" "mkdir -p $remote_dir"
|
||||
scp "$source_path" "$host:$remote_tmp"
|
||||
ssh "$host" "mv $remote_tmp $remote_path"
|
||||
|
||||
if [ -n "$remote_cmd" ]; then
|
||||
ssh "$host" "$remote_cmd"
|
||||
fi
|
||||
}
|
||||
|
||||
sync_wallpaper() {
|
||||
wallpaper_path_file="$1"
|
||||
|
||||
[ -f "$wallpaper_path_file" ] || exit 0
|
||||
|
||||
wallpaper_path="$(cat "$wallpaper_path_file")"
|
||||
[ -n "$wallpaper_path" ] || exit 0
|
||||
[ -f "$wallpaper_path" ] || exit 0
|
||||
|
||||
base_name="$(basename "$wallpaper_path")"
|
||||
local_cache_dir="$HOME/.cache/matugen-wallpapers"
|
||||
local_copy="$local_cache_dir/$base_name"
|
||||
|
||||
mkdir -p "$local_cache_dir"
|
||||
cp -f "$wallpaper_path" "$local_copy"
|
||||
|
||||
ssh "$host" "mkdir -p ~/.cache/matugen-wallpapers"
|
||||
scp "$local_copy" "$host:~/.cache/matugen-wallpapers/$base_name"
|
||||
ssh "$host" "osascript -e 'tell application \"System Events\" to tell every desktop to set picture to POSIX file \"~/.cache/matugen-wallpapers/$base_name\"'"
|
||||
}
|
||||
|
||||
mode="${1-}"
|
||||
[ -n "$mode" ] || usage
|
||||
shift
|
||||
|
||||
{
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] mode=$mode"
|
||||
|
||||
case "$mode" in
|
||||
file)
|
||||
[ "$#" -ge 2 ] || usage
|
||||
source_path="$1"
|
||||
remote_path="$2"
|
||||
shift 2
|
||||
|
||||
remote_cmd=""
|
||||
if [ "${1-}" = "--remote-cmd" ]; then
|
||||
[ "$#" -eq 2 ] || usage
|
||||
remote_cmd="$2"
|
||||
elif [ "$#" -ne 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
sync_file "$source_path" "$remote_path" "$remote_cmd"
|
||||
;;
|
||||
|
||||
wallpaper)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
sync_wallpaper "$1"
|
||||
;;
|
||||
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
} >>"$log_file" 2>&1
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
log_file="$HOME/.cache/matugen-sync-nvim.log"
|
||||
|
||||
mkdir -p "$HOME/.cache"
|
||||
|
||||
{
|
||||
ssh mac-attio "mkdir -p ~/.config/nvim/lua/plugins"
|
||||
scp "$HOME/.config/nvim/lua/plugins/dankcolors.lua" \
|
||||
mac-attio:~/.config/nvim/lua/plugins/
|
||||
} >>"$log_file" 2>&1
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
log_file="$HOME/.cache/matugen-sync-pi.log"
|
||||
|
||||
mkdir -p "$HOME/.cache"
|
||||
|
||||
{
|
||||
ssh mac-attio "mkdir -p ~/.pi/agent/themes"
|
||||
scp "$HOME/.pi/agent/themes/matugen.json" \
|
||||
mac-attio:~/.pi/agent/themes/matugen.json.tmp
|
||||
ssh mac-attio "mv ~/.pi/agent/themes/matugen.json.tmp ~/.pi/agent/themes/matugen.json"
|
||||
} >>"$log_file" 2>&1
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
log_file="$HOME/.cache/matugen-sync-tmux.log"
|
||||
|
||||
mkdir -p "$HOME/.cache"
|
||||
|
||||
{
|
||||
ssh mac-attio "mkdir -p ~/.config/tmux"
|
||||
scp "$HOME/.config/tmux/colors.conf" \
|
||||
mac-attio:~/.config/tmux/
|
||||
ssh mac-attio 'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && tmux source-file ~/.config/tmux/tmux.conf 2>/dev/null || true'
|
||||
} >>"$log_file" 2>&1
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
log_file="$HOME/.cache/matugen-sync-wallpaper.log"
|
||||
|
||||
mkdir -p "$HOME/.cache"
|
||||
|
||||
{
|
||||
wallpaper_path="$(cat "$HOME/.cache/matugen-last-image")"
|
||||
if [ -n "$wallpaper_path" ]; then
|
||||
base_name="$(basename "$wallpaper_path")"
|
||||
dest_path="$HOME/.cache/matugen-wallpapers/$base_name"
|
||||
|
||||
mkdir -p "$HOME/.cache/matugen-wallpapers"
|
||||
cp -f "$wallpaper_path" "$dest_path"
|
||||
|
||||
ssh mac-attio "mkdir -p ~/.cache/matugen-wallpapers"
|
||||
scp "$dest_path" "mac-attio:~/.cache/matugen-wallpapers/$base_name"
|
||||
ssh mac-attio "osascript -e 'tell application \"System Events\" to tell every desktop to set picture to POSIX file \"~/.cache/matugen-wallpapers/$base_name\"'"
|
||||
fi
|
||||
} >>"$log_file" 2>&1
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
log_file="$HOME/.cache/matugen-sync-zellij.log"
|
||||
|
||||
mkdir -p "$HOME/.cache"
|
||||
|
||||
{
|
||||
ssh mac-attio "mkdir -p ~/.config/zellij/themes"
|
||||
scp "$HOME/.config/zellij/themes/matugen.kdl" \
|
||||
mac-attio:~/.config/zellij/themes/
|
||||
ssh mac-attio "touch ~/.config/zellij/config.kdl"
|
||||
} >>"$log_file" 2>&1
|
||||
@@ -238,7 +238,7 @@ layer-rule {
|
||||
}
|
||||
|
||||
window-rule {
|
||||
match app-id="steam" title=r#"^notificationtoasts_\d+_desktop$"#
|
||||
match app-id="steam" title=r#"notificationtoasts_\d+_desktop$"#
|
||||
default-floating-position x=10 y=10 relative-to="bottom-right"
|
||||
}
|
||||
|
||||
|
||||
@@ -51,27 +51,29 @@ return {
|
||||
-- end,
|
||||
-- desc = "Git Blame Line",
|
||||
-- },
|
||||
{
|
||||
"<leader>gf",
|
||||
function()
|
||||
Snacks.lazygit.log_file()
|
||||
end,
|
||||
desc = "Lazygit Current File History",
|
||||
},
|
||||
{
|
||||
"<leader>lg",
|
||||
function()
|
||||
Snacks.lazygit()
|
||||
end,
|
||||
desc = "Lazygit",
|
||||
},
|
||||
{
|
||||
"<leader>gl",
|
||||
function()
|
||||
Snacks.lazygit.log()
|
||||
end,
|
||||
desc = "Lazygit Log (cwd)",
|
||||
},
|
||||
--
|
||||
-- Commented out LazyGit in favor of separated jj
|
||||
-- {
|
||||
-- "<leader>gf",
|
||||
-- function()
|
||||
-- Snacks.lazygit.log_file()
|
||||
-- end,
|
||||
-- desc = "Lazygit Current File History",
|
||||
-- },
|
||||
-- {
|
||||
-- "<leader>lg",
|
||||
-- function()
|
||||
-- Snacks.lazygit()
|
||||
-- end,
|
||||
-- desc = "Lazygit",
|
||||
-- },
|
||||
-- {
|
||||
-- "<leader>gl",
|
||||
-- function()
|
||||
-- Snacks.lazygit.log()
|
||||
-- end,
|
||||
-- desc = "Lazygit Log (cwd)",
|
||||
-- },
|
||||
{
|
||||
"<leader>dn",
|
||||
function()
|
||||
|
||||
@@ -89,6 +89,11 @@ vim.o.confirm = true
|
||||
|
||||
-- vim.o.winborder = "rounded"
|
||||
|
||||
-- Clipboard: keep default y/p behavior; over SSH, route + register through OSC52
|
||||
if vim.env.SSH_TTY then
|
||||
vim.g.clipboard = "osc52"
|
||||
end
|
||||
|
||||
-- Highlight text on yank
|
||||
vim.api.nvim_create_autocmd("TextYankPost", {
|
||||
callback = function()
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
"input.required": true,
|
||||
"resource.limit": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,6 @@
|
||||
"file": "sounds/IKnowYoureThere.mp3",
|
||||
"label": "I know you're there. I can feel you here.",
|
||||
"sha256": "df3780607b7a480fd3968c8aae5e0a397ea956008a5c7a47fecb887a05d61622"
|
||||
},
|
||||
{
|
||||
"file": "sounds/HelloImbecile.mp3",
|
||||
"label": "Hello, imbecile!",
|
||||
"sha256": "dd10461e79bb4b1319f436cef5f0541f18a9505638824a6e765b9f2824a3380f"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.57.1",
|
||||
"lastChangelogVersion": "0.63.1",
|
||||
"defaultProvider": "openai-codex",
|
||||
"defaultModel": "gpt-5.3-codex",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"defaultThinkingLevel": "high",
|
||||
"theme": "matugen",
|
||||
"lsp": {
|
||||
"hookMode": "edit_write"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
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
|
||||
|
||||
Guidelines and conventions for working on the Attio frontend codebase. Use whenever modifying the frontend.
|
||||
|
||||
---
|
||||
|
||||
## Styling
|
||||
|
||||
### General
|
||||
|
||||
- We use `styled-components` to style our components within our TS code.
|
||||
- We use the `$` prefix to indicate props that are consumed in Styled Components. This prevents them from being passed to the DOM as attributes.
|
||||
|
||||
### Explore available components
|
||||
|
||||
Before writing custom components or reaching for custom CSS, check if there are existing components that fit what you're trying to do. Try asking AI agents to find existing components or have a look at available ones in storybook.
|
||||
|
||||
Run `yarn workspace @attio/design start-storybook` to open the storybook in a new tab in your browser.
|
||||
|
||||
### Re-styling existing components
|
||||
|
||||
Most reusable components offer props which affect their styling.
|
||||
|
||||
For example, `Layout.Stack` exposes various props to adjust padding (`p`, `px`, `py`, …), margins (`m`, `mx`, …), width, height, flex layout properties, etc.
|
||||
|
||||
Components like `Button` or `Typography` expose a `variant` prop to select between multiple colour variants commonly used in the codebase.
|
||||
|
||||
**Always prefer using these props over creating custom styling.** When you need to change the styling of a reusable component just for one particular part of the UI, use styled-components:
|
||||
|
||||
```jsx
|
||||
import {styled} from "styled-components"
|
||||
|
||||
const Container = styled(Layout.Stack)`
|
||||
border: 1px solid ${({theme}) => theme.tokens.stroke.primary};
|
||||
border-radius: ${({theme}) => theme.borderRadii["12"]};
|
||||
background-color: ${({theme}) => theme.tokens.surface.secondary};
|
||||
overflow: hidden;
|
||||
`
|
||||
```
|
||||
|
||||
When implementing reusable components, add a `className` prop and assign it to the topmost component in the DOM subtree. This enables re-styling via `styled`.
|
||||
|
||||
```jsx
|
||||
export function Stack({..., className}: {..., className: string | undefined}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### Transient props
|
||||
|
||||
Use [`$transient` props](https://styled-components.com/docs/api#transient-props) for anything consumed only inside a component's style. This prevents these props being passed as attributes to the underlying DOM node and generating noisy runtime errors.
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
const GoodContainer = styled.div<{$isReady: boolean}>`
|
||||
background: ${p => p.$isReady ? "green" : "red"};
|
||||
`
|
||||
|
||||
// Bad
|
||||
const BadContainer = styled.div<{isReady: boolean}>`
|
||||
background: ${p => p.isReady ? "green" : "red"};
|
||||
`
|
||||
```
|
||||
|
||||
### Data attributes vs transient props
|
||||
|
||||
While transient props offer flexibility, consider using data attributes for variant styling. This improves CSS readability with many variants/selectors and offers marginal performance improvements.
|
||||
|
||||
**Rule of thumb:**
|
||||
- **Transient props** — CSS that needs to be interpolated at runtime.
|
||||
- **Data attributes** — styling variants that can be statically defined.
|
||||
|
||||
```jsx
|
||||
// Good: transient props for runtime interpolation
|
||||
const Container = styled.div<{$size: Size}>`
|
||||
font-size: ${({ $size }) => getFontSize($size)}px;
|
||||
`
|
||||
|
||||
// Good: data attributes for variant definitions
|
||||
const Container = styled.div`
|
||||
&[data-variant="subtle"] {
|
||||
background-color: light-blue;
|
||||
}
|
||||
|
||||
&[data-variant="outline"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
`
|
||||
|
||||
// Data attributes can be typed
|
||||
enum Variants {
|
||||
SUBTLE = "subtle",
|
||||
OUTLINE = "outline"
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
&[data-variant=${Variants.SUBTLE}] {
|
||||
background-color: light-blue;
|
||||
}
|
||||
|
||||
&[data-variant=${Variants.OUTLINE}] {
|
||||
background-color: transparent;
|
||||
}
|
||||
`
|
||||
```
|
||||
|
||||
### High cardinality props
|
||||
|
||||
Any prop which changes between many values at runtime should **not** be passed to a styled component. This avoids the overhead of styled-components generating a new class for each value. Use a traditional inline `style` prop instead.
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
isReady: boolean
|
||||
widthPx: number // Varies with a continuous distribution
|
||||
}
|
||||
|
||||
// Good
|
||||
const GoodContainer = styled.div<{$isReady: boolean}>`
|
||||
background: ${p => p.$isReady ? "green" : "red"};
|
||||
`
|
||||
|
||||
export function GoodComponent({isReady, widthPx}: Props) {
|
||||
return <GoodContainer $isReady={isReady} style={{width: `${widthPx}px`}} />
|
||||
}
|
||||
|
||||
// Bad
|
||||
const BadComponent = styled.div<{$isReady: boolean, $widthPx: number}>`
|
||||
background: ${p => p.$isReady ? "green" : "red"};
|
||||
width: ${p => p.$widthPx}px;
|
||||
`
|
||||
```
|
||||
|
||||
### Spacing (padding, margins, gaps)
|
||||
|
||||
Many reusable components expose props for padding (`p`, `px`, `py`, `pr`, `pl`, `pt`, `pb`), margin (`m`, `mx`, `my`, …) and gap (`gap`). Prefer setting spacing through these props. They are limited to a specific subset of values (e.g. `"4px"`, `"8px"`) matching the design system — don't use other values for spacing 99% of the time.
|
||||
|
||||
When creating new components or overriding styles, use the `Spacing` constant from `@attio/picasso`:
|
||||
|
||||
```tsx
|
||||
import {styled} from "styled-components"
|
||||
import {Form, Spacing} from "@attio/picasso"
|
||||
|
||||
const StyledFormHelpText = styled(Form.HelpText)`
|
||||
${Spacing.px("16px")}
|
||||
${Spacing.pt("16px")}
|
||||
${Spacing.pb("0px")}
|
||||
`
|
||||
```
|
||||
|
||||
Use predefined values for border radii from theme:
|
||||
|
||||
```jsx
|
||||
export const Link = styled.a`
|
||||
border-radius: ${({theme}) => theme.borderRadii[6]};
|
||||
`
|
||||
```
|
||||
|
||||
### Color tokens
|
||||
|
||||
We have fixed tokens for colours. **Never define raw hex values or use string names for colours.**
|
||||
|
||||
Many components expose a `variant` prop to select predefined colour variants:
|
||||
|
||||
```tsx
|
||||
<Typography.Caption.Standard.Component variant="secondary">
|
||||
Some text...
|
||||
</Typography.Caption.Standard.Component>
|
||||
```
|
||||
|
||||
When creating new components or adjusting styling, always use tokens from the theme. This is critical because we support light and dark mode — tokens ensure the correct colour is selected for both.
|
||||
|
||||
In styled-components:
|
||||
|
||||
```tsx
|
||||
export const CheckoutPreviewContainer = styled(Layout.Stack)`
|
||||
${Spacing.p("20px")}
|
||||
border-left: 1px solid ${({theme}) => theme.tokens.stroke.primary};
|
||||
border-bottom-right-radius: ${({theme}) => theme.borderRadii["16"]};
|
||||
background: ${({theme}) => theme.tokens.surface.secondary};
|
||||
`
|
||||
```
|
||||
|
||||
In React components, access colours through the `useTheme` hook. Modify colours using utilities like `opacify`:
|
||||
|
||||
```jsx
|
||||
import Color from "color"
|
||||
import {opacify} from "polished"
|
||||
|
||||
const theme = useTheme()
|
||||
const backdropActiveColor = theme.alphas.bgOverlay
|
||||
const backdropColor = opacify(1)(backdropActiveColor)
|
||||
const activeOpacity = Color(backdropActiveColor).alpha()
|
||||
```
|
||||
|
||||
### Turn on scrollbars
|
||||
|
||||
Develop with scrollbars set to "Always" in macOS System Settings. This ensures you spot unexpected overflow issues that users with this setting (or Windows users) will see.
|
||||
@@ -4,9 +4,15 @@ When the user provides a screenshot path (e.g., `/tmp/pi-clipboard-xxx.png`), **
|
||||
|
||||
---
|
||||
|
||||
# Version control
|
||||
|
||||
**Prefer jj (Jujutsu) over git.** If a project has a colocated jj repo (`.jj` directory), use `jj` commands for all version control operations — rebasing, branching, log, etc. Only fall back to git when jj doesn't support something or the project isn't set up for it.
|
||||
|
||||
After pushing changes, always run `jj new` to start a fresh working copy commit.
|
||||
|
||||
# Git commits and PRs
|
||||
|
||||
Before writing any commits or PR titles, check recent git history with `git log --oneline -20` to match my style.
|
||||
Before writing any commits or PR titles, check recent history with `jj log` (or `git log --oneline -20` if jj is unavailable) to match my style.
|
||||
|
||||
My commit style:
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -9,8 +9,8 @@
|
||||
* The editor is determined by $VISUAL, then $EDITOR, then falls back to 'vi'.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { TUI, Theme, KeybindingsManager, Component } from "@mariozechner/pi-tui";
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { TUI, KeybindingsManager, Component } from "@mariozechner/pi-tui";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
done();
|
||||
done(undefined);
|
||||
|
||||
// Return dummy component
|
||||
return createDummyComponent();
|
||||
@@ -69,7 +69,7 @@ export default function editSessionExtension(pi: ExtensionAPI) {
|
||||
await ctx.ui.custom<void>(factory);
|
||||
|
||||
// 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)
|
||||
ctx.ui.notify("Reloading session...", "info");
|
||||
|
||||
@@ -80,7 +80,8 @@ export default function (pi: ExtensionAPI) {
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
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 = {
|
||||
role: "user",
|
||||
@@ -96,7 +97,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
||||
);
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,10 +13,11 @@
|
||||
"vscode-languageserver-protocol": "^3.17.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-ai": "^0.56.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.56.3",
|
||||
"@mariozechner/pi-tui": "^0.56.3",
|
||||
"@mariozechner/pi-ai": "^0.63.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.63.1",
|
||||
"@mariozechner/pi-tui": "^0.63.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"pi": {},
|
||||
|
||||
@@ -211,12 +211,12 @@ function updateWidget(ctx: ExtensionContext): void {
|
||||
(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(
|
||||
entry: ActivityEntry,
|
||||
theme: { fg: (color: string, text: string) => string },
|
||||
theme: ExtensionContext["ui"]["theme"],
|
||||
): string {
|
||||
const typeStr = entry.type === "api" ? "API" : "GET";
|
||||
const target =
|
||||
@@ -550,7 +550,7 @@ export default function (pi: ExtensionAPI) {
|
||||
} else {
|
||||
widgetUnsubscribe?.();
|
||||
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 isMultiQuery = queryList.length > 1;
|
||||
const shouldCurate = params.curate !== false && ctx?.hasUI !== false;
|
||||
@@ -613,7 +613,10 @@ export default function (pi: ExtensionAPI) {
|
||||
if (shouldCurate) {
|
||||
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 searchResults = new Map<number, QueryResultData>();
|
||||
const allUrls: string[] = [];
|
||||
@@ -637,7 +640,7 @@ export default function (pi: ExtensionAPI) {
|
||||
queryList,
|
||||
includeContent,
|
||||
numResults: params.numResults,
|
||||
recencyFilter: params.recencyFilter,
|
||||
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||
domainFilter: params.domainFilter,
|
||||
availableProviders,
|
||||
defaultProvider,
|
||||
@@ -684,7 +687,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const { answer, results } = await search(queryList[qi], {
|
||||
provider: defaultProvider as SearchProvider | undefined,
|
||||
numResults: params.numResults,
|
||||
recencyFilter: params.recencyFilter,
|
||||
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||
domainFilter: params.domainFilter,
|
||||
signal,
|
||||
});
|
||||
@@ -754,7 +757,7 @@ export default function (pi: ExtensionAPI) {
|
||||
text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
content: [{ type: "text" as const, text }],
|
||||
details: {
|
||||
phase: "curate-window",
|
||||
searchCount: searchResults.size,
|
||||
@@ -824,7 +827,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const { answer, results } = await search(query, {
|
||||
provider: resolvedProvider as SearchProvider | undefined,
|
||||
numResults: params.numResults,
|
||||
recencyFilter: params.recencyFilter,
|
||||
recencyFilter: params.recencyFilter as "day" | "week" | "month" | "year" | undefined,
|
||||
domainFilter: params.domainFilter,
|
||||
signal,
|
||||
});
|
||||
@@ -1117,7 +1120,10 @@ export default function (pi: ExtensionAPI) {
|
||||
`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) {
|
||||
for (const frame of result.frames) {
|
||||
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" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx): Promise<any> {
|
||||
const data = getResult(params.responseId);
|
||||
if (!data) {
|
||||
return {
|
||||
@@ -1477,7 +1483,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.sendMessage({
|
||||
customType: "web-search-results",
|
||||
content: [{ type: "text", text }],
|
||||
display: "tool",
|
||||
display: true,
|
||||
details: { queryCount: results.length, totalResults: urls.length },
|
||||
}, { triggerTurn: true, deliverAs: "followUp" });
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ export async function extractPDFToMarkdown(
|
||||
|
||||
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
||||
const metadata = await pdf.getMetadata();
|
||||
const info = (metadata.info ?? {}) as Record<string, unknown>;
|
||||
|
||||
// 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 title = metaTitle?.trim() || urlTitle;
|
||||
|
||||
@@ -79,8 +80,9 @@ export async function extractPDFToMarkdown(
|
||||
lines.push("");
|
||||
lines.push(`> Source: ${url}`);
|
||||
lines.push(`> Pages: ${pdf.numPages}${truncated ? ` (extracted first ${pagesToExtract})` : ""}`);
|
||||
if (metadata.info?.Author) {
|
||||
lines.push(`> Author: ${metadata.info.Author}`);
|
||||
const author = typeof info.Author === "string" ? info.Author : undefined;
|
||||
if (author) {
|
||||
lines.push(`> Author: ${author}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
|
||||
@@ -245,8 +245,8 @@ export async function condenseSearchResults(
|
||||
const model = ctx.modelRegistry.find(provider, modelId);
|
||||
if (!model) return null;
|
||||
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) return null;
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (!auth.ok) return null;
|
||||
|
||||
const queryData = [...results.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
@@ -281,7 +281,8 @@ export async function condenseSearchResults(
|
||||
: timeoutSignal;
|
||||
|
||||
const response = await complete(model, aiContext, {
|
||||
apiKey,
|
||||
apiKey: auth.apiKey,
|
||||
headers: auth.headers,
|
||||
signal: combinedSignal,
|
||||
max_tokens: MAX_TOKENS,
|
||||
} as any);
|
||||
|
||||
Generated
+31
-22
@@ -34,17 +34,20 @@ importers:
|
||||
version: 3.17.5
|
||||
devDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.56.3
|
||||
version: 0.56.3(ws@8.19.0)(zod@4.3.6)
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.56.3
|
||||
version: 0.56.3(ws@8.19.0)(zod@4.3.6)
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.56.3
|
||||
version: 0.56.3
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.1
|
||||
'@types/node':
|
||||
specifier: ^25.3.3
|
||||
version: 25.3.3
|
||||
'@types/turndown':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
@@ -289,22 +292,22 @@ packages:
|
||||
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-agent-core@0.56.3':
|
||||
resolution: {integrity: sha512-TsI1zENf3wqqKPaERnj486Q4i6Y/y6lAZipLNcfDYUDxDrLwNfQ9EW9xukkbJfTZ8zjG3VZ2pBZe3C7wM51dVQ==}
|
||||
'@mariozechner/pi-agent-core@0.63.1':
|
||||
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-ai@0.56.3':
|
||||
resolution: {integrity: sha512-l4J+cVyVeBLAlGOY/osGDvsbTz0DySCQmR171G6SdbPvIeLGhIi6siZ+zHwq91GJYjv/wtu/08M08ag2mGZKeA==}
|
||||
'@mariozechner/pi-ai@0.63.1':
|
||||
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.56.3':
|
||||
resolution: {integrity: sha512-yHgnadye+TT/4NWKBirZUjw/LWdNWTa7M4HJdX2RxRbwuj4q7RZ0Aqy+lQbOHEPDQYhxK3kZb9hjiAbbGficZQ==}
|
||||
'@mariozechner/pi-coding-agent@0.63.1':
|
||||
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-tui@0.56.3':
|
||||
resolution: {integrity: sha512-eZ1P9QRKHp78hwx+lITr/mujZqe+eCwL/bOS9vXXkFP070RW4VYum0j7TJ4BrFEH/nNkXRS1tYCXYU05une1bA==}
|
||||
'@mariozechner/pi-tui@0.63.1':
|
||||
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
@@ -568,6 +571,9 @@ packages:
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/turndown@5.0.6':
|
||||
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -1722,9 +1728,9 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
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:
|
||||
'@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:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
@@ -1734,7 +1740,7 @@ snapshots:
|
||||
- ws
|
||||
- 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:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1002.0
|
||||
@@ -1758,13 +1764,14 @@ snapshots:
|
||||
- ws
|
||||
- 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:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.56.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.56.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.56.3
|
||||
'@mariozechner/pi-agent-core': 0.63.1(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.63.1
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
ajv: 8.18.0
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
diff: 8.0.3
|
||||
@@ -1790,7 +1797,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-tui@0.56.3':
|
||||
'@mariozechner/pi-tui@0.63.1':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
chalk: 5.6.2
|
||||
@@ -2166,6 +2173,8 @@ snapshots:
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/turndown@5.0.6': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.3.3
|
||||
|
||||
@@ -135,11 +135,11 @@ export default function(pi: ExtensionAPI) {
|
||||
|
||||
// Fire-and-forget: run auto-naming in background without blocking
|
||||
const doAutoName = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
log(`Got API key: ${apiKey ? "yes" : "no"}`);
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(AUTO_NAME_MODEL);
|
||||
log(`Got API key: ${auth.ok ? "yes" : "no"}`);
|
||||
|
||||
if (!apiKey) {
|
||||
log("No API key available, aborting");
|
||||
if (!auth.ok) {
|
||||
log(`No API key available, aborting: ${auth.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function(pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers },
|
||||
);
|
||||
|
||||
log(`Response received, stopReason: ${response.stopReason}`);
|
||||
@@ -273,7 +273,8 @@ export default function(pi: ExtensionAPI) {
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
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 = {
|
||||
role: "user",
|
||||
@@ -289,7 +290,7 @@ export default function(pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
||||
);
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -165,11 +165,18 @@ function modelToProvider(modelProvider?: string): ProviderName | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pushCodexWindow(windows: RateWindow[], label: string, window?: CodexRateWindow): void {
|
||||
function codexWindowLabel(window: CodexRateWindow | undefined, fallback: string): string {
|
||||
if (!window || typeof window.limit_window_seconds !== "number" || window.limit_window_seconds <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return formatDuration(window.limit_window_seconds) ?? fallback;
|
||||
}
|
||||
|
||||
function pushCodexWindow(windows: RateWindow[], fallbackLabel: string, window?: CodexRateWindow): void {
|
||||
if (!window) return;
|
||||
const resetIso = typeof window.reset_at === "number" ? new Date(window.reset_at * 1000).toISOString() : undefined;
|
||||
windows.push({
|
||||
label,
|
||||
label: codexWindowLabel(window, fallbackLabel),
|
||||
usedPercent: clampPercent(window.used_percent),
|
||||
resetAt: resetIso,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - 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";
|
||||
|
||||
// Track session time
|
||||
@@ -41,12 +41,7 @@ function formatDuration(ms: number): string {
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const updateStatus = (ctx: {
|
||||
ui: {
|
||||
setStatus: (id: string, text: string | undefined) => void;
|
||||
theme: { fg: (color: string, text: string) => string };
|
||||
};
|
||||
}) => {
|
||||
const updateStatus = (ctx: ExtensionContext) => {
|
||||
const elapsed = Date.now() - sessionStart;
|
||||
let status = ctx.ui.theme.fg("dim", `⏱ ${formatElapsed(elapsed)}`);
|
||||
if (lastTurnDuration !== null) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"selectModel": "ctrl+space"
|
||||
"app.model.select": "ctrl+space"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Executable
+23
@@ -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
@@ -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
@@ -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}`);
|
||||
Executable
+87
@@ -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
@@ -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.`);
|
||||
Executable
+33
@@ -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
@@ -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
@@ -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.");
|
||||
}
|
||||
Executable
+15
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ Host mac mac-attio
|
||||
LocalForward 8082 localhost:8082
|
||||
LocalForward 54043 localhost:54043
|
||||
IdentitiesOnly yes
|
||||
SetEnv TERM=xterm-256color
|
||||
|
||||
Host linux-pc 192.168.1.80
|
||||
HostName 192.168.1.80
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
layout {
|
||||
default_tab_template {
|
||||
children
|
||||
pane size=1 borderless=true {
|
||||
plugin location="zellij:compact-bar"
|
||||
}
|
||||
}
|
||||
default_tab_template {
|
||||
children
|
||||
pane size=1 borderless=true {
|
||||
plugin location="zellij:compact-bar"
|
||||
}
|
||||
}
|
||||
|
||||
tab name="dotfiles" cwd="/home/thomasgl/.dotfiles" {
|
||||
pane split_direction="vertical" {
|
||||
pane stacked=true {
|
||||
pane
|
||||
pane command="nvim"
|
||||
}
|
||||
pane size="40%" command="pi"
|
||||
}
|
||||
}
|
||||
tab name="nvim + jjui" {
|
||||
pane stacked=true {
|
||||
pane command="nvim"
|
||||
pane command="jjui"
|
||||
}
|
||||
}
|
||||
|
||||
tab name="NixOS" cwd="/home/thomasgl/etc/nixos" {
|
||||
pane split_direction="vertical" {
|
||||
pane stacked=true {
|
||||
pane
|
||||
pane command="nvim"
|
||||
}
|
||||
pane size="40%" command="pi"
|
||||
}
|
||||
}
|
||||
tab name="pi + shell" {
|
||||
pane stacked=true {
|
||||
pane command="pi"
|
||||
pane
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user