@sous-io/sous 0.2.2 → 0.2.4

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
@@ -41,16 +41,22 @@ npm install
41
41
  npm link
42
42
  ```
43
43
 
44
- Then set up a project. A project needs a `.sous/` directory holding a config file, named
45
- `sous.config.js`, `sous.config.mjs`, `sous.config.json`, or `sous.config.yaml`:
44
+ Then set up a project:
46
45
 
47
46
  ```bash
48
47
  cd /path/to/your/project
49
- mkdir .sous
50
- $EDITOR .sous/sous.config.js
48
+ sous init
51
49
  ```
52
50
 
53
- A config that compiles one file:
51
+ `sous init` writes the project's `.sous/` directory and runs the first build. It creates a
52
+ commented `sous.config.js` (pass `--format json` for a JSON config bound to the shipped
53
+ schema), a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md`
54
+ at the project root, the two env files described below, and a `.sous/.gitignore` covering the
55
+ files sous keeps local to one machine. The first build compiles the starter prompt and the
56
+ `core` skills every project gets, and pins them in `.sous/sous.lock.json`. A project that
57
+ already holds a config is left untouched.
58
+
59
+ The config it writes compiles one file:
54
60
 
55
61
  ```js
56
62
  export const config = {
@@ -59,17 +65,19 @@ export const config = {
59
65
  compilation: {
60
66
  targets: [
61
67
  {
62
- entryPoint: "${sousDir}/AGENTS.md",
63
- outputs: [{ destinationFile: "${projectRoot}/CLAUDE.md" }],
68
+ entryPoint: "${sousDir}/prompts/AGENTS.md",
69
+ outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],
64
70
  },
65
71
  ],
66
72
  },
73
+ recipeOutputs: { skills: ["${projectRoot}/.claude/skills"] },
67
74
  };
68
75
  ```
69
76
 
70
77
  `${sousDir}` is the `.sous/` directory sous found, so a config can name paths relative to
71
- itself without hardcoding anything machine-specific. One config describes one project. Then
72
- build:
78
+ itself without hardcoding anything machine-specific. One config describes one project. A
79
+ `.sous/` may hold one config file, named `sous.config.js`, `sous.config.mjs`,
80
+ `sous.config.json`, `sous.config.jsonc` or `sous.config.yaml`. After editing, build:
73
81
 
74
82
  ```bash
75
83
  sous build
@@ -95,6 +103,7 @@ Useful commands:
95
103
 
96
104
  | Command | What it does |
97
105
  |---|---|
106
+ | `sous init` | Set a project up: write `.sous/`, then run the first build |
98
107
  | `sous build` | Compile, then prune stale outputs |
99
108
  | `sous build --watch` | Rebuild on source changes |
100
109
  | `sous compile` | Compile only |
@@ -19,3 +19,5 @@
19
19
  - [Troubleshooting](repositories-troubleshooting.md)
20
20
  - **ADRs**
21
21
  - [0001: Repositories](adrs/0001-repositories.md)
22
+ - [0002: Recipe answers in the template scope](adrs/0002-recipe-answers-in-templates.md)
23
+ - [0003: Project setup with sous init](adrs/0003-project-init.md)
@@ -46,6 +46,27 @@ Every topic answers to both spellings of its name: `repo` and `repos`, `subscrip
46
46
 
47
47
  ## Top-level commands
48
48
 
