From nanotars-calendar
Adds Google Calendar (gog CLI with OAuth) and CalDAV (iCloud, Nextcloud, Fastmail via cal CLI) access to NanoTars by guiding authentication and configuring environment variables.
npx claudepluginhub terrifiedbug/nanotars-skills --plugin nanotars-calendarThis skill uses the workspace's default tool permissions.
Calendar integration for NanoTars agent containers. Two tools are available:
Configures Gmail access for NanoTars agents via gog CLI, enabling search, read, and send email capabilities. Reuses Google Calendar OAuth if configured.
Syncs CalDAV calendars (iCloud, Google, Fastmail, Nextcloud) to local files with vdirsyncer and manages events (list, search, create, edit, delete) using khal on Linux.
Manages calendars and contacts on self-hosted Radicale CalDAV/CardDAV server: list, view, create, update, delete events and contacts via Python caldav CLI script.
Share bugs, ideas, or general feedback.
Calendar integration for NanoTars agent containers. Two tools are available:
gog -- Google Calendar (OAuth, read/write)cal -- CalDAV providers: iCloud, Nextcloud, Fastmail (Basic Auth, read/write)Before installing, verify NanoTars is set up:
[ -d node_modules ] && echo "DEPS: ok" || echo "DEPS: missing"
docker image inspect nanoclaw-agent:latest &>/dev/null && echo "IMAGE: ok" || echo "IMAGE: not built"
if grep -q "ANTHROPIC_API_KEY\|CLAUDE_CODE_OAUTH_TOKEN" .env 2>/dev/null || [ -f "$HOME/.claude/.credentials.json" ]; then echo "AUTH: ok"; else echo "AUTH: missing"; fi
If any check fails, tell the user to run /nanotars-setup first and stop.
Check existing configuration (credentials may be in global .env or any group's groups/*/.env):
(grep -rq "^GOG_KEYRING_PASSWORD=" .env groups/*/.env 2>/dev/null) && echo "GOOGLE: CONFIGURED" || echo "GOOGLE: NOT SET"
(grep -rq "^CALDAV_ACCOUNTS=" .env groups/*/.env 2>/dev/null) && echo "CALDAV: CONFIGURED" || echo "CALDAV: NOT SET"
If already configured, ask the user if they want to add another provider or reconfigure.
Choose provider:
Install gog on the host if needed:
which gog && echo "GOG_INSTALLED" || curl -sL "https://github.com/steipete/gogcli/releases/download/v0.9.0/gogcli_0.9.0_linux_amd64.tar.gz" | tar -xz -C /usr/local/bin gog
gogcli is installed automatically when the container image is built (via plugins/calendar/Dockerfile.partial). No manual Dockerfile changes needed.
Import OAuth credentials (user provides their client_secret.json path):
gog auth credentials /path/to/client_secret.json
OAuth login (include gmail if the Gmail plugin is already installed to preserve its scopes):
if [ -d plugins/gmail ]; then
GOG_KEYRING_PASSWORD=$(grep GOG_KEYRING_PASSWORD .env | cut -d'=' -f2) gog auth login --services calendar,gmail
else
GOG_KEYRING_PASSWORD=$(grep GOG_KEYRING_PASSWORD .env | cut -d'=' -f2) gog auth login --services calendar
fi
On a headless server, add --manual to the gog command above.
Verify: GOG_KEYRING_PASSWORD=$(grep GOG_KEYRING_PASSWORD .env | cut -d'=' -f2) gog calendar calendars
Configure environment:
grep "^GOG_KEYRING_PASSWORD=" .env 2>/dev/null && echo "ALREADY_SET" || echo 'GOG_KEYRING_PASSWORD=THEIR_PASSWORD_HERE' >> .env
Copy gog config for containers:
mkdir -p data/gogcli
cp -r ~/.config/gogcli/* data/gogcli/
chown -R 1000:1000 data/gogcli
Gather account details:
https://caldav.icloud.comhttps://YOUR_SERVER/remote.php/davhttps://caldav.fastmail.comApp-specific password instructions:
iCloud:
- Go to https://appleid.apple.com/account/manage
- Sign in > "Sign-In and Security" > "App-Specific Passwords"
- Click + > Name it "NanoTars" > Create
- Copy the password (format: xxxx-xxxx-xxxx-xxxx)
Nextcloud:
- Settings > Security > "Devices & Sessions"
- Enter "NanoTars" > "Create new app password"
Fastmail:
- Settings > Privacy & Security > Integrations
- "New app password" > Select CalDAV > Name it "NanoTars"
Save to .env:
sed -i '/^CALDAV_ACCOUNTS=/d' .env
echo 'CALDAV_ACCOUNTS=[{"name":"iCloud","serverUrl":"https://caldav.icloud.com","user":"user@icloud.com","pass":"xxxx-xxxx-xxxx-xxxx"}]' >> .env
Ask the user which groups should have access to Calendar:
main and family-chatIf the user wants to restrict access, update plugins/calendar/plugin.json after copying (Step 5) to set "groups" to the list of group folder names:
"groups": ["main", "family-chat"]
If all groups (or the user doesn't care), leave as "groups": ["*"].
Restricting access means only those groups' agents will have calendar tools. Other groups won't see calendar commands or credentials.
Copy plugin files:
mkdir -p plugins/calendar/container-skills plugins/calendar/cal-cli/src plugins/calendar/scripts
cp ${CLAUDE_PLUGIN_ROOT}/files/plugin.json plugins/calendar/
cp ${CLAUDE_PLUGIN_ROOT}/files/container-skills/SKILL.md plugins/calendar/container-skills/
cp ${CLAUDE_PLUGIN_ROOT}/files/Dockerfile.partial plugins/calendar/
cp ${CLAUDE_PLUGIN_ROOT}/files/package.json ${CLAUDE_PLUGIN_ROOT}/files/package-lock.json ${CLAUDE_PLUGIN_ROOT}/files/tsconfig.json plugins/calendar/cal-cli/
cp ${CLAUDE_PLUGIN_ROOT}/files/src/*.ts plugins/calendar/cal-cli/src/
cp ${CLAUDE_PLUGIN_ROOT}/files/scripts/gog-reauth.sh plugins/calendar/scripts/ && chmod +x plugins/calendar/scripts/gog-reauth.sh
The only mount needed is the gogcli config directory so the container can access Google OAuth tokens:
NANOCLAW_DIR=$(pwd)
cat > plugins/calendar/plugin.json << EOF
{
"name": "calendar",
"description": "Calendar access via gog CLI and CalDAV",
"containerEnvVars": ["GOG_KEYRING_PASSWORD", "GOG_ACCOUNT", "CALDAV_ACCOUNTS"],
"containerMounts": [
{"hostPath": "data/gogcli", "containerPath": "/home/node/.config/gogcli"}
],
"hooks": []
}
EOF
The runtime silently rejects container mounts not covered by ~/.config/nanotars/mount-allowlist.json. Check whether data/gogcli is already covered:
node -e '
const fs = require("fs"), path = require("path");
const home = process.env.HOME;
const allowlistPath = path.join(home, ".config/nanotars/mount-allowlist.json");
const expand = (p) => p.startsWith("~/") ? path.join(home, p.slice(2)) : path.resolve(p);
const target = path.resolve("data/gogcli");
if (!fs.existsSync(allowlistPath)) { console.log("MISSING_ALLOWLIST"); process.exit(0); }
const list = JSON.parse(fs.readFileSync(allowlistPath, "utf8"));
const covered = (list.allowedRoots || []).some(r => {
const a = expand(r.path);
return target === a || target.startsWith(a + "/");
});
console.log(covered ? "COVERED" : "NEEDS_ALLOWLIST_ENTRY");
'
If output is COVERED, skip to the next step. If NEEDS_ALLOWLIST_ENTRY or MISSING_ALLOWLIST, tell the user:
Calendar needs
~/nanotars/data/gogcliadded to the mount allowlist (~/.config/nanotars/mount-allowlist.json). I'll add a tightly-scoped entry — only this directory will be mountable, no broader access. OK?
After confirmation, append the entry (creating the file if missing):
node -e '
const fs = require("fs"), path = require("path");
const allowlistPath = path.join(process.env.HOME, ".config/nanotars/mount-allowlist.json");
fs.mkdirSync(path.dirname(allowlistPath), { recursive: true });
const list = fs.existsSync(allowlistPath)
? JSON.parse(fs.readFileSync(allowlistPath, "utf8"))
: { allowedRoots: [], blockedPatterns: [], nonMainReadOnly: true };
list.allowedRoots = list.allowedRoots || [];
list.allowedRoots.push({
path: "~/nanotars/data/gogcli",
allowReadWrite: true,
description: "calendar plugin gogcli OAuth state"
});
fs.writeFileSync(allowlistPath, JSON.stringify(list, null, 2) + "\n");
JSON.parse(fs.readFileSync(allowlistPath, "utf8"));
console.log("ALLOWLIST_UPDATED");
'
./container/build.sh
nanotars restart 2>/dev/null || echo "Restart the NanoTars service manually"
After restart, also remove any stale agent container so the next message spawns a fresh one with the new mount applied:
docker ps --format '{{.Names}}' | grep '^nanoclaw-' | xargs -r docker rm -f
Confirm no Plugin mount REJECTED warnings appear in logs/nanotars.log after the next agent run.
Tell the user:
Calendar access is configured. Test via WhatsApp: "list my calendars" or "what's on my calendar today?"
Google OAuth tokens expire periodically. When the agent reports "invalid_grant" "Token has been expired or revoked.", use the helper script:
# Check which accounts need reauth (non-destructive)
./plugins/calendar/scripts/gog-reauth.sh --check
# Reauth — auto-detects expired accounts, prompts for selection
./plugins/calendar/scripts/gog-reauth.sh
# Reauth all expired accounts in sequence
./plugins/calendar/scripts/gog-reauth.sh --all
# Reauth a specific account
./plugins/calendar/scripts/gog-reauth.sh user@gmail.com
The script automatically:
GOG_ACCOUNT values across groups/*/.env and .envGOG_KEYRING_PASSWORD from the same env file as the accountgog auth via expect (handles the CSRF state matching that breaks with separate invocations)data/gogcli/ for container accessSince Claude Code can't use read, run the script in the background and feed the redirect URL via file:
The approach uses plugins/calendar/scripts/gog-reauth.sh's expect script but non-interactively:
First, discover which account needs reauth. If the agent reported the error, check which group it was for and find the GOG_ACCOUNT in that group's .env.
Write and run the expect script from plugins/calendar/scripts/gog-reauth.sh (the section between EXPECT_EOF markers) in the background, passing $EMAIL, $SERVICES, $STATE_FILE, and $REDIRECT_FILE.
Wait for $STATE_FILE to be written (contains the CSRF state token).
Show the user the OAuth URL containing the state from $STATE_FILE.
User authorizes and pastes redirect URL.
Write the redirect URL to $REDIRECT_FILE — the expect process picks it up and completes auth.
Sync: cp -r ~/.config/gogcli/* data/gogcli/ && chown -R 1000:1000 data/gogcli/
IMPORTANT: Each gog auth invocation generates a unique CSRF state token. The redirect URL from one invocation will NOT work with another — the state must match. This is why the expect approach keeps the same process alive throughout.
cp -r ~/.config/gogcli/* data/gogcli/ && chown -R 1000:1000 data/gogcli/.data/gogcli/ exists and is chowned to 1000:1000..env and plugin.json containerEnvVars./remote.php/dav.If this plugin is already installed and you want different credentials for a specific group (e.g., a work account for one group, personal for another):
Check which groups exist:
ls -d groups/*/
Ask the user which group should get separate credentials.
Collect the new calendar credentials for that group.
Write to the group's .env file (creates if needed):
echo 'GOG_KEYRING_PASSWORD=new-password' >> groups/{folder}/.env
echo 'GOG_ACCOUNT=other@gmail.com' >> groups/{folder}/.env
echo 'CALDAV_ACCOUNTS=[{"name":"Work","serverUrl":"https://caldav.example.com","user":"work@example.com","pass":"app-password"}]' >> groups/{folder}/.env
These values override the global .env for that group's containers only.
Restart NanoTars:
nanotars restart
rm -rf plugins/calendar/.env:
sed -i '/^GOG_KEYRING_PASSWORD=/d' .env
sed -i '/^GOG_ACCOUNT=/d' .env
sed -i '/^CALDAV_ACCOUNTS=/d' .env
rm -rf data/gogcli./container/build.sh