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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Install or disable them dynamically with the `/plugin` command — enabling you
- [infrastructure-maintainer](./plugins/infrastructure-maintainer)
- [monitoring-observability-specialist](./plugins/monitoring-observability-specialist)
- [n8n-workflow-builder](./plugins/n8n-workflow-builder)
- [screenshot-janitor](./plugins/screenshot-janitor)

### Business Sales
- [b2b-project-shipper](./plugins/b2b-project-shipper)
Expand Down
20 changes: 20 additions & 0 deletions plugins/screenshot-janitor/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "screenshot-janitor",
"version": "1.0.0",
"description": "Finds screenshots that pile up during each Claude Code session and, before you leave, asks to move them to the Trash. macOS + Linux. Per-session scoping, safe (recoverable) deletion, zero dependencies.",
"author": { "name": "MECoban", "url": "https://github.com/MECoban" },
"homepage": "https://github.com/MECoban/screenshot-janitor",
"repository": "https://github.com/MECoban/screenshot-janitor",
"license": "MIT",
"keywords": [
"screenshot",
"cleanup",
"housekeeping",
"trash",
"desktop",
"macos",
"linux",
"hooks",
"skill"
]
}
21 changes: 21 additions & 0 deletions plugins/screenshot-janitor/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 MECoban

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
92 changes: 92 additions & 0 deletions plugins/screenshot-janitor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 🧹 screenshot-janitor

[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue)
![Claude Code](https://img.shields.io/badge/Claude%20Code-plugin-8A2BE2)
[![GitHub stars](https://img.shields.io/github/stars/MECoban/screenshot-janitor?style=social)](https://github.com/MECoban/screenshot-janitor/stargazers)

A [Claude Code](https://claude.com/claude-code) plugin that keeps your Desktop clean.

You share a lot of screenshots with Claude during a session — and they pile up
forever afterward. **screenshot-janitor** notices the screenshots created during
each session and, before you leave, **asks** whether to move them to the Trash.
Approve, and they're gone (recoverable from the Trash). Decline, and it won't
nag you again.

> Safe by design: it **never** runs `rm`. Files go to the system Trash and can be restored.

## Demo

<!-- Record a short clip of the end-of-session "clean up?" prompt, save it as docs/demo.gif, then uncomment the line below: -->
<!-- ![screenshot-janitor in action](docs/demo.gif) -->

> 🎥 _Demo GIF coming soon._ The end-of-session flow: Claude asks **"move these N screenshots to the Trash?"** → you approve → done.

---

## Install

```text
/plugin marketplace add MECoban/screenshot-janitor
/plugin install screenshot-janitor
```

That's it. The skill **and** the hooks are wired up automatically — no manual
`settings.json` editing. Restart Claude Code (or open `/hooks` once) to activate.

## How it works

| Piece | What it does |
|-------|--------------|
| `SessionStart` hook | Opens a per-session folder and records the start time, so each session only deals with **its own** screenshots. |
| `Stop` hook | If this session created screenshots, reminds you **once** to clean them up before you go. |
| `cleanup-screenshots` skill | Finds the screenshots, shows a clear list (name, size, time), asks for approval, and moves the approved ones to the Trash. |

You can also run it any time:

```text
/cleanup-screenshots
```

## Platforms

- **macOS** — scans `~/Desktop`, recognizes `Screenshot *`, `Ekran Resmi *`,
`CleanShot *`, etc. Trashes via the `trash` CLI or Finder/AppleScript.
- **Linux** — scans `~/Pictures/Screenshots`, `~/Pictures`, `~/Desktop`,
recognizes GNOME/KDE naming. Trashes via `gio trash` or `trash-cli`.

Zero runtime dependencies (no `jq`, no Python) — just `bash`, `find`, `stat`.

## Configuration

Don't edit the plugin files (they're overwritten on update). Instead create
`~/.claude/screenshot-janitor/config.sh` and override what you need:

```bash
# Extra folders to scan
SCAN_DIRS=( "$HOME/Desktop" "$HOME/Pictures/Screenshots" )

# Filename globs that count as screenshots
NAME_PATTERNS=( "Screenshot *" "CleanShot *" )

# Don't remind until the session is at least this old (seconds). 0 = remind ASAP.
MIN_AGE_SECONDS=300
```

## State & privacy

Per-session bookkeeping lives in `~/.claude/screenshot-janitor/`. Nothing leaves
your machine. The plugin reads filenames/sizes/timestamps only — never the image
contents.

## Uninstall

```text
/plugin uninstall screenshot-janitor
```

Optionally remove leftover state: `rm -rf ~/.claude/screenshot-janitor`.

## License

MIT © MECoban
24 changes: 24 additions & 0 deletions plugins/screenshot-janitor/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-hook.sh\""
}
]
}
]
}
}
43 changes: 43 additions & 0 deletions plugins/screenshot-janitor/scripts/config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/bin/bash
# screenshot-janitor — default configuration.
#
# Do NOT edit this file: it is overwritten on plugin updates.
# To customize, create ~/.claude/screenshot-janitor/config.sh and redefine
# any of the variables below (SCAN_DIRS, NAME_PATTERNS, MIN_AGE_SECONDS).

