Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/skills/swamp-extension-model/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ extensions:
3. If a community extension exists, install it instead of building from scratch
4. Only create a custom model if nothing exists locally or in the community

Note: Extensions from trusted collectives (`@swamp/*`, `@si/*`) auto-resolve on
first use — no manual `extension pull` needed. Just reference the type and swamp
installs the extension automatically.
Note: Extensions from trusted collectives (`@swamp/*`, `@si/*`, and your
membership collectives) auto-resolve on first use — no manual `extension pull`
needed. Just reference the type and swamp installs the extension automatically.

Extension models let you:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,17 @@ Models directory priority:

## Auto-Resolution Failures

Extensions from trusted collectives auto-resolve on first use. If
Extensions from trusted collectives (explicit `trustedCollectives` in
`.swamp.yaml` plus your membership collectives) auto-resolve on first use. If
auto-resolution fails:

| Symptom | Cause | Fix |
| ----------------------------- | ------------------------------------------ | -------------------------------------------------------- |
| "no matching extension found" | Extension doesn't exist in registry | `swamp extension search <query>` to find correct name |
| Network/timeout error | Can't reach swamp.club | Check connectivity; manual: `swamp extension pull @name` |
| Type not auto-resolving | Collective not trusted | Add to `trustedCollectives` in `.swamp.yaml` |
| Silent "Unknown model type" | Type uses non-`@` prefix or single segment | Use `@collective/name` format |
| Symptom | Cause | Fix |
| ----------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| "no matching extension found" | Extension doesn't exist in registry | `swamp extension search <query>` to find correct name |
| Network/timeout error | Can't reach swamp.club | Check connectivity; manual: `swamp extension pull @name` |
| Type not auto-resolving | Collective not trusted | Run `swamp auth whoami` to refresh, or add to `trustedCollectives` |
| Silent "Unknown model type" | Type uses non-`@` prefix or single segment | Use `@collective/name` format |
| Stale membership | Collectives changed since last login | Run `swamp auth whoami` to refresh cached collectives |

## Verification Commands

