Skip to content
Merged
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
562 changes: 76 additions & 486 deletions README.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hookwise",
"version": "1.2.0",
"version": "1.3.0",
"description": "Config-driven hook framework for Claude Code with guards, analytics, coaching, and interactive TUI",
"type": "module",
"bin": {
Expand All @@ -13,7 +13,8 @@
"files": [
"dist/",
"recipes/",
"examples/"
"examples/",
"scripts/"
],
"engines": {
"node": ">=20.0.0"
Expand Down
21 changes: 21 additions & 0 deletions scripts/calendar-feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@

Usage:
python3 calendar-feed.py --lookahead 60 --credentials /path/to/credentials.json
python3 calendar-feed.py --setup --credentials /path/to/credentials.json

Setup:
1. Create OAuth 2.0 credentials in Google Cloud Console
2. Download the credentials JSON file
3. Set the path in hookwise.yaml under feeds.calendar.credentialsPath
4. Install dependencies: pip install google-auth google-auth-oauthlib google-api-python-client
5. Or use: hookwise setup calendar (handles steps 2-4 automatically)

The first run will open a browser for OAuth consent. Subsequent runs use
the cached token at ~/.hookwise/calendar-token.json.

The --setup flag runs only the OAuth flow and validates the token, then exits.
"""

import argparse
Expand All @@ -41,6 +45,11 @@ def main():
required=True,
help="Path to Google OAuth credentials JSON file",
)
parser.add_argument(
"--setup",
action="store_true",
help="Only run the OAuth flow and validate the token, then exit",
)
args = parser.parse_args()

if not os.path.exists(args.credentials):
Expand Down Expand Up @@ -84,6 +93,18 @@ def main():
with open(TOKEN_PATH, "w") as token_file:
token_file.write(creds.to_json())

# --setup mode: validate token and exit without fetching events
if args.setup:
if os.path.exists(TOKEN_PATH):
print("Calendar OAuth setup complete. Token saved to " + TOKEN_PATH)
sys.exit(0)
else:
print(
"Setup failed: token file was not created at " + TOKEN_PATH,
file=sys.stderr,
)
sys.exit(1)

service = build("calendar", "v3", credentials=creds)

now = datetime.now(timezone.utc)
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/doctor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ function runChecks(dir: string): Check[] {
loadedConfig.feeds.calendar.enabled ||
loadedConfig.feeds.news.enabled ||
loadedConfig.feeds.insights.enabled ||
loadedConfig.feeds.practice.enabled ||
loadedConfig.feeds.weather.enabled ||
loadedConfig.feeds.memories.enabled ||
loadedConfig.feeds.custom.some((f) => f.enabled);

if (anyFeedEnabled) {
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/init.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ function applyPreset(config: HooksConfig, preset: Preset): HooksConfig {
calendar: { ...result.feeds.calendar, enabled: true },
news: { ...result.feeds.news, enabled: true },
insights: { ...result.feeds.insights, enabled: true },
practice: { ...result.feeds.practice, enabled: true },
};
result.daemon = { ...result.daemon, autoStart: true };
result.tui = { ...result.tui, autoLaunch: true };
break;
}

Expand Down
151 changes: 137 additions & 14 deletions src/cli/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,26 @@
* Uses plain stdout (not React/Ink) so it works in scripts and pipelines.
* Called directly from runCli() in app.tsx, bypassing the render() path.
*
* Currently supports: calendar (Google Calendar OAuth - stub).
* Currently supports: calendar (Google Calendar OAuth).
*
* Flow for `hookwise setup calendar`:
* 1. Check if token already exists (already configured)
* 2. Check for env vars HOOKWISE_GOOGLE_CLIENT_ID / HOOKWISE_GOOGLE_CLIENT_SECRET
* 3. Write credentials JSON from env vars
* 4. Run Python script in --setup mode to trigger OAuth consent flow
* 5. Verify token file was created
*
* Requirements: FR-10.1, FR-10.2, FR-10.3, FR-10.4, FR-10.5, NFR-3
*/

import { DEFAULT_CALENDAR_CREDENTIALS_PATH } from "../../core/constants.js";
import { existsSync } from "node:fs";
import {
DEFAULT_CALENDAR_CREDENTIALS_PATH,
DEFAULT_CALENDAR_TOKEN_PATH,
} from "../../core/constants.js";
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

/**
* Run the setup CLI command.
Expand All @@ -32,25 +45,94 @@ export async function runSetupCommand(target: string): Promise<void> {
}
}

/**
* Resolve the path to scripts/calendar-feed.py relative to this module.
*/
function getScriptPath(): string {
const thisFile = fileURLToPath(import.meta.url);
// Navigate from dist/cli/commands/ up to package root
const projectRoot = resolve(dirname(thisFile), "..", "..", "..");
return resolve(projectRoot, "scripts", "calendar-feed.py");
}

/**
* Write a Google OAuth credentials JSON file from client ID and secret.
* Creates parent directories if needed.
*/
function writeCredentialsFile(
path: string,
clientId: string,
clientSecret: string,
): void {
const credentials = {
installed: {
client_id: clientId,
client_secret: clientSecret,
redirect_uris: ["http://localhost"],
auth_uri: "https://accounts.google.com/o/oauth2/auth",
token_uri: "https://oauth2.googleapis.com/token",
},
};

mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, JSON.stringify(credentials, null, 2), {
encoding: "utf-8",
mode: 0o600,
});
}

/**
* Run the Python calendar-feed.py script in --setup mode.
* Returns true on success (exit 0), false on failure.
*/
function runOAuthSetup(credentialsPath: string): boolean {
const scriptPath = getScriptPath();

try {
const stdout = execFileSync(
"python3",
[scriptPath, "--setup", "--credentials", credentialsPath],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120_000, // 2 minutes for user to complete OAuth in browser
},
);
if (stdout.trim()) {
console.log(stdout.trim());
}
return true;
} catch (error: unknown) {
const err = error as { stderr?: string; message?: string };
if (err.stderr) {
console.error(err.stderr.trim());
} else if (err.message) {
console.error(`OAuth setup failed: ${err.message}`);
}
return false;
}
}

/**
* Set up Google Calendar integration.
*
* This is a stub implementation. The full OAuth flow requires
* Google API credentials which will be implemented in a future release.
* 1. Check for existing token (already configured)
* 2. Check for env vars with client credentials
* 3. Write credentials JSON from env vars
* 4. Run Python OAuth flow
* 5. Verify token was created
*/
async function setupCalendar(): Promise<void> {
console.log("Setting up Google Calendar integration...\n");

// Step 1: Check for existing credentials
if (existsSync(DEFAULT_CALENDAR_CREDENTIALS_PATH)) {
console.log(`Existing credentials found at ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
// Step 1: Check for existing token — means OAuth already completed
if (existsSync(DEFAULT_CALENDAR_TOKEN_PATH)) {
console.log("Calendar integration is already configured.");
console.log(`Token: ${DEFAULT_CALENDAR_TOKEN_PATH}`);
return;
}

console.log("No existing credentials found.\n");

// Step 2: Check for Google client ID
// Step 2: Check for Google client credentials in env vars
const clientId = process.env.HOOKWISE_GOOGLE_CLIENT_ID;
const clientSecret = process.env.HOOKWISE_GOOGLE_CLIENT_SECRET;

Expand All @@ -65,12 +147,53 @@ async function setupCalendar(): Promise<void> {
console.log(" export HOOKWISE_GOOGLE_CLIENT_ID=<your-client-id>");
console.log(" export HOOKWISE_GOOGLE_CLIENT_SECRET=<your-client-secret>\n");
console.log(" 6. Run 'hookwise setup calendar' again");
// Fail gracefully — don't crash, just set non-zero exit
process.exitCode = 1;
return;
}

// Step 3: Client ID present but OAuth not implemented yet
// Step 3: Write credentials JSON file from env vars
console.log("Google API credentials detected.");
console.log("OAuth flow not yet implemented. Coming in a future release.");
console.log(`\nCredentials will be stored at: ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);

// Only write the credentials file if it doesn't already exist
if (!existsSync(DEFAULT_CALENDAR_CREDENTIALS_PATH)) {
try {
writeCredentialsFile(DEFAULT_CALENDAR_CREDENTIALS_PATH, clientId, clientSecret);
console.log(`Credentials written to ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
console.error(`Failed to write credentials file: ${msg}`);
process.exitCode = 2;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} else {
console.log(`Using existing credentials at ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
}

// Step 4: Run Python OAuth flow
console.log("\nStarting OAuth flow — a browser window will open...\n");
const success = runOAuthSetup(DEFAULT_CALENDAR_CREDENTIALS_PATH);

if (!success) {
console.error("\nOAuth setup did not complete successfully.");
console.log("You can retry with: hookwise setup calendar");
process.exitCode = 2;
return;
}

// Step 5: Verify token was created
if (existsSync(DEFAULT_CALENDAR_TOKEN_PATH)) {
console.log("\nCalendar setup complete! You can now enable the calendar feed in hookwise.yaml:");
console.log("\n feeds:");
console.log(" calendar:");
console.log(" enabled: true");
} else {
console.error("\nSetup appeared to succeed but token file was not found.");
console.error(`Expected at: ${DEFAULT_CALENDAR_TOKEN_PATH}`);
console.log("You can retry with: hookwise setup calendar");
process.exitCode = 2;
}
}

// Exported for testing
export { writeCredentialsFile, runOAuthSetup, getScriptPath };
3 changes: 3 additions & 0 deletions src/cli/commands/status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ function getStatusInfo(config: HooksConfig): StatusInfo {
{ name: "Calendar", enabled: config.feeds.calendar.enabled },
{ name: "News", enabled: config.feeds.news.enabled },
{ name: "Insights", enabled: config.feeds.insights.enabled },
{ name: "Practice", enabled: config.feeds.practice.enabled },
{ name: "Weather", enabled: config.feeds.weather.enabled },
{ name: "Memories", enabled: config.feeds.memories.enabled },
],
};
}
Expand Down
Loading