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
1 change: 0 additions & 1 deletion .claude/commands/tskl

This file was deleted.

1 change: 0 additions & 1 deletion .claude/skills/taskless

This file was deleted.

6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,11 @@ node_modules/
.claude/settings.local.json
.mcp.json

# Local Taskless self-install (generated by `pnpm build:self`; never committed)
/.taskless/skills/
/.taskless/commands/
/.claude/skills/taskless/
/.claude/commands/tskl/

# Misc
tmp
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ commands/
packages/
cli/ # @taskless/cli — recipes live in cli/src/help/
scripts/
link-skills.ts # Symlinks skills into .claude/skills/
sync-skill-versions.ts # Syncs metadata.version to CLI version
.claude-plugin/ # Claude Code Plugin Marketplace manifest
```
Expand All @@ -40,6 +39,27 @@ pnpm dlx @taskless/cli@latest info
npx @taskless/cli@latest info
```

## Local development

The CLI bakes its own invocation string into the skill, command, and recipe
content it installs. Three build targets pick that string, all driven by the
`TASKLESS_BUILD_TARGET` env var via Vite `define` (same source files, no edits):

| Command | Baked invocation | Use for |
| ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------ |
| `pnpm build` | `npx @taskless/cli` | Production / published builds (default). |
| `pnpm build:dev` | `node <abs>/packages/cli/dist/index.js` | Validating this build from **another** repo (absolute path resolves anywhere). |
| `pnpm build:self` | `node packages/cli/dist/index.js` | Dogfooding **in this repo** (path is repo-root-relative; run the CLI from the root). |

`pnpm build:self` builds the CLI with the relative invocation and then runs
`taskless init --no-interactive` to install into this repo — so `.claude` gets
real reference stubs that delegate to the canonical `.taskless/` content, exactly
like any other install. (This replaces the former raw-symlink `link-skills`
step, so local dogfooding always matches a true install.)

> The `dev`/`self` invocations are local paths and must never be published —
> only `pnpm build` (or `pnpm package`) produces a release artifact.

## Releasing taskless/skills