Expand Down
8 changes: 5 additions & 3 deletions .claude/skills/swamp-model/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,14 @@ to use that field.
```

4. **Extension not auto-resolved** — types from trusted collectives (`@swamp/*`,
`@si/*`) auto-resolve on first use. If resolution failed:
`@si/*`, and your membership collectives) auto-resolve on first use. If
resolution failed:
- Check network connectivity (registry at swamp.club)
- Check if extension exists: `swamp extension search <query>`
- Manual fallback: `swamp extension pull @collective/name`
- If using a non-default collective, add it to `trustedCollectives` in
`.swamp.yaml`
- Run `swamp auth whoami` to refresh cached membership collectives
- If using a collective you're not a member of, add it to
`trustedCollectives` in `.swamp.yaml`

## Expression Debugging

Expand Down
7 changes: 6 additions & 1 deletion .claude/skills/swamp-repo/references/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,15 @@ vaultsDir: "extensions/vaults" # optional, default shown
trustedCollectives: # optional, default: ["swamp", "si"]
- swamp
- si
trustMemberCollectives: true # optional, default: true
```

`trustedCollectives` controls which extension collectives auto-resolve on first
use. The `swamp` collective is trusted by default.
use. The `swamp` and `si` collectives are trusted by default.

Additionally, collectives the user belongs to (cached during `auth login` /
`auth whoami`) are automatically trusted. Set `trustMemberCollectives: false` to
disable this and only trust the explicit list.

### CLAUDE.md

Expand Down
13 changes: 10 additions & 3 deletions design/extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,16 @@ it, and continues.

### Trusted Collectives

The `swamp` collective is trusted by default. Extensions from `@swamp/*`
auto-resolve with no configuration required.
The `swamp` and `si` collectives are trusted by default. Extensions from
`@swamp/*` and `@si/*` auto-resolve with no configuration required.

Default trusted collectives: `["swamp", "si"]`.

Additionally, collectives the user belongs to are automatically trusted. Membership
collectives are cached in `auth.json` during `auth login` and `auth whoami`, and
merged with the explicit list at CLI startup. This means if a user is a member of
`@myorg`, extensions from `@myorg/*` auto-resolve without any configuration.

Configurable via `trustedCollectives` in `.swamp.yaml`:

```yaml
Expand All @@ -184,7 +189,9 @@ trustedCollectives:
- myorg
```

Set to `[]` to disable automatic resolution entirely.
Set `trustMemberCollectives: false` to disable membership-based trust and only
use the explicit `trustedCollectives` list. Set `trustedCollectives` to `[]` and
`trustMemberCollectives: false` to disable automatic resolution entirely.

### Resolution Algorithm

Expand Down
3 changes: 3 additions & 0 deletions design/repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ attributes to control the behaviour of the swamp operations.
Multitple vaults can be specified for a each swamp repository.
- `trustedCollectives`: List of collectives whose extensions auto-resolve on
first use. Default: `["swamp", "si"]`. Set to `[]` to disable.
- `trustMemberCollectives`: Whether to auto-trust collectives the user belongs
to (cached from `auth login`/`auth whoami`). Default: `true`. Set to `false`
to only trust the explicit `trustedCollectives` list.

## RepoIndexService

Expand Down
9 changes: 7 additions & 2 deletions src/cli/commands/auth_login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
import { Command } from "@cliffy/command";
import { createContext, type GlobalOptions, isStdinTty } from "../context.ts";
import { AuthRepository } from "../../infrastructure/persistence/auth_repository.ts";
import { SwampClubClient } from "../../infrastructure/http/swamp_club_client.ts";
import {
getCollectives,
SwampClubClient,
} from "../../infrastructure/http/swamp_club_client.ts";
import { startCallbackServer } from "../../infrastructure/http/callback_server.ts";
import { openBrowser } from "../../infrastructure/process/browser.ts";
import { UserError } from "../../domain/errors.ts";
Expand Down Expand Up @@ -187,13 +190,15 @@ export const authLoginCommand = new Command()
const whoami = await client.whoami(apiKey.key);
const username = whoami.username ?? knownUsername ?? "unknown";

// Store credentials
// Store credentials (including cached collectives for auto-trust)
const collectives = getCollectives(whoami);
const repo = new AuthRepository();
await repo.save({
serverUrl,
apiKey: apiKey.key,
apiKeyId: apiKey.id,
username,
...(collectives ? { collectives } : {}),
});

spinner?.stop();
Expand Down
6 changes: 6 additions & 0 deletions src/cli/commands/auth_whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export const authWhoamiCommand = new Command()

const collectives = getCollectives(whoami);

// Refresh cached collectives in auth.json so they stay current
await repo.save({
...credentials,
collectives: collectives ?? [],
});

if (ctx.outputMode === "json") {
console.log(JSON.stringify(
{
Expand Down
40 changes: 38 additions & 2 deletions src/cli/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,32 @@ export function resolveTelemetryEndpoint(
return DEFAULT_TELEMETRY_ENDPOINT;
}

/**
* Resolves the full list of trusted collectives by merging explicit
* trustedCollectives from .swamp.yaml with the user's membership collectives
* from cached auth credentials.
*
* @internal Exported for testing
*/
export function resolveTrustedCollectives(
marker: RepoMarkerData | null,
authCollectives?: string[],
): string[] {
const explicit = marker?.trustedCollectives ?? ["swamp", "si"];

// If opt-out is set, only use explicit list
if (marker?.trustMemberCollectives === false) {
return explicit;
}

// Merge membership collectives (deduplicated)
if (authCollectives && authCollectives.length > 0) {
return [...new Set([...explicit, ...authCollectives])];
}

return explicit;
}

interface TelemetryContext {
service: TelemetryService;
userId: string | null;
Expand Down Expand Up @@ -424,8 +450,18 @@ export async function runCli(args: string[]): Promise<void> {
// Not in a swamp repo - marker stays null
}

// Create auto-resolver for trusted collectives
const trustedCollectives = marker?.trustedCollectives ?? ["swamp", "si"];
// Load cached auth collectives for membership-based trust
let authCollectives: string[] | undefined;
try {
const authRepo = new AuthRepository();
const creds = await authRepo.load();
authCollectives = creds?.collectives;
} catch {
// Auth file unreadable — continue without membership collectives
}

// Create auto-resolver for trusted collectives (merging membership collectives)
const trustedCollectives = resolveTrustedCollectives(marker, authCollectives);
if (trustedCollectives.length > 0 && marker) {
const outputMode = getOutputModeFromArgs(args);
const serverUrl = Deno.env.get("SWAMP_CLUB_URL") ?? "https://swamp.club";
Expand Down
70 changes: 70 additions & 0 deletions src/cli/mod_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resolveLogLevel,
resolveModelsDir,
resolveTelemetryEndpoint,
resolveTrustedCollectives,
resolveWorkflowsDir,
} from "./mod.ts";

Expand Down Expand Up @@ -548,3 +549,72 @@ Deno.test("isUpdateCheckDisabledByEnv returns false when env var is ''", () => {
}
}
});

// --- resolveTrustedCollectives tests ---

Deno.test("resolveTrustedCollectives returns defaults when no marker", () => {
const result = resolveTrustedCollectives(null);
assertEquals(result, ["swamp", "si"]);
});

