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
27 changes: 24 additions & 3 deletions .github/workflows/publish-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,14 @@ jobs:

- name: Publish @graspful/shared
if: github.event.inputs.dry_run != 'true'
run: cd packages/shared && npm publish --access public || echo "shared already published at this version"
run: |
cd packages/shared
VERSION=$(node -p "require('./package.json').version")
if npm view "@graspful/shared@$VERSION" version >/dev/null 2>&1; then
echo "@graspful/shared@$VERSION is already published"
else
npm publish --access public
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Expand All @@ -91,13 +98,27 @@ jobs:

- name: Publish @graspful/cli
if: (steps.target.outputs.target == 'all' || steps.target.outputs.target == 'cli') && github.event.inputs.dry_run != 'true'
run: cd packages/cli && npm publish --access public || echo "cli already published at this version"
run: |
cd packages/cli
VERSION=$(node -p "require('./package.json').version")
if npm view "@graspful/cli@$VERSION" version >/dev/null 2>&1; then
echo "@graspful/cli@$VERSION is already published"
else
npm publish --access public
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Publish @graspful/mcp
if: (steps.target.outputs.target == 'all' || steps.target.outputs.target == 'mcp') && github.event.inputs.dry_run != 'true'
run: cd packages/mcp && npm publish --access public || echo "mcp already published at this version"
run: |
cd packages/mcp
VERSION=$(node -p "require('./package.json').version")
if npm view "@graspful/mcp@$VERSION" version >/dev/null 2>&1; then
echo "@graspful/mcp@$VERSION is already published"
else
npm publish --access public
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ graspful import course.yaml --org my-org
|----------|-------------|
| `GRASPFUL_API_KEY` | API key for authenticated commands (`import`, `publish`) |
| `GRASPFUL_API_URL` | API base URL (default: `https://api.graspful.ai`) |
| `GRASPFUL_USER_ID` | Optional Graspful user ID for analytics identity continuity |
| `GRASPFUL_TELEMETRY_DISABLED` | Set to `1` to disable anonymous product analytics |

The CLI sends command usage and outcome metadata to help improve Graspful. It never sends API keys or YAML course bodies. Set `GRASPFUL_TELEMETRY_DISABLED=1` to disable this data collection.

## Links

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@graspful/cli",
"version": "0.2.6",
"version": "0.2.7",
"description": "Create adaptive learning courses from YAML. CLI and MCP server for AI agents.",
"keywords": ["course", "learning", "adaptive", "mcp", "mcp-server", "ai", "ai-agent", "agent", "cli", "education", "edtech", "knowledge-graph", "spaced-repetition", "course-creation", "yaml", "adaptive-learning", "lms"],
"repository": {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('graspful login', () => {
expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
const savedContent = JSON.parse(writeFileSyncSpy.mock.calls[0][1] as string);
expect(savedContent.apiKey).toBe('gsk_browser_flow_key');
expect(savedContent.userId).toBe('user-123');
expect(savedContent.baseUrl).toBe('http://localhost:3000');
});
});
20 changes: 13 additions & 7 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function detectEditors(): Editor[] {
return editors;
}

