Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Kiro is a supported tool profile.** `--tool kiro` writes artifacts under
`.kiro/`, uses root `AGENTS.md` for course rules, and is auto-detected from
`.kiro/steering/`, `.kiro/specs/`, `.kiro/hooks/`, `.kiro/settings/` or a bare
`.kiro/` directory. Content is
delivered in the generic variant.
- **`10x sync` — bulk download & update with change visibility.** One command to
download every unlocked lesson (`--all`) or refresh the ones you've already
downloaded (default), with a report of what changed upstream. Each not-updated
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Once your release includes it, run from the intended project directory:

This installs **both** `10x-cli-setup` and `10x-cli-guide`, each with its complete
`SKILL.md` and `references/compatibility.md`, into `.github/skills/`. Use
`--tool claude-code`, `cursor`, `codex`, `devin-desktop`, `gemini`, or `generic`
`--tool claude-code`, `cursor`, `codex`, `devin-desktop`, `gemini`, `kiro`, or `generic`
when that is your intended tool. The target must be explicit; installation is
project-only. There is no `--global`, automatic agent detection, `skills`/npx
subprocess, authentication, or network access. The helper bytes come from the
Expand Down Expand Up @@ -175,7 +175,7 @@ remain visible. Full lesson downloads and other commands remain available below.

| Flag | Description |
|------|-------------|
| `--tool <tool>` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `generic` |
| `--tool <tool>` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `kiro`, `generic` |
| `--print` | Output artifact content to stdout instead of writing files |
| `--type <type>` | Filter by artifact type: `skills`, `prompts`, `rules`, `configs` |
| `--name <name>` | Filter by artifact name (requires `--type`) |
Expand Down Expand Up @@ -302,7 +302,7 @@ scoring live in the template and the instance.
| Flag | Description |
|------|-------------|
| `--template-version <tag>` | Template tag to install (default: latest) |
| `--tool <id>` | Agent tool for skill placement (`claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `generic`) |
| `--tool <id>` | Agent tool for skill placement (`claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `kiro`, `generic`) |
| `--yes` | Run non-interactively, accepting defaults |

```bash
Expand Down Expand Up @@ -346,6 +346,7 @@ On first run, the CLI prompts you to choose your AI coding tool. Artifacts are w
| Codex CLI | `.agents/` | `AGENTS.md` |
| Devin Desktop | `.devin/` | `AGENTS.md` |
| Gemini CLI | `.gemini/` | `GEMINI.md` |
| Kiro | `.kiro/` | `AGENTS.md` |
| Generic | `.ai/` | `AGENTS.md` |

Override with `--tool <name>`. Validated writing commands save your choice in `~/.config/10x-cli/config.json`. Previews leave it unchanged.
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ The CLI writes artifacts to the correct directory for your AI coding tool:
| GitHub Copilot | `.github/skills/` | `.github/copilot-instructions.md` | `.github/config-templates/` |
| Codex CLI | `.agents/skills/` | `AGENTS.md` | `.agents/config-templates/` |
| Devin Desktop | `.devin/skills/` | `AGENTS.md` | `.devin/config-templates/` |
| Kiro | `.kiro/skills/` | `AGENTS.md` | `.kiro/config-templates/` |
| Generic | `.ai/skills/` | `AGENTS.md` | `.ai/config-templates/` |

The CLI auto-detects your tool from project markers on first run. Override anytime with `--tool`:
Expand All @@ -71,6 +72,23 @@ The CLI auto-detects your tool from project markers on first run. Override anyti
10x get m1l1 --tool cursor
```

Kiro reads the root `AGENTS.md` as steering context, so it writes to the same
file as Codex CLI, Devin Desktop and Generic. Two profiles can hold the same
sentinel block only when their course-rules content is byte-identical — for Kiro
that means Generic. Installing Kiro alongside Codex CLI or Devin Desktop in one
project leaves the first owner's block untouched and reports the rules as
`conflict: skipped (incompatible_shared_owner)`; every other artifact still
installs normally. Pick one of those profiles per project, or accept that only
the first one to run keeps the course rules.