Releases use [Changesets](https://github.com/changesets/changesets) with Turborepo for orchestration.
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
"license": "MIT",
"repository": "taskless/skills.git",
"scripts": {
"build": "run-s build:link-skills build:compile",
"build": "pnpm build:compile",
"build:compile": "turbo run build",
"build:link-skills": "tsx scripts/link-skills.ts",
"build:dev": "pnpm --filter @taskless/cli build:dev",
"build:self": "run-s build:self:compile build:self:install",
"build:self:compile": "pnpm --filter @taskless/cli build:self",
"build:self:install": "node packages/cli/dist/index.js init --no-interactive",
"bump": "run-s bump:version bump:sync",
"bump:sync": "tsx scripts/sync-skill-versions.ts",
"bump:version": "changeset version",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
},
"scripts": {
"build": "vite build",
"build:dev": "TASKLESS_BUILD_TARGET=dev vite build",
"build:self": "TASKLESS_BUILD_TARGET=self vite build",
"generate:api": "openapi-typescript https://app.taskless.io/cli/api/__schema -o src/generated/api.d.ts",
"generate:ast-grep-schema": "tsx scripts/fetch-ast-grep-schema.ts",
"test": "vitest run",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { sprintf } from "sprintf-js";
import { z } from "zod";

import { getTelemetry } from "../telemetry";
import { applyCliInvocation } from "../util/invocation";
import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create";
import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve";

Expand Down Expand Up @@ -90,7 +91,7 @@ function renderRecipe(content: string, topic: string): string {
? JSON.stringify(z.toJSONSchema(schema), null, 2)
: "(no input schema for this topic)";
}
return sprintf(content, variables);
return sprintf(applyCliInvocation(content), variables);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />

declare const __VERSION__: string;
declare const __TASKLESS_CLI__: string;
declare const __TASKLESS_CLI_NOTICE__: string;
23 changes: 18 additions & 5 deletions packages/cli/src/install/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from "node:path";

import { stringify } from "yaml";

import { applyCliInvocation, withCliBuildNotice } from "../util/invocation";
import { parseFrontmatter } from "./frontmatter";

/**
Expand Down Expand Up @@ -44,8 +45,10 @@ export function canonicalCommandPath(filename: string): string {

/**
* Write a skill's full content to the canonical store at
* `.taskless/skills/<name>/SKILL.md`. Content is written verbatim — the
* canonical store is the single source of truth.
* `.taskless/skills/<name>/SKILL.md`. The canonical store is the single source
* of truth; content is emitted as-is for prod builds. For `dev`/`self` builds
* the CLI invocation is rewritten and a build notice prepended (see
* {@link applyCliInvocation} / {@link withCliBuildNotice}); prod is unchanged.
*/
export async function writeCanonicalSkill(
cwd: string,
Expand All @@ -55,13 +58,19 @@ export async function writeCanonicalSkill(
const directory = join(cwd, CANONICAL_DIR, "skills", name);
await mkdir(directory, { recursive: true });
const path = join(directory, "SKILL.md");
await writeFile(path, content, "utf8");
await writeFile(
path,
withCliBuildNotice(applyCliInvocation(content)),
"utf8"
);
Comment thread
thecodedrift marked this conversation as resolved.
return path;
}

/**
* Write a command's full content to the canonical store at
* `.taskless/commands/tskl/<filename>`. Content is written verbatim.
* `.taskless/commands/tskl/<filename>`. Emitted as-is for prod builds; for
* `dev`/`self` builds the CLI invocation is rewritten and a build notice
* prepended (see {@link applyCliInvocation} / {@link withCliBuildNotice}).
*/
export async function writeCanonicalCommand(
cwd: string,
Expand All @@ -71,7 +80,11 @@ export async function writeCanonicalCommand(
const directory = join(cwd, CANONICAL_DIR, "commands", "tskl");
await mkdir(directory, { recursive: true });
const path = join(directory, filename);
await writeFile(path, content, "utf8");
await writeFile(
path,
withCliBuildNotice(applyCliInvocation(content)),
"utf8"
);
Comment thread
thecodedrift marked this conversation as resolved.
return path;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@ async function writeSkill(
}

const path = join(skillDirectory(cwd, target.dir, skill.name), "SKILL.md");
// A prior link-skills-style setup may have left the per-skill directory as a
// symlink to source; writing a stub through it would clobber the source file.
// Replace a symlinked target directory with a real one before writing.
await unlinkIfSymlink(dirname(path));
const meta: StubFrontmatter = {
name: skill.name,
description: skill.description,
Expand Down Expand Up @@ -412,6 +416,9 @@ async function writeCommand(
}

const path = commandFile(cwd, target.dir, command.filename);
// As in writeSkill: a symlinked command namespace directory would otherwise
// route the stub write back into source. Normalize it to a real directory.
await unlinkIfSymlink(dirname(path));
const meta: CommandStubFrontmatter = {
Comment thread
thecodedrift marked this conversation as resolved.
name: command.name,
description: command.description,
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/util/invocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* The published CLI invocation baked into skill, command, and recipe source.
* Build targets other than prod rewrite it to a local path so a locally built
* CLI can be dogfooded in this repo (`build:self`) or validated from another
* repo (`build:dev`). See `vite.config.ts` and the root `package.json` scripts.
*/
const PROD_INVOCATION = "npx @taskless/cli";

/**
* Rewrite the canonical `npx @taskless/cli` invocation to the build-target
* invocation (`__TASKLESS_CLI__`).
*
* A no-op for prod builds, where the define equals {@link PROD_INVOCATION}, so
* emitted content stays byte-identical to source. For `dev`/`self` builds it
* swaps both the bare form and the `@latest`-pinned form (the version-pinned
* form first, so the bare replacement can't leave a dangling `@latest`).
*/
export function applyCliInvocation(content: string): string {
if (__TASKLESS_CLI__ === PROD_INVOCATION) return content;
return content
.replaceAll(`${PROD_INVOCATION}@latest`, __TASKLESS_CLI__)
.replaceAll(PROD_INVOCATION, __TASKLESS_CLI__);
}

/** Matches a leading YAML frontmatter block (`---\n…\n---\n`). */
const FRONTMATTER = /^(---\n[\s\S]*?\n---\n)/;

/**
* Prepend the build-target notice (`__TASKLESS_CLI_NOTICE__`) to a canonical
* skill/command body. A no-op for prod, where the notice is empty. For
* `dev`/`self` builds the banner is inserted immediately after the frontmatter
* block so it renders as the first body line without corrupting the YAML.
*/
export function withCliBuildNotice(content: string): string {
if (__TASKLESS_CLI_NOTICE__ === "") return content;
return FRONTMATTER.test(content)
? content.replace(FRONTMATTER, `$1\n${__TASKLESS_CLI_NOTICE__}\n`)
: `${__TASKLESS_CLI_NOTICE__}\n\n${content}`;
}
61 changes: 61 additions & 0 deletions packages/cli/test/apply-install-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,67 @@ describe("applyInstallPlan", () => {
expect(await readFile(linkTarget, "utf8")).toBe(skill.content);
});

it("does not write through a symlinked skill directory (no source clobber)", async () => {
// Reproduces the link-skills relic: the per-skill *directory* is a symlink
// back to source. Writing a stub must not follow it and overwrite source.
const skill = tasklessSkill();
const sourceDirectory = join(cwd, "source", "taskless");
await mkdir(sourceDirectory, { recursive: true });
const sourceSkill = join(sourceDirectory, "SKILL.md");
await writeFile(sourceSkill, skill.content, "utf8");

const claudeSkillDirectory = join(cwd, ".claude", "skills", "taskless");
await mkdir(dirname(claudeSkillDirectory), { recursive: true });
await symlink(sourceDirectory, claudeSkillDirectory);

await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), {
cliVersion: "0.7.0",
});

// The symlinked directory was replaced by a real one holding a shim stub...
const directoryStats = await lstat(claudeSkillDirectory);
expect(directoryStats.isSymbolicLink()).toBe(false);
expect(directoryStats.isDirectory()).toBe(true);
expect(
isShimStub(await readFile(join(claudeSkillDirectory, "SKILL.md"), "utf8"))
).toBe(true);
// ...and the source file behind the former symlink is untouched.
expect(await readFile(sourceSkill, "utf8")).toBe(skill.content);
});

it("does not write through a symlinked command directory (no source clobber)", async () => {
// The command-namespace counterpart of the skill-directory case: a leftover
// `.claude/commands/tskl` symlink to source must not be written through.
const command = getEmbeddedCommands().find((c) => c.filename === "tskl.md");
if (!command) throw new Error("embedded tskl command missing");
const sourceDirectory = join(cwd, "source", "tskl");
await mkdir(sourceDirectory, { recursive: true });
const sourceCommand = join(sourceDirectory, "tskl.md");
await writeFile(sourceCommand, command.content, "utf8");

const claudeCommandDirectory = join(cwd, ".claude", "commands", "tskl");
await mkdir(dirname(claudeCommandDirectory), { recursive: true });
await symlink(sourceDirectory, claudeCommandDirectory);

await applyInstallPlan(
cwd,
buildInstallPlan([".claude"], [tasklessSkill()], [command]),
{ cliVersion: "0.7.0" }
);

// The symlinked directory was replaced by a real one holding a shim stub...
const directoryStats = await lstat(claudeCommandDirectory);
expect(directoryStats.isSymbolicLink()).toBe(false);
expect(directoryStats.isDirectory()).toBe(true);
expect(
isShimStub(
await readFile(join(claudeCommandDirectory, "tskl.md"), "utf8")
)
).toBe(true);
// ...and the source file behind the former symlink is untouched.
expect(await readFile(sourceCommand, "utf8")).toBe(command.content);
});

it("does not touch unknown files in a skills directory", async () => {
const userOwned = join(cwd, ".claude", "skills", "user-tool", "SKILL.md");
await mkdir(join(cwd, ".claude", "skills", "user-tool"), {
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,39 @@ const pkg = JSON.parse(
readFileSync(resolve(import.meta.dirname, "package.json"), "utf8")
) as { version: string };

// The CLI invocation baked into emitted skill/command/recipe content, chosen
// by the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self):
// - prod (default): the published `npx @taskless/cli`
// - dev: an absolute path, for validating this build from another repo
// - self: a repo-root-relative path, for dogfooding inside this repo
function resolveCliInvocation(): string {
switch (process.env.TASKLESS_BUILD_TARGET) {
case "self": {
return "node packages/cli/dist/index.js";
}
case "dev": {
return `node ${resolve(import.meta.dirname, "dist/index.js")}`;
}
default: {
return "npx @taskless/cli";
}
}
}

// A one-time banner prepended to canonical skill/command bodies for non-prod
// builds, so an agent that's told to call the local CLI knows how to produce it
// if the build artifact is missing. Empty for prod (no banner is emitted).
function resolveCliNotice(): string {
const target = process.env.TASKLESS_BUILD_TARGET;
if (target !== "self" && target !== "dev") return "";
const rebuild = target === "self" ? "pnpm build:self" : "pnpm build:dev";
return (
`> **Local Taskless build.** The commands below call a locally built CLI ` +
`(\`${resolveCliInvocation()}\`). If that path does not exist yet, run ` +
`\`${rebuild}\` from the repo root first.`
);
}

const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;

function assertSkillVersions(): Plugin {
Expand Down Expand Up @@ -103,6 +136,8 @@ function shebang(): Plugin {
export default defineConfig({
define: {
__VERSION__: JSON.stringify(pkg.version),
__TASKLESS_CLI__: JSON.stringify(resolveCliInvocation()),
__TASKLESS_CLI_NOTICE__: JSON.stringify(resolveCliNotice()),
},
plugins: [tsconfigPaths(), assertSkillVersions(), shebang()],
build: {
Expand Down
63 changes: 0 additions & 63 deletions scripts/link-skills.ts

This file was deleted.

Loading