@north-light/cli 0.3.476 → 0.3.478

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.
@@ -19,7 +19,7 @@ A document with no rules does exactly one thing: appears in the listing of the n
19
19
 
20
20
  - **boot** — the frozen preference system snapshot and first-message knowledge catalog assembled when a context begins. No match; the rule's presence is the match.
21
21
  - **workspace-open** — first-message context for a repo the node is eligible for (it works in the repo, or the repo is one of its profile's projects), capped by the profile's delivery limit for that repo. Repo-owned documents only.
22
- - **file-read** — a `read` tool call returned a matching file: path globs vs the file's absolute path and basename, `./`-anchored globs vs its path relative to the owning repo's checkout, `match_frontmatter` predicates over the read file's own YAML frontmatter.
22
+ - **file-read** — the node read a matching file's content: a `read` tool call, or a `bash` command whose segment is `cat`, `sed` (not `-i`), `head`, `tail` (not `-f`), `nl` or `awk` on a literal path, resolved against the cwd the command's own `cd`s produce (`~`, `$HOME`, `$CRTR_CONTEXT_DIR` expand; any other variable, glob, `$( )` or interpreter one-liner is skipped). Searching is not reading — `rg`, `grep`, `find`, `ls`, `wc`, `stat` never fire it; use `command` for those. Path globs vs the file's absolute path and basename, `./`-anchored globs vs its path relative to the owning repo's checkout, `match_frontmatter` predicates over the read file's own YAML frontmatter.
23
23
  - **document-read** — another document's full body entered context, including `crtr canvas read`: name globs vs its name, `./` anchored to this document's own name.
24
24
  - **command** — a matching shell command ran: globs vs the whole command string, `*` crossing `/`. Delivery is post-execution — right for "you are now in this territory"; when the point is "don't run this at all," use `pre-command` instead.
25
25
  - **pre-command** — a matching `bash` command is about to run. When the document has not been read yet, the command does not execute — the document comes back as the tool result instead, and the agent re-issues the command, which then runs. Use it to guarantee a document is seen before a sensitive action is taken. Same globs as `command`, matched at command position; `match` is required. A guardrail against the faithful-but-uninformed action, not an enforcement boundary.
@@ -39,7 +39,7 @@ npx @north-light/sdk skill
39
39
 
40
40
  The project copy is the default so it is committed with the project, and teammates get it from git. It matches the SDK version your project pins.
41
41
 
42
- Checked: the files `init` writes, and that `skill` rewrites both, from the SDK source built on this branch. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
42
+ Checked on production: the files `init` writes, that `init` in a directory that already has a `package.json` pins the SDK and writes the key and skill but no app code, and that `skill` rewrites both skill files. `init` in such a directory writes no `.gitignore`, so add `local/` to yours. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
43
43
 
44
44
  ## Without a project: the hosted skill
45
45
 
@@ -49,6 +49,8 @@ An agent that has no project yet can read the hosted copy:
49
49
 
50
50
  The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
51
51
 
52
+ Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory): given only that sentence it read the skill and asked what to build. Given the sentence plus "I want the quickstart app: sign in with Northlight, start a run, text me", it ran `init my-app`, read the generated `server.ts`, and stopped with the console steps and the public key for you to paste, asking for the client ID. Name what you want built in the prompt.
53
+
52
54
  ## How it stays current
53
55
 
54
56
  The skill's frontmatter carries `metadata.sdk-version`. Its first instruction tells the agent to compare that number with `node_modules/@north-light/sdk/package.json`. If they differ, the agent runs `npx @north-light/sdk skill`, which rewrites the skill from the installed package. A skill therefore always documents the SDK your project pins, and a newer SDK never gets documented against an older one.
@@ -106,4 +106,4 @@ Test it: send a callback with a recognizable fake code and assert that the code,
106
106
 
107
107
  ## Checked and not checked
108
108
 
