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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

## Automation boundary

All Things reads, writes, and navigation use `osascript -l JavaScript` and bundle identifier `com.culturedcode.ThingsMac`. User-controlled values are passed as JSON process arguments and are never interpolated into JXA source. The CLI never reads or writes Things' SQLite database and has no database path or auth-token configuration.
All Things reads, writes, and navigation use `osascript -l JavaScript` and bundle identifier `com.culturedcode.ThingsMac`. User-controlled values are passed as JSON process arguments and are never interpolated into JXA source. The CLI never reads or writes Things' SQLite database and has no database path configuration. `update --heading NAME` is the one exception to the scripting-dictionary rule: headings are not scriptable, so it moves a todo under a heading of its project through the `things:///update` URL scheme. It needs the Things URL auth token in `THINGS_AUTH_TOKEN` (Things > Settings > General > Enable Things URLs > Manage); JXA reads it from the environment so it never appears in process arguments. The move is applied asynchronously by Things, and reads still report `heading: null`.

Reads use native Things list membership/order for Inbox, Today, Upcoming, Anytime, Someday, Logbook, and Trash. General queries enumerate publicly reachable items, deduplicate by Things ID, and apply status/type/tag/area/project filters. Public automation does not expose checklist items or headings; responses retain `checklist: []` and `heading: null`.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ things-cli add --title "Task" [--notes ...] [--when today] [--deadline yyyy-mm-d
[--list LIST-NAME|--list-id LIST-ID] [--project PROJECT|--project-id PROJECT-ID] [--wait]
things-cli add-project --title "Project" [--to-dos $'one\ntwo'] [--area Work] [--wait]
things-cli update <id> --title "New" --completed
THINGS_AUTH_TOKEN=... things-cli update <todo-id> --heading "Working"
things-cli move <id> --project "Launch"
things-cli detach <id> --project
things-cli delete <id> [--reveal]
Expand All @@ -86,7 +87,7 @@ things-cli search "query"

`delete` moves a todo or project to Things Trash; deleting a project also moves its children to Trash. `area delete` moves the area's children to Trash, while `tag delete` removes the tag wherever it is used. Both require `--yes`. Area tags must already exist. `empty-trash` irreversibly deletes everything in Trash and requires the explicit `--yes` confirmation. `--reveal` on `delete` opens the Trash list after deletion.

`--wait` remains accepted for compatibility but does not poll storage; a successful automation response already means the operation completed. Do not retry a write after a timeout or process failure because the outcome may be indeterminate. The following database-dependent options are intentionally unsupported: checklist items, headings, and the `evening` schedule value. The JSON read shape keeps `heading: null` and `checklist: []` because those fields are not exposed by Things' public scripting dictionary.
`--wait` remains accepted for compatibility but does not poll storage; a successful automation response already means the operation completed. Do not retry a write after a timeout or process failure because the outcome may be indeterminate. The following database-dependent options are intentionally unsupported: checklist items, reading headings, and the `evening` schedule value. `update --heading NAME` moves a todo under a heading of its project through the `things:///update` URL scheme because headings are not scriptable. It needs the Things URL auth token in `THINGS_AUTH_TOKEN` (Things > Settings > General > Enable Things URLs > Manage); JXA reads it from the environment so it never appears in process arguments. The move is applied asynchronously by Things. Reads still report `heading: null` and `checklist: []` because those fields are not exposed by Things' public scripting dictionary.

`search` opens Things' search UI and does not return search results. `query --text` searches title and notes in returned data without opening Things' UI. Query defaults to 50 results; `--all` intentionally removes that cap and may be slower because the public scripting API enumerates Things items. `--all` cannot be combined with `--limit`. Query sorting is stable, with ID tie-breaking; `native` preserves Things list order (or stable discovery order for universe-wide queries). `show` reveals an item or native list.

Expand Down
40 changes: 39 additions & 1 deletion cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ func (fixtureService) Add(context.Context, things.AddRequest) (things.ActionResu

type recordingService struct {
fixtureService
addRequest things.AddRequest
addRequest things.AddRequest
updateRequest things.UpdateRequest
}

func (s *recordingService) Update(_ context.Context, request things.UpdateRequest) (things.ActionResult, error) {
s.updateRequest = request
return things.ActionResult{Action: "update", ID: request.ID}, nil
}

func (s *recordingService) Add(_ context.Context, request things.AddRequest) (things.ActionResult, error) {
Expand Down Expand Up @@ -364,6 +370,37 @@ func TestUpdateDoesNotRequireToken(t *testing.T) {
}
}

func TestUpdateHeading(t *testing.T) {
service := &recordingService{}
oldService := thingsService
thingsService = service
t.Cleanup(func() { thingsService = oldService })
t.Setenv(authTokenEnv, "token")
root := newRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"update", "todo-1", "--heading", "Needs human"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if h := service.updateRequest.Heading; h == nil || *h != "Needs human" {
t.Fatalf("update request=%+v", service.updateRequest)
}
if strings.Contains(out.String(), "token") {
t.Fatalf("output leaks the auth token: %s", out.String())
}

for _, args := range [][]string{
{"update", "todo-1", "--heading", ""},
{"update", "proj-1", "--project", "--heading", "Working"},
} {
if _, err := runCLI(t, args...); exitCodeFor(err) != exitUsage {
t.Fatalf("args=%v exit=%d err=%v", args, exitCodeFor(err), err)
}
}
}

func TestCompleteCancelShowSearch(t *testing.T) {
oldService := thingsService
thingsService = fixtureService{}
Expand Down Expand Up @@ -603,6 +640,7 @@ func TestMoveDetachUsageErrors(t *testing.T) {
}

func TestUsageErrors(t *testing.T) {
t.Setenv(authTokenEnv, "")
_, err := runCLI(t, "query", "--limit", "0")
if exitCodeFor(err) != exitUsage {
t.Fatalf("limit exit = %d", exitCodeFor(err))
Expand Down
35 changes: 34 additions & 1 deletion cli/update.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package cli

import (
"os"

"github.com/spf13/cobra"

"github.com/thaodangspace/things-cli/things"
)

func newUpdateCommand() *cobra.Command {
var title, notes, prependNotes, appendNotes, when, deadline, tags, addTags, list, listID string
var title, notes, prependNotes, appendNotes, when, deadline, tags, addTags, list, listID, heading string
var completed, canceled, project, wait bool
cmd := &cobra.Command{
Use: "update <id>",
Expand All @@ -19,6 +21,29 @@ func newUpdateCommand() *cobra.Command {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("title") && title == "" {
return usageErrorf("--title may not be empty")
}
if err := validateWhen(when); err != nil {
return err
}
if err := validateDeadline(deadline); err != nil {
return err
}
if cmd.Flags().Changed("heading") {
if heading == "" {
return usageErrorf("--heading may not be empty")
}
if project {
return usageErrorf("--heading applies to todos, not projects")
}
if os.Getenv(authTokenEnv) == "" {
return usageErrorf("--heading needs %s (Things > Settings > General > Enable Things URLs > Manage)", authTokenEnv)
}
}
if cmd.Flags().Changed("completed") && cmd.Flags().Changed("canceled") {
return usageErrorf("--completed and --canceled cannot both be set")
}
o := things.UpdateRequest{ID: args[0], Project: project}
setUpdateString(cmd, &o.Title, "title", title)
setUpdateString(cmd, &o.Notes, "notes", notes)
Expand All @@ -28,6 +53,7 @@ func newUpdateCommand() *cobra.Command {
setUpdateString(cmd, &o.Deadline, "deadline", deadline)
setUpdateString(cmd, &o.List, "list", list)
setUpdateString(cmd, &o.ListID, "list-id", listID)
setUpdateString(cmd, &o.Heading, "heading", heading)
if cmd.Flags().Changed("tags") {
v := splitCSV(tags)
o.Tags = &v
Expand Down Expand Up @@ -66,6 +92,7 @@ func newUpdateCommand() *cobra.Command {
cmd.Flags().StringVar(&addTags, "add-tags", "", "add comma-separated tags")
cmd.Flags().StringVar(&list, "list", "", "destination list")
cmd.Flags().StringVar(&listID, "list-id", "", "destination list id")
cmd.Flags().StringVar(&heading, "heading", "", "move the todo under this heading of its project (needs "+authTokenEnv+")")
cmd.Flags().BoolVar(&completed, "completed", false, "mark completed/uncompleted")
cmd.Flags().BoolVar(&canceled, "canceled", false, "mark canceled/uncanceled")
cmd.Flags().BoolVar(&project, "project", false, "use update-project action")
Expand All @@ -79,3 +106,9 @@ func setUpdateString(cmd *cobra.Command, dest **string, name, value string) {
*dest = &v
}
}

// authTokenEnv holds the Things URL-scheme auth token. Only --heading needs it:
// headings are not in Things' scripting dictionary, so the move goes through
// things:///update. JXA reads the token from the environment, so it never
// appears in the osascript argument list.
const authTokenEnv = "THINGS_AUTH_TOKEN"
8 changes: 5 additions & 3 deletions skills/things-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Use the things-cli command-line tool to read and manipulate Things

# Things CLI

Use the locally installed `things-cli` executable to automate Things 3 on macOS. It invokes Things through the public scripting dictionary with `osascript -l JavaScript`; it never reads or writes Things' SQLite database and does not require an auth token.
Use the locally installed `things-cli` executable to automate Things 3 on macOS. It invokes Things through the public scripting dictionary with `osascript -l JavaScript`; it never reads or writes Things' SQLite database and needs no auth token except for `update --heading` (see below).

The invoking terminal, agent host, or executable must have Automation permission for Things 3 in **System Settings → Privacy & Security → Automation**. Missing Things, denied permission, malformed responses, application errors, and timeouts are reported as actionable errors.

Expand Down Expand Up @@ -113,7 +113,7 @@ Update a task or project. Only supplied flags are changed; explicit empty `--not
things-cli update <id> [--title TITLE] [--notes NOTES] \
[--prepend-notes NOTES|--append-notes NOTES] [--when SCHEDULE] \
[--deadline YYYY-MM-DD] [--tags TAG1,TAG2] [--add-tags TAG1,TAG2] \
[--list LIST-NAME|--list-id LIST-ID] [--completed|--canceled] \
[--list LIST-NAME|--list-id LIST-ID] [--heading HEADING] [--completed|--canceled] \
[--project] [--wait]
```

Expand Down Expand Up @@ -165,4 +165,6 @@ things-cli search "query"

User-controlled values are passed as JSON process arguments and are never interpolated into JXA source. Do not retry a write after a timeout or process failure: the outcome may be indeterminate. The CLI does not bypass macOS Automation/TCC permissions.

Checklist items, headings, and the `evening` schedule value are unsupported by the public scripting API, even though legacy-compatible flags may appear in older usage documentation.
Checklist items, reading headings, and the `evening` schedule value are unsupported by the public scripting API, even though legacy-compatible flags may appear in older usage documentation.

`update --heading NAME` is the one exception to the scripting-dictionary rule: headings are not scriptable, so it moves a todo under a heading of its project through the `things:///update` URL scheme. It needs the Things URL auth token in `THINGS_AUTH_TOKEN` (Things > Settings > General > Enable Things URLs > Manage); JXA reads it from the environment so it never appears in process arguments. The move is applied asynchronously by Things, and reads still report `heading: null`.
48 changes: 39 additions & 9 deletions things/automation.js
Original file line number Diff line number Diff line change
Expand Up @@ -763,17 +763,25 @@ function addTodo(app, request) {
throw new Error("a todo cannot target both a list and a project");
}
var project = null;
var task;
if (request.project || request.project_id) {
project = resolveProjectTarget(app, request.project_id, request.project);
task = newTodo(app, project, request.title);
} else {
task = newTodo(app, resolveListTarget(app, "", "Inbox"), request.title);
}
applyCommonFields(app, task, request);
if (!project) applyListDestination(app, task, request);
if (request.completed) task.status = "completed";
if (request.canceled) task.status = "canceled";
// Things 3.24+ ignores the location of `project.toDos.push(...)` / `make ... at`:
// the todo lands in the Inbox and the returned specifier no longer resolves.
// So create in the Inbox, re-resolve by id, and assign `project` afterwards.
var created = app.make({ new: "toDo", withProperties: { name: request.title } });
var task = app.toDos.byId(created.id());
try {
applyCommonFields(app, task, request);
if (project) task.project = project;
else applyListDestination(app, task, request);
if (request.completed) task.status = "completed";
if (request.canceled) task.status = "canceled";
} catch (error) {
// Never leave a half-built todo behind: callers retry failed adds.
try { app.delete(task); } catch (ignored) {}
throw error;
}
if (request.reveal) task.show();
return actionResult("add", task);
}
Expand All @@ -784,7 +792,12 @@ function addProject(app, request) {
if (request.area || request.area_id) project.area = resolveAreaTarget(app, request.area_id, request.area);
var children = request.to_dos || [];
for (var i = 0; i < children.length; i++) {
newTodo(app, project, children[i]);
var child = app.make({ new: "toDo", withProperties: { name: children[i] } });
try {
child.project = project;
} catch (error) {
app.move(child, { to: project });
}
}
if (request.reveal) project.show();
return actionResult("add-project", project);
Expand All @@ -810,6 +823,7 @@ function updateTask(app, request) {
if (request.deadline !== undefined) task.dueDate = request.deadline ? parseDateValue(request.deadline) : null;
if (request.when !== undefined) applyWhen(app, task, request.when);
applyListDestination(app, task, request);
if (request.heading !== undefined) moveToHeading(task, request.heading);
if (request.completed !== undefined && request.completed) task.status = "completed";
if (request.completed !== undefined && !request.completed && text(callValue(task, "status")) === "completed") task.status = "open";
if (request.canceled !== undefined && request.canceled) task.status = "canceled";
Expand Down Expand Up @@ -932,6 +946,22 @@ function encodeQuery(value) {
});
}

// Headings are not in Things' scripting dictionary, so a heading move goes
// through the URL scheme, which needs the auth token. The token comes from the
// environment rather than the JSON request so it never shows up in `ps`.
// openLocation is fire-and-forget: Things applies the move asynchronously.
function moveToHeading(task, heading) {
if (!heading) throw new Error("heading may not be empty");
ObjC.import("Foundation");
var token = $.NSProcessInfo.processInfo.environment.objectForKey("THINGS_AUTH_TOKEN");
token = token && !token.isNil() ? token.js : "";
if (!token) throw new Error("THINGS_AUTH_TOKEN is required to move a todo under a heading");
var current = Application.currentApplication();
current.includeStandardAdditions = true;
current.openLocation("things:///update?id=" + encodeQuery(objectID(task)) +
"&heading=" + encodeQuery(heading) + "&auth-token=" + encodeQuery(token));
}

function searchTarget(query) {
var current = Application.currentApplication();
current.includeStandardAdditions = true;
Expand Down
46 changes: 33 additions & 13 deletions things/automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,23 +165,31 @@ func TestExecScriptProjectTodoCreationContract(t *testing.T) {
}
source := strings.Replace(defaultAutomationScript, applicationLine,
`var app = contractApplication("com.culturedcode.ThingsMac");`, 1) + `
var contractInserted = false;
var contractAssigned = null;
var contractDeleted = false;
var contractIDReads = 0;
var contractTask = {
id: function () {
if (!contractInserted) throw new Error("todo was not inserted into project");
return "created-id";
},
// First read resolves the freshly made todo; later reads (the result) prove the project was assigned.
id: function () { return contractIDReads++ === 0 || contractAssigned ? "created-id" : "unassigned-id"; },
show: function () {}
};
Object.defineProperty(contractTask, "project", {
set: function (value) {
if (value !== contractProject) throw new Error("unexpected project assignment");
contractAssigned = value;
}
});
Object.defineProperty(contractTask, "notes", {
set: function (value) {
if (value === "boom") throw { toString: function () { return "notes write failed; rolled back=" + contractDeleted; } };
}
});
var contractProject = {
exists: function () { return true; },
id: function () { return "project-id"; },
name: function () { return "vsee"; },
toDos: {
push: function (task) {
if (task !== contractTask) throw new Error("unexpected todo inserted into project");
contractInserted = true;
}
push: function () { throw new Error("project.toDos.push must not be used (Things 3.24 drops it in the Inbox)"); }
}
};
var missingProject = {
Expand All @@ -194,12 +202,18 @@ function contractApplication(identifier) {
byId: function (id) { return id === "project-id" ? contractProject : missingProject; },
byName: function (name) { return name === "vsee" ? contractProject : missingProject; }
},
ToDo: function (properties) {
if (!properties || properties.name !== "test2") throw new Error("unexpected todo properties");
toDos: {
byId: function (id) {
if (id !== "created-id") throw new Error("unexpected todo id: " + id);
return contractTask;
}
},
make: function (spec) {
if (!spec || spec["new"] !== "toDo" || spec.withProperties.name !== "test2") throw new Error("unexpected make");
return contractTask;
},
make: function () { throw new Error("make must not be called for a project destination"); },
move: function () { throw new Error("move must not be called for a project destination"); }
move: function () { throw new Error("move must not be called for a project destination"); },
"delete": function (task) { if (task === contractTask) contractDeleted = true; }
};
}
`
Expand All @@ -219,6 +233,12 @@ function contractApplication(identifier) {

var result ActionResult
err := RunJSON(context.Background(), ExecScript{Source: source}, "add",
AddRequest{Title: "test2", ProjectID: "project-id", Notes: "boom"}, &result)
if err == nil || !strings.Contains(err.Error(), "rolled back=true") {
t.Fatalf("error=%v, want failed add to delete the half-built todo", err)
}

err = RunJSON(context.Background(), ExecScript{Source: source}, "add",
AddRequest{Title: "test2", Project: "missing"}, &result)
if err == nil || !strings.Contains(err.Error(), "project not found: missing") {
t.Fatalf("error=%v, want project-not-found failure", err)
Expand Down
1 change: 1 addition & 0 deletions things/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ type UpdateRequest struct {
AddTags *[]string `json:"add_tags,omitempty"`
List *string `json:"list,omitempty"`
ListID *string `json:"list_id,omitempty"`
Heading *string `json:"heading,omitempty"`
Completed *bool `json:"completed,omitempty"`
Canceled *bool `json:"canceled,omitempty"`
Project bool `json:"project"`
Expand Down
Loading