@north-light/sdk 0.3.470 → 0.3.472

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 CHANGED
@@ -10,7 +10,15 @@
10
10
 
11
11
  It is a client, not a runtime. It never runs an agent itself, and it does not bundle a model: the daemon it talks to does, using the provider that daemon is signed in to. In Node, `new Crouter()` connects to the local daemon over its unix socket and starts it on the first request if it is not running. In a browser or from another machine it needs the daemon's TCP URL and a bearer token. The lower-level contract it is built on is [`@north-light/api`](https://www.npmjs.com/package/@north-light/api); most applications should install this package instead.
12
12
 
13
- [Docs](https://docs.northlight.ai/docs/sdk) · [Client construction](https://docs.northlight.ai/docs/sdk/client) · [Resource map](https://docs.northlight.ai/docs/sdk/resources) · [Recipes](https://docs.northlight.ai/docs/sdk/guides) · [crouter repository](https://github.com/Northlight-Systems/crouter)
13
+ [Docs](https://crouter.docs.northlight.ai/docs/sdk) · [Client construction](https://crouter.docs.northlight.ai/docs/sdk/client) · [Resource map](https://crouter.docs.northlight.ai/docs/sdk/resources) · [Recipes](https://crouter.docs.northlight.ai/docs/sdk/guides) · [crouter repository](https://github.com/Northlight-Systems/crouter)
14
+
15
+ ## Start an app with the CLI
16
+
17
+ ```bash
18
+ npx @north-light/sdk@latest init my-app
19
+ ```
20
+
21
+ `init` writes a runnable app, a signing key and the `northlight` Agent Skill for Claude Code, Codex, Cursor and Copilot, and prints the console steps. `npx @north-light/sdk skill` refreshes the skill after you change the pinned SDK version, and `npx @north-light/sdk env --client-id <client id>` writes the app's env file. None of them needs crtr.
14
22
 
15
23
  ## Install
16
24
 
@@ -174,7 +182,7 @@ See the complete [localhost SDK demo](https://github.com/Northlight-Systems/crou
174
182
 
175
183
  ## Reference
176
184
 
177
- The [SDK guide](https://docs.northlight.ai/docs/sdk) covers [client construction](https://docs.northlight.ai/docs/sdk/client), [nodes and scopes](https://docs.northlight.ai/docs/sdk/nodes), [streaming](https://docs.northlight.ai/docs/sdk/streaming), [files](https://docs.northlight.ai/docs/sdk/files), [bash](https://docs.northlight.ai/docs/sdk/bash), [Docker](https://docs.northlight.ai/docs/sdk/docker), [errors](https://docs.northlight.ai/docs/sdk/errors), and the complete [resource map](https://docs.northlight.ai/docs/sdk/resources).
185
+ The [SDK guide](https://crouter.docs.northlight.ai/docs/sdk) covers [client construction](https://crouter.docs.northlight.ai/docs/sdk/client), [nodes and scopes](https://crouter.docs.northlight.ai/docs/sdk/nodes), [streaming](https://crouter.docs.northlight.ai/docs/sdk/streaming), [files](https://crouter.docs.northlight.ai/docs/sdk/files), [bash](https://crouter.docs.northlight.ai/docs/sdk/bash), [Docker](https://crouter.docs.northlight.ai/docs/sdk/docker), [errors](https://crouter.docs.northlight.ai/docs/sdk/errors), and the complete [resource map](https://crouter.docs.northlight.ai/docs/sdk/resources).
178
186
 
179
187
  `@north-light/sdk/errors` exports `errorCodes`/`ErrorCode` (the daemon's codes), `sdkErrorCodes`/`SdkErrorCode` (the codes the SDK raises itself, such as `oauth_state_mismatch`; `CrouterError.code` is typed with them), and `mapError`, which turns a plain `APIError` into its status class (`NotFoundError`, …) for app tests.
180
188
 
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The absolute path of a file the build put under the package's `dist/` (for example `skill/northlight/SKILL.md`).
3
+ * Compiled code finds it beside itself; code running from `src/` under tsx finds it in the package's `dist/`.
4
+ */
5
+ export declare function assetPath(relative: string): string;
package/dist/assets.js ADDED
@@ -0,0 +1,13 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ /**
4
+ * The absolute path of a file the build put under the package's `dist/` (for example `skill/northlight/SKILL.md`).
5
+ * Compiled code finds it beside itself; code running from `src/` under tsx finds it in the package's `dist/`.
6
+ */
7
+ export function assetPath(relative) {
8
+ const candidates = [new URL(`./${relative}`, import.meta.url), new URL(`../dist/${relative}`, import.meta.url)].map((url) => fileURLToPath(url));
9
+ const found = candidates.find((path) => existsSync(path));
10
+ if (!found)
11
+ throw new Error(`The package is missing ${relative}. Reinstall @north-light/sdk, or run \`npm run build\` in a checkout.`);
12
+ return found;
13
+ }
@@ -0,0 +1,13 @@
1
+ export interface CommandUsage {
2
+ name: string;
3
+ /** The command's forms after `crouter-sdk` (the bin name); the skill table writes them after `npx @north-light/sdk`. */
4
+ forms: string[];
5
+ summary: string;
6
+ /** What, if anything, needs a joined `crtr`. */
7
+ crtr: string;
8
+ }
9
+ export declare const COMMANDS: CommandUsage[];
10
+ /** The usage text a command prints on a usage error. */
11
+ export declare function usageOf(name: string): string;
12
+ /** The text `crouter-sdk` prints with no command, an unknown one, or `--help`. */
13
+ export declare function overviewUsage(): string;
@@ -0,0 +1,49 @@
1
+ // The developer CLI's command table. One source for the usage lines the commands print and for the
2
+ // command table in the generated Agent Skill (scripts/build-skill.mjs reads it), so the two cannot drift.
3
+ // Keep this file free of imports and of TypeScript-only syntax beyond type annotations: the skill generator loads it directly.
4
+ export const COMMANDS = [
5
+ {
6
+ name: 'init',
7
+ forms: ['init [dir] [--agent claude|agents|all] [--global-skill]'],
8
+ summary: 'Scaffold a Northlight app in an empty or new directory (server.ts, key, env file, skill), or add the SDK, a key and the skill to an existing project. Never overwrites a file.',
9
+ crtr: 'No',
10
+ },
11
+ {
12
+ name: 'skill',
13
+ forms: ['skill [--agent claude|agents|all] [--global] [--print]'],
14
+ summary: 'Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout.',
15
+ crtr: 'No',
16
+ },
17
+ {
18
+ name: 'keygen',
19
+ forms: ['keygen [--out <path>] [--app <client id>] [--yes]'],
20
+ summary: 'Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register.',
21
+ crtr: 'Only with `--app`',
22
+ },
23
+ {
24
+ name: 'env',
25
+ forms: [
26
+ 'env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]',
27
+ 'env --app <client id> [--out <path>]',
28
+ ],
29
+ summary: 'Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr.',
30
+ crtr: 'Only with `--app`',
31
+ },
32
+ ];
33
+ const BIN = 'crouter-sdk';
34
+ /** The usage text a command prints on a usage error. */
35
+ export function usageOf(name) {
36
+ const command = COMMANDS.find((entry) => entry.name === name);
37
+ if (!command)
38
+ throw new Error(`unknown command ${name}`);
39
+ const [first, ...rest] = command.forms;
40
+ return [`Usage: ${BIN} ${first}`, ...rest.map((form) => ` or: ${BIN} ${form}`)].join('\n');
41
+ }
42
+ /** The text `crouter-sdk` prints with no command, an unknown one, or `--help`. */
43
+ export function overviewUsage() {
44
+ return [
45
+ `Usage: ${BIN} <command>`,
46
+ '',
47
+ ...COMMANDS.flatMap((command) => command.forms.map((form) => ` ${form}`)),
48
+ ].join('\n');
49
+ }
@@ -1,4 +1,7 @@
1
- export declare const ENV_USAGE = "Usage: crouter-sdk env --app <client id> [--out <path>]";
1
+ export declare const ENV_USAGE: string;
2
+ /** The directory every Northlight app signs in against, and the origin `init` registers for local development. */
3
+ export declare const DEFAULT_ISSUER = "https://account.northlight.ai";
4
+ export declare const DEFAULT_ORIGIN = "http://localhost:3000";
2
5
  /** The variables `env` owns, in the order it writes them. `CROUTER_KEY_FILE` is never one. */
3
6
  declare const NAMES: readonly ["CROUTER_ISSUER", "CROUTER_CLIENT_ID", "APP_ORIGIN"];
4
7
  type Name = typeof NAMES[number];
@@ -8,8 +11,10 @@ export interface EnvOptions {
8
11
  }
9
12
  /** Rewrites each assignment to a name in `values`, appends the names it had none for, and keeps every other line. */
10
13
  export declare function updateEnvText(text: string, values: Record<Name, string>): string;
14
+ /** The last non-empty value the file assigns to `name`, or undefined. */
15
+ export declare function envValueOf(text: string, name: string): string | undefined;
11
16
  /** The last `CROUTER_KEY_FILE` the file assigns, or undefined. */
12
- export declare function keyFileOf(text: string): string | undefined;
17
+ export declare const keyFileOf: (text: string) => string | undefined;
13
18
  /** The `crouter-sdk env` command. Returns the process exit code. */
14
19
  export declare function runEnv(argv: string[], options?: EnvOptions): Promise<number>;
15
20
  export {};
@@ -1,30 +1,35 @@
1
1
  import { mkdir, open, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
  import { CRTR_TIMEOUT_MS, notJoinedLines, parseJson, runCrtr, say } from './crtr.js';
4
- export const ENV_USAGE = 'Usage: crouter-sdk env --app <client id> [--out <path>]';
4
+ import { usageOf } from './cli-usage.js';
5
+ export const ENV_USAGE = usageOf('env');
6
+ /** The directory every Northlight app signs in against, and the origin `init` registers for local development. */
7
+ export const DEFAULT_ISSUER = 'https://account.northlight.ai';
8
+ export const DEFAULT_ORIGIN = 'http://localhost:3000';
5
9
  const DEFAULT_KEY_FILE = 'local/app-key.json';
6
10
  /** The variables `env` owns, in the order it writes them. `CROUTER_KEY_FILE` is never one. */
7
11
  const NAMES = ['CROUTER_ISSUER', 'CROUTER_CLIENT_ID', 'APP_ORIGIN'];
12
+ const FLAGS = { '--out': 'path', '--app': 'app', '--client-id': 'clientId', '--origin': 'origin', '--issuer': 'issuer' };
8
13
  function parseArgs(args) {
9
14
  if (args[0] !== 'env')
10
15
  throw new Error(ENV_USAGE);
11
- let path = 'local/app.env';
12
- let app;
16
+ const parsed = { path: 'local/app.env' };
13
17
  const seen = new Set();
14
18
  for (let i = 1; i < args.length; i++) {
15
19
  const flag = args[i];
16
20
  const value = args[++i];
17
- if (seen.has(flag) || (flag !== '--out' && flag !== '--app') || !value || value.startsWith('--'))
21
+ const key = FLAGS[flag];
22
+ if (seen.has(flag) || !key || !value || value.startsWith('--'))
18
23
  throw new Error(ENV_USAGE);
19
24
  seen.add(flag);
20
- if (flag === '--out')
21
- path = value;
22
- else
23
- app = value;
25
+ parsed[key] = value;
24
26
  }
25
- if (!app)
27
+ // Exactly one source for the client id; --origin and --issuer only mean something when env writes them itself.
28
+ if (!parsed.app === !parsed.clientId)
26
29
  throw new Error(ENV_USAGE);
27
- return { path, app };
30
+ if (parsed.app && (parsed.origin || parsed.issuer))
31
+ throw new Error(ENV_USAGE);
32
+ return parsed;
28
33
  }
29
34
  /** One `NAME=value` assignment line, with an optional `export ` prefix. */
30
35
  const ASSIGNMENT = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=)(.*)$/;
@@ -64,16 +69,18 @@ export function updateEnvText(text, values) {
64
69
  out.push(`${name}=${formatValue(values[name])}`);
65
70
  return `${out.join('\n')}\n`;
66
71
  }
67
- /** The last `CROUTER_KEY_FILE` the file assigns, or undefined. */
68
- export function keyFileOf(text) {
72
+ /** The last non-empty value the file assigns to `name`, or undefined. */
73
+ export function envValueOf(text, name) {
69
74
  let found;
70
75
  for (const line of text.split('\n')) {
71
76
  const match = ASSIGNMENT.exec(line);
72
- if (match?.[2] === 'CROUTER_KEY_FILE')
77
+ if (match?.[2] === name)
73
78
  found = readValue(match[4] ?? '');
74
79
  }
75
80
  return found || undefined;
76
81
  }
82
+ /** The last `CROUTER_KEY_FILE` the file assigns, or undefined. */
83
+ export const keyFileOf = (text) => envValueOf(text, 'CROUTER_KEY_FILE');
77
84
  function envOf(answer) {
78
85
  const env = answer?.env;
79
86
  if (!env || typeof env !== 'object')
@@ -88,9 +95,45 @@ function envOf(answer) {
88
95
  return values;
89
96
  }
90
97
  const byHand = (path) => `Set ${NAMES.join(', ')} in ${path} by hand: \`crtr cloud apps get --app-id <client id>\` shows them under env.`;
98
+ async function writeEnv(path, existing, values) {
99
+ const next = updateEnvText(existing ?? '', values);
100
+ if (existing === undefined) {
101
+ await mkdir(dirname(path), { recursive: true });
102
+ const file = await open(path, 'wx', 0o600);
103
+ try {
104
+ await file.writeFile(next);
105
+ }
106
+ finally {
107
+ await file.close();
108
+ }
109
+ }
110
+ else if (next !== existing) {
111
+ await writeFile(path, next);
112
+ }
113
+ say([
114
+ `Set in ${path}:`,
115
+ ...NAMES.map((name) => ` ${name}=${values[name]}`),
116
+ `Key file: ${keyFileOf(next) ?? DEFAULT_KEY_FILE} (set CROUTER_KEY_FILE to change it).`,
117
+ ]);
118
+ return 0;
119
+ }
91
120
  /** The `crouter-sdk env` command. Returns the process exit code. */
92
121
  export async function runEnv(argv, options = {}) {
93
- const { path, app } = parseArgs(argv);
122
+ const { path, app, clientId, origin, issuer } = parseArgs(argv);
123
+ const existing = await readFile(path, 'utf8').catch((error) => {
124
+ if (error.code === 'ENOENT')
125
+ return undefined;
126
+ throw error;
127
+ });
128
+ if (clientId) {
129
+ // No crtr and no network: the issuer is fixed, the client id is what the console showed, and the origin is
130
+ // what the developer registered, which is already in the file when `init` wrote it.
131
+ return writeEnv(path, existing, {
132
+ CROUTER_ISSUER: issuer ?? DEFAULT_ISSUER,
133
+ CROUTER_CLIENT_ID: clientId,
134
+ APP_ORIGIN: origin ?? envValueOf(existing ?? '', 'APP_ORIGIN') ?? DEFAULT_ORIGIN,
135
+ });
136
+ }
94
137
  const outcome = await runCrtr(['cloud', 'apps', 'get', '--app-id', app, '--json'], options.crtrTimeoutMs ?? CRTR_TIMEOUT_MS);
95
138
  if (outcome.kind === 'missing') {
96
139
  say(["Couldn't find crtr, so nothing was written.", byHand(path)]);
@@ -110,29 +153,5 @@ export async function runEnv(argv, options = {}) {
110
153
  say([`crtr answered without the app's env: ${last ?? '(no output)'}`, 'Nothing was written.']);
111
154
  return 2;
112
155
  }
113
- const existing = await readFile(path, 'utf8').catch((error) => {
114
- if (error.code === 'ENOENT')
115
- return undefined;
116
- throw error;
117
- });
118
- const next = updateEnvText(existing ?? '', values);
119
- if (existing === undefined) {
120
- await mkdir(dirname(path), { recursive: true });
121
- const file = await open(path, 'wx', 0o600);
122
- try {
123
- await file.writeFile(next);
124
- }
125
- finally {
126
- await file.close();
127
- }
128
- }
129
- else if (next !== existing) {
130
- await writeFile(path, next);
131
- }
132
- say([
133
- `Set in ${path}:`,
134
- ...NAMES.map((name) => ` ${name}=${values[name]}`),
135
- `Key file: ${keyFileOf(next) ?? DEFAULT_KEY_FILE} (set CROUTER_KEY_FILE to change it).`,
136
- ]);
137
- return 0;
156
+ return writeEnv(path, existing, values);
138
157
  }
@@ -0,0 +1,5 @@
1
+ /** The `crouter-sdk init` command. Returns the process exit code. */
2
+ export declare function runInit(argv: string[], options?: {
3
+ cwd?: string;
4
+ home?: string;
5
+ }): Promise<number>;
@@ -0,0 +1,159 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, open, readFile, stat } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, relative } from 'node:path';
6
+ import { assetPath } from './assets.js';
7
+ import { usageOf } from './cli-usage.js';
8
+ import { say } from './crtr.js';
9
+ import { DEFAULT_ISSUER, DEFAULT_ORIGIN } from './env-command.js';
10
+ import { createKeyFile } from './keygen-command.js';
11
+ import { isAgent, packagedSkill, skillTargets, writeSkillFile } from './skill-command.js';
12
+ import { SDK_VERSION } from './version.js';
13
+ const CONSOLE_URL = 'https://account.northlight.ai/console/new';
14
+ const KEY_FILE = 'local/app-key.json';
15
+ const ENV_FILE = 'local/app.env';
16
+ const START = 'node --env-file=local/app.env --import tsx server.ts';
17
+ function parseArgs(args) {
18
+ if (args[0] !== 'init')
19
+ throw new Error(usageOf('init'));
20
+ const parsed = { dir: '.', agent: 'all', globalSkill: false };
21
+ const seen = new Set();
22
+ let haveDir = false;
23
+ for (let i = 1; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (!arg.startsWith('--')) {
26
+ if (haveDir)
27
+ throw new Error(usageOf('init'));
28
+ haveDir = true;
29
+ parsed.dir = arg;
30
+ continue;
31
+ }
32
+ if (seen.has(arg))
33
+ throw new Error(usageOf('init'));
34
+ seen.add(arg);
35
+ if (arg === '--global-skill')
36
+ parsed.globalSkill = true;
37
+ else if (arg === '--agent') {
38
+ const value = args[++i];
39
+ if (!isAgent(value))
40
+ throw new Error(usageOf('init'));
41
+ parsed.agent = value;
42
+ }
43
+ else
44
+ throw new Error(usageOf('init'));
45
+ }
46
+ return parsed;
47
+ }
48
+ const packageJson = () => `${JSON.stringify({
49
+ name: 'my-northlight-app',
50
+ private: true,
51
+ type: 'module',
52
+ scripts: { start: START },
53
+ dependencies: { '@north-light/sdk': SDK_VERSION, tsx: '^4.20.0' },
54
+ }, null, 2)}\n`;
55
+ const ENV_TEXT = `CROUTER_ISSUER=${DEFAULT_ISSUER}\nAPP_ORIGIN=${DEFAULT_ORIGIN}\nCROUTER_CLIENT_ID=\nCROUTER_KEY_FILE=${KEY_FILE}\n`;
56
+ const exists = (path) => stat(path).then(() => true, () => false);
57
+ /** Writes `text` to a file that must not exist yet. */
58
+ async function createFile(path, text, mode = 0o644) {
59
+ await mkdir(dirname(path), { recursive: true });
60
+ const file = await open(path, 'wx', mode);
61
+ try {
62
+ await file.writeFile(text);
63
+ }
64
+ finally {
65
+ await file.close();
66
+ }
67
+ }
68
+ /** Runs `npm i --save-exact @north-light/sdk@<this version>` in `cwd` (no shell). */
69
+ function installSdk(cwd) {
70
+ const result = spawnSync('npm', ['i', '--save-exact', `@north-light/sdk@${SDK_VERSION}`], { cwd, stdio: ['ignore', 2, 2] });
71
+ if (result.error) {
72
+ say([`Couldn't run npm: ${result.error.message}`]);
73
+ return 1;
74
+ }
75
+ return result.status ?? 1;
76
+ }
77
+ /** The `crouter-sdk init` command. Returns the process exit code. */
78
+ export async function runInit(argv, options = {}) {
79
+ const { dir: given, agent, globalSkill } = parseArgs(argv);
80
+ const root = join(options.cwd ?? process.cwd(), given);
81
+ const shown = (path) => relative(options.cwd ?? process.cwd(), path) || '.';
82
+ const existingProject = existsSync(join(root, 'package.json'));
83
+ const skillBase = globalSkill ? options.home ?? homedir() : root;
84
+ const skillPaths = skillTargets(skillBase, agent);
85
+ const keyPath = join(root, KEY_FILE);
86
+ // Decide every refusal before writing anything, so a refused init leaves the directory as it found it.
87
+ const scaffold = {
88
+ 'package.json': packageJson(),
89
+ 'server.ts': await readFile(assetPath('templates/server.ts'), 'utf8'),
90
+ '.gitignore': 'local/\nnode_modules/\n',
91
+ [ENV_FILE]: ENV_TEXT,
92
+ };
93
+ const refused = [];
94
+ if (!existingProject) {
95
+ for (const name of Object.keys(scaffold))
96
+ if (await exists(join(root, name)))
97
+ refused.push(join(root, name));
98
+ if (await exists(keyPath))
99
+ refused.push(keyPath);
100
+ }
101
+ // A project skill is never replaced by init; `skill` replaces it. The global one is the user's own and init refreshes it.
102
+ if (!globalSkill)
103
+ for (const path of skillPaths)
104
+ if (await exists(path))
105
+ refused.push(path);
106
+ if (refused.length > 0) {
107
+ say([
108
+ ...refused.map((path) => `Refusing to overwrite ${shown(path)}.`),
109
+ 'Nothing was written.',
110
+ ...(refused.some((path) => path.endsWith('SKILL.md')) ? ['To replace the skill with the one this package ships, run `npx @north-light/sdk skill`.'] : []),
111
+ ]);
112
+ return 1;
113
+ }
114
+ const skill = await packagedSkill();
115
+ if (existingProject) {
116
+ // An existing project keeps its own code: only the SDK, a key and the skill are added.
117
+ const status = installSdk(root);
118
+ if (status !== 0) {
119
+ say([`\`npm i --save-exact @north-light/sdk@${SDK_VERSION}\` failed (exit ${status}), so nothing else was written.`]);
120
+ return status;
121
+ }
122
+ }
123
+ else {
124
+ await mkdir(root, { recursive: true });
125
+ for (const [name, text] of Object.entries(scaffold))
126
+ await createFile(join(root, name), text, name === ENV_FILE ? 0o600 : 0o644);
127
+ }
128
+ let publicJwk;
129
+ if (!(await exists(keyPath)))
130
+ publicJwk = JSON.stringify((await createKeyFile(keyPath)).publicJwk);
131
+ for (const path of skillPaths)
132
+ await writeSkillFile(path, skill);
133
+ const cd = given === '.' ? [] : [`cd ${given}`];
134
+ say([
135
+ existingProject ? `Added @north-light/sdk@${SDK_VERSION}, a signing key and the \`northlight\` skill to ${shown(root)}.` : `Created a Northlight app in ${shown(root)}:`,
136
+ ...(existingProject ? [] : [' package.json server.ts .gitignore local/app.env', '']),
137
+ `Private key: ${shown(keyPath)} (mode 0600, kept out of git). Never paste or commit it.`,
138
+ 'Skill:',
139
+ ...skillPaths.map((path) => ` ${globalSkill ? path : shown(path)}`),
140
+ '',
141
+ 'Next, in this order:',
142
+ " 1. Sign in at https://account.northlight.ai/signup if you don't have an account.",
143
+ ` 2. Register the app at ${CONSOLE_URL} with these values:`,
144
+ ` Origin URL: ${DEFAULT_ORIGIN}`,
145
+ ` Redirect URI: ${DEFAULT_ORIGIN}/auth/callback`,
146
+ ...(publicJwk ? [' Public key: the JWK printed on stdout'] : [` Public key: the public half of ${KEY_FILE}`]),
147
+ ' Then open the app\'s "What it asks for" tab, choose Add permissions, and add sms:send.',
148
+ ' 3. Write the client ID the console shows into the env file:',
149
+ ' npx @north-light/sdk env --client-id <client id>',
150
+ ' 4. Start it:',
151
+ ...cd.map((line) => ` ${line}`),
152
+ ...(existingProject ? [] : [' npm i']),
153
+ ` ${START}`,
154
+ ` then open ${DEFAULT_ORIGIN}/auth/login`,
155
+ ]);
156
+ if (publicJwk)
157
+ console.log(publicJwk);
158
+ return 0;
159
+ }
@@ -1,11 +1,28 @@
1
1
  #!/usr/bin/env node
2
- // The `crouter-sdk` bin: `keygen` and `env`. The file keeps its first command's name because the
3
- // published bin path (`dist/keygen-cli.js`) is part of the package.
2
+ // The `crouter-sdk` bin: `init`, `skill`, `keygen` and `env`. The file keeps its first command's name because the
3
+ // published bin path (`dist/keygen-cli.js`) is part of the package, and the package keeps exactly one bin so that
4
+ // `npx @north-light/sdk <command>` works without naming it.
5
+ import { overviewUsage } from './cli-usage.js';
4
6
  import { runEnv } from './env-command.js';
7
+ import { runInit } from './init-command.js';
5
8
  import { runKeygen } from './keygen-command.js';
9
+ import { runSkill } from './skill-command.js';
6
10
  const args = process.argv.slice(2);
7
- const run = args[0] === 'env' ? runEnv(args) : runKeygen(args);
8
- run.then((code) => { process.exitCode = code; }, (error) => {
11
+ const commands = { init: runInit, skill: runSkill, keygen: runKeygen, env: runEnv };
12
+ const command = args[0] ?? '';
13
+ async function main() {
14
+ if (command === '--help' || command === '-h' || command === 'help') {
15
+ console.log(overviewUsage());
16
+ return 0;
17
+ }
18
+ const run = Object.hasOwn(commands, command) ? commands[command] : undefined;
19
+ if (!run) {
20
+ console.error(overviewUsage());
21
+ return 1;
22
+ }
23
+ return run(args);
24
+ }
25
+ main().then((code) => { process.exitCode = code; }, (error) => {
9
26
  console.error(error instanceof Error ? error.message : String(error));
10
27
  process.exitCode = 1;
11
28
  });
@@ -1,6 +1,15 @@
1
+ import { generateKeyPair } from './oauth/keygen.js';
1
2
  export interface KeygenOptions {
2
3
  /** How long `crtr cloud apps keys add` may run before keygen gives up. */
3
4
  crtrTimeoutMs?: number;
4
5
  }
6
+ /**
7
+ * Reserves `path` (mode 0600, never truncating an existing file), writes a fresh private JWK into it and returns the
8
+ * public JWK and key id. Throws, naming the path, when the file already exists.
9
+ */
10
+ export declare function createKeyFile(path: string): Promise<{
11
+ publicJwk: Awaited<ReturnType<typeof generateKeyPair>>['publicJwk'];
12
+ kid: string;
13
+ }>;
5
14
  /** The `crouter-sdk keygen` command. Returns the process exit code. */
6
15
  export declare function runKeygen(argv: string[], options?: KeygenOptions): Promise<number>;
@@ -2,8 +2,9 @@ import { mkdir, open, unlink } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { CRTR_TIMEOUT_MS, notJoinedLines, parseJson, runCrtr, say } from './crtr.js';
5
+ import { usageOf } from './cli-usage.js';
5
6
  import { generateKeyPair } from './oauth/keygen.js';
6
- const USAGE = 'Usage: crouter-sdk keygen [--out <path>] [--app <client id>] [--yes]';
7
+ const USAGE = usageOf('keygen');
7
8
  function parseArgs(args) {
8
9
  if (args[0] !== 'keygen')
9
10
  throw new Error(USAGE);
@@ -71,9 +72,11 @@ async function addKey(app, publicJwk, timeoutMs) {
71
72
  /** Removes a key file keygen wrote for a key that no app holds, so a rerun is not refused over it. */
72
73
  const discardKey = (path) => unlink(path).catch(() => undefined);
73
74
  const discarded = (path) => `Removed ${path}, and the public key printed above is not on any app, so don't add it. Fix the above and run keygen again.`;
74
- /** The `crouter-sdk keygen` command. Returns the process exit code. */
75
- export async function runKeygen(argv, options = {}) {
76
- const { path, app, yes } = parseArgs(argv);
75
+ /**
76
+ * Reserves `path` (mode 0600, never truncating an existing file), writes a fresh private JWK into it and returns the
77
+ * public JWK and key id. Throws, naming the path, when the file already exists.
78
+ */
79
+ export async function createKeyFile(path) {
77
80
  await mkdir(dirname(path), { recursive: true });
78
81
  // Reserve the destination before generating or printing the public key; never truncate an existing key.
79
82
  const file = await open(path, 'wx', 0o600).catch((error) => {
@@ -81,19 +84,22 @@ export async function runKeygen(argv, options = {}) {
81
84
  throw new Error(`${path} already exists. Delete it or pass --out to write somewhere else.`);
82
85
  throw error;
83
86
  });
84
- let publicJwk;
85
- let kid;
86
87
  try {
87
88
  const pair = await generateKeyPair();
88
89
  await file.writeFile(`${JSON.stringify(pair.privateJwk)}\n`);
89
- ({ publicJwk, kid } = pair);
90
+ await file.close();
91
+ return { publicJwk: pair.publicJwk, kid: pair.kid };
90
92
  }
91
93
  catch (error) {
92
- await file.close();
94
+ await file.close().catch(() => undefined);
93
95
  await unlink(path);
94
96
  throw error;
95
97
  }
96
- await file.close();
98
+ }
99
+ /** The `crouter-sdk keygen` command. Returns the process exit code. */
100
+ export async function runKeygen(argv, options = {}) {
101
+ const { path, app, yes } = parseArgs(argv);
102
+ const { publicJwk, kid } = await createKeyFile(path);
97
103
  const publicLine = JSON.stringify(publicJwk);
98
104
  console.error(`Saved the private key to ${path} (key id ${kid}). Keep it out of git.`);
99
105
  console.log(publicLine);
@@ -0,0 +1,200 @@
1
+ ---
2
+ name: northlight
3
+ description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
4
+ metadata:
5
+ sdk-version: "0.3.472"
6
+ ---
7
+
8
+ # Building a Northlight app
9
+
10
+ An app on Northlight signs people in with their Northlight account, then starts agent runs and calls providers (sms, search, integrations, context, device) on that person's own Northlight-hosted runtime, using their AI subscription. You write and host the app server with `@north-light/sdk`. Northlight hosts the directory (accounts, app registration, consent, the marketplace) and each person's runtime. It does not host your app's code. See [Where your app runs](https://sdk.docs.northlight.ai/docs/how-it-fits.md).
11
+
12
+ ## First: check this skill matches the SDK
13
+
14
+ This skill is for SDK version `0.3.472`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
15
+
16
+ ## Where things run
17
+
18
+ - **Your server** runs wherever you choose (your laptop while developing). It holds the app's private signing key and each person's stored connection.
19
+ - **account.northlight.ai** is the directory. People sign up and consent there. You register the app in its web console, `https://account.northlight.ai/console`.
20
+ - **The person's runtime** runs their agents. Your server reaches it with `oauth.client(userId)` and calls `runs.*` and `providers.*` on it.
21
+
22
+ An app signs requests with a key pair, not a client secret. The private key stays in `local/app-key.json`; the console holds only the public key.
23
+
24
+ ## The journey
25
+
26
+ You do the command-line steps. The person does the console steps in a browser: ask them, name the exact field labels, and wait for the values you need back (the client ID).
27
+
28
+ 1. **Account (person).** They sign up at `https://account.northlight.ai/signup`. Signup texts a code over iMessage, so the phone must have iMessage.
29
+ 2. **Scaffold (you).** In a new folder run `npx @north-light/sdk@latest init my-app`. In an existing Node project run `npx @north-light/sdk init` instead: it adds the SDK pinned to an exact version, a key and this skill, and overwrites nothing. It prints the public JWK on stdout. `local/app.env` gets the issuer, the origin `http://localhost:3000`, and an empty `CROUTER_CLIENT_ID=`.
30
+ 3. **Register (person).** At `https://account.northlight.ai/console/new`, under **Register an app**, they fill in:
31
+ - **Name**, shown on the consent page.
32
+ - **Origin URL**: `http://localhost:3000` for development.
33
+ - **Logo URL**: a square image.
34
+ - **Why your app needs access**: one sentence, quoted to people on the consent page.
35
+ - **Redirect URIs**: `http://localhost:3000/auth/callback`. Sign-in returns only to a URI listed here, matched exactly, so `localhost` and `127.0.0.1` differ and the port counts.
36
+ - **Public key**: the JWK that `init` printed. Never ask for, accept or paste the private key.
37
+
38
+ The console then shows the **client ID**. Ask for it.
39
+ 4. **Say what the app asks for (person).** On the app's **What it asks for** tab, **Add permissions**, then add every provider scope the code requests (the quickstart needs `sms:send`). A new app starts with `crtr:llm` and `crtr:act`. Without a scope listed here the consent page does not offer it and the provider call is refused ([Scopes and grants](https://sdk.docs.northlight.ai/docs/concepts/scopes-and-grants.md)).
40
+ 5. **Write the env file (you).** `npx @north-light/sdk env --client-id <client id>`. It sets `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` in `local/app.env` and keeps every other line.
41
+ 6. **Run it (you).** `npm i`, then `node --env-file=local/app.env --import tsx server.ts`. Ask the person to open `http://localhost:3000/auth/login`, sign in, and choose Allow. The page then starts a run and sends them a text.
42
+ 7. **Testing mode.** A new app is in testing: only its owner and up to 100 testers can sign in. Testers are added by email or phone on the console's **Testers** tab. Anyone else's callback gets `error=access_denied` with `error_description=app_in_testing` ([Testing mode](https://sdk.docs.northlight.ai/docs/concepts/testing-mode.md)). Going public needs verification: email support@northlight.ai.
43
+
44
+ Check each step worked before the next: a `local/app-key.json` file exists, a client ID is in `local/app.env`, the server prints `Listening on http://localhost:3000`, `/auth/login` reaches the Northlight consent page, and the callback lands back on your server. If a step fails, read the error and the page it names before changing code.
45
+
46
+ ## Code patterns
47
+
48
+ These come from the `server.ts` that `init` writes. The SDK reads no environment: your code passes the values in.
49
+
50
+ ### Configure `OAuth2Client`
51
+
52
+ ```ts
53
+ const store = new MemoryConnectionStore(); // one process only; use a persistent ConnectionStore in production
54
+ const oauth = new OAuth2Client({
55
+ clientId: process.env.CROUTER_CLIENT_ID ?? '',
56
+ issuer: process.env.CROUTER_ISSUER ?? 'https://account.northlight.ai',
57
+ redirectUri: `${origin}/auth/callback`,
58
+ privateKey: JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8')),
59
+ store,
60
+ });
61
+ ```
62
+
63
+ ### Sign in: `/auth/login` and `/auth/callback`
64
+
65
+ ```ts
66
+ if (url.pathname === '/auth/login') {
67
+ const {url: authorizeUrl, codeVerifier, state, nonce} = await oauth.authorizeUrl({
68
+ scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
69
+ state: randomBytes(16).toString('base64url'),
70
+ });
71
+ pending.set(state, {codeVerifier, nonce});
72
+ return void res.writeHead(303, {location: authorizeUrl}).end();
73
+ }
74
+
75
+ if (url.pathname === '/auth/callback') {
76
+ if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
77
+ return page(res, 403, '<h1>This app is still in testing</h1><p>Ask its owner to add you on the Testers tab in the console.</p>');
78
+ }
79
+ const state = url.searchParams.get('state') ?? '';
80
+ const code = url.searchParams.get('code');
81
+ const saved = pending.get(state);
82
+ if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
83
+ pending.delete(state);
84
+ const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
85
+ await store.save(connection);
86
+ const sid = randomBytes(16).toString('base64url');
87
+ sessions.set(sid, connection.userId);
88
+ return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
89
+ }
90
+ ```
91
+
92
+ Keep `codeVerifier` and `nonce` per sign-in, keyed by `state`, and pass the same `state` as `expectedState` when exchanging the code. The example keeps them in a `Map` and sessions in memory; use real sessions in production ([Connections](https://sdk.docs.northlight.ai/docs/concepts/connections.md), [Go live](https://sdk.docs.northlight.ai/docs/guides/go-live.md)).
93
+
94
+ ### Start a run and call a provider
95
+
96
+ ```ts
97
+ const runtime = await oauth.client(userId); // a client on this person's runtime, from their stored connection
98
+ const started = await runtime.runs.start({prompt: 'Reply with exactly the word: pong', scopes: ['crtr:llm', 'crtr:act']});
99
+ const run = await runtime.runs.wait(started.run_id); // status is 'idle' once the reply is done
100
+
101
+ // Texts the signed-in person's own verified phone through the sms provider (50 per person per UTC day).
102
+ const sent = await runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body: 'Hello from my first Northlight app.'}});
103
+ ```
104
+
105
+ ### Get a typed result back with `runs.parse`
106
+
107
+ ```ts
108
+ import {z} from 'zod';
109
+ import type {ConnectedCrouter} from '@north-light/sdk';
110
+
111
+ const Profile = z.object({
112
+ display_name: z.string(),
113
+ bio: z.string(),
114
+ facts: z.array(z.string()),
115
+ });
116
+
117
+ /** Start a run on the person's runtime and get a value back that was checked against the schema. */
118
+ export async function draftProfile(runtime: ConnectedCrouter) {
119
+ const outcome = await runtime.runs.parse({
120
+ prompt: 'Draft a public profile for me from my notes.',
121
+ scopes: ['crtr:llm'], // submitting a result needs no other scope
122
+ output_schema: Profile,
123
+ });
124
+
125
+ if (outcome.kind === 'result') return outcome.output_parsed; // {display_name, bio, facts}, typed from Profile
126
+ if (outcome.reason === 'declined') {
127
+ // The agent could not honestly satisfy the schema; outcome.details says why.
128
+ throw new Error(`Declined: ${outcome.details?.reason ?? 'no reason given'}`);
129
+ }
130
+ throw new Error(`The run ended without a result: ${outcome.reason}`); // canceled, deadline_exceeded or runtime_error
131
+ }
132
+ ```
133
+
134
+ ### List a provider's tools, then call one
135
+
136
+ ```ts
137
+ import type {ConnectedCrouter} from '@north-light/sdk';
138
+
139
+ /** List the tools a provider offers this person, then call one. Scope strings come from the console's
140
+ * "Add permissions" list; tool names and arguments come from `providers.tools`. Never invent either. */
141
+ export async function textMe(runtime: ConnectedCrouter, body: string) {
142
+ const sms = await runtime.providers.tools('sms');
143
+ console.log(JSON.stringify(sms.groups, null, 2)); // each leaf: tool, description, params
144
+
145
+ return runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body}});
146
+ }
147
+ ```
148
+
149
+ ## Rules
150
+
151
+ Details behind each rule: [Errors](https://sdk.docs.northlight.ai/docs/errors.md), [Typed results](https://sdk.docs.northlight.ai/docs/guides/typed-results.md), [Connections](https://sdk.docs.northlight.ai/docs/concepts/connections.md).
152
+
153
+ - **Pin the exact SDK version** (`npm i --save-exact`). A later release can change a type or method, and `^` picks it up on the next `npm i`.
154
+ - **Take scope strings from the console's Add permissions list or from `providers.tools`.** Never invent one. Tool names and arguments also come from `providers.tools('<provider>')`.
155
+ - **Never print, paste, log or commit `local/app-key.json`.** `local/` is in `.gitignore`. If it leaks, add a new key in the console, switch to it, and retire the old one.
156
+ - **Use `oauth.client(userId)` for a person's runtime. Never `new Crouter()`.** `Crouter` is the client for a runtime you run yourself, not for apps.
157
+ - **Use a persistent `ConnectionStore` in production.** `MemoryConnectionStore` loses every sign-in on restart and does not work across processes. `@north-light/sdk/stores/postgres` is the production store.
158
+ - **Use `runs.parse` with a schema for data. Never parse `runs.trace`.** The trace is what the model said; `output_parsed` is a value checked against your schema.
159
+ - **`426 runtime_version_unsupported` means the SDK is newer than production.** Install the previous version (`npm view @north-light/sdk versions`) with `--save-exact`.
160
+ - **Handle a refused connection in one place** with the `onRefusal` option of `OAuth2Client`; `grant_removed` is the only code on which you may delete a person's data.
161
+ - **Do not claim the app is hosted by Northlight.** The server runs where the developer puts it.
162
+
163
+ ## The CLI
164
+
165
+ `npx @north-light/sdk <command>`. None needs crtr unless noted.
166
+
167
+ | Command | Effect | Needs crtr |
168
+ |---|---|---|
169
+ | `init [dir] [--agent claude|agents|all] [--global-skill]` | Scaffold a Northlight app in an empty or new directory (server.ts, key, env file, skill), or add the SDK, a key and the skill to an existing project. Never overwrites a file. | No |
170
+ | `skill [--agent claude|agents|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
171
+ | `keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
172
+ | `env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br>`env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
173
+
174
+ ## Pages
175
+
176
+ Read a page as markdown by adding `.md` to its URL. The whole site as one file: https://sdk.docs.northlight.ai/llms-full.txt.
177
+
178
+ - [Quick setup](https://sdk.docs.northlight.ai/docs/quickstart.md)
179
+ - [Use your coding agent](https://sdk.docs.northlight.ai/docs/coding-agents.md)
180
+ - [Where your app runs](https://sdk.docs.northlight.ai/docs/how-it-fits.md)
181
+ - [Apps and sign-in](https://sdk.docs.northlight.ai/docs/concepts/apps-and-sign-in.md)
182
+ - [Scopes and grants](https://sdk.docs.northlight.ai/docs/concepts/scopes-and-grants.md)
183
+ - [Connections](https://sdk.docs.northlight.ai/docs/concepts/connections.md)
184
+ - [Runs](https://sdk.docs.northlight.ai/docs/concepts/runs.md)
185
+ - [Testing mode](https://sdk.docs.northlight.ai/docs/concepts/testing-mode.md)
186
+ - [Providers](https://sdk.docs.northlight.ai/docs/concepts/providers.md)
187
+ - [Typed results](https://sdk.docs.northlight.ai/docs/guides/typed-results.md)
188
+ - [Stream a run](https://sdk.docs.northlight.ai/docs/guides/stream-a-run.md)
189
+ - [Background runs](https://sdk.docs.northlight.ai/docs/guides/background-runs.md)
190
+ - [Many users](https://sdk.docs.northlight.ai/docs/guides/many-users.md)
191
+ - [Grant changes](https://sdk.docs.northlight.ai/docs/guides/grant-changes.md)
192
+ - [Files and documents](https://sdk.docs.northlight.ai/docs/guides/files-and-documents.md)
193
+ - [Test your app](https://sdk.docs.northlight.ai/docs/guides/test-your-app.md)
194
+ - [Manage your app](https://sdk.docs.northlight.ai/docs/guides/manage-your-app.md)
195
+ - [Go live](https://sdk.docs.northlight.ai/docs/guides/go-live.md)
196
+ - [Providers index](https://sdk.docs.northlight.ai/docs/providers.md)
197
+ - [Texting (sms)](https://sdk.docs.northlight.ai/docs/providers/sms.md)
198
+ - [Errors](https://sdk.docs.northlight.ai/docs/errors.md)
199
+ - [CLI reference](https://sdk.docs.northlight.ai/docs/cli.md)
200
+ - [Release notes](https://sdk.docs.northlight.ai/docs/release-notes.md)
@@ -0,0 +1,14 @@
1
+ export declare const SKILL_NAME = "northlight";
2
+ export type SkillAgent = 'claude' | 'agents' | 'all';
3
+ /** The `SKILL.md` paths for `agent` under `base` (the project directory, or the home directory for a global install). */
4
+ export declare function skillTargets(base: string, agent: SkillAgent): string[];
5
+ /** The skill this package ships, generated by `scripts/build-skill.mjs`. */
6
+ export declare const packagedSkill: () => Promise<string>;
7
+ /** Writes `text` to `path` through a temporary file, so an agent reading the skill never sees half of it. */
8
+ export declare function writeSkillFile(path: string, text: string): Promise<void>;
9
+ export declare const isAgent: (value: string | undefined) => value is SkillAgent;
10
+ /** The `crouter-sdk skill` command. Returns the process exit code. */
11
+ export declare function runSkill(argv: string[], options?: {
12
+ cwd?: string;
13
+ home?: string;
14
+ }): Promise<number>;
@@ -0,0 +1,65 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { say } from './crtr.js';
5
+ import { usageOf } from './cli-usage.js';
6
+ import { assetPath } from './assets.js';
7
+ export const SKILL_NAME = 'northlight';
8
+ /** Where each agent family reads project skills: Codex, Cursor, Copilot and VS Code read `.agents/skills`; Claude Code reads `.claude/skills`. */
9
+ const SKILL_ROOTS = { agents: '.agents/skills', claude: '.claude/skills' };
10
+ /** The `SKILL.md` paths for `agent` under `base` (the project directory, or the home directory for a global install). */
11
+ export function skillTargets(base, agent) {
12
+ const agents = agent === 'all' ? ['agents', 'claude'] : [agent];
13
+ return agents.map((name) => join(base, SKILL_ROOTS[name], SKILL_NAME, 'SKILL.md'));
14
+ }
15
+ /** The skill this package ships, generated by `scripts/build-skill.mjs`. */
16
+ export const packagedSkill = () => readFile(assetPath('skill/northlight/SKILL.md'), 'utf8');
17
+ /** Writes `text` to `path` through a temporary file, so an agent reading the skill never sees half of it. */
18
+ export async function writeSkillFile(path, text) {
19
+ await mkdir(dirname(path), { recursive: true });
20
+ const temporary = `${path}.${process.pid}.tmp`;
21
+ await writeFile(temporary, text);
22
+ await rename(temporary, path);
23
+ }
24
+ export const isAgent = (value) => value === 'claude' || value === 'agents' || value === 'all';
25
+ function parseArgs(args) {
26
+ if (args[0] !== 'skill')
27
+ throw new Error(usageOf('skill'));
28
+ const parsed = { agent: 'all', global: false, print: false };
29
+ const seen = new Set();
30
+ for (let i = 1; i < args.length; i++) {
31
+ const flag = args[i];
32
+ if (seen.has(flag))
33
+ throw new Error(usageOf('skill'));
34
+ seen.add(flag);
35
+ if (flag === '--global')
36
+ parsed.global = true;
37
+ else if (flag === '--print')
38
+ parsed.print = true;
39
+ else if (flag === '--agent') {
40
+ const value = args[++i];
41
+ if (!isAgent(value))
42
+ throw new Error(usageOf('skill'));
43
+ parsed.agent = value;
44
+ }
45
+ else
46
+ throw new Error(usageOf('skill'));
47
+ }
48
+ if (parsed.print && (parsed.global || seen.has('--agent')))
49
+ throw new Error(usageOf('skill'));
50
+ return parsed;
51
+ }
52
+ /** The `crouter-sdk skill` command. Returns the process exit code. */
53
+ export async function runSkill(argv, options = {}) {
54
+ const { agent, global, print } = parseArgs(argv);
55
+ const skill = await packagedSkill();
56
+ if (print) {
57
+ process.stdout.write(skill);
58
+ return 0;
59
+ }
60
+ const targets = skillTargets(global ? options.home ?? homedir() : options.cwd ?? process.cwd(), agent);
61
+ for (const target of targets)
62
+ await writeSkillFile(target, skill);
63
+ say(['Wrote the `northlight` skill (replacing any older copy):', ...targets.map((target) => ` ${target}`)]);
64
+ return 0;
65
+ }
@@ -0,0 +1,81 @@
1
+ // The server `npx @north-light/sdk init` writes as server.ts: sign a person in with Northlight, then start a run on
2
+ // their runtime and send them a text. It keeps sign-in state in memory, so it is a sketch for one process, not production.
3
+ import {createServer, type IncomingMessage, type ServerResponse} from 'node:http';
4
+ import {randomBytes} from 'node:crypto';
5
+ import {readFile} from 'node:fs/promises';
6
+ import {MemoryConnectionStore, OAuth2Client, authorizationRefusalOf} from '@north-light/sdk';
7
+
8
+ const origin = process.env.APP_ORIGIN ?? 'http://localhost:3000';
9
+
10
+ const store = new MemoryConnectionStore(); // one process only; use a persistent ConnectionStore in production
11
+ const oauth = new OAuth2Client({
12
+ clientId: process.env.CROUTER_CLIENT_ID ?? '',
13
+ issuer: process.env.CROUTER_ISSUER ?? 'https://account.northlight.ai',
14
+ redirectUri: `${origin}/auth/callback`,
15
+ privateKey: JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8')),
16
+ store,
17
+ });
18
+
19
+ const pending = new Map<string, {codeVerifier: string; nonce: string}>(); // sign-ins in flight, keyed by state
20
+ const sessions = new Map<string, string>(); // session id -> userId; use a real session store in production
21
+
22
+ const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
23
+ const page = (res: ServerResponse, status: number, body: string) => {
24
+ res.writeHead(status, {'content-type': 'text/html; charset=utf-8'}).end(`<!doctype html><meta charset="utf-8"><title>Northlight app</title><body style="font-family:system-ui;max-width:40rem;margin:3rem auto">${body}`);
25
+ };
26
+ const userOf = (req: IncomingMessage) => {
27
+ const sid = /(?:^|; )sid=([\w-]+)/.exec(req.headers.cookie ?? '')?.[1];
28
+ return sid ? sessions.get(sid) : undefined;
29
+ };
30
+
31
+ const server = createServer(async (req, res) => {
32
+ const url = new URL(req.url ?? '/', origin);
33
+ try {
34
+ if (!process.env.CROUTER_CLIENT_ID) {
35
+ return page(res, 200, '<h1>One step left</h1><p>Register the app in the console, then run <code>npx @north-light/sdk env --client-id &lt;client id&gt;</code> and restart this server.</p>');
36
+ }
37
+
38
+ if (url.pathname === '/auth/login') {
39
+ const {url: authorizeUrl, codeVerifier, state, nonce} = await oauth.authorizeUrl({
40
+ scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
41
+ state: randomBytes(16).toString('base64url'),
42
+ });
43
+ pending.set(state, {codeVerifier, nonce});
44
+ return void res.writeHead(303, {location: authorizeUrl}).end();
45
+ }
46
+
47
+ if (url.pathname === '/auth/callback') {
48
+ if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
49
+ return page(res, 403, '<h1>This app is still in testing</h1><p>Ask its owner to add you on the Testers tab in the console.</p>');
50
+ }
51
+ const state = url.searchParams.get('state') ?? '';
52
+ const code = url.searchParams.get('code');
53
+ const saved = pending.get(state);
54
+ if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
55
+ pending.delete(state);
56
+ const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
57
+ await store.save(connection);
58
+ const sid = randomBytes(16).toString('base64url');
59
+ sessions.set(sid, connection.userId);
60
+ return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
61
+ }
62
+
63
+ const userId = userOf(req);
64
+ if (!userId) return page(res, 200, '<h1>Northlight app</h1><p><a href="/auth/login">Sign in with Northlight</a></p>');
65
+
66
+ if (url.pathname === '/run' && req.method === 'POST') {
67
+ const runtime = await oauth.client(userId); // a client on this person's runtime, from their stored connection
68
+ const started = await runtime.runs.start({prompt: 'Reply with exactly the word: pong', scopes: ['crtr:llm', 'crtr:act']});
69
+ const run = await runtime.runs.wait(started.run_id); // status is 'idle' once the reply is done
70
+ // Texts the signed-in person's own verified phone through the sms provider (50 per person per UTC day).
71
+ const sent = await runtime.providers.call({provider: 'sms', tool: 'send', arguments: {body: 'Hello from my first Northlight app.'}});
72
+ return page(res, 200, `<h1>Done</h1><p>The run finished with status <code>${escapeHtml(run.status)}</code>. A text is on its way to your phone.</p><pre>${escapeHtml(JSON.stringify({run: run.run_id, outcome: run.outcome, report: run.last_report, text: sent}, null, 2))}</pre><p><a href="/">Back</a></p>`);
73
+ }
74
+ return page(res, 200, `<h1>Signed in</h1><p>You are <code>${escapeHtml(userId)}</code>.</p><form method="post" action="/run"><button>Start a run and send me a text</button></form>`);
75
+ } catch (error) {
76
+ console.error(error);
77
+ return page(res, 500, `<h1>Something failed</h1><pre>${escapeHtml(error instanceof Error ? error.message : String(error))}</pre>`);
78
+ }
79
+ });
80
+
81
+ server.listen(Number(new URL(origin).port) || 3000, () => console.log(`Listening on ${origin}`));
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.470";
2
+ export declare const SDK_VERSION = "0.3.472";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.470';
3
+ export const SDK_VERSION = '0.3.472';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.470",
3
+ "version": "0.3.472",
4
4
  "crouterContract": "1.7",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
@@ -37,12 +37,12 @@
37
37
  ],
38
38
  "sideEffects": false,
39
39
  "scripts": {
40
- "build": "tsc -p tsconfig.json && chmod 755 dist/keygen-cli.js",
40
+ "build": "tsc -p tsconfig.json && node scripts/build-skill.mjs && chmod 755 dist/keygen-cli.js",
41
41
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
42
42
  },
43
43
  "dependencies": {
44
- "@north-light/api": "0.3.470",
45
- "@north-light/identity": "0.3.470",
44
+ "@north-light/api": "0.3.472",
45
+ "@north-light/identity": "0.3.472",
46
46
  "jose": "^6.2.1"
47
47
  },
48
48
  "peerDependencies": {