109
- Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.
109
+ Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in as the account that owns the app, through the consent page and the callback, was run on production with the quickstart server. A sign-in by a second person, token refresh and the refusals are not yet checked on production.
@@ -73,4 +73,4 @@ if (context?.state !== 'ready') {
73
73
 
74
74
  ## Checked and not checked
75
75
 
76
- From the SDK source (`providers.tools`, `providers.call`, `grant.get`) and the directory source. The sms limits come from the existing SDK guide. A real `providers.call` for `sms` on production is not yet checked.
76
+ From the SDK source (`providers.tools`, `providers.call`, `grant.get`) and the directory source. The sms limits come from the existing SDK guide. A real `providers.call` for `sms` on production returned a `message_id` for a text to the signed-in person. That the text arrived is not yet checked.
@@ -98,4 +98,4 @@ A run you start and do not wait on ends while nobody holds a request. `oauth.wat
98
98
 
99
99
  ## Checked and not checked
100
100
 
101
- From the SDK source and its tests. The `runs.start`, `runs.wait` and `activity` behavior against a real runtime on production is not yet checked.
101
+ From the SDK source and its tests. On production, `runs.start` and `runs.wait` ran the quickstart's prompt and settled with status `idle`; the run's reply text and `activity` are not yet checked.
@@ -61,6 +61,6 @@ Once your app is live you can keep developing on your laptop against production
61
61
 
62
62
  On the **Profile** tab, the **Delete this app** card lists what goes: every person's connection to the app, yours included (each person's runtime deletes the app's runs, space and schedules), the app's sign-ins and keys, and its testers and marketplace listing. This cannot be undone, and the client ID is never reused.
63
63
 
64
- To delete, type the app's full name in **Type the app's name, *name*, to confirm**. **Delete app** stays disabled until what you typed matches the name (surrounding spaces are ignored), and the directory checks the name again when you submit. After it deletes, the console returns to your app list. The app's next refresh for any person fails with `invalid_grant`, its key stops working and its page answers 404.
64
+ To delete, type the app's full name in **Type the app's name, *name*, to confirm**. **Delete app** stays disabled until what you typed matches the name (surrounding spaces are ignored), and the directory checks the name again when you submit. After it deletes, the console returns to your app list. Checked on production: the app is gone from the list, the console's API answers 404 `App not found` for it, and `/authorize` with its client ID shows "We couldn't start that sign-in" (HTTP 400). The app's next refresh for any person failing with `invalid_grant` is not yet checked on production.
65
65
 
66
66
  The card is not shown for a provider or a first-party app, and the directory refuses to delete a provider (`app_is_provider`). If you only want a clean slate for tests, changing the app's redirect URIs and keys is often simpler.
@@ -13,7 +13,7 @@ npx @north-light/sdk@latest init my-app
13
13
 
14
14
  This command creates a small Node server, a signing key and an env file. About ten minutes later you have an app on your laptop that signs you in with Northlight, starts an agent run on your own Northlight runtime, and texts your phone. The app runs on your machine and talks to Northlight's production directory at `https://account.northlight.ai`. There is no local Northlight stack to run, and you do not need crouter.
15
15
 
16
- You need Node.js 22.19 or later and a phone that can receive iMessage.
16
+ You need Node.js 22 or later and a phone that can receive iMessage. The quickstart ran on Node 22.14, 22.22 and 26.12; `--env-file` needs Node 20.6 or later.
17
17
 
18
18
  Using a coding agent? Run the same `init` command, or tell your agent: *Read https://sdk.docs.northlight.ai/SKILL.md and follow it.* See [[northlight-apps/coding-agents]].
19
19
 
@@ -23,7 +23,7 @@ Each step below ends with what you should see when it worked. A step marked "(no
23
23
 
24
24
  Open `https://account.northlight.ai/signup` and create your account. Signup texts a confirmation code to your phone, and that text is sent over iMessage. A number that cannot receive iMessage gets "We couldn't send the text", and retrying does not help. One phone number belongs to one account.
25
25
 
26
- You should see: your account's page after you enter the code. Opening `https://account.northlight.ai/console` while signed in shows the console, where your apps are listed. Checked on production: `/signup` redirects to the phone step at `/signup/phone`, and `/console` while signed out redirects to sign-in. Completing signup and the signed-in console are not yet checked on production.
26
+ You should see: your account's page after you enter the code. Opening `https://account.northlight.ai/console` while signed in shows the console, where your apps are listed. Checked on production: `/signup` redirects to the phone step at `/signup/phone`, which asks for a name and a phone number and offers **Text me a confirmation**; `/console` while signed out redirects to sign-in; and the signed-in console lists your apps and has **Register an app**. Completing signup, the confirmation code and the "We couldn't send the text" refusal are not yet checked on production (the walk used an existing account).
27
27
 
28
28
  ## 2. Scaffold the app
29
29
 
@@ -51,7 +51,7 @@ You should see, on the terminal: `Created a Northlight app in my-app:` with the
51
51
 
52
52
  Copy that line. You paste it into the console in the next step. If a file already exists, `init` refuses, names it and exits non-zero without writing anything.
53
53
 
54
- Checked: this output and these files, from the SDK source built on this branch in an empty directory with no crtr on `PATH`. `npx @north-light/sdk@latest` fetches the npm `latest` release, and that release does not include `init` until the site goes live (not yet checked on production).
54
+ Checked on production: `npx @north-light/sdk@latest init my-app` (npm `latest` 0.3.476) in an empty directory with no crtr on `PATH` printed this output and wrote these files, the private key has mode `0600`, and `server.ts` is the one listed at the end of this page. Running it again names the skill file and writes nothing.
55
55
 
56
56
  ## 3. Register the app in the console
57
57
 
@@ -68,17 +68,17 @@ Open `https://account.northlight.ai/console/new`. The page is titled **Register
68
68
 
69
69
  Choose **Register app**.
70
70
 
71
- You should see: a page titled "*your app's name* is registered" with a **Client ID** and, under it, the key ID of the key you pasted. Copy the client ID. If it says "Next: add your app's public key on the Sign-in tab", the key was left out: open the app's **Sign-in** tab and choose **Add key**. The app starts asking for `crtr:llm` and `crtr:act`.
71
+ You should see: a page titled "*your app's name* is registered" with a **Client ID** and, under it, the key ID of the key you pasted. Copy the client ID, then choose **Continue to *your app's name***. If it says "Next: add your app's public key on the Sign-in tab", the key was left out: open the app's **Sign-in** tab and choose **Add key**. The app starts asking for `crtr:llm` and `crtr:act`.
72
72
 
73
- The field labels are from the console's source. Pasting a key at registration is not yet checked on production.
73
+ Checked on production: these field labels, registering with a pasted key, and the app page opening in testing with that key active and `crtr:llm` and `crtr:act` listed. The "Next: add your app's public key" message, which shows when the key is left out, is not yet checked on production.
74
74
 
75
75
  ## 4. Add the permission to text
76
76
 
77
- On the app's page, open the **What it asks for** tab and choose **Add permissions**. Add `sms:send` from the list.
77
+ On the app's page, open the **What it asks for** tab and choose **Add permissions**. Choose **Texting**, tick `sms:send` ("Send you text messages from Northlight's number"), and choose **Done**. Then choose **Save** under the list: **Done** only closes the dialog, and the change is not stored until you save.
78
78
 
79
- You should see: `sms:send` under what your app asks for, next to `crtr:llm` and `crtr:act`. The server in this guide asks for all three, so without `sms:send` the consent page does not offer it and the text in step 6 is refused. Take scope strings from this list. Do not invent them. See [[northlight-apps/concepts/scopes-and-grants]].
79
+ You should see: `sms:send` under what your app asks for after **Save** says "Saved", next to `crtr:llm` and `crtr:act`. The server in this guide asks for all three, so without `sms:send` the consent page does not offer it and the text in step 6 is refused. Take scope strings from this list. Do not invent them. See [[northlight-apps/concepts/scopes-and-grants]].
80
80
 
81
- Not yet checked on production.
81
+ Checked on production: after **Save** and a reload, the tab lists `crtr:llm` and `crtr:act` under Agents and `sms:send` under Texting, and the consent page in step 6 offered the text permission.
82
82
 
83
83
  ## 5. Write the client ID into the env file
84
84
 
@@ -98,7 +98,7 @@ Key file: local/app-key.json (set CROUTER_KEY_FILE to change it).
98
98
 
99
99
  `local/app.env` now has all four settings. The command makes no network call and keeps every other line in the file. Add `--origin <url>` if your app does not run on `http://localhost:3000`.
100
100
 
101
- Checked from the SDK source built on this branch.
101
+ Checked on production: this output, and that the command works in a directory with no `local/` yet. `--origin` is not yet checked on production.
102
102
 
103
103
  ## 6. Run it
104
104
 
@@ -109,9 +109,9 @@ node --env-file=local/app.env --import tsx server.ts
109
109
 
110
110
  (`npm start` runs the same command.) You should see `Listening on http://localhost:3000`.
111
111
 
112
- Open `http://localhost:3000/auth/login`. Your browser goes to `account.northlight.ai`. Sign in if asked, read the consent page, and choose Allow. You land back on `http://localhost:3000/` with "Signed in" and your Northlight user id. Press **Start a run and send me a text**. The page answers "Done", shows the run's status `idle`, and your phone receives "Hello from my first Northlight app." from Northlight's number.
112
+ Open `http://localhost:3000/auth/login`. Your browser goes to `account.northlight.ai`. Sign in if asked, read the consent page, and choose Allow. You land back on `http://localhost:3000/` with "Signed in" and your Northlight user id. Press **Start a run and send me a text**. The page answers "Done", shows the run's status `idle` and the JSON of the text request (a `message_id` and a `note` from the sms provider), and your phone receives "Hello from my first Northlight app." from Northlight's number.
113
113
 
114
- What was checked: the server starts, `/` shows the sign-in link, and `/auth/login` answers `303` to `https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…`. The consent page, the callback, the run and the text are not yet checked on production.
114
+ Checked on production, as the account that owns the app: the server starts (with `npm start` too), `/` shows the sign-in link, and `/auth/login` answers `303` to `https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…`. The consent page listed the four permissions and the monthly cap, **Allow** returned to `http://localhost:3000/` with "Signed in" and the user id, and the button page answered "Done" with status `idle` and a `message_id` for the text. In the console the key then read "Last used just now". Not yet checked on production: that the text reached the phone (the `message_id` is the provider's answer, not a delivery receipt), that the run's reply was `pong`, and the `426` case. Signing in as a second person needs a second account and is not covered here.
115
115
 
116
116
  If `/` shows "One step left", `CROUTER_CLIENT_ID` is empty: repeat step 5 and restart. If a call fails with `426 runtime_version_unsupported`, your SDK is newer than the Northlight runtimes. Install the previous version with `npm i --save-exact @north-light/sdk@<version>` and tell support@northlight.ai.
117
117
 
@@ -1,2 +1,2 @@
1
- var f=Object.defineProperty;var o=(e,t)=>f(e,"name",{value:t,configurable:!0});import{usage as m}from"../../core/errors.js";import{ApiError as w}from"../../api/errors.js";import{rethrowAsCliError as c}from"../api-client.js";import{RULE_ON as g,RULE_DELIVER as v,DOC_KINDS as y}from"../../api/dto/docs.js";import{PRIVACY_LEVELS_DTO as b}from"../../api/dto/objects.js";const j="A document name (with or without [[ ]]) or a raw object id.",p="node | user | profile | profile:<name> | app | repo | repo:<handle>";function O(e){return{kind:"flag",name:"owner",type:"string",required:!1,constraint:e,focusedHelp:{whenToUse:"Omit it for a document only this node and the nodes it points at need: the writing node owns it and it is deleted with the node, unless a kept document links to it. Give another owner when the document must outlive this node or reach other nodes by itself. A document worth another owner carries reusable current truth: a non-obvious procedure, a gotcha, a lasting preference, a convention across repos. Task notes, conversation summaries and facts the repo already records stay with the node or are not written. Before writing one, search for a document that already covers it (`crtr canvas search`) and revise that one with `crtr doc edit` instead of adding a near copy. When one document grows long, keep the most important content in it and move separate subjects into documents named under it (`topic/part`), each pointed at with a `[[link]]`. Choose the narrowest owner that still reaches the next agent who needs it.",value:`One of ${p}. \`node\` \u2014 the writing node; only this node loads it by rule, other nodes read it when pointed at it; deleted with the node. \`profile\` \u2014 the node's profile, or \`profile:<name>\`; loads for nodes running in that profile; stays on this runtime. \`app\` \u2014 the app that runs the node; kept until deleted; readable by that app's nodes. \`repo\` \u2014 the repo the node works in (from its working directory), or \`repo:<handle>\`; every clone and worktree of the repo shares it, and it syncs to collaborators over the repo's \`refs/crtr/docs\`. A repo owns only public documents: a private or friends document that should load in one repo is owned by \`user\` and carries a rule with \`if: {"repo": "<handle>"}\`. \`user\` \u2014 you, the person; loads for every node by its rules; readable by apps by privacy level. Default: the writing node; a person's own run (not a node) defaults to \`user\`. The owner becomes the start of the document's full name (\`<owner-handle>/<name>\`); \`crtr doc move --owner\` changes it later and keeps the id and every link.`,effects:["Decides who may read and change the document, where it syncs, and what deleting takes it with."]}}}o(O,"ownerParam");const k="`if` is a condition on the receiving node. Fields: `kind`, `mode`, `lifecycle`, `cwd`, `repo` (the handle of a repo the node is eligible for: those enclosing its cwd and those of its profile's projects; use `cwd` for where it works), `profile`, `depth`. A field map is ANDed across fields; dotted fields resolve nested values. A scalar matches by equality (a list value matches when any element is equal); a list matcher matches by membership or overlap; an object matcher takes `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, `lte`. `all`, `any` and `not` combine conditions; field matchers beside them are ANDed in. An empty condition always matches; an unknown operator never matches. `match_frontmatter` uses the same language against the read file's YAML frontmatter.";function S(e){return{kind:"flag",name:"rule",type:"string",required:!1,repeatable:!0,constraint:e,focusedHelp:{whenToUse:"Omit it for most documents: a document with no rule is read when a node is pointed at it (`[[name]]`), finds it with `crtr canvas search`, or sees it in a listing. Add a rule only when the document must load on its own because of what a node is doing; every rule costs context in every node it fires on. Prefer the narrowest trigger: a `file-read` rule with a real glob over `boot` for knowledge about specific code; `boot` for knowledge about a person or process with no file boundary. When the whole document is one sentence, deliver `content` \u2014 a preview line would be longer than the document.",value:`One JSON object per occurrence: \`{"on": \u2026, "match"?: [globs], "match_frontmatter"?: {\u2026}, "if"?: {\u2026}, "deliver": \u2026}\`. \`on\` is the node's activity: ${g.join(" | ")}. \`boot\` \u2014 every session start of the node. \`workspace-open\` \u2014 the node's first message when it works in the document's repo. \`file-read\` \u2014 the node reads a file whose absolute path or basename matches a \`match\` glob, or whose frontmatter matches \`match_frontmatter\`. \`document-read\` \u2014 another document whose name matches is delivered at content, including an explicit read. \`command\` \u2014 a shell segment of a bash command matches (segments split on \`&&\`, \`||\`, \`;\`, \`|\` and newlines outside quotes, leading \`VAR=value\` dropped; \`*\` spans whole whitespace-separated tokens, \`?\` one character, quoted text is opaque). \`pre-command\` \u2014 as \`command\`, but runs before the command: when the node has not read the document, the command does not run and the document is returned instead; the node re-issues the command. \`slash-command\` \u2014 the document is a \`/<name>\` slash command whose body is the injected prompt. \`match\` is required on \`file-read\` (unless \`match_frontmatter\` is given), \`document-read\`, \`command\` and \`pre-command\`, and not allowed on \`boot\`, \`workspace-open\` and \`slash-command\`. `+k+` \`deliver\` is ${v.join(" | ")}: the bare name, the preview line, or the whole body. The flag set replaces the document's whole rule list; on \`crtr doc edit\`, \`--unset rules\` removes every rule.`,effects:["When several rules deliver one document to a node, the highest delivery wins: content over preview over name; content already in the node's context is not delivered again.","A document delivered at content is a read: it records the node as a watcher, so the node is pushed later edits.","The profile's delivery limit per owner caps what `boot` and `workspace-open` rules deliver for that owner.",'A rule that delivers at `preview` requires the preview in the shape "When <situation in the reader\'s work>, read this because <what the reader gains>"; `crtr doc lint` checks it. There the line alone decides whether a node that did not ask for the document opens it.']}}}o(S,"ruleParam");const N={preview:o(e=>({kind:"flag",name:"preview",type:"string",required:!1,constraint:e}),"preview"),kind:{kind:"flag",name:"kind",type:"enum",choices:[...y],required:!1,constraint:"knowledge is consulted for facts or procedures; preference directs behavior. Default knowledge."},summary:{kind:"flag",name:"summary",type:"string",required:!1,constraint:"A very short version of the content, shown in `crtr canvas list`."},privacy:o(e=>({kind:"flag",name:"privacy",type:"enum",choices:[...b],required:!1,constraint:e}),"privacy"),whyItExists:{kind:"flag",name:"why-it-exists",type:"string",required:!1,constraint:"The observed failure this document exists to prevent; shown only with `crtr canvas read --fields`."},unlisted:o(e=>({kind:"flag",name:"unlisted",type:"bool",required:!1,constraint:e}),"unlisted"),lintIgnore:{kind:"flag",name:"lint-ignore",type:"string",required:!1,repeatable:!0,constraint:"A `crtr doc lint` rule name to skip for this document."},extension:{kind:"flag",name:"extension",type:"string",required:!1,repeatable:!0,constraint:"One declared plugin field as `<plugin>.<field>=<json value>`."}};function h(e){return e===void 0?[]:Array.isArray(e)?e.map(String):[String(e)]}o(h,"list");function T(e){const t=[],n={};for(const[r,a]of[["preview","preview"],["kind","kind"],["summary","summary"],["privacy","privacy"],["whyItExists","why_it_exists"]])typeof e[r]=="string"&&(n[a]=e[r]);typeof n.preview=="string"&&/[\r\n]/.test(n.preview)&&t.push("--preview must be one line"),e.unlisted===!0&&(n.unlisted=!0);const i=h(e.lintIgnore);i.length>0&&(n.lint_ignore=i);const s=h(e.rule);if(s.length>0){const r=[];s.forEach((a,u)=>{try{const d=JSON.parse(a);typeof d!="object"||d===null||Array.isArray(d)?t.push(`--rule #${u+1} must be a JSON object`):r.push(d)}catch{t.push(`--rule #${u+1} is not valid JSON: ${a}`)}}),n.rules=r}const l={};for(const r of h(e.extension)){const a=/^([^.=]+)\.([^=]+)=(.*)$/s.exec(r);if(!a){t.push(`--extension ${r} is not <plugin>.<field>=<json value>`);continue}try{(l[a[1]]??={})[a[2]]=JSON.parse(a[3])}catch{t.push(`--extension ${r}: the value after = is not valid JSON`)}}if(Object.keys(l).length>0&&(n.extensions=l),t.length>0)throw m(t.join("; "),{next:"Fix every value above and rerun; `--rule -h` gives the rule grammar."});return n}o(T,"fieldsFromInput");function D(e){const t=e.owner;if(t===void 0)return{};const n=String(t);if(!/^(node|user|profile|app|repo)$|^(profile|repo):.+$/.test(n))throw m(`invalid --owner ${n}`,{field:"owner",next:`Pass one of ${p}.`});return{owner:n}}o(D,"ownerFromInput");function I(e,t){e instanceof w&&(e.code==="read_first"&&c(e,`Read it first with \`crtr canvas read ${t}\`, then rerun the edit.`),e.code==="stale_revision"&&c(e,`Someone edited it after your read. Read it again with \`crtr canvas read ${t}\`, make your change on the current text, and rerun the edit.`)),c(e)}o(I,"rethrowDocError");function R(e){const t=[],n=e.unresolved_links,i=e.remote_links;return n?.length&&t.push(`Unresolved links (no document by these names yet): ${n.map(s=>`[[${s}]]`).join(", ")}`),i?.length&&t.push(`Not on this runtime: ${i.map(s=>`[[${s}]]`).join(", ")} \u2014 each may be on another runtime, or the name may be misspelled.`),t.map(s=>`
2
- ${s}`).join("")}o(R,"renderLinkReport");export{N as FIELD_FLAGS,p as OWNER_VALUES,j as REF_CONSTRAINT,T as fieldsFromInput,D as ownerFromInput,O as ownerParam,R as renderLinkReport,I as rethrowDocError,S as ruleParam};
1
+ var f=Object.defineProperty;var o=(e,t)=>f(e,"name",{value:t,configurable:!0});import{usage as m}from"../../core/errors.js";import{ApiError as w}from"../../api/errors.js";import{rethrowAsCliError as h}from"../api-client.js";import{RULE_ON as g,RULE_DELIVER as v,DOC_KINDS as y}from"../../api/dto/docs.js";import{PRIVACY_LEVELS_DTO as b}from"../../api/dto/objects.js";const j="A document name (with or without [[ ]]) or a raw object id.",p="node | user | profile | profile:<name> | app | repo | repo:<handle>";function O(e){return{kind:"flag",name:"owner",type:"string",required:!1,constraint:e,focusedHelp:{whenToUse:"Omit it for a document only this node and the nodes it points at need: the writing node owns it and it is deleted with the node, unless a kept document links to it. Give another owner when the document must outlive this node or reach other nodes by itself. A document worth another owner carries reusable current truth: a non-obvious procedure, a gotcha, a lasting preference, a convention across repos. Task notes, conversation summaries and facts the repo already records stay with the node or are not written. Before writing one, search for a document that already covers it (`crtr canvas search`) and revise that one with `crtr doc edit` instead of adding a near copy. When one document grows long, keep the most important content in it and move separate subjects into documents named under it (`topic/part`), each pointed at with a `[[link]]`. Choose the narrowest owner that still reaches the next agent who needs it.",value:`One of ${p}. \`node\` \u2014 the writing node; only this node loads it by rule, other nodes read it when pointed at it; deleted with the node. \`profile\` \u2014 the node's profile, or \`profile:<name>\`; loads for nodes running in that profile; stays on this runtime. \`app\` \u2014 the app that runs the node; kept until deleted; readable by that app's nodes. \`repo\` \u2014 the repo the node works in (from its working directory), or \`repo:<handle>\`; every clone and worktree of the repo shares it, and it syncs to collaborators over the repo's \`refs/crtr/docs\`. A repo owns only public documents: a private or friends document that should load in one repo is owned by \`user\` and carries a rule with \`if: {"repo": "<handle>"}\`. \`user\` \u2014 you, the person; loads for every node by its rules; readable by apps by privacy level. Default: the writing node; a person's own run (not a node) defaults to \`user\`. The owner becomes the start of the document's full name (\`<owner-handle>/<name>\`); \`crtr doc move --owner\` changes it later and keeps the id and every link.`,effects:["Decides who may read and change the document, where it syncs, and what deleting takes it with."]}}}o(O,"ownerParam");const k="`if` is a condition on the receiving node. Fields: `kind`, `mode`, `lifecycle`, `cwd`, `repo` (the handle of a repo the node is eligible for: those enclosing its cwd and those of its profile's projects; use `cwd` for where it works), `profile`, `depth`. A field map is ANDed across fields; dotted fields resolve nested values. A scalar matches by equality (a list value matches when any element is equal); a list matcher matches by membership or overlap; an object matcher takes `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, `lte`. `all`, `any` and `not` combine conditions; field matchers beside them are ANDed in. An empty condition always matches; an unknown operator never matches. `match_frontmatter` uses the same language against the read file's YAML frontmatter.";function S(e){return{kind:"flag",name:"rule",type:"string",required:!1,repeatable:!0,constraint:e,focusedHelp:{whenToUse:"Omit it for most documents: a document with no rule is read when a node is pointed at it (`[[name]]`), finds it with `crtr canvas search`, or sees it in a listing. Add a rule only when the document must load on its own because of what a node is doing; every rule costs context in every node it fires on. Prefer the narrowest trigger: a `file-read` rule with a real glob over `boot` for knowledge about specific code; `boot` for knowledge about a person or process with no file boundary. When the whole document is one sentence, deliver `content` \u2014 a preview line would be longer than the document.",value:`One JSON object per occurrence: \`{"on": \u2026, "match"?: [globs], "match_frontmatter"?: {\u2026}, "if"?: {\u2026}, "deliver": \u2026}\`. \`on\` is the node's activity: ${g.join(" | ")}. \`boot\` \u2014 every session start of the node. \`workspace-open\` \u2014 the node's first message when it works in the document's repo. \`file-read\` \u2014 the node reads the content of a file whose absolute path or basename matches a \`match\` glob, or whose frontmatter matches \`match_frontmatter\`; through the Read tool, or a bash segment of \`cat\`, \`sed\` (not \`-i\`), \`head\`, \`tail\` (not \`-f\`), \`nl\` or \`awk\` on a literal path (resolved after the command's own \`cd\`s; \`rg\`, \`grep\`, \`find\`, \`ls\`, \`wc\`, \`stat\` are searches, not reads, and never fire it). \`document-read\` \u2014 another document whose name matches is delivered at content, including an explicit read. \`command\` \u2014 a shell segment of a bash command matches (segments split on \`&&\`, \`||\`, \`;\`, \`|\` and newlines outside quotes, leading \`VAR=value\` dropped; \`*\` spans whole whitespace-separated tokens, \`?\` one character, quoted text is opaque). \`pre-command\` \u2014 as \`command\`, but runs before the command: when the node has not read the document, the command does not run and the document is returned instead; the node re-issues the command. \`slash-command\` \u2014 the document is a \`/<name>\` slash command whose body is the injected prompt. \`match\` is required on \`file-read\` (unless \`match_frontmatter\` is given), \`document-read\`, \`command\` and \`pre-command\`, and not allowed on \`boot\`, \`workspace-open\` and \`slash-command\`. `+k+` \`deliver\` is ${v.join(" | ")}: the bare name, the preview line, or the whole body. The flag set replaces the document's whole rule list; on \`crtr doc edit\`, \`--unset rules\` removes every rule.`,effects:["When several rules deliver one document to a node, the highest delivery wins: content over preview over name; content already in the node's context is not delivered again.","A document delivered at content is a read: it records the node as a watcher, so the node is pushed later edits.","The profile's delivery limit per owner caps what `boot` and `workspace-open` rules deliver for that owner.",'A rule that delivers at `preview` requires the preview in the shape "When <situation in the reader\'s work>, read this because <what the reader gains>"; `crtr doc lint` checks it. There the line alone decides whether a node that did not ask for the document opens it.']}}}o(S,"ruleParam");const N={preview:o(e=>({kind:"flag",name:"preview",type:"string",required:!1,constraint:e}),"preview"),kind:{kind:"flag",name:"kind",type:"enum",choices:[...y],required:!1,constraint:"knowledge is consulted for facts or procedures; preference directs behavior. Default knowledge."},summary:{kind:"flag",name:"summary",type:"string",required:!1,constraint:"A very short version of the content, shown in `crtr canvas list`."},privacy:o(e=>({kind:"flag",name:"privacy",type:"enum",choices:[...b],required:!1,constraint:e}),"privacy"),whyItExists:{kind:"flag",name:"why-it-exists",type:"string",required:!1,constraint:"The observed failure this document exists to prevent; shown only with `crtr canvas read --fields`."},unlisted:o(e=>({kind:"flag",name:"unlisted",type:"bool",required:!1,constraint:e}),"unlisted"),lintIgnore:{kind:"flag",name:"lint-ignore",type:"string",required:!1,repeatable:!0,constraint:"A `crtr doc lint` rule name to skip for this document."},extension:{kind:"flag",name:"extension",type:"string",required:!1,repeatable:!0,constraint:"One declared plugin field as `<plugin>.<field>=<json value>`."}};function c(e){return e===void 0?[]:Array.isArray(e)?e.map(String):[String(e)]}o(c,"list");function T(e){const t=[],n={};for(const[r,a]of[["preview","preview"],["kind","kind"],["summary","summary"],["privacy","privacy"],["whyItExists","why_it_exists"]])typeof e[r]=="string"&&(n[a]=e[r]);typeof n.preview=="string"&&/[\r\n]/.test(n.preview)&&t.push("--preview must be one line"),e.unlisted===!0&&(n.unlisted=!0);const i=c(e.lintIgnore);i.length>0&&(n.lint_ignore=i);const s=c(e.rule);if(s.length>0){const r=[];s.forEach((a,u)=>{try{const d=JSON.parse(a);typeof d!="object"||d===null||Array.isArray(d)?t.push(`--rule #${u+1} must be a JSON object`):r.push(d)}catch{t.push(`--rule #${u+1} is not valid JSON: ${a}`)}}),n.rules=r}const l={};for(const r of c(e.extension)){const a=/^([^.=]+)\.([^=]+)=(.*)$/s.exec(r);if(!a){t.push(`--extension ${r} is not <plugin>.<field>=<json value>`);continue}try{(l[a[1]]??={})[a[2]]=JSON.parse(a[3])}catch{t.push(`--extension ${r}: the value after = is not valid JSON`)}}if(Object.keys(l).length>0&&(n.extensions=l),t.length>0)throw m(t.join("; "),{next:"Fix every value above and rerun; `--rule -h` gives the rule grammar."});return n}o(T,"fieldsFromInput");function R(e){const t=e.owner;if(t===void 0)return{};const n=String(t);if(!/^(node|user|profile|app|repo)$|^(profile|repo):.+$/.test(n))throw m(`invalid --owner ${n}`,{field:"owner",next:`Pass one of ${p}.`});return{owner:n}}o(R,"ownerFromInput");function D(e,t){e instanceof w&&(e.code==="read_first"&&h(e,`Read it first with \`crtr canvas read ${t}\`, then rerun the edit.`),e.code==="stale_revision"&&h(e,`Someone edited it after your read. Read it again with \`crtr canvas read ${t}\`, make your change on the current text, and rerun the edit.`)),h(e)}o(D,"rethrowDocError");function I(e){const t=[],n=e.unresolved_links,i=e.remote_links;return n?.length&&t.push(`Unresolved links (no document by these names yet): ${n.map(s=>`[[${s}]]`).join(", ")}`),i?.length&&t.push(`Not on this runtime: ${i.map(s=>`[[${s}]]`).join(", ")} \u2014 each may be on another runtime, or the name may be misspelled.`),t.map(s=>`
2
+ ${s}`).join("")}o(I,"renderLinkReport");export{N as FIELD_FLAGS,p as OWNER_VALUES,j as REF_CONSTRAINT,T as fieldsFromInput,R as ownerFromInput,O as ownerParam,I as renderLinkReport,D as rethrowDocError,S as ruleParam};
@@ -0,0 +1,20 @@
1
+ import { type CommandToken } from './shell-segments.js';
2
+ /** What the extractor may expand. Anything else that looks dynamic is skipped. */
3
+ export interface ShellReadEnv {
4
+ /** `~` and `$HOME`. Defaults to `os.homedir()`. */
5
+ home?: string;
6
+ /** `$CRTR_CONTEXT_DIR`; unset means the variable is treated as unknown. */
7
+ contextDir?: string;
8
+ }
9
+ /** The file arguments of one segment whose head is in the catch list, as the
10
+ * author wrote them (not yet resolved), or null when the segment is not a
11
+ * content read: a `sed -i` edit, a `tail -f`, stdin-only, no file at all. */
12
+ export declare function segmentFileArgs(tokens: CommandToken[]): string[] | null;
13
+ /** Absolute paths of every file whose content `command` reads, in order of
14
+ * first appearance. `cwd` is the directory the command starts in; a `cd` in
15
+ * the command moves it for the segments after, a `cd` the extractor cannot
16
+ * resolve makes it unknown (relative paths after it are skipped), and a `cd`
17
+ * inside `( … )` does not leak past the closing paren. The splitter's
18
+ * `background` flag is ignored: it also fires on the `&` inside `2>&1`, which
19
+ * trails a third of all `cat FILE` reads. */
20
+ export declare function bashFileReads(command: string, cwd: string, env?: ShellReadEnv): string[];
@@ -0,0 +1 @@
1
+ var W=Object.defineProperty;var f=(i,o)=>W(i,"name",{value:o,configurable:!0});import{homedir as F}from"node:os";import{isAbsolute as S,resolve as m}from"node:path";import{commandSegments as E,stripHeredocs as D}from"./shell-segments.js";const R=new Set(["cat","sed","head","tail","nl","awk"]),b=/^(\d*[<>]{1,2}|&>|>\||<<-?|<&|>&)$/,A=/^(\d*[<>]|&>)/;function $(i){return/[$`*?[{}]/.test(i)||i.endsWith(")")||i.startsWith("(")}f($,"isDynamic");function q(i,o){const u=o.home??F();let n=i;return(n==="~"||n.startsWith("~/"))&&(n=u+n.slice(1)),n=n.replace(/^\$\{?HOME\}?(?=\/|$)/,u),o.contextDir!==void 0&&(n=n.replace(/^\$\{?CRTR_CONTEXT_DIR\}?(?=\/|$)/,o.contextDir)),$(n)?null:n}f(q,"expandKnown");function g(i,o){const u=[];let n=!1;for(let e=0;e<i.length;e+=1){const t=i[e],s=t.text;if(b.test(s)){e+=1;continue}if(!A.test(s)){if(!n&&!t.opaque){if(s==="--"){n=!0;continue}if(s.startsWith("-")&&s.length>1){o.valueFlags.has(s)?e+=1:o.glued!==void 0&&o.glued.test(s);continue}}u.push(s)}}return u}f(g,"positionals");const v=new Set,O=new Set(["do","then","else","{","!"]);function _(i){if(i.length===0||i[0].opaque)return null;const o=i[0].text;if(!R.has(o))return null;const u=i.slice(1);if(u.some(e=>!e.opaque&&/\$\(|`|[<>]\(/.test(e.text)))return null;const n=u.filter(e=>!e.opaque&&e.text.startsWith("-")&&e.text.length>1).map(e=>e.text);switch(o){case"cat":case"nl":{const e=g(u,{valueFlags:o==="nl"?new Set(["-b","-d","-f","-h","-i","-l","-n","-s","-v","-w"]):v});return e.length===0?null:e}case"head":case"tail":{if(o==="tail"&&n.some(t=>/^-[a-zA-Z]*[fF]/.test(t)||t==="--follow"))return null;const e=g(u,{valueFlags:new Set(["-n","-c"]),glued:/^-(\d|n\+?\d|c\d|-lines=|-bytes=)/}).filter(t=>!/^[+-]?\d+$/.test(t));return e.length===0?null:e}case"sed":{if(n.some(a=>/^-[a-zA-Z]*i/.test(a)||a.startsWith("--in-place")))return null;const e=n.some(a=>a==="-e"||a==="-f"||/^-[a-zA-Z]*[ef]./.test(a)||a.startsWith("--expression")||a.startsWith("--file")),t=g(u,{valueFlags:new Set(["-e","-f","--expression","--file"])}),s=e?t:t.slice(1);return s.length===0?null:s}case"awk":{const e=n.some(a=>a==="-f"||/^-f./.test(a)),t=g(u,{valueFlags:new Set(["-F","-v","-f"]),glued:/^-(F|v|f)./}).filter(a=>!/^[A-Za-z_][A-Za-z0-9_]*=/.test(a)),s=e?t:t.slice(1);return s.length===0?null:s}}}f(_,"segmentFileArgs");function H(i,o,u={}){const n=[],e=new Set;let t=o,s;for(const a of E(D(i))){let l=a.tokens;if(l.length===0||!l[0].opaque&&l[0].text.startsWith("(")&&(s=t,l=[{text:l[0].text.slice(1),opaque:!1},...l.slice(1)].filter(c=>c.text!==""||c.opaque),l.length===0))continue;const x=l[l.length-1],w=s!==void 0&&!x.opaque&&x.text.endsWith(")");w&&(l=[...l.slice(0,-1),{text:x.text.slice(0,-1),opaque:!1}].filter(c=>c.text!==""||c.opaque)),l.length>1&&!l[0].opaque&&O.has(l[0].text)&&(l=l.slice(1));const d=l[0]?.text;if(d==="cd"){const c=l[1];if(c===void 0)t=u.home??F();else if(c.text==="-")t=null;else{const r=q(c.text,u);t=r===null?null:S(r)?r:t===null?null:m(t,r)}}else if(d==="pushd"||d==="popd")t=null;else if(d!==void 0){const c=_(l);for(const r of c??[]){const h=q(r,u);if(h===null)continue;const p=S(h)?m(h):t===null?null:m(t,h);p===null||e.has(p)||(e.add(p),n.push(p))}}w&&(t=s,s=void 0)}return n}f(H,"bashFileReads");export{H as bashFileReads,_ as segmentFileArgs};
@@ -1,9 +1,9 @@
1
- var y=Object.defineProperty;var c=(n,e)=>y(n,"name",{value:e,configurable:!0});import{envNodeId as p}from"../shared/env.js";import{homedir as b}from"node:os";import{join as w,resolve as g}from"node:path";import{brokerDelivery as m}from"../core/runtime/broker/daemon-ops.js";import{autoLoadedContextInner as x,mergeAutoLoadedContext as N}from"./envelope-merge.js";const O=`
1
+ var h=Object.defineProperty;var c=(n,t)=>h(n,"name",{value:t,configurable:!0});import{envNodeId as y}from"../shared/env.js";import{homedir as p}from"node:os";import{join as b,resolve as w}from"node:path";import{brokerDelivery as m}from"../core/runtime/broker/daemon-ops.js";import{bashFileReads as g}from"../core/shell-file-reads.js";import{ApiError as _}from"../api/errors.js";import{autoLoadedContextInner as x,mergeAutoLoadedContext as N}from"./envelope-merge.js";const O=`
2
2
 
3
- Guidelines:`;function _(n){const e=n?.command;return typeof e=="string"&&e.trim()!==""?e:null}c(_,"bashCommandOf");function L(n){return["HELD \u2014 nothing ran.","","Your bash command was NOT executed. It matched memory you had not yet read, and that memory is below.","","Read it, then re-issue your WHOLE command unchanged \u2014 every clause of it, including any heredoc, redirect, or `$(\u2026)` that built its input, because none of that ran either. The re-issue executes: this memory is now in your transcript, so there is nothing left to deliver and nothing left to hold.","",n].join(`
4
- `)}c(L,"heldReason");const E=["HELD \u2014 nothing ran.","","Your bash command was NOT executed. Another command in this same message was held against memory you had not read, and that memory was returned with it.","","Read it, then re-issue your WHOLE command unchanged. The re-issue executes."].join(`
5
- `);function j(n){const e=p();if(e===void 0||e.trim()==="")return;n.on("before_agent_start",async t=>{const o=(await m(e,{activity:"system-prompt"})).content;if(o==="")return;const r=t.systemPrompt.indexOf(O);return r===-1?{systemPrompt:`${t.systemPrompt}
3
+ Guidelines:`;function l(n){const t=n?.command;return typeof t=="string"&&t.trim()!==""?t:null}c(l,"bashCommandOf");function E(n){return["HELD \u2014 nothing ran.","","Your bash command was NOT executed. It matched memory you had not yet read, and that memory is below.","","Read it, then re-issue your WHOLE command unchanged \u2014 every clause of it, including any heredoc, redirect, or `$(\u2026)` that built its input, because none of that ran either. The re-issue executes: this memory is now in your transcript, so there is nothing left to deliver and nothing left to hold.","",n].join(`
4
+ `)}c(E,"heldReason");const R=["HELD \u2014 nothing ran.","","Your bash command was NOT executed. Another command in this same message was held against memory you had not read, and that memory was returned with it.","","Read it, then re-issue your WHOLE command unchanged. The re-issue executes."].join(`
5
+ `);function T(n){const t=y();if(t===void 0||t.trim()==="")return;n.on("before_agent_start",async e=>{const o=(await m(t,{activity:"system-prompt"})).content;if(o==="")return;const a=e.systemPrompt.indexOf(O);return a===-1?{systemPrompt:`${e.systemPrompt}
6
6
 
7
- ${o}`}:{systemPrompt:`${t.systemPrompt.slice(0,r)}
7
+ ${o}`}:{systemPrompt:`${e.systemPrompt.slice(0,a)}
8
8
 
9
- ${o}${t.systemPrompt.slice(r)}`}});let i=!1;n.on("turn_end",()=>{i=!1}),n.on("session_compact",async()=>{await m(e,{activity:"compact"}),i=!1}),n.on("tool_call",async t=>{if(t.toolName!=="bash")return;const o=_(t.input);if(o===null)return;if(i)return{block:!0,reason:E};const r=(await m(e,{activity:"pre-command",subject:o})).content;if(r!=="")return i=!0,{block:!0,reason:L(r)}}),n.on("tool_result",async(t,o)=>{if(t.isError===!0)return;let r=null,d=null;if(t.toolName==="read"){const a=t.input,s=a?.path??a?.file_path;if(s===void 0||s==="")return;const f=typeof o?.cwd=="string"&&o.cwd!==""?o.cwd:process.cwd(),h=s.startsWith("~")?w(b(),s.slice(1)):s;r=g(f,h)}else if(t.toolName==="bash"){const a=t.input;if(typeof a?.command!="string"||a.command.trim()==="")return;d=a.command}else return;const u=(await m(e,r!==null?{activity:"file-read",subject:r}:{activity:"command",subject:d})).content;if(u==="")return;const l=x(u);if(l===null)throw new Error("substrate renderer returned an invalid auto-loaded context block");return{content:N([l],t.content,t.toolName==="bash"?"after":"before")}})}c(j,"registerCanvasDocSubstrate");var C=j;export{C as default,j as registerCanvasDocSubstrate};
9
+ ${o}${e.systemPrompt.slice(a)}`}});let s=!1;n.on("turn_end",()=>{s=!1}),n.on("session_compact",async()=>{await m(t,{activity:"compact"}),s=!1}),n.on("tool_call",async e=>{if(e.toolName!=="bash")return;const o=l(e.input);if(o===null)return;if(s)return{block:!0,reason:R};const a=(await m(t,{activity:"pre-command",subject:o})).content;if(a!=="")return s=!0,{block:!0,reason:E(a)}}),n.on("tool_result",async(e,o)=>{if(e.isError===!0)return;const a=typeof o?.cwd=="string"&&o.cwd!==""?o.cwd:process.cwd(),u=[],d=c(i=>{if(i==="")return;const r=x(i);if(r===null)throw new Error("substrate renderer returned an invalid auto-loaded context block");u.push(r)},"collect");if(e.toolName==="read"){const i=e.input,r=i?.path??i?.file_path;if(r===void 0||r==="")return;const f=r.startsWith("~")?b(p(),r.slice(1)):r;d((await m(t,{activity:"file-read",subject:w(a,f)})).content)}else if(e.toolName==="bash"){const i=l(e.input);if(i===null)return;d((await m(t,{activity:"command",subject:i})).content);for(const r of g(i,a,{contextDir:process.env.CRTR_CONTEXT_DIR}))d(await L(t,r))}else return;if(u.length!==0)return{content:N(u,e.content,e.toolName==="bash"?"after":"before")}})}c(T,"registerCanvasDocSubstrate");async function L(n,t){try{return(await m(n,{activity:"file-read",subject:t})).content}catch(s){if(s instanceof _&&s.code==="scope_missing")return"";throw s}}c(L,"fileReadDelivery");var v=T;export{v as default,T as registerCanvasDocSubstrate};
@@ -36,7 +36,7 @@ npx @north-light/sdk skill
36
36
 