Kiro is detected from `.kiro/steering/`, `.kiro/specs/`, `.kiro/hooks/`,
`.kiro/settings/` or a bare `.kiro/` directory.

`.kiro/prompts/` and `.kiro/config-templates/` are staging locations the CLI
manages but Kiro does not read automatically — move any artifact you want Kiro
to pick up to the place Kiro expects it (`.kiro/steering/` for always-on
context, `.kiro/hooks/` for hooks).

`windsurf` remains a backward-compatible alias for `devin-desktop`. New files
use Devin Desktop's `.devin/` workspace convention; legacy `.windsurf/` markers
are still detected so existing 10x artifacts can be migrated.
Expand Down
2 changes: 1 addition & 1 deletion src/commands/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function registerGetCommand(cli: CAC): void {
.option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)")
.option(
"--tool <tool>",
"AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)",
"AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, kiro, generic)",
)
.option("--print", "Print artifact content to stdout instead of writing to files")
.option("--type <type>", "Artifact type filter: skills, prompts, rules, configs")
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function registerSyncCommand(cli: CAC): void {
.option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)")
.option(
"--tool <tool>",
"AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)",
"AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, kiro, generic)",
)
.option("--lang <lang>", "Content language: en (default) or pl")
.option(
Expand Down
29 changes: 28 additions & 1 deletion src/lib/tool-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* of signals. The interactive prompt uses the top signal to pre-fill
* `initialValue`; the user still confirms before anything is saved.
*
* Pure file-system read; ≤12 `existsSync` calls, no I/O beyond that.
* Pure file-system read; a bounded number of `existsSync` calls, no I/O
* beyond that.
*/

import { existsSync } from "node:fs";
Expand Down Expand Up @@ -137,6 +138,31 @@ export function detectTools(projectRoot: string): DetectionSignal[] {
signals.push({ profileId: "gemini", confidence: "medium", reason: ".gemini/ directory" });
}

// Kiro — .kiro/ holds steering, specs, hooks, settings and skills. The
// directory name is Kiro-specific enough that even a bare .kiro/ is a strong
// signal; the sub-marker branches exist only to give a precise hint reason.
if (hit(".kiro/" + MANIFEST_FILENAME)) {
signals.push({
profileId: "kiro",
confidence: "strong",
reason: ".kiro/.10x-cli-manifest.json",
});
} else if (hit(".kiro/steering") || hit(".kiro/specs")) {
signals.push({
profileId: "kiro",
confidence: "strong",
reason: ".kiro/steering/ or .kiro/specs/",
});
} else if (hit(".kiro/hooks") || hit(".kiro/settings")) {
signals.push({
profileId: "kiro",
confidence: "strong",
reason: ".kiro/hooks/ or .kiro/settings/",
});
} else if (hit(".kiro")) {
signals.push({ profileId: "kiro", confidence: "strong", reason: ".kiro/ directory" });
}

// Generic — .ai/ is a project-defined convention
if (hit(".ai/" + MANIFEST_FILENAME)) {
signals.push({
Expand All @@ -161,6 +187,7 @@ const PROFILE_ORDER = [
"codex",
"devin-desktop",
"gemini",
"kiro",
"generic",
];

Expand Down
17 changes: 17 additions & 0 deletions src/lib/tool-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ export const PROFILES: Record<string, ToolProfile> = {
sentinelBegin: SENTINEL_BEGIN,
sentinelEnd: SENTINEL_END,
},
kiro: {
toolId: "kiro",
// No `kiro` transform exists in the delivery API; the generic variant is
// the agreed content representation until one does.
contentToolId: "generic",
displayName: "Kiro",
skillPath: (n) => `.kiro/skills/${n}/SKILL.md`,
skillDir: (n) => `.kiro/skills/${n}`,
promptPath: (n) => `.kiro/prompts/${n}.md`,
configPath: (n) => `.kiro/config-templates/${n}`,
// Kiro loads AGENTS.md as steering context, so it co-owns the root file
// with codex, devin-desktop and generic.
rulesFile: "AGENTS.md",
Comment on lines +114 to +116

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kiro AGENTS.md support — version reference

Full (nested, per-directory) support for AGENTS.md was introduced separately for each Kiro surface:

  • Kiro IDE: version 1.0.309 (Aug 13, 2026) — "Add AGENTS.md files throughout your workspace to give Kiro instructions scoped to each directory tree." (changelog
    (https://kiro.dev/changelog/ide/1-0-309/))
  • Kiro CLI: version 2.18 — "AGENTS.md files now load as steering context from anywhere in your workspace tree, not just the workspace root and ~/.kiro/steering/." (changelog
    (https://kiro.dev/changelog/cli/2-18/))

Before these releases, AGENTS.md was only picked up from the workspace root.

manifestDir: ".kiro",
sentinelBegin: SENTINEL_BEGIN,
sentinelEnd: SENTINEL_END,
},
generic: {
toolId: "generic",
displayName: "Other / Generic",
Expand Down
23 changes: 17 additions & 6 deletions tests/helpers/helper-install-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,24 @@ export function helperInstallContract(label: string, command: () => string[]): v
expect(tree(f.home)).toEqual({});
});

it("previews the chosen profile without writes", () => {
const f = fixture(); const before = tree(f.project);
const p = f.run("helpers", "install", "--tool", "claude-code", "--dry-run");
for (const [tool, dir] of [["claude-code", ".claude"], ["kiro", ".kiro"]] as const) {
it(`previews the chosen ${tool} profile without writes`, () => {
const f = fixture(); const before = tree(f.project);
const p = f.run("helpers", "install", "--tool", tool, "--dry-run");
expect(p.exitCode).toBe(0);
const files = JSON.parse(p.stdout.toString()).data.files;
expect(files.length).toBeGreaterThan(0);
expect(files.every((x: { path: string; action: string }) => x.path.startsWith(dir) && x.action === "would_create")).toBe(true);
expect(tree(f.project)).toEqual(before);
});
}

it("installs the complete packaged tree for kiro under .kiro/skills", () => {
const f = fixture();
const p = f.run("helpers", "install", "--tool", "kiro");
expect(p.exitCode).toBe(0);
const files = JSON.parse(p.stdout.toString()).data.files;
expect(files.every((x: { path: string; action: string }) => x.path.startsWith(".claude") && x.action === "would_create")).toBe(true);
expect(tree(f.project)).toEqual(before);
expect(tree(join(f.project, ".kiro/skills"))).toEqual(tree(SOURCE));
expect(existsSync(join(f.project, ".claude"))).toBe(false);
});

it("preserves local edits in either helper and returns nonzero before installing the other", () => {
Expand Down
80 changes: 80 additions & 0 deletions tests/tool-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,79 @@ describe("detectTools", () => {
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("strong");
});

it(".kiro manifest → kiro (strong)", () => {
writeManifestAt(".kiro");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
});

it(".kiro/steering/ → kiro (strong)", () => {
touchDir(".kiro/steering");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
});

it(".kiro/specs/ → kiro (strong)", () => {
touchDir(".kiro/specs");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
});

it(".kiro/hooks/ → kiro (strong)", () => {
touchDir(".kiro/hooks");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
expect(signals[0]!.reason).toBe(".kiro/hooks/ or .kiro/settings/");
});

it(".kiro/settings/ → kiro (strong)", () => {
touchDir(".kiro/settings");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
expect(signals[0]!.reason).toBe(".kiro/hooks/ or .kiro/settings/");
});

it("bare .kiro/ directory → kiro (strong)", () => {
touchDir(".kiro");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
expect(signals[0]!.reason).toBe(".kiro/ directory");
});

it(".kiro/steering/ + AGENTS.md → kiro (strong) outranks codex (medium)", () => {
touchDir(".kiro/steering");
touchFile("AGENTS.md", "# agents\n");
const signals = detectTools(tmp);
expect(signals[0]!.profileId).toBe("kiro");
expect(signals[0]!.confidence).toBe("strong");
expect(signals.slice(1).map((s) => s.profileId)).toEqual(["codex", "generic"]);
});

it("bare .kiro/ + AGENTS.md → kiro outranks codex on confidence", () => {
// `.kiro/` is Kiro-specific, so it never degrades to `medium` and cannot
// lose a PROFILE_ORDER tie to codex's AGENTS.md signal.
touchDir(".kiro");
touchFile("AGENTS.md", "# agents\n");
const signals = detectTools(tmp);
expect(signals.map((s) => [s.profileId, s.confidence])).toEqual([
["kiro", "strong"],
["codex", "medium"],
["generic", "weak"],
]);
});
});

describe("topDetectedProfile", () => {
Expand All @@ -188,4 +261,11 @@ describe("topDetectedProfile", () => {
const profile = topDetectedProfile(signals);
expect(profile?.toolId).toBe("cursor");
});

it("resolves the Kiro profile from a .kiro/ signal", () => {
touchDir(".kiro/steering");
expect(topDetectedProfile(detectTools(tmp))?.toolId).toBe("kiro");
rmSync(join(tmp, ".kiro/steering"), { recursive: true, force: true });
expect(topDetectedProfile(detectTools(tmp))?.toolId).toBe("kiro");
});
});
14 changes: 13 additions & 1 deletion tests/tool-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PROFILES,
SENTINEL_BEGIN,
SENTINEL_END,
contentToolId,
} from "../src/lib/tool-profile";
import { readToolConfig, saveToolConfig, toolConfigPath } from "../src/lib/config";
import { prepareToolForWrite, resolveToolProfile } from "../src/lib/tool-prompt";
Expand All @@ -26,7 +27,7 @@ import { clackMockState, resetClackMock, type SelectOpts } from "./helpers/clack
import { redirectConfigDir, restoreConfigDir } from "./helpers/config-isolation";

// ---------------------------------------------------------------------------
// Profile path tests — each of the 5 tool profiles
// Profile path tests — one case per tool profile
// ---------------------------------------------------------------------------

describe("tool profiles — path generation", () => {
Expand Down Expand Up @@ -76,6 +77,17 @@ describe("tool profiles — path generation", () => {
expect(p.manifestDir).toBe(".devin");
});

it("kiro profile produces .kiro/ paths and requests the generic content variant", () => {
const p = PROFILES["kiro"]!;
expect(p.displayName).toBe("Kiro");
expect(p.skillPath("code-review")).toBe(".kiro/skills/code-review/SKILL.md");
expect(p.promptPath("plan")).toBe(".kiro/prompts/plan.md");
expect(p.configPath("settings.json")).toBe(".kiro/config-templates/settings.json");
expect(p.rulesFile).toBe("AGENTS.md");
expect(p.manifestDir).toBe(".kiro");
expect(contentToolId(p)).toBe("generic");
});

it("generic profile produces .ai/ paths", () => {
const p = PROFILES["generic"]!;
expect(p.skillPath("code-review")).toBe(".ai/skills/code-review/SKILL.md");
Expand Down
44 changes: 44 additions & 0 deletions tests/tool-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,50 @@ describe("profile migration shared rules", () => {
expect(readFileSync(join(tmp, "AGENTS.md"))).toEqual(before);
expect(readManifest(join(tmp, ".ai"))!.managedRules).toBeDefined();
});
it("transfers AGENTS.md ownership from kiro to codex without rewriting the file", async () => {
const { applyBundle, findOrphanedManifests } = await import("../src/lib/writer");
const source = PROFILES.kiro!;
const destination = PROFILES.codex!;
const bundle = { lessonId: "m1l1", module: 1, lesson: 1, title: "A", summary: "", skills: [{ name: "a", files: [{ path: "SKILL.md", content: "A" }] }], prompts: [], configs: [], rules: [{ name: "rules", content: "shared" }] };
await applyBundle(bundle, tmp, { profile: source });
const before = readFileSync(join(tmp, "AGENTS.md"));
expect(before.toString()).toContain(SENTINEL_BEGIN);
const orphan = findOrphanedManifests(tmp, destination).find((entry) => entry.profile.toolId === "kiro")!;
const result = migrateArtifacts(tmp, orphan, destination);
expect(result.sentinelStripped).toBe(false);
expect(readFileSync(join(tmp, "AGENTS.md"))).toEqual(before);
expect(readManifest(join(tmp, ".kiro"))).toBeNull();
expect(readManifest(join(tmp, ".agents"))!.managedRules?.upstreamHash).toBe(orphan.manifest.managedRules?.upstreamHash);
expect(existsSync(join(tmp, destination.skillPath("a")))).toBe(true);
});
it("blocks kiro from rewriting a codex-owned AGENTS.md block when the rules bytes differ", async () => {
// The migration/cleanup cases above deliberately use one shared body so the
// upstream hashes match. In production the co-owners request different
// content transforms (codex vs generic), so the hashes diverge and
// planManagedRules must fail closed rather than clobber the other owner.
const { applyBundle } = await import("../src/lib/writer");
const base = { lessonId: "m1l1", module: 1, lesson: 1, title: "A", summary: "", skills: [], prompts: [], configs: [] };
await applyBundle({ ...base, rules: [{ name: "rules", content: "codex variant" }] }, tmp, { profile: PROFILES.codex! });
const before = readFileSync(join(tmp, "AGENTS.md"), "utf8");
const result = await applyBundle({ ...base, rules: [{ name: "rules", content: "kiro variant" }] }, tmp, { profile: PROFILES.kiro!, onConflict: async () => "overwrite" });
expect(result.rules.action).toBe("conflict_skipped");
expect(result.rules.reason).toBe("incompatible_shared_owner");
expect(readFileSync(join(tmp, "AGENTS.md"), "utf8")).toBe(before);
expect(readManifest(join(tmp, ".kiro"))?.managedRules).toBeUndefined();
expect(readManifest(join(tmp, ".agents"))!.managedRules).toBeDefined();
});
it("cleanup releases the kiro owner while codex keeps the shared AGENTS.md block", async () => {
const { applyBundle, findOrphanedManifests } = await import("../src/lib/writer");
const bundle = { lessonId: "m1l1", module: 1, lesson: 1, title: "A", summary: "", skills: [], prompts: [], configs: [], rules: [{ name: "rules", content: "shared" }] };
await applyBundle(bundle, tmp, { profile: PROFILES.kiro! });
await applyBundle(bundle, tmp, { profile: PROFILES.codex!, onConflict: async () => "overwrite" });
const before = readFileSync(join(tmp, "AGENTS.md"));
const orphan = findOrphanedManifests(tmp, PROFILES.codex!).find((entry) => entry.profile.toolId === "kiro")!;
const result = deleteArtifacts(tmp, orphan);
expect(result.sentinelStripped).toBe(false);
expect(readFileSync(join(tmp, "AGENTS.md"))).toEqual(before);
expect(readManifest(join(tmp, ".agents"))!.managedRules).toBeDefined();
});
it("retains unresolved source rules and their ledger instead of adopting a local edit", () => {
const text = `${SENTINEL_BEGIN}\n\ntrusted\n\n${SENTINEL_END}\n`;
const orphan = seedOrphan({ skills: ["a"], rulesFileContent: text });
Expand Down
Loading
Loading