49
+ ### `sous init [DIRECTORY]`
50
+ Sets a project up for sous: writes its `.sous/` directory, then runs the first build. It is the one command that
51
+ runs before a config exists, and the starting point for a project that has never used sous. It writes a commented
52
+ primary config, a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md` at the
53
+ project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`. The
54
+ first build compiles the starter prompt and the `core` skills, and pins them in `.sous/sous.lock.json`.
55
+
56
+ A project whose `.sous/` already holds a primary config is refused, and nothing is written. Setting up a directory
57
+ inside a project that is already set up is a question rather than an error, since a subproject may want its own
58
+ instructions; `-y, --yes` answers it ahead of time, and a run with no terminal fails naming that flag.
59
+
60
+ - `DIRECTORY`: the project directory to set up; the current one by default. `--sous-dir` and `SOUS_DIR` also say
61
+ where, when no directory is given.
62
+ - `--format <js|json>`: which config to write, `js` by default. The JSON config carries `$schema`, bound to the
63
+ schema artifact published for the running sous version.
64
+ - `--name <name>`: the display name written into the config; the directory's own name by default.
65
+ - `--no-build`: write the setup without running the first build.
66
+ - `--dry-run`: print the files that would be written without writing them.
67
+
68
+ Example: `sous init --format json`
69
+
49
70
  ### `sous build`
50
71
  Compiles this project's outputs, then removes the ones its config no longer produces. It is compile plus prune,
51
72
  and the command you want almost always. Takes `--dry-run`.
@@ -57,8 +57,9 @@ first, so setting a location var inside `.env.local` has no effect on discovery.
57
57
 
58
58
  All are hard `ConfigError`s; sous never guesses:
59
59
 
60
- - **No config found**: the error lists every directory checked during the walk and shows a
61
- minimal starter config.
60
+ - **No config found**: the error lists every directory checked during the walk and names the two
61
+ fixes: run `sous init` in the project's root directory, which writes the config and everything
62
+ else a first build needs, or pass `--config <path>`.
62
63
  - **Multiple primary configs**: two or more of `sous.config.js|mjs|json|jsonc|yaml` in the same
63
64
  `.sous/` is an error naming every candidate.
64
65
  - **Duplicate layer baseNames**: any two loaded files (primary or conf.d) whose names differ
@@ -27,7 +27,10 @@ tooling:
27
27
  { "$schema": "./path/to/sous.config.schema.json" }
28
28
  ```
29
29
 
30
- The `$schema` key is accepted at the top level and ignored by sous itself.
30
+ The `$schema` key is accepted at the top level and ignored by sous itself. A config written by
31
+ `sous init --format json` binds it to the same artifact as published on GitHub for the running
32
+ version, so an editor resolves it without a path into the installed package; point it at the
33
+ package-local copy instead when the editor should work offline.
31
34
 
32
35
  ## sous config show
33
36
 
@@ -39,7 +39,8 @@ export const config = {
39
39
  ```
40
40
 
41
41
  A JSON config may set `"$schema"` to bind the `sous.config.schema.json` artifact shipped with
42
- sous for editor autocompletion; sous accepts and ignores the key.
42
+ sous for editor autocompletion; sous accepts and ignores the key. `sous init --format json`
43
+ writes one already bound to the copy of that artifact published for the running version.
43
44
 
44
45
  ?> Configs use `${var}` syntax. Template files use LiquidJS double-brace syntax instead; the two
45
46
  are resolved at different stages and never mix.
@@ -264,7 +264,9 @@ $ sous build
264
264
  ```
265
265
 
266
266
  A variable with no answer in the committed `.sous/.env` and none in the environment renders empty
267
- rather than stopping the build; run `sous vars ask` from a terminal to fill it in.
267
+ rather than stopping the build, and the build prints a warning naming it before it compiles; run
268
+ `sous vars ask` from a terminal to fill it in. How an answer reaches a template is described in
269
+ [How a template reads an answer](repositories-variables.md#how-a-template-reads-an-answer).
268
270
 
269
271
  ## Control freshness and the store
270
272
 
@@ -24,7 +24,10 @@ echo 'name: my-project' > .sous/sous.config.yaml
24
24
  ```
25
25
 
26
26
  Everything else has a default: skills compile into `<project root>/.claude/skills`, and the
27
- lockfile, the state file and the config layers sous writes land under `.sous/`. `SOUS_HOME` puts
27
+ lockfile, the state file and the config layers sous writes land under `.sous/`. Outside a
28
+ walkthrough, `sous init` writes a fuller starting point (a commented config, a starter prompt,
29
+ the env files and the ignore block) and runs the first build for you; the one-line config is
30
+ used here so that each step shows one thing. `SOUS_HOME` puts
28
31
  the machine-wide store somewhere throwaway, so this walkthrough leaves your real one alone. See