37
37
  The project copy is the default so it is committed with the project, and teammates get it from git. It matches the SDK version your project pins.
38
38
 
39
- Checked: the files `init` writes, and that `skill` rewrites both, from the SDK source built on this branch. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
39
+ Checked on production: the files `init` writes, that `init` in a directory that already has a `package.json` pins the SDK and writes the key and skill but no app code, and that `skill` rewrites both skill files. `init` in such a directory writes no `.gitignore`, so add `local/` to yours. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
40
40
 
41
41
  ## Without a project: the hosted skill
42
42
 
@@ -46,6 +46,8 @@ An agent that has no project yet can read the hosted copy:
46
46
 
47
47
  The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
48
48
 
49
+ Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory): given only that sentence it read the skill and asked what to build. Given the sentence plus "I want the quickstart app: sign in with Northlight, start a run, text me", it ran `init my-app`, read the generated `server.ts`, and stopped with the console steps and the public key for you to paste, asking for the client ID. Name what you want built in the prompt.
50
+
49
51
  ## How it stays current
50
52
 
51
53
  The skill's frontmatter carries `metadata.sdk-version`. Its first instruction tells the agent to compare that number with `node_modules/@north-light/sdk/package.json`. If they differ, the agent runs `npx @north-light/sdk skill`, which rewrites the skill from the installed package. A skill therefore always documents the SDK your project pins, and a newer SDK never gets documented against an older one.