function writeMcpConfig(configPath: string, apiKey: string): void {
function writeMcpConfig(configPath: string, apiKey: string, userId?: string): void {
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
Expand All @@ -64,7 +64,10 @@ function writeMcpConfig(configPath: string, apiKey: string): void {
mcpServers['graspful'] = {
command: 'npx',
args: ['-y', '@graspful/mcp'],
env: { GRASPFUL_API_KEY: apiKey },
env: {
GRASPFUL_API_KEY: apiKey,
...(userId ? { GRASPFUL_USER_ID: userId } : {}),
},
};
existing.mcpServers = mcpServers;

Expand All @@ -91,7 +94,7 @@ export function registerInitCommand(program: Command) {

// Still configure MCP if requested
if (opts.mcp) {
configureMcp(existingCreds.apiKey);
configureMcp(existingCreds.apiKey, existingCreds.userId);
}

output(
Expand Down Expand Up @@ -120,7 +123,7 @@ export function registerInitCommand(program: Command) {

// ── Configure MCP ───────────────────────────────────────────────
if (opts.mcp) {
configureMcp(data.apiKey);
configureMcp(data.apiKey, data.userId);
}

output(
Expand All @@ -147,7 +150,7 @@ export function registerInitCommand(program: Command) {
});
}

function configureMcp(apiKey: string): void {
function configureMcp(apiKey: string, userId?: string): void {
const editors = detectEditors();

if (editors.length === 0) {
Expand All @@ -157,15 +160,18 @@ function configureMcp(apiKey: string): void {
graspful: {
command: 'npx',
args: ['-y', '@graspful/mcp'],
env: { GRASPFUL_API_KEY: apiKey },
env: {
GRASPFUL_API_KEY: apiKey,
...(userId ? { GRASPFUL_USER_ID: userId } : {}),
},
},
},
}, null, 2));
return;
}

for (const editor of editors) {
writeMcpConfig(editor.configPath, apiKey);
writeMcpConfig(editor.configPath, apiKey, userId);
cliCapture('cli initialized', { editor: editor.name });
console.log(`\nMCP configured for ${editor.name}: ${editor.configPath}`);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function registerLoginCommand(program: Command) {
userId: string;
};

saveApiKeyCredentials(apiKey, baseUrl);
saveApiKeyCredentials(apiKey, baseUrl, userId);
cliCapture('cli logged in', { method: 'email-password' });
output(
{ authenticated: true, baseUrl, tokenType: 'apiKey', orgSlug, userId },
Expand All @@ -70,6 +70,7 @@ export function registerLoginCommand(program: Command) {
noBrowser: opts.browser === false,
});

cliCapture('cli logged in', { method: 'browser-auth' });
output(
{
authenticated: true,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const program = new Command();
program
.name('graspful')
.description('Create adaptive learning courses from YAML')
.version('0.1.0')
.version('0.2.7')
.option('--format <format>', 'Output format: human or json', 'human')
.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/lib/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ describe('cliDistinctId', () => {
);
});

test('uses the stored user ID before hashing stored credentials', () => {
delete process.env.GRASPFUL_USER_ID;
delete process.env.GRASPFUL_API_KEY;

expect(cliDistinctId({ apiKey: 'gsk_stored', userId: 'user-stored' })).toBe(
'user-stored',
);
});

test('uses a stable identifier for the current anonymous process', () => {
delete process.env.GRASPFUL_USER_ID;
delete process.env.GRASPFUL_API_KEY;
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import * as path from 'node:path';
import type { Credentials } from './auth';
import { resolveCredentials } from './auth';

const posthogKey = process.env.POSTHOG_API_KEY || process.env.NEXT_PUBLIC_POSTHOG_KEY;
const DEFAULT_POSTHOG_KEY = 'phc_ahQLCJsOBzeuro1yDeurs1a3xx07pIreJWeXG9T4d4';
const telemetryDisabled =
process.env.GRASPFUL_TELEMETRY_DISABLED === '1' ||
process.env.NODE_ENV === 'test';
const posthogKey = telemetryDisabled
? null
: process.env.POSTHOG_API_KEY ||
process.env.NEXT_PUBLIC_POSTHOG_KEY ||
DEFAULT_POSTHOG_KEY;

let client: PostHog | null = null;
let anonymousDistinctId: string | null = null;
Expand Down Expand Up @@ -53,14 +61,18 @@ function getOrCreateAnonymousDistinctId(): string {
}

export function cliDistinctId(
credentials?: Pick<Credentials, 'apiKey' | 'jwt'>,
credentials?: Pick<Credentials, 'apiKey' | 'jwt' | 'userId'>,
fallbackAnonymousId?: string,
): string {
if (process.env.GRASPFUL_USER_ID) {
return process.env.GRASPFUL_USER_ID;
}

const resolvedCredentials = credentials ?? resolveCredentials();
if (resolvedCredentials.userId) {
return resolvedCredentials.userId;
}

const credential =
process.env.GRASPFUL_API_KEY ??
resolvedCredentials.apiKey ??
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as os from 'os';
export interface Credentials {
apiKey?: string;
jwt?: string;
userId?: string;
baseUrl: string;
}

Expand All @@ -20,17 +21,25 @@ export function resolveCredentials(): Credentials {
// 1. API key (agent mode)
const apiKey = process.env.GRASPFUL_API_KEY;
if (apiKey) {
return { apiKey, baseUrl };
return { apiKey, userId: process.env.GRASPFUL_USER_ID, baseUrl };
}

// 2. Stored credentials (interactive or registered)
if (fs.existsSync(CREDENTIALS_PATH)) {
try {
const stored = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
if (stored.apiKey) {
return { apiKey: stored.apiKey, baseUrl: stored.baseUrl || baseUrl };
return {
apiKey: stored.apiKey,
userId: stored.userId,
baseUrl: stored.baseUrl || baseUrl,
};
}
return { jwt: stored.jwt, baseUrl: stored.baseUrl || baseUrl };
return {
jwt: stored.jwt,
userId: stored.userId,
baseUrl: stored.baseUrl || baseUrl,
};
} catch {
// Invalid file
}
Expand All @@ -51,14 +60,14 @@ export function saveCredentials(jwt: string, baseUrl?: string): void {
);
}

export function saveApiKeyCredentials(apiKey: string, baseUrl?: string): void {
export function saveApiKeyCredentials(apiKey: string, baseUrl?: string, userId?: string): void {
const dir = path.dirname(CREDENTIALS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(
CREDENTIALS_PATH,
JSON.stringify({ apiKey, baseUrl: baseUrl || getBaseUrl() }, null, 2),
JSON.stringify({ apiKey, userId, baseUrl: baseUrl || getBaseUrl() }, null, 2),
{ mode: 0o600 },
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/browser-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export async function runBrowserAuthFlow(options: BrowserAuthOptions): Promise<B
throw new Error('Browser authentication expired. Start again.');
}

saveApiKeyCredentials(exchangeData.apiKey, baseUrl);
saveApiKeyCredentials(exchangeData.apiKey, baseUrl, exchangeData.userId);
return {
apiKey: exchangeData.apiKey,
orgSlug: exchangeData.orgSlug,
Expand Down
4 changes: 4 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ Same pattern -- point `command` at `npx` and `args` at `@graspful/mcp`. The serv
|----------|:---:|-------------|
| `GRASPFUL_API_KEY` | For import/publish/list | API key for authenticated operations |
| `GRASPFUL_API_URL` | No | API base URL (default: `https://api.graspful.ai`) |
| `GRASPFUL_USER_ID` | No | Graspful user ID for analytics identity continuity |
| `GRASPFUL_TELEMETRY_DISABLED` | No | Set to `1` to disable anonymous product analytics |

The MCP server sends tool usage and outcome metadata to help improve Graspful. It never sends API keys or YAML course bodies. Set `GRASPFUL_TELEMETRY_DISABLED=1` to disable this data collection.

## Links

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@graspful/mcp",
"version": "0.2.4",
"version": "0.2.5",
"description": "MCP server for Graspful — create adaptive learning courses from AI agents",
"keywords": ["mcp", "mcp-server", "graspful", "course", "course-creation", "adaptive-learning", "ai-agent", "knowledge-graph", "spaced-repetition", "edtech", "education", "yaml", "lms", "model-context-protocol"],
"repository": {
Expand Down
10 changes: 9 additions & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ import {

// ─── PostHog analytics ──────────────────────────────────────────────────────

const posthogKey = process.env.POSTHOG_API_KEY || process.env.NEXT_PUBLIC_POSTHOG_KEY;
const DEFAULT_POSTHOG_KEY = 'phc_ahQLCJsOBzeuro1yDeurs1a3xx07pIreJWeXG9T4d4';
const telemetryDisabled =
process.env.GRASPFUL_TELEMETRY_DISABLED === '1' ||
process.env.NODE_ENV === 'test';
const posthogKey = telemetryDisabled
? null
: process.env.POSTHOG_API_KEY ||
process.env.NEXT_PUBLIC_POSTHOG_KEY ||
DEFAULT_POSTHOG_KEY;
const posthogClient = posthogKey
? new PostHog(posthogKey, {
host: process.env.POSTHOG_HOST || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
Expand Down
Loading