Deno.test("resolveTrustedCollectives returns explicit collectives from marker", () => {
const marker = {
swampVersion: "0.1.0",
initializedAt: "2024-01-01T00:00:00Z",
trustedCollectives: ["myorg"],
};
const result = resolveTrustedCollectives(marker);
assertEquals(result, ["myorg"]);
});

Deno.test("resolveTrustedCollectives merges auth collectives with defaults", () => {
const result = resolveTrustedCollectives(null, ["myorg", "other"]);
assertEquals(result, ["swamp", "si", "myorg", "other"]);
});

Deno.test("resolveTrustedCollectives deduplicates merged collectives", () => {
const marker = {
swampVersion: "0.1.0",
initializedAt: "2024-01-01T00:00:00Z",
trustedCollectives: ["swamp", "si", "myorg"],
};
const result = resolveTrustedCollectives(marker, ["myorg", "neworg"]);
assertEquals(result, ["swamp", "si", "myorg", "neworg"]);
});

Deno.test("resolveTrustedCollectives respects trustMemberCollectives false", () => {
const marker = {
swampVersion: "0.1.0",
initializedAt: "2024-01-01T00:00:00Z",
trustedCollectives: ["swamp"],
trustMemberCollectives: false,
};
const result = resolveTrustedCollectives(marker, ["myorg", "other"]);
assertEquals(result, ["swamp"]);
});

Deno.test("resolveTrustedCollectives merges when trustMemberCollectives is true", () => {
const marker = {
swampVersion: "0.1.0",
initializedAt: "2024-01-01T00:00:00Z",
trustedCollectives: ["swamp"],
trustMemberCollectives: true,
};
const result = resolveTrustedCollectives(marker, ["myorg"]);
assertEquals(result, ["swamp", "myorg"]);
});

Deno.test("resolveTrustedCollectives handles undefined auth collectives", () => {
const marker = {
swampVersion: "0.1.0",
initializedAt: "2024-01-01T00:00:00Z",
trustedCollectives: ["swamp", "si"],
};
const result = resolveTrustedCollectives(marker, undefined);
assertEquals(result, ["swamp", "si"]);
});

Deno.test("resolveTrustedCollectives handles empty auth collectives", () => {
const result = resolveTrustedCollectives(null, []);
assertEquals(result, ["swamp", "si"]);
});
2 changes: 2 additions & 0 deletions src/domain/auth/auth_credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export interface AuthCredentials {
apiKeyId: string;
/** The authenticated username */
username: string;
/** Cached collective memberships (slugs) from the last login/whoami */
collectives?: string[];
}
42 changes: 42 additions & 0 deletions src/infrastructure/persistence/auth_repository_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,48 @@ Deno.test("AuthRepository - delete is idempotent (no error when missing)", async
}
});

Deno.test("AuthRepository - save and load round trip with collectives", async () => {
const tmpDir = await Deno.makeTempDir();
const originalXdg = Deno.env.get("XDG_CONFIG_HOME");
try {
Deno.env.set("XDG_CONFIG_HOME", tmpDir);
const repo = new AuthRepository();

const credsWithCollectives: AuthCredentials = {
...TEST_CREDENTIALS,
collectives: ["myorg", "swamp"],
};
await repo.save(credsWithCollectives);
const loaded = await repo.load();

assertExists(loaded);
assertEquals(loaded.collectives, ["myorg", "swamp"]);
} finally {
if (originalXdg) Deno.env.set("XDG_CONFIG_HOME", originalXdg);
else Deno.env.delete("XDG_CONFIG_HOME");
await Deno.remove(tmpDir, { recursive: true });
}
});

Deno.test("AuthRepository - load without collectives returns undefined for field", async () => {
const tmpDir = await Deno.makeTempDir();
const originalXdg = Deno.env.get("XDG_CONFIG_HOME");
try {
Deno.env.set("XDG_CONFIG_HOME", tmpDir);
const repo = new AuthRepository();

await repo.save(TEST_CREDENTIALS);
const loaded = await repo.load();

assertExists(loaded);
assertEquals(loaded.collectives, undefined);
} finally {
if (originalXdg) Deno.env.set("XDG_CONFIG_HOME", originalXdg);
else Deno.env.delete("XDG_CONFIG_HOME");
await Deno.remove(tmpDir, { recursive: true });
}
});

Deno.test("AuthRepository - save sets restrictive file permissions", async () => {
if (Deno.build.os === "windows") return; // mode not enforced on Windows
const tmpDir = await Deno.makeTempDir();
Expand Down
1 change: 1 addition & 0 deletions src/infrastructure/persistence/repo_marker_repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface RepoMarkerData {
gitignoreManaged?: boolean;
datastore?: DatastoreConfigData;
trustedCollectives?: string[];
trustMemberCollectives?: boolean;
}

/**
Expand Down
Loading