@@ -79,4 +79,4 @@ Test it: send a callback with a recognizable fake code and assert that the code,
79
79
 
80
80
  ## Checked and not checked
81
81
 
82
- Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.
82
+ Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in as the account that owns the app, through the consent page and the callback, was run on production with the quickstart server. A sign-in by a second person, token refresh and the refusals are not yet checked on production.
@@ -56,4 +56,4 @@ if (context?.state !== 'ready') {
56
56
 
57
57
  ## Checked and not checked
58
58
 
59
- From the SDK source (`providers.tools`, `providers.call`, `grant.get`) and the directory source. The sms limits come from the existing SDK guide. A real `providers.call` for `sms` on production is not yet checked.
59
+ From the SDK source (`providers.tools`, `providers.call`, `grant.get`) and the directory source. The sms limits come from the existing SDK guide. A real `providers.call` for `sms` on production returned a `message_id` for a text to the signed-in person. That the text arrived is not yet checked.
@@ -91,4 +91,4 @@ A run you start and do not wait on ends while nobody holds a request. `oauth.wat
91
91
 
92
92
  ## Checked and not checked
93
93
 
94
- From the SDK source and its tests. The `runs.start`, `runs.wait` and `activity` behavior against a real runtime on production is not yet checked.
94
+ From the SDK source and its tests. On production, `runs.start` and `runs.wait` ran the quickstart's prompt and settled with status `idle`; the run's reply text and `activity` are not yet checked.
@@ -59,6 +59,6 @@ Once your app is live you can keep developing on your laptop against production
59
59
 
60
60
  On the **Profile** tab, the **Delete this app** card lists what goes: every person's connection to the app, yours included (each person's runtime deletes the app's runs, space and schedules), the app's sign-ins and keys, and its testers and marketplace listing. This cannot be undone, and the client ID is never reused.
61
61
 
62
- To delete, type the app's full name in **Type the app's name, *name*, to confirm**. **Delete app** stays disabled until what you typed matches the name (surrounding spaces are ignored), and the directory checks the name again when you submit. After it deletes, the console returns to your app list. The app's next refresh for any person fails with `invalid_grant`, its key stops working and its page answers 404.
62
+ To delete, type the app's full name in **Type the app's name, *name*, to confirm**. **Delete app** stays disabled until what you typed matches the name (surrounding spaces are ignored), and the directory checks the name again when you submit. After it deletes, the console returns to your app list. Checked on production: the app is gone from the list, the console's API answers 404 `App not found` for it, and `/authorize` with its client ID shows "We couldn't start that sign-in" (HTTP 400). The app's next refresh for any person failing with `invalid_grant` is not yet checked on production.
63
63
 
64
64
  The card is not shown for a provider or a first-party app, and the directory refuses to delete a provider (`app_is_provider`). If you only want a clean slate for tests, changing the app's redirect URIs and keys is often simpler.
@@ -11,7 +11,7 @@ npx @north-light/sdk@latest init my-app
11
11
 
12
12
  This command creates a small Node server, a signing key and an env file. About ten minutes later you have an app on your laptop that signs you in with Northlight, starts an agent run on your own Northlight runtime, and texts your phone. The app runs on your machine and talks to Northlight's production directory at `https://account.northlight.ai`. There is no local Northlight stack to run, and you do not need crouter.
13
13
 
14
- You need Node.js 22.19 or later and a phone that can receive iMessage.
14
+ You need Node.js 22 or later and a phone that can receive iMessage. The quickstart ran on Node 22.14, 22.22 and 26.12; `--env-file` needs Node 20.6 or later.
15
15
 
16
16
  Using a coding agent? Run the same `init` command, or tell your agent: *Read https://sdk.docs.northlight.ai/SKILL.md and follow it.* See [Use your coding agent](/docs/coding-agents).
17
17
 
@@ -21,7 +21,7 @@ Each step below ends with what you should see when it worked. A step marked "(no
21
21
 
22
22
  Open `https://account.northlight.ai/signup` and create your account. Signup texts a confirmation code to your phone, and that text is sent over iMessage. A number that cannot receive iMessage gets "We couldn't send the text", and retrying does not help. One phone number belongs to one account.
23
23
 
24
- You should see: your account's page after you enter the code. Opening `https://account.northlight.ai/console` while signed in shows the console, where your apps are listed. Checked on production: `/signup` redirects to the phone step at `/signup/phone`, and `/console` while signed out redirects to sign-in. Completing signup and the signed-in console are not yet checked on production.
24
+ You should see: your account's page after you enter the code. Opening `https://account.northlight.ai/console` while signed in shows the console, where your apps are listed. Checked on production: `/signup` redirects to the phone step at `/signup/phone`, which asks for a name and a phone number and offers **Text me a confirmation**; `/console` while signed out redirects to sign-in; and the signed-in console lists your apps and has **Register an app**. Completing signup, the confirmation code and the "We couldn't send the text" refusal are not yet checked on production (the walk used an existing account).
25
25
 
26
26
  ## 2. Scaffold the app
27
27
 
@@ -49,7 +49,7 @@ You should see, on the terminal: `Created a Northlight app in my-app:` with the
49
49
 
50
50
  Copy that line. You paste it into the console in the next step. If a file already exists, `init` refuses, names it and exits non-zero without writing anything.
51
51
 
52
- Checked: this output and these files, from the SDK source built on this branch in an empty directory with no crtr on `PATH`. `npx @north-light/sdk@latest` fetches the npm `latest` release, and that release does not include `init` until the site goes live (not yet checked on production).
52
+ Checked on production: `npx @north-light/sdk@latest init my-app` (npm `latest` 0.3.476) in an empty directory with no crtr on `PATH` printed this output and wrote these files, the private key has mode `0600`, and `server.ts` is the one listed at the end of this page. Running it again names the skill file and writes nothing.
53
53
 
54
54
  ## 3. Register the app in the console
55
55
 
@@ -66,17 +66,17 @@ Open `https://account.northlight.ai/console/new`. The page is titled **Register
66
66
 
67
67
  Choose **Register app**.
68
68
 
69
- You should see: a page titled "*your app's name* is registered" with a **Client ID** and, under it, the key ID of the key you pasted. Copy the client ID. If it says "Next: add your app's public key on the Sign-in tab", the key was left out: open the app's **Sign-in** tab and choose **Add key**. The app starts asking for `crtr:llm` and `crtr:act`.
69
+ You should see: a page titled "*your app's name* is registered" with a **Client ID** and, under it, the key ID of the key you pasted. Copy the client ID, then choose **Continue to *your app's name***. If it says "Next: add your app's public key on the Sign-in tab", the key was left out: open the app's **Sign-in** tab and choose **Add key**. The app starts asking for `crtr:llm` and `crtr:act`.
70
70
 
71
- The field labels are from the console's source. Pasting a key at registration is not yet checked on production.
71
+ Checked on production: these field labels, registering with a pasted key, and the app page opening in testing with that key active and `crtr:llm` and `crtr:act` listed. The "Next: add your app's public key" message, which shows when the key is left out, is not yet checked on production.
72
72
 
73
73
  ## 4. Add the permission to text
74
74
 
75
- On the app's page, open the **What it asks for** tab and choose **Add permissions**. Add `sms:send` from the list.
75
+ On the app's page, open the **What it asks for** tab and choose **Add permissions**. Choose **Texting**, tick `sms:send` ("Send you text messages from Northlight's number"), and choose **Done**. Then choose **Save** under the list: **Done** only closes the dialog, and the change is not stored until you save.
76
76
 
77
- You should see: `sms:send` under what your app asks for, next to `crtr:llm` and `crtr:act`. The server in this guide asks for all three, so without `sms:send` the consent page does not offer it and the text in step 6 is refused. Take scope strings from this list. Do not invent them. See [Scopes and grants](/docs/concepts/scopes-and-grants).
77
+ You should see: `sms:send` under what your app asks for after **Save** says "Saved", next to `crtr:llm` and `crtr:act`. The server in this guide asks for all three, so without `sms:send` the consent page does not offer it and the text in step 6 is refused. Take scope strings from this list. Do not invent them. See [Scopes and grants](/docs/concepts/scopes-and-grants).
78
78
 
79
- Not yet checked on production.
79
+ Checked on production: after **Save** and a reload, the tab lists `crtr:llm` and `crtr:act` under Agents and `sms:send` under Texting, and the consent page in step 6 offered the text permission.
80
80
 
81
81
  ## 5. Write the client ID into the env file
82
82
 
@@ -96,7 +96,7 @@ Key file: local/app-key.json (set CROUTER_KEY_FILE to change it).
96
96
 
97
97
  `local/app.env` now has all four settings. The command makes no network call and keeps every other line in the file. Add `--origin <url>` if your app does not run on `http://localhost:3000`.
98
98
 
99
- Checked from the SDK source built on this branch.
99
+ Checked on production: this output, and that the command works in a directory with no `local/` yet. `--origin` is not yet checked on production.
100
100
 
101
101
  ## 6. Run it
102
102
 
@@ -107,9 +107,9 @@ node --env-file=local/app.env --import tsx server.ts
107
107
 
108
108
  (`npm start` runs the same command.) You should see `Listening on http://localhost:3000`.
109
109
 
110
- Open `http://localhost:3000/auth/login`. Your browser goes to `account.northlight.ai`. Sign in if asked, read the consent page, and choose Allow. You land back on `http://localhost:3000/` with "Signed in" and your Northlight user id. Press **Start a run and send me a text**. The page answers "Done", shows the run's status `idle`, and your phone receives "Hello from my first Northlight app." from Northlight's number.
110
+ Open `http://localhost:3000/auth/login`. Your browser goes to `account.northlight.ai`. Sign in if asked, read the consent page, and choose Allow. You land back on `http://localhost:3000/` with "Signed in" and your Northlight user id. Press **Start a run and send me a text**. The page answers "Done", shows the run's status `idle` and the JSON of the text request (a `message_id` and a `note` from the sms provider), and your phone receives "Hello from my first Northlight app." from Northlight's number.
111
111
 
112
- What was checked: the server starts, `/` shows the sign-in link, and `/auth/login` answers `303` to `https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…`. The consent page, the callback, the run and the text are not yet checked on production.
112
+ Checked on production, as the account that owns the app: the server starts (with `npm start` too), `/` shows the sign-in link, and `/auth/login` answers `303` to `https://account.northlight.ai/authorize?client_id=…&scope=openid+profile+crtr%3Allm+crtr%3Aact+sms%3Asend…&code_challenge_method=S256…`. The consent page listed the four permissions and the monthly cap, **Allow** returned to `http://localhost:3000/` with "Signed in" and the user id, and the button page answered "Done" with status `idle` and a `message_id` for the text. In the console the key then read "Last used just now". Not yet checked on production: that the text reached the phone (the `message_id` is the provider's answer, not a delivery receipt), that the run's reply was `pong`, and the `426` case. Signing in as a second person needs a second account and is not covered here.
113
113
 
114
114
  If `/` shows "One step left", `CROUTER_CLIENT_ID` is empty: repeat step 5 and restart. If a call fails with `426 runtime_version_unsupported`, your SDK is newer than the Northlight runtimes. Install the previous version with `npm i --save-exact @north-light/sdk@<version>` and tell support@northlight.ai.
115
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.476",
3
+ "version": "0.3.478",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  "license": "UNLICENSED",
139
139
  "dependencies": {
140
- "@north-light/identity": "0.3.476",
140
+ "@north-light/identity": "0.3.478",
141
141
  "@earendil-works/pi-agent-core": "1.1.0",
142
142
  "@earendil-works/pi-ai": "1.1.0",
143
143
  "@earendil-works/pi-coding-agent": "1.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.476",
3
+ "version": "0.3.478",
4
4
  "description": "Northlight access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.476",
3
+ "version": "0.3.478",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.476",
9
+ "version": "0.3.478",
10
10
  "hasInstallScript": true,
11
11
  "license": "UNLICENSED",
12
12
  "workspaces": [
@@ -18,7 +18,7 @@
18
18
  "@earendil-works/pi-ai": "1.1.0",
19
19
  "@earendil-works/pi-coding-agent": "1.1.0",
20
20
  "@earendil-works/pi-tui": "1.1.0",
21
- "@north-light/identity": "0.3.476",
21
+ "@north-light/identity": "0.3.478",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -12116,17 +12116,17 @@
12116
12116
  },
12117
12117
  "packages/crouter-api": {
12118
12118
  "name": "@north-light/api",
12119
- "version": "0.3.476",
12119
+ "version": "0.3.478",
12120
12120
  "license": "UNLICENSED"
12121
12121
  },
12122
12122
  "packages/crouter-env-docker": {
12123
12123
  "name": "@north-light/env-docker",
12124
- "version": "0.3.476",
12124
+ "version": "0.3.478",
12125
12125
  "license": "UNLICENSED"
12126
12126
  },
12127
12127
  "packages/crouter-identity": {
12128
12128
  "name": "@north-light/identity",
12129
- "version": "0.3.476",
12129
+ "version": "0.3.478",
12130
12130
  "license": "UNLICENSED",
12131
12131
  "dependencies": {
12132
12132
  "jose": "^6.2.1"
@@ -12134,20 +12134,20 @@
12134
12134
  },
12135
12135
  "packages/crouter-plugin": {
12136
12136
  "name": "@north-light/plugin",
12137
- "version": "0.3.476",
12137
+ "version": "0.3.478",
12138
12138
  "license": "UNLICENSED",
12139
12139
  "dependencies": {
12140
- "@north-light/api": "0.3.476",
12141
- "@north-light/identity": "0.3.476"
12140
+ "@north-light/api": "0.3.478",
12141
+ "@north-light/identity": "0.3.478"
12142
12142
  }
12143
12143
  },
12144
12144
  "packages/crouter-sdk": {
12145
12145
  "name": "@north-light/sdk",
12146
- "version": "0.3.476",
12146
+ "version": "0.3.478",
12147
12147
  "license": "UNLICENSED",
12148
12148
  "dependencies": {
12149
- "@north-light/api": "0.3.476",
12150
- "@north-light/identity": "0.3.476",
12149
+ "@north-light/api": "0.3.478",
12150
+ "@north-light/identity": "0.3.478",
12151
12151
  "jose": "^6.2.1"
12152
12152
  },
12153
12153
  "bin": {