if [ "$(uname -s)" = "Darwin" ]; then
# macOS: screenshots land on the Desktop by default.
SCAN_DIRS=( "$HOME/Desktop" )
NAME_PATTERNS=(
"Screenshot *"
"Screen Shot *"
"Ekran Resmi *"
"Ekran Görüntüsü *"
"CleanShot *"
)
else
# Linux: GNOME/KDE/others use a variety of folders and names.
SCAN_DIRS=(
"$HOME/Pictures/Screenshots"
"$HOME/Pictures"
"$HOME/Desktop"
)
NAME_PATTERNS=(
"Screenshot from *"
"Screenshot_*"
"Screenshot-*"
"Screenshot *"
"screenshot*"
"Bildschirmfoto *"
"Ekran*"
)
fi

# The Stop hook only reminds once the session has lived at least this many
# seconds — avoids nagging in the very first turns. Set to 0 to remind ASAP.
MIN_AGE_SECONDS=300

# Optional user override (kept outside the plugin so updates don't clobber it).
_sj_user_cfg="${SJ_STATE_DIR:-$HOME/.claude/screenshot-janitor}/config.sh"
# shellcheck disable=SC1090
[ -f "$_sj_user_cfg" ] && source "$_sj_user_cfg"
28 changes: 28 additions & 0 deletions plugins/screenshot-janitor/scripts/find.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash
# Finds the screenshots that belong to the active (or most recent) session.
# Usage: find.sh [session_id]
# - If omitted, uses $CLAUDE_SESSION_ID, else the most recent session.
# Output:
# SESSION_META:<path to meta.json>
# STARTED_AT:<epoch>
# followed by scan.sh TSV lines (mtime\tsize\tpath)
# or the single line NO_SESSION if no session state exists.
set -u
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$DIR/lib.sh"

sid="${1:-${CLAUDE_SESSION_ID:-}}"
meta=""
if [ -n "$sid" ] && [ -f "$SJ_STATE_DIR/sessions/$sid/meta.json" ]; then
meta="$SJ_STATE_DIR/sessions/$sid/meta.json"
else
meta="$(ls -t "$SJ_STATE_DIR"/sessions/*/meta.json 2>/dev/null | head -1)"
fi

[ -n "$meta" ] && [ -f "$meta" ] || { echo "NO_SESSION"; exit 0; }

started="$(sj_json_num "$(cat "$meta")" started_at)"
echo "SESSION_META:$meta"
echo "STARTED_AT:${started:-0}"
bash "$DIR/scan.sh" "${started:-0}"
60 changes: 60 additions & 0 deletions plugins/screenshot-janitor/scripts/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/bin/bash
# screenshot-janitor — shared helpers. Abstracts macOS/Linux differences.
# No external dependencies (no jq, no python).

# Where per-session state lives (writable, persistent across the session).
SJ_STATE_DIR="${SJ_STATE_DIR:-$HOME/.claude/screenshot-janitor}"

sj_os() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux) echo "linux" ;;
*) echo "other" ;;
esac
}

# File modification time as a Unix epoch.
sj_mtime() { # <file>
if [ "$(uname -s)" = "Darwin" ]; then stat -f '%m' "$1" 2>/dev/null
else stat -c '%Y' "$1" 2>/dev/null; fi
}

# File size in bytes.
sj_size() { # <file>
if [ "$(uname -s)" = "Darwin" ]; then stat -f '%z' "$1" 2>/dev/null
else stat -c '%s' "$1" 2>/dev/null; fi
}

# Extract a JSON string field value (best-effort, dependency-free).
sj_json_str() { # <json> <key>
printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*"([^"]*)"$/\1/'
}

# Extract a JSON number field value.
sj_json_num() { # <json> <key>
printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*[0-9]*" | grep -o '[0-9]*' | head -1
}