29
32
  [The config file](configuration.md) for the keys you will want later.
30
33
 
@@ -297,20 +297,39 @@ nothing about it sensitive, committing it to `.sous/.env` is simpler: a fresh cl
297
297
 
298
298
  ## How a template reads an answer
299
299
 
300
- Answers are environment variables, and a template renders config variables. The bridge is one `_env` entry
301
- in your config, naming the variable the answer is stored under (`sous vars show` prints it as `stored-as`):
302
-
303
- ```json
304
- { "_env": { "taskFileRoot": "SOUS_VAR_TASK_FILE_ROOT" } }
305
- ```
306
-
307
- With that line `{{ taskFileRoot }}` renders in any template this project compiles, and `${taskFileRoot}`
308
- works in `_vars` and every other config value.
309
-
310
- !> Answers are not injected into the template scope on their own, and `_env` names one exact environment
311
- variable rather than walking the ladder. A recipe's own templates read the project's `_vars` and the
312
- auto-injected `sous*` variables; they do not see the answers to their own questions unless your config maps
313
- them in.
300
+ A build lays the answers into the template scope itself. For every variable a subscribed recipe publishes,
301
+ the build walks the ladder above, takes the first value it finds, and adds it to the scope under the
302
+ variable's own name. So once `taskFileRoot` is answered, `{{ taskFileRoot }}` renders in the recipe's own
303
+ skills and in any template this project compiles, and `${taskFileRoot}` works in `_vars` and every other
304
+ config value. Nothing has to be mapped by hand.
305
+
306
+ The answers sit under your config, not over it. The scope a template renders with is assembled in this
307
+ order, each layer overriding the one before:
308
+
309
+ 1. The auto-injected `sous*` variables.
310
+ 2. The recipe answers, found through the ladder.
311
+ 3. Your `_env` block.
312
+ 4. Your `_vars` block.
313
+
314
+ So a project that already carries an answer in `_vars`, or maps one through `_env`, keeps rendering exactly
315
+ what it did; the answer in the env files is simply shadowed, and `sous vars list` still reports it.
316
+
317
+ When no rung answers, the definition's own `default` is what renders, because the description a publisher
318
+ writes promises what the default does. A required variable with no answer and no default renders as an
319
+ empty string, and the build says so before it compiles, naming each such variable, the recipe that asks for
320
+ it, and `sous vars ask` as the way to answer. The build still succeeds; an unanswered question is
321
+ something to tell you about, not a reason to refuse the rest of the project.
322
+
323
+ ?> Two recipes may ask the same question. Their shared answer renders in both, and in your own templates.
324
+ When the recipe-scoped name gives one of them a different answer, that recipe's own files render its own
325
+ answer while everything else, your templates included, renders the first definition's; `sous vars show`
326
+ tells you which names are in play.
327
+
328
+ An answer is laid in exactly as it is stored: a path stays the string you typed, relative or absolute, and a
329
+ number stays text. A template that needs an absolute path from a relative answer composes one under another
330
+ name in `_vars`, for instance `taskFileDir: "${sousDir}/../${taskFileRoot}"`.
331
+
332
+ The `_env` block is still the way to reach any environment variable no recipe asks about.
314
333
 
315
334
  ## Where to go next
316
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.2
14
+ version: 0.2.4
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -40,6 +40,23 @@ import { reportCommandError } from "./utils/command-errors.js";
40
40
  * lives in `lib/interactive.ts`, which also treats a truthy `CI` and a
41
41
  * non-terminal stdin or stdout the same way.
42
42
  */
