basecamp-skill 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/api.js +62 -0
- package/dist/auth.js +143 -0
- package/dist/cli.js +96 -0
- package/dist/commands/cards.js +53 -0
- package/dist/commands/projects.js +19 -0
- package/dist/commands/show.js +46 -0
- package/dist/config.js +11 -0
- package/dist/credentials.js +78 -0
- package/dist/types.js +2 -0
- package/package.json +24 -0
- package/skill/SKILL.md +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Basecamp Skill for Claude Code
|
|
2
|
+
|
|
3
|
+
Read Basecamp card tables and comments directly in Claude Code.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
- pnpm or npm
|
|
9
|
+
- A Basecamp 3 account
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone git@github.com:dicoding-dev/basecamp-skill.git
|
|
15
|
+
cd basecamp-skill
|
|
16
|
+
bash install.sh
|
|
17
|
+
basecamp auth
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`basecamp auth` opens a browser to authorize your Basecamp account. Tokens are stored locally in `~/.config/basecamp/credentials` and refreshed automatically — you only need to do this once.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
| Command | Description |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `/basecamp:projects` | List all active projects |
|
|
27
|
+
| `/basecamp:cards <project_id>` | Show card tables in a project |
|
|
28
|
+
| `/basecamp:cards <project_id> <board_id>` | Show a specific board |
|
|
29
|
+
| `/basecamp:show <card_id>` | Show card detail and comments |
|
|
30
|
+
|
|
31
|
+
### Example
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
/basecamp:projects
|
|
35
|
+
/basecamp:cards 44666023
|
|
36
|
+
/basecamp:show 9651774284
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
Each user authorizes with their own Basecamp account via OAuth — no shared tokens. The skill installs a `basecamp` CLI to `~/.local/bin/` and a `SKILL.md` to `~/.claude/skills/basecamp/` that teaches Claude when and how to call it.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getCredentials = getCredentials;
|
|
4
|
+
exports.apiGet = apiGet;
|
|
5
|
+
const credentials_1 = require("./credentials");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
async function refreshToken(creds) {
|
|
8
|
+
const res = await fetch('https://launchpad.37signals.com/authorization/token', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
11
|
+
body: new URLSearchParams({
|
|
12
|
+
type: 'refresh',
|
|
13
|
+
refresh_token: creds.refreshToken,
|
|
14
|
+
client_id: config_1.CLIENT_ID,
|
|
15
|
+
client_secret: config_1.CLIENT_SECRET,
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
throw new Error(`Token refresh failed (${res.status}). Run \`basecamp auth\` to re-authorize.`);
|
|
20
|
+
}
|
|
21
|
+
const data = (await res.json());
|
|
22
|
+
const updated = {
|
|
23
|
+
...creds,
|
|
24
|
+
accessToken: data.access_token,
|
|
25
|
+
refreshToken: data.refresh_token ?? creds.refreshToken,
|
|
26
|
+
tokenExpiresAt: Math.floor(Date.now() / 1000) + (data.expires_in ?? 7_776_000),
|
|
27
|
+
};
|
|
28
|
+
(0, credentials_1.saveCredentials)(updated);
|
|
29
|
+
return updated;
|
|
30
|
+
}
|
|
31
|
+
async function getCredentials() {
|
|
32
|
+
let creds = (0, credentials_1.readCredentials)();
|
|
33
|
+
if (!creds) {
|
|
34
|
+
console.error('Not authorized. Run `basecamp auth` first.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
// Refresh if expiring within 60 seconds
|
|
38
|
+
if (creds.tokenExpiresAt - Math.floor(Date.now() / 1000) < 60) {
|
|
39
|
+
creds = await refreshToken(creds);
|
|
40
|
+
}
|
|
41
|
+
return creds;
|
|
42
|
+
}
|
|
43
|
+
async function apiGet(path) {
|
|
44
|
+
const creds = await getCredentials();
|
|
45
|
+
const url = path.startsWith('http')
|
|
46
|
+
? path
|
|
47
|
+
: `https://3.basecampapi.com/${creds.accountId}${path}`;
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
51
|
+
'User-Agent': config_1.USER_AGENT,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
if (res.status === 401) {
|
|
55
|
+
console.error('Authorization expired. Run `basecamp auth` to re-authorize.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new Error(`API error ${res.status} ${res.statusText} — ${url}`);
|
|
60
|
+
}
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runAuth = runAuth;
|
|
37
|
+
const http = __importStar(require("http"));
|
|
38
|
+
const crypto = __importStar(require("crypto"));
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
40
|
+
const config_1 = require("./config");
|
|
41
|
+
const credentials_1 = require("./credentials");
|
|
42
|
+
function openBrowser(url) {
|
|
43
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
44
|
+
(0, child_process_1.exec)(`${cmd} "${url}"`);
|
|
45
|
+
}
|
|
46
|
+
function waitForCallback(expectedState) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const server = http.createServer((req, res) => {
|
|
49
|
+
const url = new URL(req.url, `http://localhost:${config_1.CALLBACK_PORT}`);
|
|
50
|
+
if (url.pathname !== '/callback') {
|
|
51
|
+
res.end('Not found');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const error = url.searchParams.get('error');
|
|
55
|
+
if (error) {
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
57
|
+
res.end('<html><body><h2>Authorization failed.</h2><p>You can close this tab.</p></body></html>');
|
|
58
|
+
server.close();
|
|
59
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (url.searchParams.get('state') !== expectedState) {
|
|
63
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
64
|
+
res.end('<html><body><h2>Invalid state. Possible CSRF.</h2></body></html>');
|
|
65
|
+
server.close();
|
|
66
|
+
reject(new Error('OAuth state mismatch'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const code = url.searchParams.get('code');
|
|
70
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
71
|
+
res.end('<html><body><h2>Authorized!</h2><p>You can close this tab and return to your terminal.</p></body></html>');
|
|
72
|
+
server.close();
|
|
73
|
+
resolve(code);
|
|
74
|
+
});
|
|
75
|
+
server.listen(config_1.CALLBACK_PORT, 'localhost');
|
|
76
|
+
server.on('error', reject);
|
|
77
|
+
// Timeout after 5 minutes
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
server.close();
|
|
80
|
+
reject(new Error('Timed out waiting for authorization (5 minutes). Try again.'));
|
|
81
|
+
}, 5 * 60 * 1000);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function runAuth() {
|
|
85
|
+
if (config_1.CLIENT_ID === 'YOUR_CLIENT_ID') {
|
|
86
|
+
console.error('Error: CLIENT_ID not configured.');
|
|
87
|
+
console.error('Set env var BASECAMP_CLIENT_ID or edit src/config.ts after registering at https://integrate.37signals.com');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
91
|
+
const authUrl = `https://launchpad.37signals.com/authorization/new` +
|
|
92
|
+
`?type=web_server` +
|
|
93
|
+
`&client_id=${config_1.CLIENT_ID}` +
|
|
94
|
+
`&redirect_uri=${encodeURIComponent(config_1.REDIRECT_URI)}` +
|
|
95
|
+
`&state=${state}`;
|
|
96
|
+
console.log('Opening browser for Basecamp authorization...');
|
|
97
|
+
console.log(`If your browser does not open automatically, visit:\n${authUrl}\n`);
|
|
98
|
+
openBrowser(authUrl);
|
|
99
|
+
console.log('Waiting for authorization callback...');
|
|
100
|
+
const code = await waitForCallback(state);
|
|
101
|
+
// Exchange authorization code for tokens
|
|
102
|
+
const tokenRes = await fetch('https://launchpad.37signals.com/authorization/token', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
105
|
+
body: new URLSearchParams({
|
|
106
|
+
type: 'web_server',
|
|
107
|
+
client_id: config_1.CLIENT_ID,
|
|
108
|
+
client_secret: config_1.CLIENT_SECRET,
|
|
109
|
+
redirect_uri: config_1.REDIRECT_URI,
|
|
110
|
+
code,
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
if (!tokenRes.ok) {
|
|
114
|
+
const body = await tokenRes.text();
|
|
115
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
|
|
116
|
+
}
|
|
117
|
+
const tokens = (await tokenRes.json());
|
|
118
|
+
const expiresAt = Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 7_776_000);
|
|
119
|
+
// Fetch identity to get account_id and user name
|
|
120
|
+
const identityRes = await fetch('https://launchpad.37signals.com/authorization.json', {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
123
|
+
'User-Agent': config_1.USER_AGENT,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
if (!identityRes.ok) {
|
|
127
|
+
throw new Error(`Failed to fetch identity (${identityRes.status})`);
|
|
128
|
+
}
|
|
129
|
+
const identity = (await identityRes.json());
|
|
130
|
+
const account = identity.accounts.find((a) => a.product === 'bc3');
|
|
131
|
+
if (!account) {
|
|
132
|
+
throw new Error('No Basecamp 3 account found on this 37signals login.');
|
|
133
|
+
}
|
|
134
|
+
(0, credentials_1.saveCredentials)({
|
|
135
|
+
accountId: String(account.id),
|
|
136
|
+
accessToken: tokens.access_token,
|
|
137
|
+
refreshToken: tokens.refresh_token,
|
|
138
|
+
tokenExpiresAt: expiresAt,
|
|
139
|
+
});
|
|
140
|
+
const fullName = `${identity.identity.first_name} ${identity.identity.last_name}`.trim();
|
|
141
|
+
console.log(`\nAuthorized as ${fullName} (${identity.identity.email_address})`);
|
|
142
|
+
console.log(`Account: ${account.name} (${account.id})`);
|
|
143
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const [, , command, ...args] = process.argv;
|
|
41
|
+
async function main() {
|
|
42
|
+
switch (command) {
|
|
43
|
+
case 'auth': {
|
|
44
|
+
const { runAuth } = await Promise.resolve().then(() => __importStar(require('./auth')));
|
|
45
|
+
await runAuth();
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'projects': {
|
|
49
|
+
const { runProjects } = await Promise.resolve().then(() => __importStar(require('./commands/projects')));
|
|
50
|
+
await runProjects();
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'cards': {
|
|
54
|
+
const { runCards } = await Promise.resolve().then(() => __importStar(require('./commands/cards')));
|
|
55
|
+
await runCards(args[0], args[1]);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'setup': {
|
|
59
|
+
const skillSrc = path.join(__dirname, '../skill/SKILL.md');
|
|
60
|
+
const skillDest = path.join(os.homedir(), '.claude', 'skills', 'basecamp', 'SKILL.md');
|
|
61
|
+
if (!fs.existsSync(skillSrc)) {
|
|
62
|
+
console.error('Error: SKILL.md not found in package. Try reinstalling.');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
fs.mkdirSync(path.dirname(skillDest), { recursive: true });
|
|
66
|
+
fs.copyFileSync(skillSrc, skillDest);
|
|
67
|
+
console.log(`Installed SKILL.md to ${skillDest}`);
|
|
68
|
+
console.log("Run 'basecamp auth' to connect your Basecamp account.");
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'show': {
|
|
72
|
+
if (!args[0]) {
|
|
73
|
+
console.error('Usage: basecamp show <card_id> [project_id]');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const { runShow } = await Promise.resolve().then(() => __importStar(require('./commands/show')));
|
|
77
|
+
await runShow(args[0], args[1]);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
console.log('Usage: basecamp <command> [args]');
|
|
82
|
+
console.log('');
|
|
83
|
+
console.log('Commands:');
|
|
84
|
+
console.log(' auth Authorize your Basecamp account');
|
|
85
|
+
console.log(' projects List active projects');
|
|
86
|
+
console.log(' cards [project_id] Show card table for a project');
|
|
87
|
+
console.log(' show <card_id> [project_id] Show card detail and comments');
|
|
88
|
+
console.log(' setup Install Claude Code skill to ~/.claude/skills/');
|
|
89
|
+
if (command)
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
main().catch((err) => {
|
|
94
|
+
console.error(`Error: ${err.message}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCards = runCards;
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
async function runCards(projectId, boardId) {
|
|
6
|
+
const creds = await (0, api_1.getCredentials)();
|
|
7
|
+
const pid = projectId ?? creds.defaultProject;
|
|
8
|
+
if (!pid) {
|
|
9
|
+
console.error('No project ID provided. Usage: basecamp cards <project_id> [board_id]');
|
|
10
|
+
console.error('Run `basecamp projects` to find your project ID.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
const project = await (0, api_1.apiGet)(`/projects/${pid}.json`);
|
|
14
|
+
const boards = project.dock.filter((d) => d.name === 'kanban_board' && d.enabled);
|
|
15
|
+
if (boards.length === 0) {
|
|
16
|
+
console.log('No card tables (kanban boards) found in this project.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// If multiple boards and no board_id specified, list them
|
|
20
|
+
if (boards.length > 1 && !boardId) {
|
|
21
|
+
console.log(`## Card Tables in ${project.name}\n`);
|
|
22
|
+
console.log('Multiple boards found. Use: basecamp cards <project_id> <board_id>\n');
|
|
23
|
+
console.log('| ID | Name |');
|
|
24
|
+
console.log('|---|---|');
|
|
25
|
+
for (const b of boards) {
|
|
26
|
+
console.log(`| ${b.id} | ${b.title} |`);
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const board = boardId
|
|
31
|
+
? boards.find((b) => String(b.id) === boardId)
|
|
32
|
+
: boards[0];
|
|
33
|
+
if (!board) {
|
|
34
|
+
console.error(`Board ID ${boardId} not found. Run \`basecamp cards ${pid}\` to list boards.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const table = await (0, api_1.apiGet)(board.url);
|
|
38
|
+
console.log(`## ${table.title}\n`);
|
|
39
|
+
for (const column of table.lists) {
|
|
40
|
+
console.log(`### ${column.title}`);
|
|
41
|
+
if (column.cards_count === 0) {
|
|
42
|
+
console.log('_(empty)_\n');
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const cards = await (0, api_1.apiGet)(`/buckets/${pid}/card_tables/lists/${column.id}/cards.json`);
|
|
46
|
+
for (const card of cards) {
|
|
47
|
+
const assignees = card.assignees?.map((a) => `@${a.name}`).join(', ') || 'unassigned';
|
|
48
|
+
const due = card.due_on ? ` — due ${card.due_on}` : '';
|
|
49
|
+
console.log(`- [#${card.id}] ${card.title} — ${assignees}${due}`);
|
|
50
|
+
}
|
|
51
|
+
console.log('');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runProjects = runProjects;
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
async function runProjects() {
|
|
6
|
+
const projects = await (0, api_1.apiGet)('/projects.json');
|
|
7
|
+
const active = projects.filter((p) => p.status === 'active');
|
|
8
|
+
if (active.length === 0) {
|
|
9
|
+
console.log('No active projects found.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log('## Active Projects\n');
|
|
13
|
+
console.log('| ID | Name | Description |');
|
|
14
|
+
console.log('|---|---|---|');
|
|
15
|
+
for (const p of active) {
|
|
16
|
+
const desc = (p.description ?? '').replace(/\n/g, ' ').slice(0, 80);
|
|
17
|
+
console.log(`| ${p.id} | ${p.name} | ${desc} |`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runShow = runShow;
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
function stripHtml(html) {
|
|
6
|
+
return html
|
|
7
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
8
|
+
.replace(/<\/p>/gi, '\n')
|
|
9
|
+
.replace(/<[^>]+>/g, '')
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, "'")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
async function runShow(cardId, projectId) {
|
|
18
|
+
const creds = await (0, api_1.getCredentials)();
|
|
19
|
+
const pid = projectId ?? creds.defaultProject;
|
|
20
|
+
if (!pid) {
|
|
21
|
+
console.error('No project ID provided. Usage: basecamp show <card_id> <project_id>');
|
|
22
|
+
console.error('Run `basecamp projects` to find your project ID.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const card = await (0, api_1.apiGet)(`/buckets/${pid}/card_tables/cards/${cardId}.json`);
|
|
26
|
+
const assignees = card.assignees?.map((a) => `@${a.name}`).join(', ') || 'unassigned';
|
|
27
|
+
const column = card.parent?.title ?? 'Unknown';
|
|
28
|
+
console.log(`## [#${card.id}] ${card.title}\n`);
|
|
29
|
+
console.log(`**Column:** ${column}`);
|
|
30
|
+
console.log(`**Assignees:** ${assignees}`);
|
|
31
|
+
if (card.due_on)
|
|
32
|
+
console.log(`**Due:** ${card.due_on}`);
|
|
33
|
+
const rawDesc = card.description ?? card.content;
|
|
34
|
+
if (rawDesc) {
|
|
35
|
+
console.log('\n### Description');
|
|
36
|
+
console.log(stripHtml(rawDesc));
|
|
37
|
+
}
|
|
38
|
+
const comments = await (0, api_1.apiGet)(`/buckets/${pid}/recordings/${cardId}/comments.json`);
|
|
39
|
+
if (comments.length > 0) {
|
|
40
|
+
console.log('\n### Comments');
|
|
41
|
+
for (const c of comments) {
|
|
42
|
+
const date = c.created_at.slice(0, 10);
|
|
43
|
+
console.log(`**${c.creator.name}** (${date}): ${stripHtml(c.content)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.USER_AGENT = exports.CALLBACK_PORT = exports.REDIRECT_URI = exports.CLIENT_SECRET = exports.CLIENT_ID = void 0;
|
|
4
|
+
// Register your OAuth app at https://integrate.37signals.com
|
|
5
|
+
// Set redirect URI to: http://localhost:45678/callback
|
|
6
|
+
// Then replace the placeholders below, or set env vars.
|
|
7
|
+
exports.CLIENT_ID = process.env.BASECAMP_CLIENT_ID ?? '172b13548acf0011660e7dec23a9e73def9081c1';
|
|
8
|
+
exports.CLIENT_SECRET = process.env.BASECAMP_CLIENT_SECRET ?? '65a61b2c383483ba625b26f2cf61742f316423a6';
|
|
9
|
+
exports.REDIRECT_URI = 'http://localhost:45678/callback';
|
|
10
|
+
exports.CALLBACK_PORT = 45678;
|
|
11
|
+
exports.USER_AGENT = 'BasecampSkill (hasbi@dicoding.com)';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readCredentials = readCredentials;
|
|
37
|
+
exports.saveCredentials = saveCredentials;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const os = __importStar(require("os"));
|
|
41
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), '.config', 'basecamp');
|
|
42
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials');
|
|
43
|
+
function readCredentials() {
|
|
44
|
+
if (!fs.existsSync(CREDENTIALS_FILE))
|
|
45
|
+
return null;
|
|
46
|
+
const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
|
|
47
|
+
const data = {};
|
|
48
|
+
for (const line of content.split('\n')) {
|
|
49
|
+
if (line.startsWith('#') || !line.includes('='))
|
|
50
|
+
continue;
|
|
51
|
+
const eq = line.indexOf('=');
|
|
52
|
+
data[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
53
|
+
}
|
|
54
|
+
if (!data.BASECAMP_ACCESS_TOKEN || !data.BASECAMP_ACCOUNT_ID)
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
accountId: data.BASECAMP_ACCOUNT_ID,
|
|
58
|
+
accessToken: data.BASECAMP_ACCESS_TOKEN,
|
|
59
|
+
refreshToken: data.BASECAMP_REFRESH_TOKEN ?? '',
|
|
60
|
+
tokenExpiresAt: parseInt(data.BASECAMP_TOKEN_EXPIRES_AT ?? '0', 10),
|
|
61
|
+
defaultProject: data.BASECAMP_DEFAULT_PROJECT,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function saveCredentials(creds) {
|
|
65
|
+
if (!fs.existsSync(CREDENTIALS_DIR)) {
|
|
66
|
+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
const lines = [
|
|
69
|
+
`BASECAMP_ACCOUNT_ID=${creds.accountId}`,
|
|
70
|
+
`BASECAMP_ACCESS_TOKEN=${creds.accessToken}`,
|
|
71
|
+
`BASECAMP_REFRESH_TOKEN=${creds.refreshToken}`,
|
|
72
|
+
`BASECAMP_TOKEN_EXPIRES_AT=${creds.tokenExpiresAt}`,
|
|
73
|
+
];
|
|
74
|
+
if (creds.defaultProject) {
|
|
75
|
+
lines.push(`BASECAMP_DEFAULT_PROJECT=${creds.defaultProject}`);
|
|
76
|
+
}
|
|
77
|
+
fs.writeFileSync(CREDENTIALS_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
|
78
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "basecamp-skill",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Claude Code skill for reading Basecamp card tables and comments",
|
|
5
|
+
"bin": {
|
|
6
|
+
"basecamp": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"skill"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/node": "^22.0.0",
|
|
15
|
+
"typescript": "^5.5.3"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc && node -e \"const fs=require('fs');const f='dist/cli.js';fs.writeFileSync(f,'#!/usr/bin/env node\\n'+fs.readFileSync(f,'utf8'));require('fs').chmodSync(f,0o755);\"",
|
|
22
|
+
"dev": "node --loader ts-node/esm src/cli.ts"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: basecamp
|
|
3
|
+
description: Read Basecamp card tables and card comments. Requires `basecamp auth` on first use.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Basecamp Skill
|
|
7
|
+
|
|
8
|
+
Read Basecamp 3 Card Tables, cards, and comments using the `basecamp` CLI.
|
|
9
|
+
|
|
10
|
+
## First-time setup
|
|
11
|
+
|
|
12
|
+
If credentials are missing or the user gets an authorization error, instruct them to run:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
basecamp auth
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This opens a browser to authorize their Basecamp account. It only needs to be done once. Tokens are stored in `~/.config/basecamp/credentials` and refreshed automatically.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
### `/basecamp:auth`
|
|
23
|
+
|
|
24
|
+
Use when the user wants to connect their Basecamp account, or when any command reports "Not authorized".
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
basecamp auth
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### `/basecamp:projects`
|
|
31
|
+
|
|
32
|
+
List all active Basecamp projects the user has access to. Always run this first if the user does not know their project ID.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
basecamp projects
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Output is a markdown table with project IDs, names, and descriptions. The ID is needed for `cards` and `show`.
|
|
39
|
+
|
|
40
|
+
### `/basecamp:cards [project_id]`
|
|
41
|
+
|
|
42
|
+
Show the card table for a project, grouped by column. If the user has set `BASECAMP_DEFAULT_PROJECT` in their credentials file, the project ID can be omitted.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
basecamp cards <project_id>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Present results as organized lists grouped by column (status). Each card shows its ID, title, assignees, and due date if set.
|
|
49
|
+
|
|
50
|
+
### `/basecamp:show <card_id>`
|
|
51
|
+
|
|
52
|
+
Show full card detail: metadata, description, and all comments in chronological order.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
basecamp show <card_id> [project_id]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Present the card clearly — metadata up top, description next, then comments. Reference card IDs as `#42` in conversation.
|
|
59
|
+
|
|
60
|
+
## Error handling
|
|
61
|
+
|
|
62
|
+
| Error message | Action |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `Not authorized. Run basecamp auth first.` | Ask user to run `basecamp auth` |
|
|
65
|
+
| `No project ID provided` | Ask user to run `basecamp projects` and pick an ID |
|
|
66
|
+
| `API error 404` | The card or project ID may be wrong — verify with `basecamp projects` |
|
|
67
|
+
| `Token refresh failed` | Ask user to run `basecamp auth` again |
|