# Move files to the Trash (NEVER permanent rm). Returns 0 on success.
# Tries, in order: `trash` CLI -> macOS Finder/AppleScript -> `gio trash` -> `trash-put`.
sj_trash() { # <file...>
[ "$#" -gt 0 ] || return 0
if command -v trash >/dev/null 2>&1; then
trash "$@"; return $?
fi
if [ "$(uname -s)" = "Darwin" ]; then
local f rc=0
for f in "$@"; do
osascript -e "tell application \"Finder\" to delete (POSIX file \"$f\")" >/dev/null 2>&1 || rc=1
done
return $rc
fi
if command -v gio >/dev/null 2>&1; then
gio trash "$@"; return $?
fi
if command -v trash-put >/dev/null 2>&1; then
trash-put "$@"; return $?
fi
echo "ERROR: no Trash command found. Install one: macOS 'brew install trash', Linux 'gio' or 'trash-cli'." >&2
return 2
}
34 changes: 34 additions & 0 deletions plugins/screenshot-janitor/scripts/scan.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/bash
# Usage: scan.sh <since_epoch>
# Prints screenshot files (in configured dirs) modified at/after <since_epoch>
# as TSV: <mtime_epoch>\t<size_bytes>\t<path>
# since_epoch=0 returns every matching screenshot.
set -u
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$DIR/lib.sh"
# shellcheck disable=SC1091
source "$DIR/config.sh"

since="${1:-0}"

for d in "${SCAN_DIRS[@]}"; do
[ -d "$d" ] || continue
args=()
first=1
for p in "${NAME_PATTERNS[@]}"; do
if [ "$first" -eq 1 ]; then
args+=( -iname "$p" ); first=0
else
args+=( -o -iname "$p" )
fi
done
find "$d" -maxdepth 1 -type f \( "${args[@]}" \) -print0 2>/dev/null
done | while IFS= read -r -d '' f; do
m="$(sj_mtime "$f")"
[ -n "$m" ] || continue
if [ "$m" -ge "$since" ]; then
s="$(sj_size "$f")"
printf '%s\t%s\t%s\n' "$m" "${s:-0}" "$f"
fi
done
25 changes: 25 additions & 0 deletions plugins/screenshot-janitor/scripts/session-start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash
# SessionStart hook — opens a per-session folder and records the start time.
# That timestamp is how "screenshots created during THIS session" are scoped.
# On resume, the existing start time is preserved.
set -u
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$DIR/lib.sh"

input="$(cat)"
sid="$(sj_json_str "$input" session_id)"
cwd="$(sj_json_str "$input" cwd)"
[ -z "$sid" ] && sid="unknown-$(date +%s)"

mkdir -p "$SJ_STATE_DIR"
# Record where the scripts live so the skill can find them regardless of how
# it is invoked (works even if CLAUDE_PLUGIN_ROOT is not set in skill context).
printf '%s\n' "$DIR" > "$SJ_STATE_DIR/scripts-path"

d="$SJ_STATE_DIR/sessions/$sid"
mkdir -p "$d"
if [ ! -f "$d/meta.json" ]; then
printf '{"session_id":"%s","started_at":%s,"cwd":"%s"}\n' "$sid" "$(date +%s)" "$cwd" > "$d/meta.json"
fi
exit 0
34 changes: 34 additions & 0 deletions plugins/screenshot-janitor/scripts/stop-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/bash
# Stop hook — runs at the end of a response turn. If this session produced new
# screenshots, it injects an instruction telling the assistant to offer cleanup.
# Reminds ONCE per session (reminded marker); stays silent after cleanup (handled).
set -u
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$DIR/lib.sh"
# shellcheck disable=SC1091
source "$DIR/config.sh"

input="$(cat)"
sid="$(sj_json_str "$input" session_id)"
[ -z "$sid" ] && exit 0

d="$SJ_STATE_DIR/sessions/$sid"
[ -f "$d/meta.json" ] || exit 0
[ -f "$d/handled" ] && exit 0
[ -f "$d/reminded" ] && exit 0

started="$(sj_json_num "$(cat "$d/meta.json")" started_at)"
[ -z "$started" ] && exit 0

age=$(( $(date +%s) - started ))
[ "$age" -lt "${MIN_AGE_SECONDS:-0}" ] && exit 0

count="$(bash "$DIR/scan.sh" "$started" | wc -l | tr -d ' ')"
if [ "${count:-0}" -gt 0 ]; then
touch "$d/reminded"
# ASCII-only (no double quotes / backslashes / newlines) so the JSON stays valid.
reason="This Claude Code session produced $count new screenshot(s). Before the user leaves, ask them in ONE short sentence whether to move these to the Trash. If they agree, run the cleanup-screenshots skill. If they decline, do not ask again; this reminder fires only once per session. Respond in the user configured language."
printf '{"decision":"block","reason":"%s"}\n' "$reason"
fi
exit 0
Loading