43
+ /**
44
+ * What the config-locating flags and environment said on this run, resolved to
45
+ * absolute paths but not yet turned into a config.
46
+ */
47
+ export type ConfigLocator = {
48
+ /** The working directory discovery started from. */
49
+ cwd: string;
50
+ /**
51
+ * The explicit config location, when one was given: the resolved path and
52
+ * the flag or environment variable that supplied it (`--config`,
53
+ * `--sous-config`, `SOUS_CONFIG`, `--sous-dir` or `SOUS_DIR`).
54
+ */
55
+ primary?: { value: string; source: string };
56
+ /** The conf.d directory override, when `--sous-confd` or `SOUS_CONFD` gave one. */
57
+ confDirOverride?: string;
58
+ };
59
+
43
60
  export abstract class BaseCommand extends Command {
44
61
  static baseFlags = {
45
62
  config: Flags.string({
@@ -60,11 +77,29 @@ export abstract class BaseCommand extends Command {
60
77
  "non-interactive": nonInteractiveFlag(),
61
78
  };
62
79
 
80
+ /**
81
+ * Whether a run of this command needs a project config to exist. Every
82
+ * command that works on a project leaves this true, and a run that finds no
83
+ * config fails before `run()` with the "No sous config found" block. The one
84
+ * command whose job is to CREATE the config sets it false: discovery still
85
+ * runs, so the config-locating flags still say where the project is, but
86
+ * finding nothing is the expected case rather than an error, and `settings`
87
+ * stays unset until `adoptConfig` is given the config that was written.
88
+ */
89
+ static requiresConfig = true;
90
+
63
91
  protected settings!: Settings;
64
92
 
65
93
  /** Where the active config was found. */
66
94
  protected configContext!: ConfigContext;
67
95
 
96
+ /**
97
+ * What the config-locating flags and environment said, before discovery
98
+ * turned it into a config. A command that runs without a config reads the
99
+ * project's location from here.
100
+ */
101
+ protected configLocator!: ConfigLocator;
102
+
68
103
  /** The full discovery result, including how the config was located. */
69
104
  protected discovered!: DiscoveredConfig;
70
105
 
@@ -142,6 +177,16 @@ export abstract class BaseCommand extends Command {
142
177
  [sousDirEnv, "SOUS_DIR"],
143
178
  ];
144
179
  const primary = primaryCandidates.find(([value]) => value !== undefined);
180
+ const requiresConfig = (this.constructor as typeof BaseCommand).requiresConfig;
181
+
182
+ this.configLocator = {
183
+ cwd,
184
+ primary:
185
+ primary !== undefined
186
+ ? { value: path.resolve(cwd, expandHome(primary[0] as string)), source: primary[1] }
187
+ : undefined,
188
+ confDirOverride,
189
+ };
145
190
 
146
191
  let discovered: DiscoveredConfig | null;
147
192
 
@@ -150,6 +195,9 @@ export abstract class BaseCommand extends Command {
150
195
  try {
151
196
  discovered = resolveConfigFlag(primarySource, cwd, confDirOverride, sourceLabel);
152
197
  } catch (error) {
198
+ // A command that creates the config is pointed at a place with none in
199
+ // it by design; the flag still says where, so this is not a failure.
200
+ if (!requiresConfig) return;
153
201
  displayError(error instanceof Error ? error.message : String(error), this.errorSink);
154
202
  return this.exit(1);
155
203
  }
@@ -158,10 +206,29 @@ export abstract class BaseCommand extends Command {
158
206
  }
159
207
 
160
208
  if (!discovered) {
209
+ if (!requiresConfig) return;
161
210
  displayErrorBlock(formatNotFoundMessage(), this.errorSink);
162
211
  return this.exit(1);
163
212
  }
164
213
 
214
+ await this.adoptConfig(discovered);
215
+ }
216
+
217
+ /** True once a config has been discovered or adopted and its settings loaded. */
218
+ protected get hasConfig(): boolean {
219
+ return this.discovered !== undefined;
220
+ }
221
+
222
+ /**
223
+ * Makes a discovered config THE config for the rest of the run: loads its
224
+ * env files, enumerates its layers again now that `SOUS_HOME` may have
225
+ * changed, and loads the settings. `init()` calls it for the config discovery
226
+ * found; a command that creates a config calls it for the one it wrote, so
227
+ * both go through the same steps in the same order.
228
+ *
229
+ * @param discovered - The config to adopt.
230
+ */
231
+ protected async adoptConfig(discovered: DiscoveredConfig): Promise<void> {
165
232
  // Inject .sous/.env.local and .sous/.env before anything resolves variables,
166
233
  // keeping a copy of what the shell itself set so the variables layer can
167
234
  // still tell the two apart.
@@ -172,27 +239,28 @@ export abstract class BaseCommand extends Command {
172
239
  // file-settable, and it decides where the store holding a subscribed
173
240
  // recipe's config layers is. A first pass already ran during discovery, when
174
241
  // only the real environment was known.
242
+ let refreshed: DiscoveredConfig;
175
243
  try {
176
- discovered = refreshDiscoveredConfig(discovered);
244
+ refreshed = refreshDiscoveredConfig(discovered);
177
245
  } catch (error) {
178
246
  displayErrorBlock(error instanceof Error ? error.message : String(error), this.errorSink);
179
247
  return this.exit(1);
180
248
  }
181
249
 
182
- this.discovered = discovered;
250
+ this.discovered = refreshed;
183
251
  this.configContext = {
184
- sousDir: discovered.sousDir,
185
- configPath: discovered.configPath,
186
- confDir: discovered.confDir,
187
- layerPaths: discovered.layerPaths,
252
+ sousDir: refreshed.sousDir,
253
+ configPath: refreshed.configPath,
254
+ confDir: refreshed.confDir,
255
+ layerPaths: refreshed.layerPaths,
188
256
  };
189
257
 
190
258
  // Routed through the command's error sink, not stdout: a recipe layer
191
259
  // warning must not land in the middle of `sous config show | jq`.
192
- for (const notice of discovered.recipeLayerWarnings) warning(notice, this.errorSink);
260
+ for (const notice of refreshed.recipeLayerWarnings) warning(notice, this.errorSink);
193
261
 
194
262
  try {
195
- this.settings = await loadSettings(discovered);
263
+ this.settings = await loadSettings(refreshed);
196
264
  } catch (error) {
197
265
  displayErrorBlock(error instanceof Error ? error.message : String(error), this.errorSink);
198
266
  return this.exit(1);
@@ -5,15 +5,12 @@ import { PidService } from "../lib/pid-service.js";
5
5
  import { resolveRootScope } from "../lib/settings.js";
6
6
  import { describeLinkedRepos } from "../lib/repos/links.js";
7
7
  import { resolveStoreSettings } from "../lib/repos/store/settings.js";
8
- import {
9
- subscriptionServiceFor,
10
- type SubscriptionService,
11
- } from "../lib/repos/subscription-service.js";
8
+ import { prepareRepositoriesForBuild } from "../lib/build-preparation.js";
9
+ import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
12
10
  import { buildReloadWatchConfig, startConfigReloadWatch } from "../lib/watch-loop.js";
13
11
  import type { WatchHandle } from "../lib/watch-service.js";
14
12
  import { WatchService } from "../lib/watch-service.js";
15
13
  import {
16
- blankLine,
17
14
  footer,
18
15
  heading,
19
16
  log,
@@ -87,7 +84,7 @@ export default class Build extends BaseCommand {
87
84
  });
88
85
 
89
86
  if (!flags["dry-run"] && !flags["no-compile"]) {
90
- await this.prepareRepositories(repositories);
87
+ await prepareRepositoriesForBuild(repositories);
91
88
  }
92
89
 
93
90
  heading("Building");
@@ -196,78 +193,4 @@ export default class Build extends BaseCommand {
196
193
  await new Promise(() => {}); // keep process alive
197
194
  }
198
195
  }
199
-
200
- /**
201
- * Gets this project's recipes ready to compile: restores whatever the store is
202
- * missing (a fresh clone, or a collected store) and then asks upstream for the
203
- * repositories that prefer a newer in-range version.
204
- *
205
- * Restoring asks nothing and decides nothing; it fetches exactly what the
206
- * lockfile pins. An upstream check that fails is reported and then ignored,
207
- * because a build must not depend on the network being up.
208
- *
209
- * @param repositories - The subscription service for this project.
210
- */
211
- private async prepareRepositories(repositories: SubscriptionService): Promise<void> {
212
- const needsRestore = repositories.needsRestore();
213
- if (needsRestore) {
214
- heading("Restoring recipes");
215
- blankLine();
216
- paragraph(
217
- "This project's lockfile pins recipes that are not in the store on this " +
218
- "machine, so they are being fetched at exactly the versions it records."
219
- );
220
- }
221
-
222
- const { seed, subscriptions, restored, upstream } =
223
- await repositories.prepareForBuild();
224
-
225
- // Seeding the packaged core recipe is silent when it works, which is almost
226
- // always; it is only worth a word when it could not be done at all.
227
- if (seed.skippedBecause !== undefined) warning(seed.skippedBecause);
228
-
229
- // A subscription the lockfile did not pin yet has just been pinned. That is
230
- // a change to a committed file, so it is always announced.
231
- if (subscriptions.added.length > 0 || subscriptions.moved.length > 0) {
232
- heading("Locking subscribed recipes");
233
- blankLine();
234
- for (const entry of subscriptions.added) {
235
- paragraph(` pinned: ${entry.key} at version ${entry.version}.`);
236
- }
237
- for (const change of subscriptions.moved) {
238
- paragraph(` ${change.key} moved from version ${change.from} to version ${change.to}.`);
239
- }
240
- blankLine();
241
- paragraph(
242
- "The lockfile has been updated. Commit it, so everyone building this project " +
243
- "gets exactly these versions."
244
- );
245
- footer();
246
- }
247
-
248
- for (const failure of subscriptions.failed) {
249
- warning(
250
- `Sous could not work out which version of '${failure.key}' to use, so nothing ` +
251
- `from it was compiled.\n${failure.reason}`
252
- );
253
- }
254
-
255
- if (restored !== undefined && restored.restored.length > 0) {
256
- blankLine();
257
- for (const key of restored.restored) paragraph(` restored: ${key}`);
258
- }
259
-
260
- for (const change of upstream.updated) {
261
- paragraph(` ${change.key} moved from ${change.from} to ${change.to}.`);
262
- }
263
-
264
- for (const failure of upstream.failed) {
265
- warning(
266
- `Sous could not check the repository '${failure.repo}' for a newer version, so ` +
267
- `this build uses the versions it already had.\n${failure.reason}`
268
- );
269
- }
270
-
271
- if (needsRestore) footer();
272
- }
273
196
  }
@@ -3,6 +3,7 @@ import { BaseCommand } from "../base-command.js";
3
3
  import { CompilationService } from "../lib/markdown-compiler.js";
4
4
  import { resolveCompilation, resolveRootScope } from "../lib/settings.js";
5
5
  import {
6
+ resolveProjectAnswers,
6
7
  resolveRecipeTargets,
7
8
  resolveStateFilePath,
8
9
  withRecipeTargets,
@@ -57,8 +58,9 @@ export default class Compile extends BaseCommand {
57
58
  // The recipes this project subscribes to contribute compile targets
58
59
  // alongside its own; both go through the same compiler.
59
60
  const withRecipes = () => {
60
- const scope = resolveRootScope(this.settings, this.configContext);
61
- const recipes = resolveRecipeTargets(this.settings, scope, this.configContext);
61
+ const answers = resolveProjectAnswers(this.settings, this.configContext);
62
+ const scope = resolveRootScope(this.settings, this.configContext, { answers });
63
+ const recipes = resolveRecipeTargets(this.settings, scope, this.configContext, answers);
62
64
  for (const notice of recipes.warnings) warning(notice);
63
65
  return withRecipeTargets(
64
66
  resolveCompilation(this.settings, scope),